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:
parent
d910d570a3
commit
4db0b2f16c
2 changed files with 580 additions and 34 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue