diff --git a/Makefile b/Makefile index 779edff74..a1a4b33ab 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) build-sr-voice run-sr-voice test-voice-mock test-voice-real \ perf-baseline debug-schedule \ test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \ - screenshot visual-movie test-visual visual-update + screenshot visual-movie test-visual visual-update \ + manifest # --- Configuration --- @@ -62,6 +63,7 @@ help: @echo " make test-visual Run visual golden regression tests" @echo " make visual-update Regenerate visual goldens and stage for commit" @echo "" + @echo " make manifest Regenerate assets/characters/manifest.json from asset dirs (#720)" @echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)" @echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)" @echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)" @@ -399,6 +401,12 @@ test-voice-real: cd server && cargo test --test voice_pipeline -- --nocapture @echo "Results: .tmp/voice-test/results.txt" +# --- Asset manifest --- + +manifest: + @tooling/generate-character-manifest + @echo "Manifest regenerated — commit client/assets/characters/manifest.json if changed." + # --- Clean --- clean: diff --git a/client/assets/characters/manifest.json b/client/assets/characters/manifest.json index b2876b0ce..83623dc69 100644 --- a/client/assets/characters/manifest.json +++ b/client/assets/characters/manifest.json @@ -1,13 +1,75 @@ { - "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": [], + "body_types": [ + "average_f", + "average_m", + "child", + "heavy_f", + "heavy_m", + "muscular_f", + "muscular_m", + "teen_f", + "teen_m", + "thin_f", + "thin_m" + ], + "heads": [ + "head_001", + "head_002", + "head_003", + "head_004" + ], + "hair": [ + "bald", + "balding", + "bob", + "buns", + "buzzed", + "buzzed_female", + "dreads", + "long", + "long_dreads", + "mohawk", + "ponytail", + "ponytail_f", + "simple_parted", + "slick_back" + ], + "facial_hair": [ + "beard", + "moustache", + "mutton_chops" + ], + "eyebrows": [ + "female", + "regular", + "teen", + "thick" + ], "clothing": { - "peasant_tunic": {"slot": "torso"}, - "peasant_pants": {"slot": "legs"}, - "peasant_shoes": {"slot": "feet"} + "boots_work": { + "slot": "feet" + }, + "coveralls_basic": { + "slot": "torso" + }, + "jacket_utility": { + "slot": "torso" + }, + "pants_cargo": { + "slot": "legs" + }, + "peasant_pants": { + "slot": "legs" + }, + "peasant_shoes": { + "slot": "feet" + }, + "peasant_tunic": { + "slot": "torso" + }, + "shirt_henley": { + "slot": "torso" + } }, "accessories": [] } diff --git a/client/ui/character_creation.gd b/client/ui/character_creation.gd index ec4490a76..7f44841f4 100644 --- a/client/ui/character_creation.gd +++ b/client/ui/character_creation.gd @@ -669,6 +669,11 @@ func _build_hair_color_dock() -> Control: func(c): _on_hair_primary_changed(c)) row.add_child(_hair_primary_swatch) + # #719 (Option B): highlight is auto-derived from primary — display-only, not editable. + _hair_highlight_swatch = _make_display_swatch( + _derive_hair_highlight(_descriptor.hair_tint), "Highlight") + row.add_child(_hair_highlight_swatch) + _eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)) row.add_child(_eyebrow_tint_swatch) @@ -1840,45 +1845,6 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void: child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q) -# ============================================================================= -# Asset scanning helpers -# ============================================================================= - -## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/). -## Returns fallback list if the directory is absent or empty. -## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK). -static func _scan_subdirs(dir_path: String, fallback: Array) -> Array: - var dir := DirAccess.open(dir_path) - if dir == null: - return fallback - var ids: Array = [] - dir.list_dir_begin() - var name := dir.get_next() - while name != "": - if dir.current_is_dir() and not name.begins_with("."): - ids.append(name) - name = dir.get_next() - dir.list_dir_end() - return ids if not ids.is_empty() else fallback - - -## Scan a directory for .glb asset IDs. Returns fallback list if directory absent. -## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK). -static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array: - var dir := DirAccess.open(dir_path) - if dir == null: - return fallback - var ids: Array = [] - dir.list_dir_begin() - var name := dir.get_next() - while name != "": - if not dir.current_is_dir() and name.ends_with(ext): - ids.append(name.get_basename()) - name = dir.get_next() - dir.list_dir_end() - return ids if not ids.is_empty() else fallback - - func _get_clothing_ids_for_slot(slot: String) -> Array: # Clothing items from manifest — slot assignment is explicit, not prefix-based. var clothing_data: Variant = _manifest.get("clothing", {}) diff --git a/tooling/generate-character-manifest b/tooling/generate-character-manifest new file mode 100755 index 000000000..eabbe8385 --- /dev/null +++ b/tooling/generate-character-manifest @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Generate client/assets/characters/manifest.json from the asset directories. + +Run this whenever artists add new assets so the manifest stays in sync: + tooling/generate-character-manifest + +The manifest is the single source of truth for CharacterCreation asset IDs. +DirAccess.open() cannot enumerate res:// paths in exported PCK builds (#720). + +Output: client/assets/characters/manifest.json +""" + +import json +import os +import sys + +ASSETS_ROOT = os.path.join(os.path.dirname(__file__), "..", "client", "assets", "characters") +MANIFEST_PATH = os.path.join(ASSETS_ROOT, "manifest.json") + + +def scan_glb_ids(dir_path: str) -> list[str]: + """Return sorted list of .glb basenames (without extension) in dir_path.""" + if not os.path.isdir(dir_path): + return [] + ids = sorted( + os.path.splitext(f)[0] + for f in os.listdir(dir_path) + if f.endswith(".glb") and not f.startswith(".") + ) + return ids + + +def scan_subdirs(dir_path: str) -> list[str]: + """Return sorted list of subdirectory names in dir_path.""" + if not os.path.isdir(dir_path): + return [] + return sorted( + d for d in os.listdir(dir_path) + if os.path.isdir(os.path.join(dir_path, d)) and not d.startswith(".") + ) + + +def infer_clothing_slot(item_id: str) -> str: + """Infer clothing slot from item_id by convention.""" + id_lower = item_id.lower() + if any(k in id_lower for k in ("boot", "shoe", "sandal", "slipper")): + return "feet" + if any(k in id_lower for k in ("pant", "trouser", "skirt", "short")): + return "legs" + if any(k in id_lower for k in ("glove", "gauntlet")): + return "hands" + # Default: torso (jacket, tunic, shirt, coveralls, vest, etc.) + return "torso" + + +def build_manifest() -> dict: + # Body types: subdirs under bodies/ + body_types = scan_subdirs(os.path.join(ASSETS_ROOT, "bodies")) + + # Heads: .glb files in heads/templates/ + heads = scan_glb_ids(os.path.join(ASSETS_ROOT, "heads", "templates")) + + # Hair: .glb files in hair/ + hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "hair")) + + # Facial hair: .glb files in facial_hair/ + facial_hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "facial_hair")) + + # Eyebrows: .glb files in eyebrows/ + eyebrows = scan_glb_ids(os.path.join(ASSETS_ROOT, "eyebrows")) + + # Clothing: subdirs under clothing/, each assigned a slot + clothing_items = scan_subdirs(os.path.join(ASSETS_ROOT, "clothing")) + clothing: dict = {} + for item_id in clothing_items: + clothing[item_id] = {"slot": infer_clothing_slot(item_id)} + + # Accessories: no directory yet — leave empty + accessories: list = [] + acc_dir = os.path.join(ASSETS_ROOT, "accessories") + if os.path.isdir(acc_dir): + accessories = scan_glb_ids(acc_dir) + + return { + "body_types": body_types, + "heads": heads, + "hair": hair, + "facial_hair": facial_hair, + "eyebrows": eyebrows, + "clothing": clothing, + "accessories": accessories, + } + + +def main() -> None: + manifest = build_manifest() + output = json.dumps(manifest, indent=2) + "\n" + with open(MANIFEST_PATH, "w", encoding="utf-8") as f: + f.write(output) + print(f"Written: {MANIFEST_PATH}") + print(f" body_types : {len(manifest['body_types'])}") + print(f" heads : {len(manifest['heads'])}") + print(f" hair : {len(manifest['hair'])}") + print(f" facial_hair: {len(manifest['facial_hair'])}") + print(f" eyebrows : {len(manifest['eyebrows'])}") + print(f" clothing : {len(manifest['clothing'])}") + print(f" accessories: {len(manifest['accessories'])}") + + +if __name__ == "__main__": + main()