feat(characters): add modular woodland traveler kit

This commit is contained in:
Rijad Zuzo
2026-09-05 22:54:54 +02:00
parent 70ee6feddf
commit 4ff8858b19
39 changed files with 2037 additions and 50 deletions
+196
View File
@@ -0,0 +1,196 @@
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)