diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 61d95ee..4d77ef2 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -229,21 +229,26 @@ public sealed class AnimationSequencer public float CurrentSpeedMod { get; private set; } = 1f; /// - /// World-space per-second velocity from the currently active - /// (Sequence.Velocity in retail). Zero when no - /// motion data carries a velocity. Scaled by speedMod at enqueue - /// time. + /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field. + /// Updated each time a MotionData is appended or combined — reflects the + /// MOST RECENT MotionData's velocity × speedMod, matching + /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130, + /// MotionTable.add_motion L358-L370). + /// + /// + /// 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. + /// /// - public Vector3 CurrentVelocity => - _currNode?.Value.Velocity ?? Vector3.Zero; + public Vector3 CurrentVelocity { get; private set; } /// - /// Radians-per-second omega (axis-angle integration rate) from the - /// currently active . Scaled by speedMod - /// at enqueue time. + /// Sequence-wide omega, matching 's semantics. /// - 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; } /// @@ -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); } + /// + /// 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. + /// + private void ClearPhysics() + { + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; + } + /// /// Append all AnimData entries from 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); diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 8292bbd..55fc874 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -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 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 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]