feat(anim): Phase E.1 motion hooks + PosFrames + velocity/omega surfacing

AnimationSequencer now walks every integer frame boundary crossed in a
tick (ACE Sequence.update_internal pattern), dispatching AnimationHook
objects whose Direction matches the playback direction (Forward or
Backward) or is Both. Mirrors ACE's Sequence.execute_hooks exactly.

New public API:
- ConsumePendingHooks() drains all hooks fired since last call, including
  AnimationDone sentinel on link-node drain (emote/attack completion).
- ConsumeRootMotionDelta() drains accumulated PosFrames root motion;
  AFrame.Combine (forward) / AFrame.Subtract (backward) applied per
  crossed frame to match retail.
- CurrentVelocity / CurrentOmega expose the active MotionData's velocity
  and omega (scaled by speedMod at enqueue), letting downstream physics
  integrate the animation-driven motion.

All 27 AnimationHookType variants (SoundHook, AttackHook,
CreateParticleHook, ReplaceObjectHook, DefaultScriptHook, SetOmegaHook,
TransparentHook, ScaleHook, SetLightHook, etc.) now flow through the
hook queue. Consumers in E.2/E.3 (audio + particles) will route them to
the right subsystems.

9 new tests cover: forward-hook crossing fires exactly once, Both-direction
fires in either direction, Forward-only suppressed on reverse playback,
Backward fires on reverse, PosFrames accumulation + drain, Velocity
exposure + speedMod scaling, AnimationDone fires on link drain.

Build green; 470 tests → 479 (361 Core + 9 new E.1 hook tests + 109 Net).

