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:
parent
6e589d3b89
commit
24974cfbb9
2 changed files with 120 additions and 19 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,67 @@ public sealed class AnimationSequencerTests
|
|||
Assert.Equal(1.5f, seq.CurrentSpeedMod, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentVelocity_PersistsThroughLinkTransition()
|
||||
{
|
||||
// Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity):
|
||||
// sequence.Velocity is REPLACED by the most-recent MotionData's
|
||||
// velocity. When SetCycle enqueues [link][cycle], after the final
|
||||
// add_motion the velocity is the cycle's velocity — ALREADY.
|
||||
// So even while the link animation plays visually, dead-reckoning
|
||||
// reads the cycle's run-speed and moves the entity smoothly.
|
||||
// Crucial: otherwise remote entities would stutter at every stance
|
||||
// transition while the link plays.
|
||||
const uint Style = 0x003Du;
|
||||
const uint IdleMotion = 0x0003u;
|
||||
const uint WalkMotion = 0x0005u;
|
||||
const uint CycleAnim = 0x03000601u;
|
||||
const uint LinkAnim = 0x03000602u;
|
||||
|
||||
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||||
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
|
||||
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = new MotionTable();
|
||||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||||
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion;
|
||||
|
||||
int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
|
||||
var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
|
||||
QualifiedDataId<Animation> cycleQid = CycleAnim;
|
||||
cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
|
||||
mt.Cycles[cycleKey] = cycleMd;
|
||||
|
||||
// Link from idle → walk. Link MotionData has no velocity (typical).
|
||||
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||||
var linkCmdData = new MotionCommandData();
|
||||
var linkMd = new MotionData(); // no HasVelocity flag
|
||||
QualifiedDataId<Animation> linkQid = LinkAnim;
|
||||
linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
|
||||
linkCmdData.MotionData[(int)WalkMotion] = linkMd;
|
||||
mt.Links[linkOuter] = linkCmdData;
|
||||
|
||||
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);
|
||||
|
||||
// We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
|
||||
// the link, but CurrentVelocity reflects the most recent
|
||||
// SetVelocity call — the cycle's. So velocity is 3.12 even before
|
||||
// the link plays out.
|
||||
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
|
||||
|
||||
// Advance past the link frames (2 frames at 10fps = 0.2s).
|
||||
seq.Advance(0.25f);
|
||||
|
||||
// Still 3.12 — cycle is now current.
|
||||
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
|
||||
}
|
||||
|
||||
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue