feat(client): character creation screen with live 3D preview (#705)

Full character creation UI with 5-tab customisation panel (Body, Head,
Hair, Clothing, Accessories), live 3D compositor preview via SubViewport,
D-165 color picker modal, keyboard navigation, and game flow integration.

- CharacterCreation scene + script (~1440 lines) implementing wireframe spec
- SubViewport with Camera3D (frontal/dramatic/overhead presets per D-158)
- Cardinal rotation (Q/E) per D-155, randomise (R), Tab/Enter/Esc navigation
- Body type grid (11 types, D-159), skin tone dock (9 tones, shared state)
- Head template, hair, facial hair, eyebrow grids with DirAccess asset scanning
- Clothing slot selector with per-item Primary/Secondary/Accent tints
- Accessory slot selector with Primary/Secondary tints (Array[Color] descriptor)
- 54-swatch color picker modal (D-165) with hex input and recent colors
- Auto-derive flags for highlight, eyebrow tint, facial hair tint from hair primary
- Game flow: main_menu → archetype select → character creation → game start
- GameState.character_visual_descriptor field for gameplay consumption
- Updated accessory_tints from single Color to Array[Color] in descriptor + compositor
- 40+ GdUnit tests covering scene structure, signals, keyboard nav, color derivation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:16:47 +01:00
co-authored by Claude Opus 4.6
parent fa0af110f1
commit 56e8c70081
8 changed files with 2366 additions and 26 deletions
+3 -1
View File
@@ -518,7 +518,9 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
push_warning("CharacterVisual: unknown accessory slot '%s'" % slot)
continue
var path := BASE_PATH + "accessories/%s.glb" % item_id
var tint: Color = desc.accessory_tints.get(item_id, Color.WHITE)
# accessory_tints is now Array[Color] (Primary + Secondary). Pass Primary ([0]).
var tints: Array = desc.accessory_tints.get(item_id, [])
var tint: Color = tints[0] if not tints.is_empty() else Color.WHITE
var att := _attach_to_bone(path, bone_name, tint)
if att != null:
# Track accessory attachments separately for get_accessory_node_count()
@@ -97,7 +97,8 @@ var clothing_tints: Dictionary = {}
## Accessory slot → item_id ("hat" -> "hat_hardhat")
var accessory_slots: Dictionary = {}
## item_id → Color for accessories
## item_id → Array[Color] for accessories (Primary + Secondary). Matches clothing_tints shape.
## Changed from single Color per Tyre architecture review (Task #6).
var accessory_tints: Dictionary = {}
@@ -133,7 +134,7 @@ static func from_dict(data: Dictionary) -> CharacterVisualDescriptor:
desc.clothing_slots = data.get("clothing_slots", {})
desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {}))
desc.accessory_slots = data.get("accessory_slots", {})
desc.accessory_tints = _decode_single_tint_map(data.get("accessory_tints", {}))
desc.accessory_tints = _decode_tint_map(data.get("accessory_tints", {}))
return desc
@@ -153,7 +154,7 @@ func to_dict() -> Dictionary:
"clothing_slots": clothing_slots,
"clothing_tints": _encode_tint_map(clothing_tints),
"accessory_slots": accessory_slots,
"accessory_tints": _encode_single_tint_map(accessory_tints),
"accessory_tints": _encode_tint_map(accessory_tints),
}
@@ -194,17 +195,3 @@ static func _encode_tint_map(data: Dictionary) -> Dictionary:
return result
## Decode accessory_tints: item_id → single Color.
static func _decode_single_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
result[key] = _decode_color(data[key], Color.WHITE)
return result
## Encode accessory_tints to wire format.
static func _encode_single_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
result[key] = _encode_color(data[key])
return result