refactor(anim): sequence-wide velocity/omega matching retail Sequence

Before: CurrentVelocity was a pass-through of the current AnimNode's
Velocity. So during a stance transition, while the link animation
played (with no velocity of its own), CurrentVelocity returned (0,0,0)
and remote dead-reckoning briefly stopped advancing the entity. Visible
as a hitch at every idle → walk or walk → run transition.

Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega
are Sequence-wide fields updated by MotionTable.add_motion's
Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new
MotionData is appended, the sequence velocity is REPLACED by that data's
velocity × speedMod. In SetCycle's rebuild path the order is:
  1. clear_physics      → zero
  2. add_motion(link)   → velocity = link's (typically 0)
  3. add_motion(cycle)  → velocity = cycle's (the real walk/run velocity)

After step 3, Sequence.Velocity is the CYCLE's velocity even though
CurrAnim is the link node. So dead-reckoning reads the cycle's velocity
from frame zero of the transition — no stutter.

This commit:

- Converts AnimationSequencer.CurrentVelocity / CurrentOmega from
  per-node computed properties to sequence-wide private-set properties.
- Adds ClearPhysics() helper (mirrors Sequence.clear_physics).
- EnqueueMotionData now updates the sequence velocity/omega (matching
  add_motion's SetVelocity semantics). Only replaces when the
  MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity
  modifiers don't zero the running cycle, matching retail.
- SetCycle's rebuild path calls ClearPhysics before the new add_motion
  chain (matches MotionTable.cs L100-L101, L152-L153).
- MultiplyCyclicFramerate scales the sequence-wide velocity/omega
  instead of per-node fields — algebraically equivalent to retail's
  subtract_motion(old) + combine_motion(new) pair in change_cycle_speed.

New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that
after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's
velocity even during the link frames. Catches the old bug directly.

All 659 tests pass (was 658).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:41:21 +02:00
parent 6e589d3b89
commit 24974cfbb9
2 changed files with 120 additions and 19 deletions

View file

@ -229,21 +229,26 @@ public sealed class AnimationSequencer
public float CurrentSpeedMod { get; private set; } = 1f;
/// <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.
/// Sequence-wide velocity mirror of ACE's <c>Sequence.Velocity</c> field.
/// Updated each time a MotionData is appended or combined — reflects the
/// MOST RECENT MotionData's velocity × speedMod, matching
/// <c>Sequence.SetVelocity</c> semantics (ACE Sequence.cs L127-L130,
/// <c>MotionTable.add_motion</c> L358-L370).
///
/// <para>
/// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
/// reads this to integrate position without gapping during stance
/// transitions.
/// </para>
/// </summary>
public Vector3 CurrentVelocity =>
_currNode?.Value.Velocity ?? Vector3.Zero;
public Vector3 CurrentVelocity { get; private set; }
/// <summary>
/// Radians-per-second omega (axis-angle integration rate) from the
/// currently active <see cref="MotionData"/>. Scaled by <c>speedMod</c>
/// at enqueue time.
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// </summary>
public Vector3 CurrentOmega =>
_currNode?.Value.Omega ?? Vector3.Zero;
public Vector3 CurrentOmega { get; private set; }
// Diagnostics
public int QueueCount => _queue.Count;
@ -375,6 +380,11 @@ public sealed class AnimationSequencer
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail();
// Clear sequence-wide physics before the rebuild. Retail's
// GetObjectSequence calls sequence.clear_physics() before each
// add_motion chain (MotionTable.cs L100-L101, L152-L153).
ClearPhysics();
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
@ -445,15 +455,15 @@ public sealed class AnimationSequencer
for (var node = _firstCyclic; node != null; node = node.Next)
{
node.Value.MultiplyFramerate((double)factor);
// Velocity/Omega carried on the node scale with the framerate, so
// the physics velocity surfaced by CurrentVelocity matches the
// animation playback. (ACE does the same: add_motion sets both
// to the scaled value and multiply_cyclic_animation_framerate is
// preceded by subtract_motion/combine_motion in change_cycle_speed
// to keep them aligned — MotionTable.cs:372-379.)
node.Value.Velocity *= factor;
node.Value.Omega *= factor;
}
// Sequence-wide velocity/omega scale too. Retail's flow is
// subtract_motion(oldSpeed) + combine_motion(newSpeed) in
// MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which
// algebraically equals scaling by newSpeed/oldSpeed — exactly
// what the factor represents here.
CurrentVelocity *= factor;
CurrentOmega *= factor;
}
/// <summary>
@ -738,6 +748,8 @@ public sealed class AnimationSequencer
CurrentStyle = 0;
CurrentMotion = 0;
CurrentSpeedMod = 1f;
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
// ── Private helpers ──────────────────────────────────────────────────────
@ -829,6 +841,17 @@ public sealed class AnimationSequencer
omega);
}
/// <summary>
/// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics,
/// ACE Sequence.cs L256-L260). Called before a style-transition rebuild
/// in SetCycle so we don't inherit velocity from the previous cycle.
/// </summary>
private void ClearPhysics()
{
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the
@ -842,6 +865,23 @@ public sealed class AnimationSequencer
Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega)
? motionData.Omega * speedMod : Vector3.Zero;
// Sequence-wide velocity/omega update, matching ACE's
// MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity
// REPLACES the previous sequence velocity. When SetCycle enqueues
// link then cycle, the final CurrentVelocity is the cycle's — which
// is what dead-reckoning needs to read from the first frame of the
// link transition (the cycle velocity is already "queued up" even
// while a zero-velocity link plays visually).
//
// Only replace if HasVelocity (else we'd zero out a running cycle
// when a transient HasVelocity=0 modifier enqueues). Matches
// retail's conditional behavior: MotionData without HasVelocity
// doesn't touch the sequence velocity.
if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity))
CurrentVelocity = vel;
if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega))
CurrentOmega = omg;
for (int i = 0; i < motionData.Anims.Count; i++)
{
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);