Files
sanspie 515f4ca1b3 Stage 11a: animation (D333–D340, D344, D354–D357)
Clips (.aeanim) and controllers (.aectrl) with importers, AnimationPlayer and Animator with crossfades, root motion and events on fixed steps, rewind support. Editor: Animation panel (dope sheet, curves, record, preview, trajectory), Animator panel, sprite sheet slicer and flipbooks, Programming workspace, export panel redesign. Motion 2D ledge climb. 11c: the component and Create palette.
2026-10-25 15:31:22 +03:00

411 lines
10 KiB
Go

package animation_test
import (
"testing"
"aego/anim"
"aego/app/assembly"
"aego/app/assembly/builtin"
"aego/asset"
"aego/core/ids"
"aego/core/mathx"
"aego/engine"
"aego/engine/recorder"
"aego/format/aetext"
"aego/modules/animation"
"aego/modules/render2d"
"aego/modules/tween"
"aego/render/mesh"
"aego/render/texture"
"aego/scene"
"aego/script/bind"
)
const dt = float32(1) / 64
var (
runID = ids.AssetID{Hi: 0xa11, Lo: 1}
flashID = ids.AssetID{Hi: 0xa11, Lo: 2}
spriteH = uint64(0xa0e1000000000000)
)
const runClip = `anim version=1 length=0.5 loop=loop {
frames "" fps=8 {
sprites=[asset("a0e10000000000000000000000000001"), asset("a0e10000000000000000000000000002"), asset("a0e10000000000000000000000000003"), asset("a0e10000000000000000000000000004")]
}
track "" prop="SpriteRenderer.tint" kind=color {
key t=0 v=[1, 1, 1, 1] interp=linear {}
key t=0.5 v=[1, 0, 0, 1] {}
}
track "" prop="transform.scale.y" kind=float32 {
key t=0 v=1 interp=linear {}
key t=0.5 v=1.5 {}
}
event t=0.25 name="Hit" payload=2 {}
}
`
const flashClip = `anim version=1 length=0.25 {
track "" prop="SpriteRenderer.tint" kind=color onEnd=restore {
key t=0 v=[1, 0, 0, 1] {}
key t=0.25 v=[1, 0, 0, 1] {}
}
frames "" fps=4 {
sprites=[asset("a0e10000000000000000000000000004")]
}
}
`
type decKey struct {
id ids.AssetID
key string
}
type assets struct {
clips map[ids.AssetID][]byte
ctrls map[ids.AssetID][]byte
cache map[decKey]any
gen uint32
}
func (a *assets) Texture(ids.AssetID) (texture.Region, bool) { return texture.Region{}, false }
func (a *assets) Mesh(ids.AssetID) (*mesh.Mesh, bool) { return nil, false }
func (a *assets) Scene(ids.AssetID) ([]byte, bool) { return nil, false }
func (a *assets) Prefab(ids.AssetID) ([]byte, bool) { return nil, false }
func (a *assets) Generation() uint32 { return a.gen }
func (a *assets) Subscribe(func(asset.Reloaded)) func() { return func() {} }
func (a *assets) Anim(id ids.AssetID) ([]byte, bool) {
b, ok := a.clips[id]
return b, ok
}
func (a *assets) Controller(id ids.AssetID) ([]byte, bool) {
b, ok := a.ctrls[id]
return b, ok
}
func (a *assets) Decoded(id ids.AssetID, key string, build func() (any, error)) (any, error) {
k := decKey{id: id, key: key}
if v, ok := a.cache[k]; ok {
return v, nil
}
v, err := build()
if err == nil {
a.cache[k] = v
}
return v, err
}
func (a *assets) putCtrl(t testing.TB, id ids.AssetID, src string) {
t.Helper()
doc, err := aetext.Parse([]byte(src), "test.aectrl")
if err != nil {
t.Fatal(err)
}
g, probs, err := anim.DecodeGraph(doc)
if err != nil || len(probs) > 0 || len(g.Check(nil)) > 0 {
t.Fatalf("controller: %v %+v %+v", err, probs, g.Check(nil))
}
a.ctrls[id] = anim.MarshalGraph(g)
clear(a.cache)
a.gen++
}
func (a *assets) put(t testing.TB, id ids.AssetID, src string) {
t.Helper()
doc, err := aetext.Parse([]byte(src), "clip.aeanim")
if err != nil {
t.Fatal(err)
}
c, err := anim.Decode(doc)
if err != nil {
t.Fatal(err)
}
if ps := c.Check(nil); len(ps) > 0 {
t.Fatalf("clip problems %+v", ps)
}
a.clips[id] = anim.Marshal(c)
clear(a.cache)
a.gen++
}
type rig struct {
s *scene.Scene
w *engine.World
res *assets
svc *animation.Service
tick uint64
}
func newRig(t testing.TB) *rig {
t.Helper()
types, err := assembly.NewTypes()
if err != nil {
t.Fatal(err)
}
if _, err := builtin.Default().Load(types, bind.New(), assembly.Options{}); err != nil {
t.Fatal(err)
}
res := &assets{clips: map[ids.AssetID][]byte{}, ctrls: map[ids.AssetID][]byte{}, cache: map[decKey]any{}, gen: 1}
s := scene.New(types, res, scene.Options{})
w := engine.NewWorld("anim")
w.Recorder = recorder.New(1024)
mod := animation.New()
mod.SetEvents([]animation.EventDesc{{Name: "Hit", Payload: animation.PayloadFloat}, {Name: "Step"}})
return &rig{s: s, w: w, res: res, svc: animation.NewService(s, w, mod)}
}
func (r *rig) node(clip ids.AssetID) scene.NodeHandle {
h := r.s.Create(r.s.Root(), "runner")
*scene.Add[render2d.SpriteRenderer](r.s, h) = render2d.DefaultSpriteRenderer()
p := animation.DefaultPlayer()
p.Clip = animation.ClipRef{ID: clip}
*scene.Add[animation.AnimationPlayer](r.s, h) = p
return h
}
func (r *rig) step() {
r.tick++
r.w.Recorder.Stamp(uint64(r.w.ID()), r.tick, r.tick)
r.s.Flush()
r.s.UpdateTransforms()
r.svc.Fixed(dt)
}
func (r *rig) present(alpha float32) {
r.svc.Present(alpha, dt, dt)
}
func (r *rig) steps(n int) {
for range n {
r.step()
r.present(0)
}
}
func (r *rig) sprite(h scene.NodeHandle) ids.AssetID {
sr, _ := scene.Get[render2d.SpriteRenderer](r.s, h)
return sr.Sprite.ID
}
func (r *rig) tint(h scene.NodeHandle) mathx.Color {
sr, _ := scene.Get[render2d.SpriteRenderer](r.s, h)
return sr.Tint
}
func (r *rig) ticks(name string) []uint64 {
var out []uint64
for _, rec := range r.w.Recorder.Snapshot(nil) {
if rec.Kind == recorder.Animation && rec.Name == name && rec.Detail == "" {
out = append(out, rec.Tick)
}
}
return out
}
func near(a, b float32) bool {
d := a - b
return d < 1e-4 && d > -1e-4
}
func TestFlipbookTintAndPose(t *testing.T) {
r := newRig(t)
r.res.put(t, runID, runClip)
h := r.node(runID)
r.steps(1)
if id := r.sprite(h); id.Hi != spriteH || id.Lo != 1 {
t.Fatalf("first frame %v", id)
}
r.steps(7)
if id := r.sprite(h); id.Lo != 2 {
t.Fatalf("frame at 0.125: %v", id)
}
r.steps(8)
if c := r.tint(h); !near(c.R, 1) || !near(c.G, 0.5) || !near(c.B, 0.5) {
t.Fatalf("tint at 0.25: %+v", c)
}
if sy := r.s.Local(h).Scale.Y; sy != 1 {
t.Fatalf("presentation track wrote the simulation scale: %v", sy)
}
p, ok := r.s.PresentationLocal(h)
if !ok || !near(p.Scale.Y, 1.25) {
t.Fatalf("presentation scale %v %v", ok, p.Scale)
}
}
func TestGameplayEventOncePerCycle(t *testing.T) {
r := newRig(t)
r.res.put(t, runID, runClip)
r.node(runID)
r.steps(64)
if got := r.ticks("Hit"); len(got) != 2 || got[0] != 16 || got[1] != 48 {
t.Fatalf("Hit on ticks %v, want [16 48]", got)
}
}
func TestPresentDoesNotChangeSimulation(t *testing.T) {
a, b := newRig(t), newRig(t)
a.res.put(t, runID, runClip)
b.res.put(t, runID, runClip)
ha, hb := a.node(runID), b.node(runID)
for range 96 {
a.step()
a.present(0)
b.step()
for k := range 3 {
b.present(float32(k) / 3)
}
}
ta, tb := a.ticks("Hit"), b.ticks("Hit")
if len(ta) != len(tb) {
t.Fatalf("events %v vs %v", ta, tb)
}
for i := range ta {
if ta[i] != tb[i] {
t.Fatalf("events %v vs %v", ta, tb)
}
}
if a.sprite(ha) != b.sprite(hb) || a.tint(ha) != b.tint(hb) || a.s.Local(ha) != b.s.Local(hb) {
t.Fatal("presentation rate changed the simulation state")
}
}
func TestRestoreOnEnd(t *testing.T) {
r := newRig(t)
r.res.put(t, flashID, flashClip)
h := r.node(flashID)
sr, _ := scene.Get[render2d.SpriteRenderer](r.s, h)
grey := mathx.Color{R: 0.5, G: 0.5, B: 0.5, A: 1}
sr.Tint = grey
r.steps(4)
if c := r.tint(h); c.G != 0 {
t.Fatalf("flash tint %+v", c)
}
r.steps(28)
p, _ := scene.Get[animation.AnimationPlayer](r.s, h)
if p.Playing || r.tint(h) != grey {
t.Fatalf("playing %v tint %+v", p.Playing, r.tint(h))
}
if r.sprite(h).Lo != 4 {
t.Fatal("a hold track must keep its last value")
}
if n := len(r.ticks("Finished")); n != 1 {
t.Fatalf("Finished recorded %d times", n)
}
}
func TestTweenStopsOnAnimatedProperty(t *testing.T) {
r := newRig(t)
r.res.put(t, runID, runClip)
h := r.node(runID)
tw := tween.NewService(r.s, r.w)
r.steps(1)
hd, err := tw.ToColor(h, "SpriteRenderer.Tint", mathx.Color{G: 1, A: 1}, 1, tween.Options{})
if err != nil {
t.Fatal(err)
}
tw.Step(dt, false)
if !tw.Done(hd) {
t.Fatal("a tween on a property the animation drives must stop")
}
}
func hundred(t testing.TB) *rig {
r := newRig(t)
r.res.put(t, runID, runClip)
for range 100 {
r.node(runID)
}
r.steps(70)
return r
}
func TestHundredPlayersAllocateNothing(t *testing.T) {
r := hundred(t)
if n := testing.AllocsPerRun(50, func() {
r.step()
r.present(0.5)
}); n != 0 {
t.Fatalf("%v allocations per tick", n)
}
}
func BenchmarkPlayers100(b *testing.B) {
r := hundred(b)
b.ReportAllocs()
for b.Loop() {
r.step()
r.present(0.5)
}
}
func TestBuiltinClips(t *testing.T) {
for _, id := range []ids.AssetID{asset.BuiltinRunClip, asset.BuiltinBobClip} {
src, ok := asset.BuiltinAnimText(id)
if !ok {
t.Fatalf("%v has no text", id)
}
doc, err := aetext.Parse([]byte(src), "builtin.aeanim")
if err != nil {
t.Fatal(err)
}
c, err := anim.Decode(doc)
if err != nil {
t.Fatal(err)
}
if ps := c.Check(nil); len(ps) > 0 {
t.Fatalf("%v: %+v", id, ps)
}
if id == asset.BuiltinRunClip {
keys := c.Tracks[0].Keys
if len(keys) != 8 || keys[3].V.A != asset.BuiltinRunner(3) {
t.Fatalf("run frames %+v", keys)
}
}
}
a, _ := asset.BuiltinTexture(asset.BuiltinRunner(0))
b, _ := asset.BuiltinTexture(asset.BuiltinRunner(2))
if a == nil || b == nil || a.Width != 16 || string(a.Pixels) == string(b.Pixels) {
t.Fatal("runner frames must be 16x16 and differ")
}
src, ok := asset.BuiltinCtrlText(asset.BuiltinPlatformerCtrl)
if !ok {
t.Fatal("no built-in platformer controller")
}
doc, err := aetext.Parse([]byte(src), "builtin.aectrl")
if err != nil {
t.Fatal(err)
}
g, probs, err := anim.DecodeGraph(doc)
if err != nil || len(probs) > 0 || len(g.Check(nil)) > 0 {
t.Fatalf("platformer controller: %v %+v %+v", err, probs, g.Check(nil))
}
if g.StateIndex("Run") < 0 || g.States[g.StateIndex("Run")].Clip != asset.BuiltinRunClip {
t.Fatal("Run plays the built-in run clip")
}
mod := animation.New()
if id, ok := mod.EventID(animation.EventHit); !ok || mod.EventName(id) != animation.EventHit {
t.Fatal("Hit must be declared without a project section")
}
}
func TestDisabledPlayerKeepsItsBindings(t *testing.T) {
r := newRig(t)
r.res.put(t, runID, runClip)
h := r.node(runID)
r.steps(8)
if r.sprite(h).Lo != 2 {
t.Fatalf("frame at 0.125: %v", r.sprite(h))
}
r.s.SetEnabled(h, false)
r.steps(2)
r.s.Create(r.s.Root(), "other")
r.steps(2)
r.s.SetEnabled(h, true)
r.steps(8)
if id := r.sprite(h); id.Lo != 3 {
t.Fatalf("after a rebind while disabled the player must go on writing, frame %v", id)
}
}