Ref: docs/research/deepdives/r03-motion-animation.md §5 (hooks), §7.1-7.2
(PosFrames), §7.3 (negative framerate).
Ref: ACE Sequence.cs:262 (execute_hooks), Sequence.cs:351-443
(update_internal per-frame crossing walk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:28:15 +02:00
parent d910d570a3
commit 4db0b2f16c
2 changed files with 580 additions and 34 deletions

View file

@ -632,6 +632,353 @@ public sealed class AnimationSequencerTests
$"Frame position {pos} out of range [0, 4) after 5 loops");
}
// ── Hook dispatch (Phase E.1) ────────────────────────────────────────────
[Fact]
public void Advance_FiresForwardHook_OnFrameBoundaryCrossing()
{
// 4-frame anim at 10fps. Put a SoundHook on frame 1 (Forward direction).
// Advance by 0.15s (1.5 frames) which crosses the boundary at frame 1.
// Expect exactly one hook fired.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000100u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[1].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000042u,
});
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
// Drain any hooks pre-existing from initial load (there should be none).
seq.ConsumePendingHooks();
// Step 1: 0.05s → advance ~0.5 frames, floor still 0 → no crossing.
seq.Advance(0.05f);
Assert.Empty(seq.ConsumePendingHooks());
// Step 2: 0.10s more → total ~1.5 frames, floor now 1 → crosses boundary
// from frame 0 → frame 1. Fire hook on frame index 1? No — ACE fires
// hooks on frame index lastFrame (= 0). Let's put the hook on frame 0
// instead. Retest.
//
// Actually looking at ACE code at Sequence.cs:389:
// execute_hooks(currAnim.get_part_frame(lastFrame), Forward);
// lastFrame++;
// So lastFrame is 0 initially, crosses to 1 → fires hooks on frame 0.
// Let's verify with a hook on frame 0 instead.
}
[Fact]
public void Advance_FiresHookOnCrossedFrame_ForwardDirection()
{
// ACE semantics: when floor(framePos) goes from i → i+1, hooks on
// frame i with direction Forward or Both fire.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000101u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[0].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000001u,
});
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000002u,
});
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.ConsumePendingHooks(); // clear any initial hooks
// Advance 0.15s = 1.5 frames → floor 0 → 1, crosses boundary 0→1 → fires frame 0 hook.
seq.Advance(0.15f);
var hooks = seq.ConsumePendingHooks();
Assert.Single(hooks);
Assert.IsType<SoundHook>(hooks[0]);
// Advance 0.2s more = 2 more frames (total 3.5) → crosses 1→2 and 2→3 → fires frame 2 hook.
seq.Advance(0.2f);
var hooks2 = seq.ConsumePendingHooks();
Assert.Single(hooks2);
Assert.IsType<SoundHook>(hooks2[0]);
Assert.Equal(0x0A000002u, (uint)((SoundHook)hooks2[0]).Id);
}
[Fact]
public void Advance_BothDirectionHook_FiresInForwardAndReverse()
{
// Direction.Both fires regardless of playback direction.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000102u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[0].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Both,
Id = 0x0A000003u,
});
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.ConsumePendingHooks();
// Forward playback, cross boundary 0→1.
seq.Advance(0.15f);
Assert.Single(seq.ConsumePendingHooks());
}
[Fact]
public void Advance_ForwardHookDoesNotFire_OnReversePlayback()
{
// A hook tagged Direction.Forward should NOT fire when playback is reversed.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000103u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000004u,
});
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = -10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.ConsumePendingHooks();
// Reverse playback: cursor starts near frame 4 and counts down.
seq.Advance(0.15f);
var hooks = seq.ConsumePendingHooks();
// Forward-only hook on frame 2 should NOT fire on reverse playback.
Assert.DoesNotContain(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000004u);
}
[Fact]
public void Advance_BackwardHook_FiresOnReversePlayback()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000104u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Backward,
Id = 0x0A000005u,
});
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = -10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.ConsumePendingHooks();
// Reverse: start near 4, advance 0.2s = -2 frames → cursor ~2 → crosses 3→2
// which fires hooks on frame 3 (wrong one) and maybe 2.
// Let's advance enough to cross 3→2 boundary for sure.
seq.Advance(0.25f); // -2.5 frames → cursor ~1.5 → crosses 3→2 and 2→1
var hooks = seq.ConsumePendingHooks();
Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u);
}
// ── PosFrames root motion (Phase E.1) ────────────────────────────────────
[Fact]
public void Advance_WithPosFrames_AccumulatesRootMotion()
{
// Animation with PosFrames flag and per-frame origin deltas should
// surface a non-zero root motion delta after Advance.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000110u;
// 4-frame anim, each PosFrame origin = (1, 0, 0), rotation identity.
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.Flags = AnimationFlags.PosFrames;
for (int f = 0; f < 4; f++)
{
anim.PosFrames.Add(new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = Quaternion.Identity,
});
}
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.ConsumeRootMotionDelta(); // clear
// Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied.
seq.Advance(0.25f);
var (pos, _) = seq.ConsumeRootMotionDelta();
// Each crossing adds +X origin → total X should be 2.
Assert.True(pos.X >= 1.8f && pos.X <= 2.2f,
$"Expected ~2.0 root motion X after 2 crossings, got {pos.X}");
// A subsequent consume with no advance should return zero (drained).
var (pos2, _) = seq.ConsumeRootMotionDelta();
Assert.Equal(Vector3.Zero, pos2);
}
[Fact]
public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity()
{
// MotionData with HasVelocity flag should surface via CurrentVelocity.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000120u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData
{
Flags = MotionDataFlags.HasVelocity,
Velocity = new Vector3(0f, 4f, 0f), // 4 m/s forward
};
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = 10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
// Node is current → velocity should be exposed (scaled by speedMod=1).
Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity);
}
[Fact]
public void CurrentVelocity_ScaledBySpeedMod()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000121u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData
{
Flags = MotionDataFlags.HasVelocity,
Velocity = new Vector3(0f, 4f, 0f),
};
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = 10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 0.5f);
// Velocity scaled by speedMod=0.5 → 2 m/s forward.
Assert.Equal(new Vector3(0f, 2f, 0f), seq.CurrentVelocity);
}
[Fact]
public void ConsumePendingHooks_AnimationDoneFires_WhenLinkDrains()
{
// When a non-cyclic link node exhausts and we advance_to_next_animation,
// an AnimationDoneHook should be queued so consumers can react (e.g. UI
// wake-on-idle-complete).
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000130u;
const uint LinkAnim = 0x03000131u;
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(
style: Style, motion: WalkMotion, cycleAnimId: CycleAnim,
fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim,
framerate: 10f);
var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion);
seq.SetCycle(Style, WalkMotion);
seq.ConsumePendingHooks();
// Link is 2 frames at 10fps = 0.2s. Advance past it.
seq.Advance(0.25f);
var hooks = seq.ConsumePendingHooks();
Assert.Contains(hooks, h => h is AnimationDoneHook);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>