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
|
|
@ -41,6 +41,8 @@ public sealed class DatCollectionLoader : IAnimationLoader
|
|||
// FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent)
|
||||
// FUN_005360d0 — quaternion slerp with dot-product sign-flip
|
||||
// MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping
|
||||
// Sequence.cs:262-270 (ACE) — execute_hooks (Both or matching direction fires)
|
||||
// Sequence.cs:351-443 (ACE) — update_internal with per-frame hook dispatch
|
||||
//
|
||||
// DatReaderWriter types used:
|
||||
// MotionTable.Links : Dictionary<int, MotionCommandData>
|
||||
|
|
@ -48,9 +50,14 @@ public sealed class DatCollectionLoader : IAnimationLoader
|
|||
// MotionCommandData.MotionData : Dictionary<int, MotionData>
|
||||
// key = target motion (int cast of MotionCommand)
|
||||
// MotionData.Anims : List<AnimData>
|
||||
// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics)
|
||||
// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02)
|
||||
// AnimData.AnimId : QualifiedDataId<Animation>
|
||||
// Animation.PartFrames : List<AnimationFrame>
|
||||
// Animation.PosFrames : List<Frame> (root motion, present if Flags & PosFrames)
|
||||
// Animation.Flags : AnimationFlags (PosFrames = 0x01)
|
||||
// AnimationFrame.Frames : List<Frame>
|
||||
// AnimationFrame.Hooks : List<AnimationHook>
|
||||
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -74,10 +81,10 @@ public readonly struct PartTransform
|
|||
/// One entry in the animation queue (link transition or looping cycle).
|
||||
///
|
||||
/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18.
|
||||
/// When speedScale < 0, startFrame and endFrame are swapped at construction
|
||||
/// time (FUN_005267E0 / multiply_framerate) so the advance loop always has:
|
||||
/// forward: startFrame ≤ endFrame (framePosition counts up)
|
||||
/// reverse: startFrame ≥ endFrame (framePosition counts down)
|
||||
/// Carries the parent <see cref="DatReaderWriter.Types.MotionData"/>'s
|
||||
/// Velocity and Omega fields so per-tick physics deltas can be surfaced
|
||||
/// while this node is current (ACE Sequence.Velocity / Omega equivalent
|
||||
/// for the single-active-MotionData case).
|
||||
/// </summary>
|
||||
internal sealed class AnimNode
|
||||
{
|
||||
|
|
@ -86,14 +93,32 @@ internal sealed class AnimNode
|
|||
public int StartFrame; // inclusive start frame (post-swap for negative speed)
|
||||
public int EndFrame; // inclusive end frame (post-swap for negative speed)
|
||||
public bool IsLooping; // true only for the tail cyclic node
|
||||
public bool HasPosFrames; // mirror of Anim.Flags & AnimationFlags.PosFrames
|
||||
|
||||
public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
|
||||
// Carried from the source MotionData (one MotionData may produce N nodes;
|
||||
// each carries the same vel/omega, and when the node becomes current the
|
||||
// sequencer surfaces these values).
|
||||
public Vector3 Velocity; // meters/sec, world-space
|
||||
public Vector3 Omega; // radians/sec per axis
|
||||
|
||||
public AnimNode(
|
||||
Animation anim,
|
||||
double framerate,
|
||||
int startFrame,
|
||||
int endFrame,
|
||||
bool isLooping,
|
||||
bool hasPosFrames,
|
||||
Vector3 velocity,
|
||||
Vector3 omega)
|
||||
{
|
||||
Anim = anim;
|
||||
Framerate = framerate;
|
||||
StartFrame = startFrame;
|
||||
EndFrame = endFrame;
|
||||
IsLooping = isLooping;
|
||||
HasPosFrames = hasPosFrames;
|
||||
Velocity = velocity;
|
||||
Omega = omega;
|
||||
}
|
||||
|
||||
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
|
||||
|
|
@ -139,13 +164,17 @@ internal sealed class AnimNode
|
|||
/// 64-bit field at Sequence+0x30.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Negative framerate means reverse playback; startFrame/endFrame are
|
||||
/// swapped at node construction time (FUN_005267E0).
|
||||
/// Negative framerate means reverse playback.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// When a node's frames are exhausted, <c>advance_to_next_animation</c>
|
||||
/// wraps to <c>_firstCyclic</c> (the looping tail of the queue).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Every integer frame boundary crossed in a tick fires the hooks at
|
||||
/// that frame whose <see cref="AnimationHookDir"/> matches the playback
|
||||
/// direction (or <c>Both</c>). Mirrors ACE Sequence.execute_hooks.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
|
|
@ -156,7 +185,8 @@ internal sealed class AnimNode
|
|||
/// seq.SetCycle(style, motion, speedMod);
|
||||
/// // each frame:
|
||||
/// var transforms = seq.Advance(dt);
|
||||
/// // rebuild MeshRefs from transforms
|
||||
/// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage
|
||||
/// var root = seq.ConsumeRootMotionDelta(); // add to AFrame if desired
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
|
|
@ -170,6 +200,23 @@ public sealed class AnimationSequencer
|
|||
/// <summary>Current cyclic motion command.</summary>
|
||||
public uint CurrentMotion { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// World-space per-second velocity from the currently active
|
||||
/// <see cref="MotionData"/> (Sequence.Velocity in retail). Zero when no
|
||||
/// motion data carries a velocity. Scaled by <c>speedMod</c> at enqueue
|
||||
/// time.
|
||||
/// </summary>
|
||||
public Vector3 CurrentVelocity =>
|
||||
_currNode?.Value.Velocity ?? Vector3.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Radians-per-second omega (axis-angle integration rate) from the
|
||||
/// currently active <see cref="MotionData"/>. Scaled by <c>speedMod</c>
|
||||
/// at enqueue time.
|
||||
/// </summary>
|
||||
public Vector3 CurrentOmega =>
|
||||
_currNode?.Value.Omega ?? Vector3.Zero;
|
||||
|
||||
// Diagnostics
|
||||
public int QueueCount => _queue.Count;
|
||||
public bool HasCurrentNode => _currNode != null;
|
||||
|
|
@ -189,6 +236,16 @@ public sealed class AnimationSequencer
|
|||
// Named _framePosition to distinguish it from the old float _frameNum.
|
||||
private double _framePosition;
|
||||
|
||||
// Hooks pending dispatch. Accumulated during Advance; drained via
|
||||
// ConsumePendingHooks.
|
||||
private readonly List<AnimationHook> _pendingHooks = new();
|
||||
|
||||
// Root motion (PosFrames) delta accumulated during Advance. Drained via
|
||||
// ConsumeRootMotionDelta. Matches the retail client's AFrame.Combine /
|
||||
// AFrame.Subtract chain in Sequence.update_internal.
|
||||
private Vector3 _rootMotionPos;
|
||||
private Quaternion _rootMotionRot = Quaternion.Identity;
|
||||
|
||||
private const double FrameEpsilon = 1e-5;
|
||||
private const double RateEpsilon = 1e-6;
|
||||
|
||||
|
|
@ -321,15 +378,21 @@ public sealed class AnimationSequencer
|
|||
/// per-part transforms for the current blended keyframe.
|
||||
///
|
||||
/// <para>
|
||||
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0) in a
|
||||
/// simplified form: no frame-trigger events (PhysicsObject not modelled
|
||||
/// here), but correct boundary detection, remainder propagation, and
|
||||
/// advance_to_next_animation wrapping.
|
||||
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0 / ACE
|
||||
/// Sequence.cs:351-443): walks every integer frame boundary crossed in
|
||||
/// this tick, calls <c>execute_hooks</c> for each with the playback
|
||||
/// direction, and accumulates <see cref="Animation.PosFrames"/> root
|
||||
/// motion into the pending delta. Hooks fire only once per crossing
|
||||
/// regardless of framerate scaling.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The slerp algorithm mirrors the decompiled retail client's
|
||||
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
|
||||
/// Crossing semantics (forward): as <c>floor(framePos)</c> increments
|
||||
/// from <c>i</c> to <c>i+1</c>, hooks attached to frame <c>i</c> with
|
||||
/// direction <c>Forward</c> or <c>Both</c> fire. Reverse: as
|
||||
/// <c>floor(framePos)</c> decrements from <c>i</c> to <c>i-1</c>,
|
||||
/// hooks with direction <c>Backward</c> or <c>Both</c> fire on frame
|
||||
/// <c>i</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="dt">Elapsed time in seconds since the last call.</param>
|
||||
|
|
@ -344,11 +407,12 @@ public sealed class AnimationSequencer
|
|||
if (_currNode == null || dt <= 0f)
|
||||
return BuildIdentityFrame(partCount);
|
||||
|
||||
// ── update_internal (FUN_005261D0) ───────────────────────────────
|
||||
// ── update_internal (FUN_005261D0 / ACE Sequence.update_internal) ─
|
||||
// Loop because a large dt can exhaust multiple nodes sequentially.
|
||||
double timeRemaining = (double)dt;
|
||||
int safety = 64; // cap in case of a degenerate motion table
|
||||
|
||||
while (timeRemaining > 0.0 && _currNode != null)
|
||||
while (timeRemaining > 0.0 && _currNode != null && safety-- > 0)
|
||||
{
|
||||
var curr = _currNode.Value;
|
||||
double rate = curr.Framerate; // signed (negative = reverse)
|
||||
|
|
@ -357,6 +421,9 @@ public sealed class AnimationSequencer
|
|||
if (Math.Abs(delta) < RateEpsilon)
|
||||
break; // rate ≈ 0 — nothing to do
|
||||
|
||||
// lastFrame = floor(_framePosition) BEFORE advance (ACE pattern).
|
||||
int lastFrame = (int)Math.Floor(_framePosition);
|
||||
|
||||
double newPos = _framePosition + delta;
|
||||
bool wrapped = false;
|
||||
double overflow = 0.0;
|
||||
|
|
@ -364,48 +431,65 @@ public sealed class AnimationSequencer
|
|||
if (delta > 0.0)
|
||||
{
|
||||
// ── FORWARD PLAYBACK ──────────────────────────────────────
|
||||
// End boundary = endFrame + 1. Pseudocode: floor(newPos) > maxFrame.
|
||||
double maxBoundary = (double)(curr.EndFrame + 1);
|
||||
if (newPos >= maxBoundary - FrameEpsilon)
|
||||
{
|
||||
// How much time spilled past the boundary?
|
||||
// Time spilled past the boundary.
|
||||
overflow = (newPos - maxBoundary) / rate;
|
||||
if (overflow < 0.0) overflow = 0.0;
|
||||
|
||||
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
|
||||
_framePosition = maxBoundary - FrameEpsilon;
|
||||
wrapped = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_framePosition = newPos;
|
||||
}
|
||||
|
||||
// Walk every integer frame boundary crossed: apply posFrame
|
||||
// delta and fire hooks with Forward direction.
|
||||
while ((int)Math.Floor(_framePosition) > lastFrame)
|
||||
{
|
||||
ApplyPosFrame(curr, lastFrame, reverse: false);
|
||||
ExecuteHooks(curr, lastFrame, AnimationHookDir.Forward);
|
||||
lastFrame++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── REVERSE PLAYBACK ─────────────────────────────────────
|
||||
// No swap: StartFrame is still the LOW value (e.g. 0).
|
||||
// GetStartFramePosition placed cursor at (EndFrame+1)-eps (near high).
|
||||
// The cursor counts DOWN toward StartFrame.
|
||||
double minBoundary = (double)curr.StartFrame;
|
||||
if (newPos <= minBoundary)
|
||||
{
|
||||
// How much time spilled past the lower boundary?
|
||||
overflow = (newPos - minBoundary) / rate;
|
||||
if (overflow < 0.0) overflow = 0.0;
|
||||
|
||||
_framePosition = minBoundary; // clamp to lower boundary
|
||||
_framePosition = minBoundary;
|
||||
wrapped = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_framePosition = newPos;
|
||||
}
|
||||
|
||||
// Walk every integer boundary crossed DOWN: subtract posFrame
|
||||
// delta and fire hooks with Backward direction.
|
||||
while ((int)Math.Floor(_framePosition) < lastFrame)
|
||||
{
|
||||
ApplyPosFrame(curr, lastFrame, reverse: true);
|
||||
ExecuteHooks(curr, lastFrame, AnimationHookDir.Backward);
|
||||
lastFrame--;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wrapped)
|
||||
break; // consumed all dt without hitting boundary — done
|
||||
break; // consumed all dt without hitting node boundary — done
|
||||
|
||||
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
|
||||
// Fire AnimationDone for any drained link node before wrap.
|
||||
if (_currNode != null && !_currNode.Value.IsLooping)
|
||||
_pendingHooks.Add(AnimationDoneSentinel);
|
||||
|
||||
AdvanceToNextAnimation();
|
||||
timeRemaining = overflow; // continue with leftover time
|
||||
}
|
||||
|
|
@ -413,6 +497,37 @@ public sealed class AnimationSequencer
|
|||
return BuildBlendedFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve and clear the list of hooks that fired since the last call.
|
||||
/// Empty when no frame boundary was crossed. Safe to call multiple
|
||||
/// times per frame; second and subsequent calls return an empty list.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
|
||||
{
|
||||
if (_pendingHooks.Count == 0)
|
||||
return Array.Empty<AnimationHook>();
|
||||
|
||||
var result = _pendingHooks.ToArray();
|
||||
_pendingHooks.Clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve and clear the root-motion displacement accumulated from
|
||||
/// <see cref="Animation.PosFrames"/> during the last <see cref="Advance"/>
|
||||
/// calls. Returns (Zero, Identity) when no PosFrames exist on the
|
||||
/// current animation. The caller should combine this with their AFrame
|
||||
/// (object placement) to propagate root motion — e.g. baked-in footsteps
|
||||
/// on a running animation.
|
||||
/// </summary>
|
||||
public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()
|
||||
{
|
||||
var result = (_rootMotionPos, _rootMotionRot);
|
||||
_rootMotionPos = Vector3.Zero;
|
||||
_rootMotionRot = Quaternion.Identity;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play a one-shot action/modifier motion (Jump, emote, attack, etc.)
|
||||
/// on top of the current cycle. The action frames are inserted in the
|
||||
|
|
@ -479,10 +594,15 @@ public sealed class AnimationSequencer
|
|||
|
||||
// Build AnimNodes from the action's AnimData list. All non-looping —
|
||||
// they drain once, then the queue falls through to _firstCyclic.
|
||||
Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity)
|
||||
? data.Velocity * speedMod : Vector3.Zero;
|
||||
Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega)
|
||||
? data.Omega * speedMod : Vector3.Zero;
|
||||
|
||||
var newNodes = new List<AnimNode>(data.Anims.Count);
|
||||
for (int i = 0; i < data.Anims.Count; i++)
|
||||
{
|
||||
var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false);
|
||||
var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false, vel, omg);
|
||||
if (node != null) newNodes.Add(node);
|
||||
}
|
||||
if (newNodes.Count == 0) return;
|
||||
|
|
@ -529,12 +649,20 @@ public sealed class AnimationSequencer
|
|||
_currNode = null;
|
||||
_firstCyclic = null;
|
||||
_framePosition = 0.0;
|
||||
_pendingHooks.Clear();
|
||||
_rootMotionPos = Vector3.Zero;
|
||||
_rootMotionRot = Quaternion.Identity;
|
||||
CurrentStyle = 0;
|
||||
CurrentMotion = 0;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// Sentinel hook fired when a non-cyclic link node drains naturally.
|
||||
// Mirrors ACE's PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook).
|
||||
private static readonly AnimationDoneHook AnimationDoneSentinel =
|
||||
new() { Direction = AnimationHookDir.Both };
|
||||
|
||||
/// <summary>
|
||||
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
|
||||
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
|
||||
|
|
@ -569,12 +697,13 @@ public sealed class AnimationSequencer
|
|||
/// <summary>
|
||||
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
|
||||
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
|
||||
///
|
||||
/// Implements <c>FUN_005267E0</c> (multiply_framerate): when
|
||||
/// <c>fr < 0</c>, startFrame and endFrame are swapped so the advance
|
||||
/// loop's boundary logic works uniformly for both directions.
|
||||
/// </summary>
|
||||
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
|
||||
private AnimNode? LoadAnimNode(
|
||||
AnimData ad,
|
||||
float speedMod,
|
||||
bool isLooping,
|
||||
Vector3 velocity,
|
||||
Vector3 omega)
|
||||
{
|
||||
uint animId = (uint)ad.AnimId;
|
||||
if (animId == 0) return null;
|
||||
|
|
@ -602,19 +731,37 @@ public sealed class AnimationSequencer
|
|||
// from high→low instead of being stuck in [0,1).
|
||||
if (low > high) high = low;
|
||||
|
||||
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
|
||||
bool hasPosFrames = anim.Flags.HasFlag(AnimationFlags.PosFrames)
|
||||
&& anim.PosFrames.Count >= numFrames;
|
||||
|
||||
return new AnimNode(
|
||||
anim,
|
||||
fr,
|
||||
startFrame: low,
|
||||
endFrame: high,
|
||||
isLooping,
|
||||
hasPosFrames,
|
||||
velocity,
|
||||
omega);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append all AnimData entries from <paramref name="motionData"/> to the
|
||||
/// queue. Each AnimData becomes one AnimNode.
|
||||
/// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the
|
||||
/// MotionData are applied to every resulting node so they remain active
|
||||
/// while the node is current.
|
||||
/// </summary>
|
||||
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
|
||||
{
|
||||
Vector3 vel = motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)
|
||||
? motionData.Velocity * speedMod : Vector3.Zero;
|
||||
Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega)
|
||||
? motionData.Omega * speedMod : Vector3.Zero;
|
||||
|
||||
for (int i = 0; i < motionData.Anims.Count; i++)
|
||||
{
|
||||
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);
|
||||
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling);
|
||||
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling, vel, omg);
|
||||
if (node != null)
|
||||
_queue.AddLast(node);
|
||||
}
|
||||
|
|
@ -680,6 +827,58 @@ public sealed class AnimationSequencer
|
|||
_framePosition = _currNode.Value.GetStartFramePosition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch any hooks on the given part frame whose direction matches
|
||||
/// the playback direction (or <c>Both</c>). Mirrors ACE's
|
||||
/// <c>Sequence.execute_hooks</c> (Sequence.cs:262).
|
||||
/// </summary>
|
||||
private void ExecuteHooks(AnimNode node, int frameIndex, AnimationHookDir playbackDir)
|
||||
{
|
||||
if (frameIndex < 0 || frameIndex >= node.Anim.PartFrames.Count) return;
|
||||
var frame = node.Anim.PartFrames[frameIndex];
|
||||
if (frame.Hooks.Count == 0) return;
|
||||
|
||||
for (int i = 0; i < frame.Hooks.Count; i++)
|
||||
{
|
||||
var hook = frame.Hooks[i];
|
||||
if (hook == null) continue;
|
||||
// ACE: hook.Direction == Both || hook.Direction == playbackDir
|
||||
if (hook.Direction == AnimationHookDir.Both
|
||||
|| hook.Direction == playbackDir)
|
||||
{
|
||||
_pendingHooks.Add(hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the <see cref="Animation.PosFrames"/> (root motion) delta for
|
||||
/// <paramref name="frameIndex"/> to the accumulated pending delta.
|
||||
/// Mirrors ACE's <c>AFrame.Combine</c> (forward) / <c>frame.Subtract</c>
|
||||
/// (backward) calls in <c>update_internal</c>.
|
||||
/// </summary>
|
||||
private void ApplyPosFrame(AnimNode node, int frameIndex, bool reverse)
|
||||
{
|
||||
if (!node.HasPosFrames) return;
|
||||
var posFrames = node.Anim.PosFrames;
|
||||
if (frameIndex < 0 || frameIndex >= posFrames.Count) return;
|
||||
var pf = posFrames[frameIndex];
|
||||
|
||||
if (!reverse)
|
||||
{
|
||||
// AFrame.Combine: position += rot.Rotate(pf.Origin); rot *= pf.Orientation
|
||||
_rootMotionPos += Vector3.Transform(pf.Origin, _rootMotionRot);
|
||||
_rootMotionRot = Quaternion.Normalize(_rootMotionRot * pf.Orientation);
|
||||
}
|
||||
else
|
||||
{
|
||||
// AFrame.Subtract: rot *= conj(pf.Orientation); position -= rot.Rotate(pf.Origin)
|
||||
var invRot = Quaternion.Conjugate(pf.Orientation);
|
||||
_rootMotionRot = Quaternion.Normalize(_rootMotionRot * invRot);
|
||||
_rootMotionPos -= Vector3.Transform(pf.Origin, _rootMotionRot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-part blended transform from the current animation frame.
|
||||
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using
|
||||
|
|
|
|||
|
|
@ -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