Add CharacterVisualDescriptor (11-variant BodyType enum, wire format encode/decode, body_type_key mapping) and CharacterVisual compositor (runtime 3D character assembler with slot architecture, BoneAttachment3D, clothing coverage, recolor mask loading, skin tone tinting, bone validation). Includes 64 unit tests and Blender utility scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
554 lines
21 KiB
GDScript
554 lines
21 KiB
GDScript
## Sprint 28 — CharacterVisual compositor tests (#704)
|
||
##
|
||
## Validates the character visual assembler (CharacterVisual.gd) against the
|
||
## architectural spec at docs/architecture/character-asset-organization.md.
|
||
##
|
||
## Most tests are test-first stubs: they push_warning and return when the
|
||
## compositor class or required asset files are not yet present. Once Stig's
|
||
## implementation lands, the guards are removed automatically.
|
||
##
|
||
## Test areas (per team lead focus list):
|
||
## 1. Descriptor-to-node-tree construction
|
||
## 2. Segment visibility toggling for clothing coverage
|
||
## 3. Torso variant swap (seg_torso vs seg_torso_upper)
|
||
## 4. Tint application (skin tone index → texture)
|
||
## 5. Facing rotation (set_facing / rotation change)
|
||
## 6. Edge cases: empty hair_id, no clothing, no accessories
|
||
##
|
||
## Spec: D-159 (body types), D-160 (15+2+2 segments), D-161 (head on Head bone),
|
||
## D-162 (clothing pre-baked per body type), D-164 (fork skeleton)
|
||
## Ticket: #704
|
||
class_name TestCharacterVisualSprint28
|
||
extends GdUnitTestSuite
|
||
|
||
const COMPOSITOR_PATH := "res://scripts/rendering/character_visual.gd"
|
||
const SKIN_TONES_DIR := "res://assets/characters/skin_tones/"
|
||
const BODIES_DIR := "res://assets/characters/bodies/"
|
||
|
||
const SKELETON_PATH := "res://assets/characters/skeleton/armature.glb"
|
||
|
||
## Returns a minimal valid descriptor with no optional layers.
|
||
func _make_minimal_descriptor() -> CharacterVisualDescriptor:
|
||
var d := CharacterVisualDescriptor.new()
|
||
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||
d.head_id = "head_001"
|
||
d.hair_id = "" # empty = no hair
|
||
d.facial_hair_id = "" # empty = none
|
||
d.eyebrow_id = "" # empty = no eyebrows
|
||
d.skin_tone = 0
|
||
return d
|
||
|
||
## Returns true if the compositor script is present and loadable.
|
||
func _compositor_available() -> bool:
|
||
if not ResourceLoader.exists(COMPOSITOR_PATH):
|
||
push_warning("TestCharacterVisualSprint28: character_visual.gd not found — test-first stub (awaiting #704)")
|
||
return false
|
||
return true
|
||
|
||
## Returns true if the skeleton GLB is present (required for segment/clothing tests).
|
||
func _skeleton_available() -> bool:
|
||
return ResourceLoader.exists(SKELETON_PATH)
|
||
|
||
## Loads and instantiates a CharacterVisual node. Returns null with warning if unavailable.
|
||
func _make_compositor() -> Node:
|
||
if not _compositor_available():
|
||
return null
|
||
var script: GDScript = load(COMPOSITOR_PATH)
|
||
if script == null:
|
||
push_warning("TestCharacterVisualSprint28: failed to load character_visual.gd")
|
||
return null
|
||
var node := Node3D.new()
|
||
node.set_script(script)
|
||
add_child(node)
|
||
return node
|
||
|
||
func after_test() -> void:
|
||
# Clean up any nodes added during testing
|
||
for child in get_children():
|
||
if child != self:
|
||
child.queue_free()
|
||
|
||
|
||
# =============================================================================
|
||
# 1. API presence — compositor class exists and has expected public interface
|
||
# =============================================================================
|
||
|
||
func test_compositor_script_file_exists() -> void:
|
||
# Gate: is the file present at all?
|
||
if not ResourceLoader.exists(COMPOSITOR_PATH):
|
||
push_warning("TestCharacterVisualSprint28: character_visual.gd missing — test-first stub")
|
||
return
|
||
assert_bool(ResourceLoader.exists(COMPOSITOR_PATH)).is_true()
|
||
|
||
|
||
func test_compositor_has_load_descriptor_method() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
assert_bool(node.has_method("load_descriptor")).override_failure_message(
|
||
"CharacterVisual must expose load_descriptor(desc: CharacterVisualDescriptor)"
|
||
).is_true()
|
||
|
||
|
||
func test_compositor_has_set_facing_method() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
assert_bool(node.has_method("set_facing")).override_failure_message(
|
||
"CharacterVisual must expose set_facing(direction: Vector2 or int)"
|
||
).is_true()
|
||
|
||
|
||
func test_compositor_is_node3d() -> void:
|
||
# Compositor must be a Node3D (3D scene tree, not 2D)
|
||
if not _compositor_available():
|
||
return
|
||
var script: GDScript = load(COMPOSITOR_PATH)
|
||
var node := Node3D.new()
|
||
node.set_script(script)
|
||
add_child(node)
|
||
assert_bool(node is Node3D).override_failure_message(
|
||
"CharacterVisual must extend Node3D"
|
||
).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 2. Descriptor-to-node-tree construction
|
||
# =============================================================================
|
||
|
||
func test_load_descriptor_does_not_crash_with_minimal_desc() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
var desc := _make_minimal_descriptor()
|
||
# Must not crash — assets may be missing but the call must not throw
|
||
node.load_descriptor(desc)
|
||
assert_bool(true).is_true() # If we got here, no crash
|
||
|
||
|
||
func test_load_descriptor_produces_child_nodes() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
var desc := _make_minimal_descriptor()
|
||
node.load_descriptor(desc)
|
||
# A fully loaded compositor must have at least one child (body skeleton or armature)
|
||
# Guard: skip if assets genuinely not present yet (real test once assets land)
|
||
if node.get_child_count() == 0:
|
||
push_warning("TestCharacterVisualSprint28: no child nodes after load_descriptor — assets may not be imported yet")
|
||
return
|
||
assert_bool(node.get_child_count() > 0).is_true()
|
||
|
||
|
||
func test_reload_descriptor_clears_previous_nodes() -> void:
|
||
# Loading a second descriptor must replace the first, not accumulate nodes
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
var desc1 := _make_minimal_descriptor()
|
||
desc1.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||
var desc2 := _make_minimal_descriptor()
|
||
desc2.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
|
||
|
||
node.load_descriptor(desc1)
|
||
var count_after_first := node.get_child_count()
|
||
node.load_descriptor(desc2)
|
||
var count_after_second := node.get_child_count()
|
||
|
||
if count_after_first == 0:
|
||
push_warning("TestCharacterVisualSprint28: node count 0 — assets may not be present yet")
|
||
return
|
||
# Second load must not double the children (would indicate no cleanup)
|
||
assert_bool(count_after_second <= count_after_first * 2).override_failure_message(
|
||
"load_descriptor must clear previous node tree before building new one — children doubled, suggesting stale nodes were kept"
|
||
).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 3. Segment visibility toggling for clothing coverage
|
||
# =============================================================================
|
||
|
||
func test_no_clothing_all_body_segments_visible() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not _skeleton_available():
|
||
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
|
||
return
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {} # no clothing
|
||
node.load_descriptor(desc)
|
||
# All non-swapped segments should be visible with no clothing.
|
||
# is_segment_visible returns false for missing segments — only meaningful when skeleton loaded.
|
||
assert_bool(node.is_segment_visible("torso")).override_failure_message(
|
||
"seg_torso must be visible when no clothing covers it"
|
||
).is_true()
|
||
assert_bool(node.is_segment_visible("arm_upper_l")).is_true()
|
||
assert_bool(node.is_segment_visible("leg_upper_l")).is_true()
|
||
|
||
|
||
func test_clothing_hides_declared_segments() -> void:
|
||
# A full coverall with hides = [torso, arm_upper_l, arm_upper_r, ...] must
|
||
# hide those segments
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("is_segment_visible"):
|
||
push_warning("TestCharacterVisualSprint28: is_segment_visible not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||
node.load_descriptor(desc)
|
||
|
||
# Edge case: coverage.json may not exist yet — guard
|
||
if not node.has_method("get_active_coverage"):
|
||
push_warning("TestCharacterVisualSprint28: coverage integration not yet wired — skipping segment hide check")
|
||
return
|
||
|
||
# Coveralls should hide the torso segment (among others)
|
||
# The exact hidden set is declared in coverage.json — we test the contract, not the file content
|
||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic")
|
||
if coverage.is_empty():
|
||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||
return
|
||
|
||
for hidden_seg: String in coverage.get("hides", []):
|
||
assert_bool(node.is_segment_visible(hidden_seg)).override_failure_message(
|
||
"Segment '%s' declared in coverage.json hides must not be visible" % hidden_seg
|
||
).is_false()
|
||
|
||
|
||
func test_segments_not_in_hides_remain_visible() -> void:
|
||
# A jacket (upper body only) must NOT hide leg segments
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not _skeleton_available():
|
||
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {"torso": "jacket_utility"} # upper body only
|
||
node.load_descriptor(desc)
|
||
|
||
var coverage: Dictionary = node.get_active_coverage("jacket_utility") if node.has_method("get_active_coverage") else {}
|
||
if coverage.is_empty():
|
||
push_warning("TestCharacterVisualSprint28: jacket_utility/coverage.json not found — stub")
|
||
return
|
||
|
||
var hides: Array = coverage.get("hides", [])
|
||
if not "leg_upper_l" in hides:
|
||
# Jacket doesn't cover legs — legs must still be visible
|
||
assert_bool(node.is_segment_visible("leg_upper_l")).override_failure_message(
|
||
"Leg segments must remain visible when only a jacket is worn"
|
||
).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 4. Torso variant swap
|
||
# =============================================================================
|
||
|
||
func test_torso_variant_full_uses_seg_torso() -> void:
|
||
# coverage.json torso_variant: "full" → seg_torso is loaded (default)
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("get_active_torso_variant"):
|
||
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub (awaiting #704)")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {} # no clothing → default torso
|
||
node.load_descriptor(desc)
|
||
|
||
assert_str(node.get_active_torso_variant()).override_failure_message(
|
||
"With no clothing, torso variant must be 'full' (uses seg_torso)"
|
||
).is_equal("full")
|
||
|
||
|
||
func test_torso_variant_upper_uses_seg_torso_upper() -> void:
|
||
# coverage.json torso_variant: "upper" → seg_torso_upper is loaded
|
||
# (e.g. tank top: exposes lower abdomen)
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("get_active_torso_variant"):
|
||
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {"torso": "shirt_tank"} # a tank top with torso_variant: "upper"
|
||
node.load_descriptor(desc)
|
||
|
||
var coverage: Dictionary = node.get_active_coverage("shirt_tank") if node.has_method("get_active_coverage") else {}
|
||
if coverage.is_empty() or coverage.get("torso_variant") != "upper":
|
||
push_warning("TestCharacterVisualSprint28: shirt_tank/coverage.json not found or no upper variant — stub")
|
||
return
|
||
|
||
assert_str(node.get_active_torso_variant()).override_failure_message(
|
||
"Tank top with torso_variant:'upper' must load seg_torso_upper"
|
||
).is_equal("upper")
|
||
|
||
|
||
func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
|
||
# A full coverall hides the torso segment entirely (not swapped to upper)
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not _skeleton_available():
|
||
push_warning("TestCharacterVisualSprint28: skeleton not found — torso hide test requires #706 assets")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||
node.load_descriptor(desc)
|
||
|
||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
|
||
if coverage.is_empty():
|
||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||
return
|
||
|
||
if "torso" in coverage.get("hides", []):
|
||
# Full coverage: torso segment is hidden, not swapped
|
||
assert_bool(node.is_segment_visible("torso")).override_failure_message(
|
||
"Full-coverage clothing with 'torso' in hides must hide seg_torso entirely"
|
||
).is_false()
|
||
|
||
|
||
# =============================================================================
|
||
# 5. Facing rotation
|
||
# =============================================================================
|
||
|
||
func test_set_facing_changes_node_rotation() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
# set_facing only writes rotation.y — no asset dependency, no load_descriptor needed
|
||
var rot_before: float = node.rotation.y
|
||
node.set_facing(Vector2(1, 0)) # east / right
|
||
var rot_after: float = node.rotation.y
|
||
# Rotation must change when facing changes (exact value is implementation-defined)
|
||
# We only check that it differs — not the exact angle
|
||
# (Different implementations may use 4-dir or 8-dir mappings)
|
||
# Guard: allow no-op only if facing east == default (unlikely)
|
||
if rot_before == rot_after:
|
||
# Try a definitely-different facing
|
||
node.set_facing(Vector2(-1, 0)) # west
|
||
var rot_west: float = node.rotation.y
|
||
assert_bool(rot_west != rot_before).override_failure_message(
|
||
"set_facing must rotate the node — east and west must produce different rotations"
|
||
).is_true()
|
||
|
||
|
||
func test_set_facing_opposite_directions_differ() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
# set_facing only writes rotation.y — no asset dependency
|
||
node.set_facing(Vector2(0, -1)) # north
|
||
var rot_north: float = node.rotation.y
|
||
node.set_facing(Vector2(0, 1)) # south
|
||
var rot_south: float = node.rotation.y
|
||
|
||
assert_bool(rot_north != rot_south).override_failure_message(
|
||
"North and south facing must produce different rotation values"
|
||
).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 6. Edge cases
|
||
# =============================================================================
|
||
|
||
func test_empty_hair_id_adds_no_hair_node() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("has_slot_node"):
|
||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub (awaiting #704)")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.hair_id = "" # explicit empty = no hair
|
||
node.load_descriptor(desc)
|
||
|
||
assert_bool(node.has_slot_node("hair")).override_failure_message(
|
||
"Empty hair_id must not add a hair node to the character tree"
|
||
).is_false()
|
||
|
||
|
||
func test_bald_hair_id_adds_no_hair_node() -> void:
|
||
# "bald" is the explicit key for no-hair — treated identically to empty string.
|
||
# Stig: `if desc.hair_id.is_empty() or desc.hair_id == "bald": return`
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("has_slot_node"):
|
||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.hair_id = "bald"
|
||
node.load_descriptor(desc)
|
||
|
||
assert_bool(node.has_slot_node("hair")).override_failure_message(
|
||
"hair_id='bald' must not add a hair node (bald is the explicit no-hair sentinel)"
|
||
).is_false()
|
||
|
||
|
||
func test_empty_facial_hair_id_adds_no_facial_hair_node() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("has_slot_node"):
|
||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.facial_hair_id = ""
|
||
node.load_descriptor(desc)
|
||
|
||
assert_bool(node.has_slot_node("facial_hair")).override_failure_message(
|
||
"Empty facial_hair_id must not add a facial hair node"
|
||
).is_false()
|
||
|
||
|
||
func test_no_clothing_slots_leaves_no_clothing_nodes() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("get_clothing_node_count"):
|
||
push_warning("TestCharacterVisualSprint28: get_clothing_node_count not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.clothing_slots = {}
|
||
node.load_descriptor(desc)
|
||
|
||
assert_int(node.get_clothing_node_count()).override_failure_message(
|
||
"No clothing slots in descriptor must produce zero clothing nodes in the scene tree"
|
||
).is_equal(0)
|
||
|
||
|
||
func test_no_accessories_adds_no_accessory_nodes() -> void:
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("get_accessory_node_count"):
|
||
push_warning("TestCharacterVisualSprint28: get_accessory_node_count not found — stub")
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.accessory_slots = {}
|
||
node.load_descriptor(desc)
|
||
|
||
assert_int(node.get_accessory_node_count()).override_failure_message(
|
||
"No accessory slots in descriptor must produce zero accessory nodes"
|
||
).is_equal(0)
|
||
|
||
|
||
func test_missing_asset_file_does_not_crash() -> void:
|
||
# If an asset file referenced by the descriptor is missing from disk,
|
||
# the compositor must degrade gracefully (skip the missing slot, not crash).
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
|
||
var desc := _make_minimal_descriptor()
|
||
desc.hair_id = "hair_does_not_exist_zzz" # guaranteed non-existent
|
||
# Must not crash — should either skip the slot or substitute a placeholder
|
||
node.load_descriptor(desc)
|
||
assert_bool(true).is_true() # survived = pass
|
||
|
||
|
||
func test_all_11_body_types_load_without_crash() -> void:
|
||
# Each of the 11 body types must not cause load_descriptor to crash.
|
||
# Assets may be missing — but the compositor must handle it gracefully.
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
|
||
var bt := CharacterVisualDescriptor.BodyType
|
||
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
|
||
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
|
||
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
|
||
var desc := _make_minimal_descriptor()
|
||
desc.body_type = variant
|
||
node.load_descriptor(desc)
|
||
# If we get here without a crash, all 11 body types were handled
|
||
assert_bool(true).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 7. Skin tone application
|
||
# =============================================================================
|
||
|
||
func test_skin_tone_index_resolves_to_correct_texture_name() -> void:
|
||
# The 9 skin tone textures are indexed 0–8 in this order (per architecture doc):
|
||
# 0:pale_cool, 1:pale_warm, 2:light_olive, 3:medium_golden,
|
||
# 4:olive_warm, 5:medium_brown, 6:deep_brown, 7:very_deep_warm, 8:very_deep_cool
|
||
var node := _make_compositor()
|
||
if node == null:
|
||
return
|
||
if not node.has_method("get_skin_tone_texture_name"):
|
||
push_warning("TestCharacterVisualSprint28: get_skin_tone_texture_name not found — stub (awaiting #704)")
|
||
return
|
||
|
||
# Spot-check a few indices
|
||
assert_str(node.get_skin_tone_texture_name(0)).override_failure_message(
|
||
"Skin tone index 0 must resolve to pale_cool.png"
|
||
).is_equal("pale_cool")
|
||
assert_str(node.get_skin_tone_texture_name(8)).override_failure_message(
|
||
"Skin tone index 8 must resolve to very_deep_cool.png"
|
||
).is_equal("very_deep_cool")
|
||
|
||
|
||
func test_skin_tone_textures_exist_on_disk() -> void:
|
||
# Verify all 9 expected skin tone PNGs are present (copied from spike in #702)
|
||
var expected := [
|
||
"pale_cool.png", "pale_warm.png", "light_olive.png", "medium_golden.png",
|
||
"olive_warm.png", "medium_brown.png", "deep_brown.png",
|
||
"very_deep_warm.png", "very_deep_cool.png",
|
||
]
|
||
for filename: String in expected:
|
||
var path := SKIN_TONES_DIR + filename
|
||
assert_bool(ResourceLoader.exists(path)).override_failure_message(
|
||
"Skin tone texture missing: %s (should have been copied in #702)" % path
|
||
).is_true()
|
||
|
||
|
||
# =============================================================================
|
||
# 8. Path construction invariants (pure logic, no assets required)
|
||
# =============================================================================
|
||
|
||
func test_body_type_key_produces_expected_path_fragment() -> void:
|
||
# Confirm that CharacterVisualDescriptor.body_type_key() returns the right
|
||
# string for use in path construction (this is the bridge between descriptor and compositor)
|
||
var d := CharacterVisualDescriptor.new()
|
||
d.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||
assert_str(d.body_type_key()).is_equal("muscular_f")
|
||
|
||
|
||
func test_all_body_type_keys_match_expected_directory_names() -> void:
|
||
# The compositor derives paths as bodies/{body_type_key}/seg_{segment}.glb
|
||
# Every key must be lowercase and match the expected directory name convention
|
||
var expected := {
|
||
CharacterVisualDescriptor.BodyType.THIN_M: "thin_m",
|
||
CharacterVisualDescriptor.BodyType.THIN_F: "thin_f",
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_M: "average_m",
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_F: "average_f",
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "muscular_m",
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "muscular_f",
|
||
CharacterVisualDescriptor.BodyType.TEEN_M: "teen_m",
|
||
CharacterVisualDescriptor.BodyType.TEEN_F: "teen_f",
|
||
CharacterVisualDescriptor.BodyType.HEAVY_M: "heavy_m",
|
||
CharacterVisualDescriptor.BodyType.HEAVY_F: "heavy_f",
|
||
CharacterVisualDescriptor.BodyType.CHILD: "child",
|
||
}
|
||
var d := CharacterVisualDescriptor.new()
|
||
for body_type: int in expected:
|
||
d.body_type = body_type
|
||
assert_str(d.body_type_key()).override_failure_message(
|
||
"BodyType %d must produce key '%s'" % [body_type, expected[body_type]]
|
||
).is_equal(expected[body_type])
|