114 lines
3.3 KiB
GDScript
114 lines
3.3 KiB
GDScript
extends SceneTree
|
|
|
|
## Regression gate for the authored emote catalog.
|
|
##
|
|
## The project used to synthesize five routines by adding sine-wave bone
|
|
## offsets to one dance clip. That made every emote look like programmer
|
|
## animation. The new contract is deliberately simpler: every wheel entry must
|
|
## select its own imported clip, the clip must move, and no DanceModifier may
|
|
## exist in the live character.
|
|
|
|
const SETTLE_FRAMES := 24
|
|
const SAMPLE_FRAMES := 36
|
|
const MIN_FRAME_MOTION := 0.01
|
|
|
|
var _failures := 0
|
|
|
|
|
|
func _init() -> void:
|
|
await process_frame
|
|
|
|
var manager = root.get_node_or_null("SkinManager")
|
|
var skin = manager.get_skin("taila") if manager else null
|
|
if skin == null or skin.model_path == "":
|
|
_expect(false, "Taila is available for the authored-emote test")
|
|
_done()
|
|
return
|
|
|
|
var model := SkinnedPlayerModel.new()
|
|
root.add_child(model)
|
|
model.skin_id = "taila"
|
|
model.load_model(skin.model_path)
|
|
for _i in 60:
|
|
model.update_state("idle", 0.0, false)
|
|
await process_frame
|
|
|
|
_expect(model.loaded and model.skeleton != null,
|
|
"the authored character and skeleton load")
|
|
_expect(DanceRoutines.count() == 5,
|
|
"the wheel exposes five authored emotes (%d)" % DanceRoutines.count())
|
|
_expect(model.find_children("*", "DanceModifier", true, false).is_empty(),
|
|
"no procedural DanceModifier is present")
|
|
|
|
var resolved_clips: Array[String] = []
|
|
for index in DanceRoutines.count():
|
|
var canonical := DanceRoutines.clip_of(index)
|
|
var resolved: String = String(model._resolved_clips.get(canonical, ""))
|
|
_expect(resolved != "",
|
|
"%s resolves to an imported clip" % DanceRoutines.name_of(index))
|
|
_expect(not resolved_clips.has(resolved),
|
|
"%s uses its own authored clip" % DanceRoutines.name_of(index))
|
|
resolved_clips.append(resolved)
|
|
|
|
model.set_dancing(true, index)
|
|
for _i in SETTLE_FRAMES:
|
|
model.update_state("idle", 0.0, false)
|
|
await process_frame
|
|
|
|
_expect(model._current_clip == resolved,
|
|
"%s selects %s" % [DanceRoutines.name_of(index), canonical])
|
|
|
|
var previous := _pose(model.skeleton)
|
|
var greatest_motion := 0.0
|
|
for _i in SAMPLE_FRAMES:
|
|
model.update_state("idle", 0.0, false)
|
|
await process_frame
|
|
var current := _pose(model.skeleton)
|
|
greatest_motion = maxf(greatest_motion, _pose_distance(previous, current))
|
|
previous = current
|
|
_expect(greatest_motion >= MIN_FRAME_MOTION,
|
|
"%s visibly animates the skeleton (%.4f rad/frame)"
|
|
% [DanceRoutines.name_of(index), greatest_motion])
|
|
|
|
model.set_dancing(false)
|
|
for _i in 12:
|
|
model.update_state("idle", 0.0, false)
|
|
await process_frame
|
|
|
|
model.queue_free()
|
|
_done()
|
|
|
|
|
|
func _pose(skeleton: Skeleton3D) -> Array[Quaternion]:
|
|
var result: Array[Quaternion] = []
|
|
result.resize(skeleton.get_bone_count())
|
|
for bone in skeleton.get_bone_count():
|
|
result[bone] = skeleton.get_bone_pose_rotation(bone)
|
|
return result
|
|
|
|
|
|
func _pose_distance(a: Array, b: Array) -> float:
|
|
var count := mini(a.size(), b.size())
|
|
if count == 0:
|
|
return 0.0
|
|
var greatest := 0.0
|
|
for index in count:
|
|
var qa: Quaternion = a[index]
|
|
var qb: Quaternion = b[index]
|
|
greatest = maxf(greatest, qa.angle_to(qb))
|
|
return greatest
|
|
|
|
|
|
func _expect(ok: bool, description: String) -> void:
|
|
if ok:
|
|
print(" OK: ", description)
|
|
else:
|
|
printerr(" FAIL: ", description)
|
|
_failures += 1
|
|
|
|
|
|
func _done() -> void:
|
|
print("\n=== AUTHORED EMOTE SUMMARY ===")
|
|
print("Failures: %d" % _failures)
|
|
quit(1 if _failures > 0 else 0)
|