#719: Add display-only hair highlight swatch (Option B) — auto-derived from primary tint, non-interactive (MOUSE_FILTER_IGNORE). #720: Remove DirAccess.open() fallback scanning (breaks in PCK exports). Fully populate manifest.json (11 body types, 14 hair, 4 heads, 4 eyebrows, 8 clothing). Add tooling/generate-character-manifest script and make manifest target for regeneration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
Executable
+111
@@ -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()
|
||||
Reference in New Issue
Block a user