feat(assets): wardrobe wave 1 — per-body shells, 8 garments, try-on UI (T-1089)
Infra: offset-shell gains --per-body mode (each body's own segments, cut/mask thresholds derived from that body's bone landmarks — reproduces the hand-calibrated reference constants exactly on average_m); compositor prefers <body>_mask.png with reference_mask.png fallback; tshirt re-authored per-body on all 11 (the Q-060 torso poke-through class is GONE — residual flags are a sleeve-hem epsilon artifact on thick arms, offset-insensitive, documented). Garments (all per-body x 11, chromakey-gated <=150px worst, previewed): hoodie (hood-down roll, kangaroo pocket, logo), button-down (collar/placket), shorts, jeans (analytic denim field driving albedo+mask together; boundary weld + open-rim flattening — real segment-splitter findings), formal pants, jacket (over-shirt standoff, zip), suit_jacket_black (lapel region, tintable shirt triangle — the hand-author proof), uniform_utility (11-segment coverall, gap-free waist join by construction, 4-zone showcase, logo patch). Try-on UI: creation screen shows per-region tint pickers (multi_region garments) + logo picker (logo_capable + logos/*.png scan), data-driven off manifest+coverage. Manifest merged by the lead: 12 clothing entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,14 @@ const EYEBROW_LABELS := ["Regular", "Female", "Teen", "Thick"]
|
||||
const CLOTHING_SLOTS := ["torso", "legs", "feet", "hands"]
|
||||
const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
|
||||
|
||||
# --- T-1089 wardrobe engine: multi-region tints + logo decals ---
|
||||
# Region indices match the offset-shell mask channels (R/G/B/A -> tint_0..3):
|
||||
# 0 = collar band, 1 = main body, 2 = sleeves, 3 = trim (generic labels).
|
||||
const REGION_TINT_LABELS := ["Collar", "Body", "Sleeves", "Trim"]
|
||||
const LOGO_DIR := "res://assets/characters/logos/"
|
||||
const CLOTHING_DIR := "res://assets/characters/clothing/"
|
||||
const DEFAULT_CLOTHING_TINT := Color(0.7, 0.65, 0.6)
|
||||
|
||||
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
|
||||
const ACCESSORY_SLOTS := [
|
||||
"hat",
|
||||
@@ -236,6 +244,9 @@ var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
|
||||
# --- T-1089 multi-region tint + logo picker state (active clothing dock only) ---
|
||||
var _clothing_logo_btns: Dictionary = {} # brand_id ("" = None) -> Button, rebuilt per dock
|
||||
|
||||
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
|
||||
# Note: hair highlight is always auto-derived (display-only swatch, no override).
|
||||
var _eyebrow_tint_auto: bool = true
|
||||
@@ -260,6 +271,8 @@ var _accessory_dock_container: Control = null
|
||||
# --- Cached asset scan results (populated once during tab build, reused by randomize) ---
|
||||
var _cached_hair_ids: Array = []
|
||||
var _cached_head_ids: Array = []
|
||||
var _cached_logo_ids: Array = [] # brand ids scanned from LOGO_DIR (filename = brand id)
|
||||
var _coverage_cache: Dictionary = {} # item_id -> coverage.json dict (UI-side, read-only)
|
||||
|
||||
# --- Appearance sub-section search text and grid references ---
|
||||
var _appearance_search: Array[String] = ["", "", "", "", ""] # one per sub-section (Body=0 … Accessories=4)
|
||||
@@ -296,6 +309,8 @@ var _screenshot_frame_count: int = 0
|
||||
var _quit_after_screenshot: bool = false
|
||||
var _screenshot_cardinals: bool = false
|
||||
var _screenshot_cardinal_idx: int = 0
|
||||
var _test_ui_tab: int = -1 # --test-config: top-level tab to open once tabs are built
|
||||
var _test_appearance_sub: int = -1 # --test-config: appearance sub-section to open
|
||||
|
||||
# resolved in _ready() — @onready path fails when instantiated as child
|
||||
var _tab_container: TabContainer = null
|
||||
@@ -346,6 +361,7 @@ func _manifest_array(key: String) -> Array:
|
||||
|
||||
func _ready() -> void:
|
||||
_load_manifest()
|
||||
_cached_logo_ids = _scan_logo_ids()
|
||||
|
||||
# Build tab panel programmatically — Godot 4.6 destroys TabContainer children
|
||||
# defined in .tscn when the scene is instantiated as a child of another scene.
|
||||
@@ -418,6 +434,14 @@ func _ready() -> void:
|
||||
_update_cam_angle_label()
|
||||
_update_start_btn_state()
|
||||
|
||||
# --test-config UI navigation (applied after tabs exist; -1 = untouched)
|
||||
if _test_ui_tab >= 0 and _tab_container != null:
|
||||
_tab_container.current_tab = clampi(_test_ui_tab, 0, _tab_container.get_tab_count() - 1)
|
||||
if _test_appearance_sub >= 0 and not _appearance_sub_sections.is_empty():
|
||||
_on_appearance_sub_selected(
|
||||
clampi(_test_appearance_sub, 0, _appearance_sub_sections.size() - 1)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Camera management (D-158)
|
||||
@@ -1321,18 +1345,41 @@ func _on_clothing_slot_selected(slot: String) -> void:
|
||||
func _rebuild_clothing_color_dock(container: Control) -> void:
|
||||
for child in container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
container.add_child(row)
|
||||
_clothing_logo_btns.clear()
|
||||
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
var multi_region: bool = not item_id.is_empty() and _is_multi_region(item_id)
|
||||
var logo_capable: bool = not item_id.is_empty() and _is_logo_capable(item_id)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
container.add_child(vbox)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(row)
|
||||
|
||||
if multi_region:
|
||||
_build_region_tint_row(row, item_id)
|
||||
else:
|
||||
_build_legacy_clothing_tint_row(row, item_id)
|
||||
|
||||
if logo_capable:
|
||||
vbox.add_child(_make_section_label("Logo"))
|
||||
vbox.add_child(_build_logo_picker_row())
|
||||
|
||||
# Logo row needs extra dock height (label + button row)
|
||||
container.custom_minimum_size = Vector2(0, 112 if logo_capable else 64)
|
||||
|
||||
|
||||
## Legacy single-mask garments: Primary + auto-derived Secondary/Accent swatches.
|
||||
func _build_legacy_clothing_tint_row(row: HBoxContainer, item_id: String) -> void:
|
||||
var tints: Array = (
|
||||
_descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||||
as Array
|
||||
)
|
||||
var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
|
||||
var primary: Color = tints[0] as Color if tints.size() > 0 else DEFAULT_CLOTHING_TINT
|
||||
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
|
||||
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
|
||||
|
||||
@@ -1351,15 +1398,104 @@ func _rebuild_clothing_color_dock(container: Control) -> void:
|
||||
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
|
||||
|
||||
|
||||
## Multi-region garments (coverage.json multi_region:true): one swatch per mask
|
||||
## region 0-3, feeding descriptor.clothing_tints[item_id] as a 4-Color array.
|
||||
func _build_region_tint_row(row: HBoxContainer, item_id: String) -> void:
|
||||
# Legacy swatch refs stay null for this slot — legacy handlers can't fire here.
|
||||
_clothing_primary_swatches[_active_clothing_slot] = null
|
||||
_clothing_secondary_swatches[_active_clothing_slot] = null
|
||||
_clothing_accent_swatches[_active_clothing_slot] = null
|
||||
|
||||
var tints := _region_tints_for(item_id)
|
||||
for i in REGION_TINT_LABELS.size():
|
||||
var swatch := _make_color_swatch(
|
||||
tints[i], REGION_TINT_LABELS[i], _on_clothing_region_tint_changed.bind(i)
|
||||
)
|
||||
row.add_child(swatch)
|
||||
|
||||
|
||||
## Resolve the effective 4 region tints for an item, mirroring the compositor's
|
||||
## fallback chain (t1<-t0, t2<-t1, t3<-t2) so the dock shows what the shader renders.
|
||||
func _region_tints_for(item_id: String) -> Array:
|
||||
var stored: Array = _descriptor.clothing_tints.get(item_id, []) as Array
|
||||
var t0: Color = stored[0] as Color if stored.size() > 0 else DEFAULT_CLOTHING_TINT
|
||||
var t1: Color = stored[1] as Color if stored.size() > 1 else t0
|
||||
var t2: Color = stored[2] as Color if stored.size() > 2 else t1
|
||||
var t3: Color = stored[3] as Color if stored.size() > 3 else t2
|
||||
return [t0, t1, t2, t3]
|
||||
|
||||
|
||||
func _on_clothing_region_tint_changed(color: Color, region_idx: int) -> void:
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
if item_id.is_empty():
|
||||
return
|
||||
# Materialize the full 4-Color array so untouched regions keep their current look.
|
||||
var tints := _region_tints_for(item_id)
|
||||
tints[region_idx] = color
|
||||
_descriptor.clothing_tints[item_id] = tints
|
||||
_refresh_preview()
|
||||
|
||||
|
||||
## Logo picker row: "None" + one button per brand PNG in LOGO_DIR (filename = brand id).
|
||||
func _build_logo_picker_row() -> Control:
|
||||
var flow := HFlowContainer.new()
|
||||
flow.add_theme_constant_override("h_separation", 4)
|
||||
flow.add_theme_constant_override("v_separation", 4)
|
||||
|
||||
var none_btn := _make_slot_btn("None")
|
||||
none_btn.pressed.connect(_on_clothing_logo_selected.bind(""))
|
||||
flow.add_child(none_btn)
|
||||
_clothing_logo_btns[""] = none_btn
|
||||
|
||||
for brand_id in _cached_logo_ids:
|
||||
var bid := str(brand_id)
|
||||
var btn := _make_slot_btn(bid.capitalize())
|
||||
btn.pressed.connect(_on_clothing_logo_selected.bind(bid))
|
||||
flow.add_child(btn)
|
||||
_clothing_logo_btns[bid] = btn
|
||||
|
||||
_update_clothing_logo_btns()
|
||||
return flow
|
||||
|
||||
|
||||
func _on_clothing_logo_selected(brand_id: String) -> void:
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
if item_id.is_empty():
|
||||
return
|
||||
if brand_id.is_empty():
|
||||
_descriptor.clothing_logos.erase(item_id)
|
||||
else:
|
||||
_descriptor.clothing_logos[item_id] = brand_id
|
||||
_update_clothing_logo_btns()
|
||||
_refresh_preview()
|
||||
|
||||
|
||||
func _update_clothing_logo_btns() -> void:
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
var current: String = str(_descriptor.clothing_logos.get(item_id, ""))
|
||||
for brand_id: String in _clothing_logo_btns:
|
||||
_set_item_selected(_clothing_logo_btns[brand_id], brand_id == current)
|
||||
|
||||
|
||||
func _on_clothing_item_selected(slot: String, item_id: String) -> void:
|
||||
var current: String = str(_descriptor.clothing_slots.get(slot, ""))
|
||||
if current == item_id:
|
||||
# Toggle off — remove from slot
|
||||
_descriptor.clothing_slots.erase(slot)
|
||||
_descriptor.clothing_tints.erase(item_id)
|
||||
_descriptor.clothing_logos.erase(item_id)
|
||||
else:
|
||||
_descriptor.clothing_slots[slot] = item_id
|
||||
_descriptor.clothing_tints[item_id] = [Color(0.7, 0.65, 0.6)]
|
||||
if _is_multi_region(item_id):
|
||||
# 4-Color array, one per mask region (collar/body/sleeves/trim)
|
||||
_descriptor.clothing_tints[item_id] = [
|
||||
DEFAULT_CLOTHING_TINT,
|
||||
DEFAULT_CLOTHING_TINT,
|
||||
DEFAULT_CLOTHING_TINT,
|
||||
DEFAULT_CLOTHING_TINT,
|
||||
]
|
||||
else:
|
||||
_descriptor.clothing_tints[item_id] = [DEFAULT_CLOTHING_TINT]
|
||||
_clothing_secondary_auto[slot] = true
|
||||
_clothing_accent_auto[slot] = true
|
||||
|
||||
@@ -1682,6 +1818,26 @@ func _load_test_config() -> void:
|
||||
var clothing: Dictionary = cfg["clothing"]
|
||||
for slot: String in clothing:
|
||||
_descriptor.clothing_slots[slot] = clothing[slot]
|
||||
if cfg.has("clothing_tints"):
|
||||
# item_id -> array of [r,g,b] / [r,g,b,a] arrays (multi-region: 4 entries)
|
||||
var tint_cfg: Dictionary = cfg["clothing_tints"]
|
||||
for item_id: String in tint_cfg:
|
||||
var colors: Array = []
|
||||
for c in tint_cfg[item_id] as Array:
|
||||
var arr: Array = c
|
||||
colors.append(
|
||||
Color(arr[0], arr[1], arr[2], arr[3] if arr.size() > 3 else 1.0)
|
||||
)
|
||||
_descriptor.clothing_tints[item_id] = colors
|
||||
if cfg.has("clothing_logos"):
|
||||
# item_id -> brand id (resolved to assets/characters/logos/<id>.png)
|
||||
var logo_cfg: Dictionary = cfg["clothing_logos"]
|
||||
for item_id: String in logo_cfg:
|
||||
_descriptor.clothing_logos[item_id] = str(logo_cfg[item_id])
|
||||
if cfg.has("ui_tab"):
|
||||
_test_ui_tab = int(cfg["ui_tab"])
|
||||
if cfg.has("appearance_sub"):
|
||||
_test_appearance_sub = int(cfg["appearance_sub"])
|
||||
|
||||
# Auto-quit after screenshot (for CI/automated testing)
|
||||
if cfg.has("screenshot_delay_frames"):
|
||||
@@ -2401,6 +2557,62 @@ func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||||
return result
|
||||
|
||||
|
||||
## Read (and cache) a clothing item's coverage.json — UI-side, read-only.
|
||||
## Mirrors CharacterVisual._read_coverage; kept separate so the dock can resolve
|
||||
## region capability before the compositor has loaded the item.
|
||||
func _get_item_coverage(item_id: String) -> Dictionary:
|
||||
if item_id.is_empty():
|
||||
return {}
|
||||
if _coverage_cache.has(item_id):
|
||||
return _coverage_cache[item_id]
|
||||
var coverage: Dictionary = {}
|
||||
var path := CLOTHING_DIR + "%s/coverage.json" % item_id
|
||||
if FileAccess.file_exists(path):
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file != null:
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if parsed is Dictionary:
|
||||
coverage = parsed as Dictionary
|
||||
_coverage_cache[item_id] = coverage
|
||||
return coverage
|
||||
|
||||
|
||||
func _is_multi_region(item_id: String) -> bool:
|
||||
return bool(_get_item_coverage(item_id).get("multi_region", false))
|
||||
|
||||
|
||||
func _is_logo_capable(item_id: String) -> bool:
|
||||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||||
if not (clothing_data is Dictionary):
|
||||
return false
|
||||
var item_info: Variant = (clothing_data as Dictionary).get(item_id, {})
|
||||
if item_info is Dictionary:
|
||||
return bool((item_info as Dictionary).get("logo_capable", false))
|
||||
return false
|
||||
|
||||
|
||||
## Scan LOGO_DIR for brand PNGs (filename = brand id). Handles both editor
|
||||
## listings (foo.png) and exported-pack listings (foo.png.import / foo.png.remap).
|
||||
func _scan_logo_ids() -> Array:
|
||||
var ids := {}
|
||||
var dir := DirAccess.open(LOGO_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
dir.list_dir_begin()
|
||||
var fname := dir.get_next()
|
||||
while not fname.is_empty():
|
||||
if not dir.current_is_dir():
|
||||
var base := fname.trim_suffix(".import").trim_suffix(".remap")
|
||||
if base.ends_with(".png"):
|
||||
ids[base.trim_suffix(".png")] = true
|
||||
fname = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
var result: Array = ids.keys()
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
|
||||
func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||||
# Accessory items from manifest, filtered by slot name prefix convention.
|
||||
var all_ids: Array = _manifest_array("accessories")
|
||||
|
||||
Reference in New Issue
Block a user