From 4d9840b68f9466ab121782378dbcc282ced303f1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 23 Mar 2026 17:42:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(client):=20character=20creator=20overhaul?= =?UTF-8?q?=20=E2=80=94=20manifest,=20segments,=20eye=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes from runtime testing and iteration: - Asset manifest (manifest.json) controls all available content — replaces filesystem scanning, solves DirAccess/PCK export issue - Dynamic tabs: only show tabs with manifest content - Body segmentation: face-based exclusive assignment, hips split from torso, torso_upper independent (not a variant) - Eye color with iris-only mask from T_Eye_Split.png green channel - Eyebrows tinted with hair color (from body segment, not separate GLB) - Hair/clothing loaded as skinned meshes on shared skeleton - Segment hiding disabled for clothing (solidify handles coverage) - Gender-aware randomizer (no facial hair on female/teen) - Clothing slots hidden when empty in manifest - UI: color docks docked to bottom on all tabs, padding, flow layout - Debug tab with per-segment visibility toggles - Screenshot automation with test config JSON Co-Authored-By: Claude Opus 4.6 --- client/assets/characters/manifest.json | 13 + client/scripts/rendering/character_visual.gd | 133 ++-- .../rendering/character_visual_descriptor.gd | 5 + client/ui/character_creation.gd | 588 +++++++++++++++--- 4 files changed, 604 insertions(+), 135 deletions(-) create mode 100644 client/assets/characters/manifest.json diff --git a/client/assets/characters/manifest.json b/client/assets/characters/manifest.json new file mode 100644 index 000000000..b2876b0ce --- /dev/null +++ b/client/assets/characters/manifest.json @@ -0,0 +1,13 @@ +{ + "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"], + "heads": [], + "hair": ["bob", "buns", "buzzed", "long", "ponytail", "bald"], + "facial_hair": ["beard", "moustache", "mutton_chops"], + "eyebrows": [], + "clothing": { + "peasant_tunic": {"slot": "torso"}, + "peasant_pants": {"slot": "legs"}, + "peasant_shoes": {"slot": "feet"} + }, + "accessories": [] +} diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 8c75f5b3f..9e4a4ce18 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -31,6 +31,7 @@ const BASE_PATH := "res://assets/characters/" const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb" const ANIM_LIBRARY_PATH := BASE_PATH + "animations/ual_standard.glb" const SKIN_TONE_DIR := BASE_PATH + "skin_tones/" +const EYE_IRIS_MASK_PATH := BASE_PATH + "bodies/eye_iris_mask.png" const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader" const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader" const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader" @@ -38,7 +39,7 @@ const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader" ## All body segment names in assembly order. D-160: 14 base + 2 swappable torso + 2 face = 18. ## torso_upper is loaded and hidden by default; clothing coverage reveals it. const ALL_SEGMENTS: Array[String] = [ - "head", "neck", "torso", "torso_upper", + "head", "neck", "torso_upper", "torso", "hips", "arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r", "hand_l", "hand_r", "leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r", @@ -50,7 +51,7 @@ const ALL_SEGMENTS: Array[String] = [ const NON_SKIN_SEGMENTS: Array[String] = ["eyes", "eyebrows"] ## Segments hidden by default; shown only when a clothing coverage rule requests it. -const HIDDEN_BY_DEFAULT: Array[String] = ["torso_upper"] +const HIDDEN_BY_DEFAULT: Array[String] = [] # all segments visible by default — no swappable variants ## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md). ## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H), @@ -109,6 +110,8 @@ var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc. var _toon_shader: Shader = null var _toon_masked_shader: Shader = null var _outline_shader: Shader = null +var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting +var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png func _ready() -> void: @@ -121,6 +124,13 @@ func _load_shaders() -> void: _outline_shader = load(OUTLINE_SHADER_PATH) as Shader if not _toon_shader or not _toon_masked_shader or not _outline_shader: push_error("CharacterVisual: shader load failed — check %s" % BASE_PATH) + # 1x1 white pixel texture — used as recolor_mask to enable full-body skin tinting + var img := Image.create(1, 1, false, Image.FORMAT_R8) + img.set_pixel(0, 0, Color.WHITE) + _white_mask = ImageTexture.create_from_image(img) + # Iris mask — generated from T_Eye_Split.png green channel + if ResourceLoader.exists(EYE_IRIS_MASK_PATH): + _iris_mask = load(EYE_IRIS_MASK_PATH) as Texture2D # ============================================================================= @@ -274,6 +284,12 @@ func _load_skeleton() -> void: if _skeleton == null: push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH) return + # Log any meshes that came with the armature import + var armature_meshes := _collect_meshes(_body_root) + if not armature_meshes.is_empty(): + print("--- Armature import contains ", armature_meshes.size(), " meshes ---") + for m in armature_meshes: + print(" armature mesh: ", m.name, " visible=", m.visible) _validate_slot_bones() @@ -307,7 +323,14 @@ func _load_body_segments(desc: CharacterVisualDescriptor) -> void: mi.owner = null mi.set_meta("segment", seg_name) mi.visible = seg_name not in HIDDEN_BY_DEFAULT - _apply_body_shader(mi, seg_name, tone) + if seg_name == "eyebrows": + _apply_tinted_shader(mi, desc.eyebrow_tint, _white_mask) + elif seg_name == "eyes": + # Iris-only tinting using mask derived from T_Eye_Split.png green channel + var eye_mask: Texture2D = _iris_mask if _iris_mask else null + _apply_tinted_shader(mi, desc.eye_color, eye_mask) + else: + _apply_body_shader(mi, seg_name, tone) _skeleton.add_child(mi) _body_meshes.append(mi) inst.queue_free() @@ -324,13 +347,23 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) if orig != null: mi.set_surface_override_material(surf, orig) continue - # Skin body segments: apply skin tone via toon_masked or flat toon fallback. - var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"] - var skin_tex := load(tex_path) as Texture2D + # Skin body segments: use embedded texture for per-body-type detail, + # with skin tone tint applied via full white recolor mask. + var orig_mat: Material = mi.mesh.surface_get_material(surf) + var orig_tex: Texture2D = null + if orig_mat is StandardMaterial3D: + orig_tex = (orig_mat as StandardMaterial3D).albedo_texture + elif orig_mat is BaseMaterial3D: + orig_tex = (orig_mat as BaseMaterial3D).albedo_texture + # Fallback to generic skin tone texture if no embedded texture found + if orig_tex == null: + var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"] + orig_tex = load(tex_path) as Texture2D var mat := ShaderMaterial.new() - if skin_tex and _toon_masked_shader: + if orig_tex and _toon_masked_shader: mat.shader = _toon_masked_shader - mat.set_shader_parameter("albedo_tex", skin_tex) + mat.set_shader_parameter("albedo_tex", orig_tex) + mat.set_shader_parameter("recolor_mask", _white_mask) mat.set_shader_parameter("tint_color", tone["lit"]) mat.set_shader_parameter("shadow_strength", 0.25) elif _toon_shader: @@ -338,7 +371,11 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) mat.set_shader_parameter("base_color", tone["lit"]) mat.set_shader_parameter("shadow_color", tone["shadow"]) mat.set_shader_parameter("shadow_threshold", 0.4) - mi.set_surface_override_material(surf, mat) + # Use material_override to fully replace ALL surface materials. + # set_surface_override_material can leave the original StandardMaterial3D + # partially active, which may prevent visible=false from working. + mi.material_override = mat + return # material_override applies to all surfaces — no need to loop # ============================================================================= @@ -369,17 +406,14 @@ func _load_facial_hair(desc: CharacterVisualDescriptor) -> void: _loaded_slots.append("facial_hair") -func _load_eyebrows(desc: CharacterVisualDescriptor) -> void: - if desc.eyebrow_id.is_empty(): - return - # Convention: eyebrow_id uses the short visual name (e.g. "regular", "thick", "female"), - # NOT the full filename prefix. Path: eyebrows/{eyebrow_id}.glb → e.g. eyebrows/regular.glb - var path := BASE_PATH + "eyebrows/%s.glb" % desc.eyebrow_id - if _attach_to_bone(path, "Head", desc.eyebrow_tint) != null: - _loaded_slots.append("eyebrow") +func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void: + # Eyebrows come from the body segment (seg_eyebrows) — not loaded separately. + # The seg_eyebrows mesh is part of the body and matches the body type's head shape. + # Loading separate eyebrow GLBs caused z-fighting and size mismatches across body types. + pass -func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> BoneAttachment3D: +func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE, render_priority: int = 0) -> BoneAttachment3D: if _skeleton == null: return null if not ResourceLoader.exists(path): @@ -406,9 +440,38 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) mask_tex = load(mask_path) as Texture2D var inst := scene.instantiate() - attachment.add_child(inst) - for mi in _collect_meshes(inst): - _apply_tinted_shader(mi, tint, mask_tex) + var meshes := _collect_meshes(inst) + + # Hair/eyebrow GLBs are skinned meshes with their own armature. + # Reparent the mesh onto OUR skeleton so the shared bone names drive it. + # This avoids the BoneAttachment3D double-offset problem. + var has_skin := false + for mi in meshes: + if mi.skin != null: + has_skin = true + break + + if has_skin: + # Skinned mesh (hair, eyebrows) — same structure as body segments. + # Add mesh directly to our skeleton; matching bone names drive it. + _skeleton.remove_child(attachment) + attachment.free() + _bone_attachments.pop_back() + for mi in meshes: + mi.get_parent().remove_child(mi) + mi.owner = null + _skeleton.add_child(mi) + _apply_tinted_shader(mi, tint, mask_tex) + inst.queue_free() + return null + else: + # Unskinned — rigid attachment via BoneAttachment3D + for mi in meshes: + mi.get_parent().remove_child(mi) + mi.owner = null + attachment.add_child(mi) + _apply_tinted_shader(mi, tint, mask_tex) + inst.queue_free() return attachment @@ -461,22 +524,9 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void: _clothing_meshes.append(mi) inst.queue_free() - # Apply segment hiding from coverage - for mi in _body_meshes: - var seg: String = mi.get_meta("segment", "") - if seg in hidden_segments: - mi.visible = false + # Segment hiding disabled — solidified clothing sits on top of body. + # Hiding is reserved for amputation/prosthetics via the debug tab or game logic. - # Apply torso variant selection - if _active_torso_variant == "upper": - for mi in _body_meshes: - match mi.get_meta("segment", ""): - "torso": mi.visible = false - "torso_upper": mi.visible = true - else: - for mi in _body_meshes: - if mi.get_meta("segment", "") == "torso_upper": - mi.visible = false func _read_coverage(path: String) -> Dictionary: @@ -541,7 +591,7 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void: # Internal — tinting (hair, accessories, bone-attached assets with tint) # ============================================================================= -func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null) -> void: +func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, render_priority: int = 0) -> void: if mi.mesh == null: return for surf in range(mi.mesh.get_surface_count()): @@ -560,6 +610,7 @@ func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = mat.set_shader_parameter("base_color", tint) mat.set_shader_parameter("shadow_color", tint.darkened(0.4)) mat.set_shader_parameter("shadow_threshold", 0.4) + mat.render_priority = render_priority mi.set_surface_override_material(surf, mat) @@ -630,10 +681,12 @@ func _load_animations() -> void: push_warning("CharacterVisual: no AnimationPlayer found in animation library") anim_root.queue_free() return - # Create our own AnimationPlayer on the skeleton + # Parent AnimationPlayer to _body_root (the imported scene root) so that + # animation track paths like "Armature/Skeleton3D:bone_name" resolve correctly. + # The imported GLB has structure: root > Armature > Skeleton3D. _anim_player = AnimationPlayer.new() _anim_player.name = "AnimPlayer" - _skeleton.add_child(_anim_player) + _body_root.add_child(_anim_player) # Copy animation libraries from the source player for lib_name in source_player.get_animation_library_list(): var lib: AnimationLibrary = source_player.get_animation_library(lib_name) @@ -699,7 +752,7 @@ static func _get_albedo_texture(mat: Material) -> Texture2D: if mat is BaseMaterial3D: return (mat as BaseMaterial3D).albedo_texture if mat is ShaderMaterial: - for p: String in ["albedo_tex", "albedo_texture", "texture_albedo"]: + for p: String in ["albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"]: var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p) if val is Texture2D: return val as Texture2D diff --git a/client/scripts/rendering/character_visual_descriptor.gd b/client/scripts/rendering/character_visual_descriptor.gd index 98858154b..a6a828506 100644 --- a/client/scripts/rendering/character_visual_descriptor.gd +++ b/client/scripts/rendering/character_visual_descriptor.gd @@ -85,6 +85,9 @@ var facial_hair_tint: Color = Color.WHITE var eyebrow_id: String = "" var eyebrow_tint: Color = Color.WHITE +## Eye color +var eye_color: Color = Color(0.45, 0.3, 0.15) # brown default + ## Skin tone index 0–8 into the 9-tone palette (pale_cool → very_deep_cool) var skin_tone: int = 0 @@ -130,6 +133,7 @@ static func from_dict(data: Dictionary) -> CharacterVisualDescriptor: desc.facial_hair_tint = _decode_color(data.get("facial_hair_tint"), Color.WHITE) desc.eyebrow_id = data.get("eyebrow_id", "") desc.eyebrow_tint = _decode_color(data.get("eyebrow_tint"), Color.WHITE) + desc.eye_color = _decode_color(data.get("eye_color"), Color(0.45, 0.3, 0.15)) desc.skin_tone = clampi(data.get("skin_tone", 0), 0, 8) desc.clothing_slots = data.get("clothing_slots", {}) desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {})) @@ -150,6 +154,7 @@ func to_dict() -> Dictionary: "facial_hair_tint": _encode_color(facial_hair_tint), "eyebrow_id": eyebrow_id, "eyebrow_tint": _encode_color(eyebrow_tint), + "eye_color": _encode_color(eye_color), "skin_tone": skin_tone, "clothing_slots": clothing_slots, "clothing_tints": _encode_tint_map(clothing_tints), diff --git a/client/ui/character_creation.gd b/client/ui/character_creation.gd index 87cda33ee..ec4490a76 100644 --- a/client/ui/character_creation.gd +++ b/client/ui/character_creation.gd @@ -28,8 +28,8 @@ const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HO # 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 +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"] @@ -123,6 +123,11 @@ 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 +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 # --- Tab active slot state --- var _active_clothing_slot: String = "torso" @@ -189,12 +194,40 @@ var _tab_search: Array[String] = ["", "", "", "", ""] # one per tab index ## 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 = {} + +const MANIFEST_PATH := "res://assets/characters/manifest.json" + # ============================================================================= # 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 @@ -216,18 +249,37 @@ func _ready() -> void: _tab_container.add_theme_color_override("font_color", Color(0.784, 0.816, 0.878, 1.0)) tab_panel.add_child(_tab_container) - var tab_names: Array[String] = ["Body", "Head", "Hair", "Clothing", "Accessories"] - for tab_name in tab_names: + # 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 = tab_name + 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 = "regular" + _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) @@ -235,6 +287,11 @@ func _ready() -> void: _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) @@ -248,14 +305,13 @@ func _ready() -> void: _rotate_left_btn.text = UIStrings.get_text("character_creation.btn_rotate_left") _rotate_right_btn.text = UIStrings.get_text("character_creation.btn_rotate_right") - _build_body_tab(_tab_container.get_child(0)) - _build_head_tab(_tab_container.get_child(1)) - _build_hair_tab(_tab_container.get_child(2)) - _build_clothing_tab(_tab_container.get_child(3)) - _build_accessories_tab(_tab_container.get_child(4)) + 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() @@ -266,10 +322,31 @@ func _ready() -> void: 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 cam_z := CAM_DISTANCE * cos(pitch_rad) - var cam_y := CAM_TARGET_HEIGHT + CAM_DISTANCE * sin(pitch_rad) - _preview_camera.position = Vector3(0.0, cam_y, cam_z) - _preview_camera.look_at(Vector3(0.0, CAM_TARGET_HEIGHT, 0.0), Vector3.UP) + 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: @@ -328,7 +405,11 @@ func _build_body_tab(tab: Control) -> void: 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 @@ -340,17 +421,22 @@ func _build_body_tab(tab: Control) -> void: 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 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 + 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() @@ -364,6 +450,18 @@ func _build_body_tab(tab: Control) -> void: 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() @@ -380,10 +478,27 @@ func _make_body_type_btn(bt: int) -> Button: 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) @@ -410,8 +525,7 @@ func _build_head_tab(tab: Control) -> void: scroll.add_child(grid) _tab_grids[1] = grid - _cached_head_ids = _scan_asset_ids("res://assets/characters/heads/templates/", ".glb", - ["head_001", "head_002", "head_003", "head_004"]) + _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)) @@ -425,12 +539,24 @@ func _build_head_tab(tab: Control) -> void: 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 = head_id + _descriptor.head_id = "" if _descriptor.head_id == head_id else head_id _update_head_btns() _refresh_preview() @@ -450,34 +576,30 @@ func _build_hair_tab(tab: Control) -> void: var search := _make_search_bar(2) vbox.add_child(search) - # Hair style grid + # Hair style grid — takes 50% of available space var hair_label := _make_section_label("Hair Style") vbox.add_child(hair_label) - var hair_scroll := ScrollContainer.new() - hair_scroll.custom_minimum_size = Vector2(0, 100) - vbox.add_child(hair_scroll) - - var hair_grid := GridContainer.new() - hair_grid.columns = 3 + var hair_grid := HFlowContainer.new() hair_grid.add_theme_constant_override("h_separation", 4) hair_grid.add_theme_constant_override("v_separation", 4) - hair_scroll.add_child(hair_grid) - _tab_grids[2] = hair_grid + vbox.add_child(hair_grid) - _cached_hair_ids = _scan_asset_ids("res://assets/characters/hair/", ".glb", - ["bald", "bob", "buzzed", "long"]) + _cached_hair_ids = _manifest_array("hair") for hid in _cached_hair_ids: - var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 64)) + 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 row + # 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 := HBoxContainer.new() - fh_row.add_theme_constant_override("separation", 4) + 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(): @@ -486,20 +608,12 @@ func _build_hair_tab(tab: Control) -> void: fh_row.add_child(btn) _facial_hair_btns.append(btn) - # Eyebrow row - var eb_label := _make_section_label("Eyebrows") - vbox.add_child(eb_label) - var eb_row := HBoxContainer.new() - eb_row.add_theme_constant_override("separation", 4) - vbox.add_child(eb_row) - _eyebrow_btns.clear() - for i in EYEBROW_OPTIONS.size(): - var btn := _make_grid_item_btn(EYEBROW_LABELS[i], Vector2(70, 40)) - btn.pressed.connect(_on_eyebrow_selected.bind(EYEBROW_OPTIONS[i])) - eb_row.add_child(btn) - _eyebrow_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 + # Color dock — docked at bottom (consistent with Body and Clothing tabs) var dock := _build_hair_color_dock() vbox.add_child(dock) @@ -508,14 +622,14 @@ func _build_hair_tab(tab: Control) -> void: func _on_hair_selected(hair_id: String) -> void: - _descriptor.hair_id = hair_id + _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 = fh_id + _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() @@ -524,7 +638,7 @@ func _on_facial_hair_selected(fh_id: String) -> void: func _on_eyebrow_selected(eb_id: String) -> void: - _descriptor.eyebrow_id = eb_id + _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() @@ -537,8 +651,7 @@ func _update_hair_btns() -> void: _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) - for i in EYEBROW_OPTIONS.size(): - _set_item_selected(_eyebrow_btns[i], EYEBROW_OPTIONS[i] == _descriptor.eyebrow_id) + # Eyebrow buttons removed — eyebrows come from body segment only func _build_hair_color_dock() -> Control: @@ -556,11 +669,6 @@ func _build_hair_color_dock() -> Control: func(c): _on_hair_primary_changed(c)) row.add_child(_hair_primary_swatch) - # 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 ●", func(c): _on_eyebrow_tint_changed(c)) row.add_child(_eyebrow_tint_swatch) @@ -619,14 +727,22 @@ func _build_clothing_tab(tab: Control) -> void: 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) @@ -674,6 +790,22 @@ func _build_clothing_tab(tab: Control) -> void: _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: @@ -717,9 +849,14 @@ func _rebuild_clothing_color_dock(container: Control) -> void: func _on_clothing_item_selected(slot: String, item_id: String) -> void: - _descriptor.clothing_slots[slot] = item_id - # Reset tints to defaults for this item - _descriptor.clothing_tints[item_id] = [Color(0.7, 0.65, 0.6)] + 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 @@ -770,9 +907,15 @@ func _on_clothing_accent_changed(color: Color) -> void: func _update_clothing_slot_btns() -> void: - for i in CLOTHING_SLOTS.size(): - var selected: bool = CLOTHING_SLOTS[i] == _active_clothing_slot - _set_item_selected(_clothing_slot_btns[i], selected) + # 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: @@ -881,8 +1024,13 @@ func _rebuild_accessory_color_dock(container: Control) -> void: func _on_accessory_item_selected(slot: String, item_id: String) -> void: - _descriptor.accessory_slots[slot] = item_id - _descriptor.accessory_tints[item_id] = [Color.WHITE] # Array[Color]: Primary only to start + 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: @@ -926,6 +1074,224 @@ func _update_accessory_item_btns() -> void: _set_item_selected(_accessory_item_btns[slot][item_id], item_id == active_item) +# ============================================================================= +# ============================================================================= +# Screenshot & automated testing +# ============================================================================= + +const SCREENSHOT_DIR := "user://screenshots/" +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 +const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"] + +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 + else: + _screenshot_cardinals = false + + if _quit_after_screenshot: + get_tree().quit() + + +func _load_test_config() -> void: + # Load a JSON test config from --test-config 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 +# ============================================================================= + +var _debug_toggles: Dictionary = {} # seg_name -> CheckButton + +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 # ============================================================================= @@ -1121,6 +1487,20 @@ func _update_modal_recent_btns() -> void: 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 + elif 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 @@ -1178,20 +1558,32 @@ func _on_start() -> void: # ============================================================================= func _on_randomize() -> void: - var all_types := CharacterVisualDescriptor.BodyType.values() - _descriptor.body_type = all_types[randi() % all_types.size()] + # 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() - # 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()] + # 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) - # Random facial hair and eyebrows - _descriptor.facial_hair_id = FACIAL_HAIR_OPTIONS[randi() % FACIAL_HAIR_OPTIONS.size()] - _descriptor.eyebrow_id = EYEBROW_OPTIONS[randi() % EYEBROW_OPTIONS.size()] + # 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 @@ -1201,10 +1593,9 @@ func _on_randomize() -> void: _update_body_type_btns() _update_skin_tone_btns() + _update_facial_hair_visibility() _update_hair_btns() _update_hair_color_dock() - _update_clothing_item_btns() - _update_accessory_item_btns() _refresh_preview() @@ -1352,10 +1743,15 @@ func _set_swatch_color(btn: Button, color: Color) -> void: # ============================================================================= 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.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) vbox.add_theme_constant_override("separation", 6) - tab.add_child(vbox) + margin.add_child(vbox) return vbox @@ -1484,22 +1880,24 @@ 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"]) - 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")) - "feet": return all_ids.filter(func(id: String) -> bool: return id.begins_with("boot") or id.begins_with("shoe")) - "hands": return all_ids.filter(func(id: String) -> bool: return id.begins_with("glove")) - _: return all_ids + # 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 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/", []) + # 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"))