197 lines
6.4 KiB
GDScript
197 lines
6.4 KiB
GDScript
extends SceneTree
|
|
## Imported geometry, independent morphology, stable identities, and real animation sockets.
|
|
|
|
const PLAYER := preload("res://player/PlayerVisual.tscn")
|
|
const NPC := preload("res://player/npc/NpcVisual.tscn")
|
|
const PROFILE_DIR := "res://assets/characters/profiles/"
|
|
const PRESETS := [
|
|
"reference_cat",
|
|
"fox_rambler",
|
|
"rabbit_scholar",
|
|
"badger_baker",
|
|
"otter_courier",
|
|
"lop_gardener"
|
|
]
|
|
|
|
var failures: Array[String] = []
|
|
var checks := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
var first := PLAYER.instantiate() as Node3D
|
|
var second := PLAYER.instantiate() as Node3D
|
|
root.add_child(first)
|
|
root.add_child(second)
|
|
first.set_process(false)
|
|
second.set_process(false)
|
|
var lean := CharacterAppearanceProfile.new()
|
|
lean.body_fullness = 0.0
|
|
var stout := CharacterAppearanceProfile.new()
|
|
stout.body_fullness = 1.0
|
|
AnimalAppearance.apply_to(first, 0, false, lean)
|
|
AnimalAppearance.apply_to(second, 0, false, stout)
|
|
for path in ["Body", "Body/Bag", "Body/Scarf"]:
|
|
var a := first.get_node(path) as MeshInstance3D
|
|
var b := second.get_node(path) as MeshInstance3D
|
|
_check(a.mesh == b.mesh, path + " must share imported geometry")
|
|
var shape := a.find_blend_shape_by_name("Stout")
|
|
_check(shape >= 0, path + " must retain its Blender Stout shape")
|
|
if shape >= 0:
|
|
_check(
|
|
is_zero_approx(a.get_blend_shape_value(shape)),
|
|
"Lean shape weight must remain independent"
|
|
)
|
|
_check(
|
|
is_equal_approx(b.get_blend_shape_value(shape), 1.0),
|
|
"Stout shape weight must remain independent"
|
|
)
|
|
_check(
|
|
first.get_node("Body").material_override != second.get_node("Body").material_override,
|
|
"Palettes must be per actor"
|
|
)
|
|
var body := first.get_node("Body") as MeshInstance3D
|
|
var arrays := body.mesh.surface_get_arrays(0)
|
|
var roles: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV2]
|
|
_check(not roles.is_empty(), "Palette roles must survive GLB import in UV2")
|
|
var coat_vertices := 0
|
|
for role in roles:
|
|
if is_equal_approx(role.x, 3.0):
|
|
coat_vertices += 1
|
|
_check(coat_vertices > 0, "Coat must have an independently recolorable palette role")
|
|
var child_count := first.find_children("*", "MeshInstance3D", true, false).size()
|
|
for preset in PRESETS:
|
|
var profile: CharacterAppearanceProfile = load(PROFILE_DIR + preset + ".tres")
|
|
AnimalAppearance.apply_to(first, 0, false, profile)
|
|
_check(
|
|
first.find_children("*", "MeshInstance3D", true, false).size() == child_count,
|
|
"Changing profiles must reuse attachment nodes"
|
|
)
|
|
_check(
|
|
first.get_node("Head/Ears").get_parent() == first.get_node("Head"),
|
|
"Ears must follow the head"
|
|
)
|
|
_check(first.get_node("Body/Bag").get_parent() == body, "Pack must follow the torso")
|
|
var triangles := 0
|
|
var draws := 0
|
|
for mesh_node: MeshInstance3D in first.find_children("*", "MeshInstance3D", true, false):
|
|
if mesh_node.mesh == null or mesh_node.name in ["Blade", "Grip"]:
|
|
continue
|
|
_check(
|
|
mesh_node.mesh.get_surface_count() == 1,
|
|
"Each character part must remain one surface"
|
|
)
|
|
triangles += mesh_node.mesh.surface_get_array_index_len(0) / 3
|
|
draws += 1
|
|
_check(
|
|
triangles < 23000 and draws <= 12,
|
|
"Selected character must stay within geometry and surface budgets"
|
|
)
|
|
for extreme in [0, 1]:
|
|
var profile := CharacterAppearanceProfile.new()
|
|
profile.height = 0.80 if extreme == 0 else 1.15
|
|
profile.leg_length = 0.85 if extreme == 0 else 1.15
|
|
profile.head_size = 0.88 if extreme == 0 else 1.12
|
|
profile.body_fullness = float(extreme)
|
|
profile.ear_length = 0.65 if extreme == 0 else 1.20
|
|
profile.species = 2
|
|
AnimalAppearance.apply_to(first, 0, false, profile)
|
|
for side in ["Left", "Right"]:
|
|
var leg := first.get_node("Leg" + side) as MeshInstance3D
|
|
var bounds := leg.transform * leg.get_aabb()
|
|
_check(
|
|
absf(bounds.position.y) < 0.03, "Leg-length variation must keep feet on the ground"
|
|
)
|
|
_check(
|
|
first.get_meta("character_top") > first.get_node("Head").position.y,
|
|
"Visible height must include ears"
|
|
)
|
|
# An ID lookup must neither consume global random state nor change its result after another lookup.
|
|
seed(8123)
|
|
var expected := randf()
|
|
seed(8123)
|
|
var identity := CharacterAppearanceProfile.for_identity(731)
|
|
for value in 30:
|
|
CharacterAppearanceProfile.for_identity(value)
|
|
var rebuilt := CharacterAppearanceProfile.for_identity(731)
|
|
_check(
|
|
(
|
|
identity.height == rebuilt.height
|
|
and identity.body_fullness == rebuilt.body_fullness
|
|
and identity.species == rebuilt.species
|
|
),
|
|
"Identity must rebuild deterministically"
|
|
)
|
|
_check(randf() == expected, "Appearance must not consume global RNG")
|
|
first.free()
|
|
second.free()
|
|
_test_animation_and_cues()
|
|
await process_frame
|
|
if failures.is_empty():
|
|
print("[TEST] PASS character_appearance (", checks, " checks)")
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func _test_animation_and_cues() -> void:
|
|
var profile := CharacterAppearanceProfile.new()
|
|
profile.species = 2
|
|
profile.height = 1.15
|
|
profile.head_size = 1.12
|
|
profile.ear_length = 1.2
|
|
profile.leg_length = 1.15
|
|
var parent := CharacterBody3D.new()
|
|
root.add_child(parent)
|
|
var player := PLAYER.instantiate() as Node3D
|
|
player.appearance = profile
|
|
parent.add_child(player)
|
|
player.set_process(false)
|
|
parent.velocity = Vector3(2, 0, 0)
|
|
var heights := AnimalAppearance.rest_heights(player)
|
|
for frame in 30:
|
|
player.call("_process", 1.0 / 60.0)
|
|
_check(
|
|
player.get_node("Head").position.y >= heights.y,
|
|
"Player gait must retain configured head height"
|
|
)
|
|
_check(
|
|
player.get_node("Body").position.y >= heights.x,
|
|
"Player gait must retain configured torso height"
|
|
)
|
|
var npc := NPC.instantiate() as Node3D
|
|
npc.appearance = profile
|
|
root.add_child(npc)
|
|
npc.set_physics_process(false)
|
|
npc.set_process(false)
|
|
npc.call("_apply_personal_details")
|
|
var cue_height: float = npc.get_node("TaskGlyphRoot").position.y
|
|
npc.call("_apply_personal_details")
|
|
_check(
|
|
is_equal_approx(cue_height, npc.get_node("TaskGlyphRoot").position.y),
|
|
"Reapplying appearance must not accumulate cue lift"
|
|
)
|
|
_check(
|
|
cue_height > float(npc.get_meta("character_top")) + 0.2,
|
|
"NPC cues must clear the tallest supported ears"
|
|
)
|
|
npc.velocity = Vector3(2, 0, 0)
|
|
for frame in 30:
|
|
npc.call("_process", 1.0 / 60.0)
|
|
_check(
|
|
npc.get_node("Head").position.y >= heights.y, "NPC gait must retain configured head height"
|
|
)
|
|
parent.free()
|
|
npc.free()
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
checks += 1
|
|
if not condition:
|
|
failures.append(message)
|