feat(client): character creator overhaul — manifest, segments, eye color
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+493
-95
@@ -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 <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
|
||||
# =============================================================================
|
||||
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user