Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct translation guide. Every key algorithmic difference from the previous patched implementation is addressed: 1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail client binary. Previously float, which accumulated rounding error over long sessions. 2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time: negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout. 3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both forward and reverse; boundary detection uses EndFrame as the lower bound for reverse playback (matching the post-swap field semantics); remainder time propagates correctly across node boundaries for large dt values. 4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0) formulas are now correct: negative speed starts at (EndFrame+1)-epsilon, ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon. 5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the linked list is exhausted, matching the retail loop-forever semantics. 6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed), WalkBackward→WalkForward (negate×0.65 BackwardsFactor). 7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the existing implementation is correct. AnimationSequencerTests grows from 9 to 17 tests: - Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1 - Reverse frame position decreases (not increases) over time - Reverse wrap at start boundary recovers and loops - advance_to_next_animation: link node drains then enters cycle - Cycle loops repeatedly without crash or position drift All 431 tests green (109 net + 322 core). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
727 lines
28 KiB
C#
727 lines
28 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Minimal interface for resolving Animation objects by id.
|
||
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
|
||
/// </summary>
|
||
public interface IAnimationLoader
|
||
{
|
||
/// <summary>Load an Animation by its dat id, or return null.</summary>
|
||
Animation? LoadAnimation(uint id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Production implementation of <see cref="IAnimationLoader"/> backed by
|
||
/// a <see cref="DatCollection"/>.
|
||
/// </summary>
|
||
public sealed class DatCollectionLoader : IAnimationLoader
|
||
{
|
||
private readonly DatCollection _dats;
|
||
public DatCollectionLoader(DatCollection dats) => _dats = dats;
|
||
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(id);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// AnimationSequencer — faithful port of the decompiled retail AC client
|
||
// animation system.
|
||
//
|
||
// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md):
|
||
// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed
|
||
// FUN_005261D0 — update_internal: the core per-frame advance loop
|
||
// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic
|
||
// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent)
|
||
// 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
|
||
//
|
||
// DatReaderWriter types used:
|
||
// MotionTable.Links : Dictionary<int, MotionCommandData>
|
||
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
|
||
// MotionCommandData.MotionData : Dictionary<int, MotionData>
|
||
// key = target motion (int cast of MotionCommand)
|
||
// MotionData.Anims : List<AnimData>
|
||
// AnimData.AnimId : QualifiedDataId<Animation>
|
||
// Animation.PartFrames : List<AnimationFrame>
|
||
// AnimationFrame.Frames : List<Frame>
|
||
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Per-part world-local transform produced by <see cref="AnimationSequencer.Advance"/>.
|
||
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
|
||
/// </summary>
|
||
public readonly struct PartTransform
|
||
{
|
||
public readonly Vector3 Origin;
|
||
public readonly Quaternion Orientation;
|
||
|
||
public PartTransform(Vector3 origin, Quaternion orientation)
|
||
{
|
||
Origin = origin;
|
||
Orientation = orientation;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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)
|
||
/// </summary>
|
||
internal sealed class AnimNode
|
||
{
|
||
public Animation Anim;
|
||
public double Framerate; // signed; negative means reverse playback
|
||
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 AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
|
||
{
|
||
Anim = anim;
|
||
Framerate = framerate;
|
||
StartFrame = startFrame;
|
||
EndFrame = endFrame;
|
||
IsLooping = isLooping;
|
||
}
|
||
|
||
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
|
||
// Returns the initial framePosition cursor for this node.
|
||
// speedScale >= 0 → (double)startFrame
|
||
// speedScale < 0 → (double)(endFrame + 1) - EPSILON
|
||
// EPSILON = _DAT_007c92b4 (a tiny float just below the boundary)
|
||
public double GetStartFramePosition()
|
||
{
|
||
if (Framerate >= 0.0)
|
||
return (double)StartFrame;
|
||
else
|
||
return (double)(EndFrame + 1) - FrameEpsilon;
|
||
}
|
||
|
||
// ── FUN_005268B0 — GetEndFramePosition ───────────────────────────────
|
||
// Returns where the cursor sits when this node is exhausted.
|
||
// speedScale >= 0 → (double)(endFrame + 1) - EPSILON
|
||
// speedScale < 0 → (double)startFrame
|
||
public double GetEndFramePosition()
|
||
{
|
||
if (Framerate >= 0.0)
|
||
return (double)(EndFrame + 1) - FrameEpsilon;
|
||
else
|
||
return (double)StartFrame;
|
||
}
|
||
|
||
// Small double constant matching _DAT_007c92b4 in the retail binary.
|
||
// Used to position the cursor just before a frame boundary.
|
||
private const double FrameEpsilon = 1e-5;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Full animation playback engine for one entity.
|
||
///
|
||
/// <para>
|
||
/// This is a faithful port of the retail AC client's Sequence object
|
||
/// (docs/research/acclient_animation_pseudocode.md, sections 5–7).
|
||
/// Key invariants:
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// <c>_framePosition</c> is a <c>double</c> matching the retail client's
|
||
/// 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).
|
||
/// </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>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Usage pattern:
|
||
/// <code>
|
||
/// var seq = new AnimationSequencer(setup, motionTable, dats);
|
||
/// seq.SetCycle(style, motion, speedMod);
|
||
/// // each frame:
|
||
/// var transforms = seq.Advance(dt);
|
||
/// // rebuild MeshRefs from transforms
|
||
/// </code>
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class AnimationSequencer
|
||
{
|
||
// ── Public state ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>Current style (stance) command.</summary>
|
||
public uint CurrentStyle { get; private set; }
|
||
|
||
/// <summary>Current cyclic motion command.</summary>
|
||
public uint CurrentMotion { get; private set; }
|
||
|
||
// ── Private state ────────────────────────────────────────────────────────
|
||
|
||
private readonly Setup _setup;
|
||
private readonly MotionTable _mtable;
|
||
private readonly IAnimationLoader _loader;
|
||
|
||
// Animation queue: non-looping link frames followed by the looping cycle.
|
||
private readonly LinkedList<AnimNode> _queue = new();
|
||
private LinkedListNode<AnimNode>? _currNode;
|
||
private LinkedListNode<AnimNode>? _firstCyclic;
|
||
|
||
// 64-bit fractional frame position — matches Sequence+0x30 in the retail client.
|
||
// Named _framePosition to distinguish it from the old float _frameNum.
|
||
private double _framePosition;
|
||
|
||
private const double FrameEpsilon = 1e-5;
|
||
private const double RateEpsilon = 1e-6;
|
||
|
||
// ── Constructor ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Create a sequencer for one entity.
|
||
/// </summary>
|
||
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
|
||
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
|
||
/// <param name="loader">
|
||
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production,
|
||
/// or inject a test double in unit tests.
|
||
/// </param>
|
||
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(setup);
|
||
ArgumentNullException.ThrowIfNull(motionTable);
|
||
ArgumentNullException.ThrowIfNull(loader);
|
||
|
||
_setup = setup;
|
||
_mtable = motionTable;
|
||
_loader = loader;
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Switch to a new cyclic motion, prepending any transition link frames
|
||
/// so the switch is smooth. If the motion table has no link for the
|
||
/// (currentStyle, currentMotion) → newMotion transition, the cycle
|
||
/// switches immediately.
|
||
///
|
||
/// <para>
|
||
/// Implements <c>adjust_motion</c> (ACE MotionInterp.cs:394-428): the AC
|
||
/// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward.
|
||
/// These are played as their right-side / forward equivalents with a
|
||
/// negated framerate so the animation runs in reverse.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
|
||
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
|
||
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
|
||
public void SetCycle(uint style, uint motion, float speedMod = 1f)
|
||
{
|
||
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
|
||
// SideStepLeft, or WalkBackward cycles; the client plays the mirror
|
||
// animation with a negated speed so it runs backward.
|
||
uint adjustedMotion = motion;
|
||
float adjustedSpeed = speedMod;
|
||
switch (motion & 0xFFFFu)
|
||
{
|
||
case 0x000E: // TurnLeft → TurnRight (negate speed)
|
||
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
|
||
adjustedSpeed = -speedMod;
|
||
break;
|
||
case 0x0010: // SideStepLeft → SideStepRight (negate speed)
|
||
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
|
||
adjustedSpeed = -speedMod;
|
||
break;
|
||
case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor)
|
||
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
|
||
adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE
|
||
break;
|
||
}
|
||
|
||
// Fast-path: already playing this exact motion at the same speed.
|
||
if (CurrentStyle == style && CurrentMotion == motion
|
||
&& _firstCyclic != null && _queue.Count > 0)
|
||
return;
|
||
|
||
// Resolve transition link (currentSubstate → adjustedMotion).
|
||
MotionData? linkData = CurrentMotion != 0
|
||
? GetLink(style, CurrentMotion, adjustedMotion)
|
||
: null;
|
||
|
||
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
|
||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
||
|
||
// Clear the old cyclic tail; keep any non-cyclic head that hasn't
|
||
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
|
||
ClearCyclicTail();
|
||
|
||
// Enqueue link frames (with adjusted speed for left→right remapping).
|
||
if (linkData is { Anims.Count: > 0 })
|
||
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
|
||
|
||
// Enqueue new cycle.
|
||
if (cycleData is { Anims.Count: > 0 })
|
||
{
|
||
EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true);
|
||
}
|
||
else if (_queue.Count == 0)
|
||
{
|
||
// No cycle and no link — nothing to play; reset fully.
|
||
_currNode = null;
|
||
_firstCyclic = null;
|
||
_framePosition = 0.0;
|
||
CurrentStyle = style;
|
||
CurrentMotion = motion;
|
||
return;
|
||
}
|
||
|
||
// Mark the first cyclic node (the looping tail after all link frames).
|
||
_firstCyclic = null;
|
||
for (var n = _queue.First; n != null; n = n.Next)
|
||
{
|
||
if (n.Value.IsLooping)
|
||
{
|
||
_firstCyclic = n;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// If we have no current anim, start at the beginning of the queue.
|
||
if (_currNode == null)
|
||
{
|
||
_currNode = _queue.First;
|
||
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
|
||
}
|
||
|
||
CurrentStyle = style;
|
||
CurrentMotion = motion;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance the animation by <paramref name="dt"/> seconds and return the
|
||
/// 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.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The slerp algorithm mirrors the decompiled retail client's
|
||
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="dt">Elapsed time in seconds since the last call.</param>
|
||
/// <returns>
|
||
/// One <see cref="PartTransform"/> per part in the Setup, in part order.
|
||
/// If no animation is loaded, all parts get identity transforms.
|
||
/// </returns>
|
||
public IReadOnlyList<PartTransform> Advance(float dt)
|
||
{
|
||
int partCount = _setup.Parts.Count;
|
||
|
||
if (_currNode == null || dt <= 0f)
|
||
return BuildIdentityFrame(partCount);
|
||
|
||
// ── update_internal (FUN_005261D0) ───────────────────────────────
|
||
// Loop because a large dt can exhaust multiple nodes sequentially.
|
||
double timeRemaining = (double)dt;
|
||
|
||
while (timeRemaining > 0.0 && _currNode != null)
|
||
{
|
||
var curr = _currNode.Value;
|
||
double rate = curr.Framerate; // signed (negative = reverse)
|
||
double delta = rate * timeRemaining;
|
||
|
||
if (Math.Abs(delta) < RateEpsilon)
|
||
break; // rate ≈ 0 — nothing to do
|
||
|
||
double newPos = _framePosition + delta;
|
||
bool wrapped = false;
|
||
double overflow = 0.0;
|
||
|
||
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?
|
||
overflow = (newPos - maxBoundary) / rate;
|
||
if (overflow < 0.0) overflow = 0.0;
|
||
|
||
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
|
||
wrapped = true;
|
||
}
|
||
else
|
||
{
|
||
_framePosition = newPos;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// ── REVERSE PLAYBACK ─────────────────────────────────────
|
||
// After FUN_005267E0 swaps low↔high for negative speed:
|
||
// StartFrame = high (e.g. 3), EndFrame = low (e.g. 0)
|
||
// GetStartFramePosition placed cursor at (EndFrame+1)-eps ≈ 0.99999.
|
||
// The cursor counts DOWN toward EndFrame. Boundary = EndFrame.
|
||
double minBoundary = (double)curr.EndFrame;
|
||
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
|
||
wrapped = true;
|
||
}
|
||
else
|
||
{
|
||
_framePosition = newPos;
|
||
}
|
||
}
|
||
|
||
if (!wrapped)
|
||
break; // consumed all dt without hitting boundary — done
|
||
|
||
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
|
||
AdvanceToNextAnimation();
|
||
timeRemaining = overflow; // continue with leftover time
|
||
}
|
||
|
||
return BuildBlendedFrame();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reset the sequencer to an unplaying state without clearing the
|
||
/// motion table reference.
|
||
/// </summary>
|
||
public void Reset()
|
||
{
|
||
_queue.Clear();
|
||
_currNode = null;
|
||
_firstCyclic = null;
|
||
_framePosition = 0.0;
|
||
CurrentStyle = 0;
|
||
CurrentMotion = 0;
|
||
}
|
||
|
||
// ── Private helpers ──────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
|
||
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
|
||
///
|
||
/// Port of ACE's MotionTable.get_link:
|
||
/// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion]
|
||
/// 2. Fallback: try Links[style<<16][toMotion]
|
||
///
|
||
/// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData>
|
||
/// where MotionCommandData.MotionData is Dictionary<int, MotionData>.
|
||
/// </summary>
|
||
private MotionData? GetLink(uint style, uint fromMotion, uint toMotion)
|
||
{
|
||
int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
||
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1))
|
||
{
|
||
if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1))
|
||
return result1;
|
||
}
|
||
|
||
// Fallback: style-level catch-all.
|
||
int outerKey2 = (int)(style << 16);
|
||
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2))
|
||
{
|
||
if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2))
|
||
return result2;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
uint animId = (uint)ad.AnimId;
|
||
if (animId == 0) return null;
|
||
|
||
var anim = _loader.LoadAnimation(animId);
|
||
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||
|
||
int numFrames = anim.PartFrames.Count;
|
||
int low = ad.LowFrame;
|
||
int high = ad.HighFrame;
|
||
|
||
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
|
||
if (high < 0) high = numFrames - 1;
|
||
if (low >= numFrames) low = numFrames - 1;
|
||
if (high >= numFrames) high = numFrames - 1;
|
||
if (low < 0) low = 0;
|
||
|
||
double fr = (double)ad.Framerate * (double)speedMod;
|
||
|
||
// ── FUN_005267E0 multiply_framerate ──────────────────────────────
|
||
// When speed is negative (TurnLeft→TurnRight, SideStepLeft→SideStepRight),
|
||
// swap Low↔High so the advance loop counts DOWN from the swapped EndFrame
|
||
// toward the swapped StartFrame. The pseudocode says:
|
||
// if speedScale < 0: swap startFrame ↔ endFrame
|
||
if (fr < 0.0)
|
||
{
|
||
(low, high) = (high, low);
|
||
// After swap: StartFrame > EndFrame (the loop detects delta < 0 and
|
||
// uses StartFrame as the lower boundary to count down toward).
|
||
}
|
||
else
|
||
{
|
||
if (low > high) high = low; // clamp for positive-speed case only
|
||
}
|
||
|
||
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Append all AnimData entries from <paramref name="motionData"/> to the
|
||
/// queue. Each AnimData becomes one AnimNode.
|
||
/// </summary>
|
||
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
|
||
{
|
||
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);
|
||
if (node != null)
|
||
_queue.AddLast(node);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Remove all cyclic (looping) nodes from the tail of the queue starting
|
||
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain so they
|
||
/// can drain naturally.
|
||
/// </summary>
|
||
private void ClearCyclicTail()
|
||
{
|
||
if (_firstCyclic == null) return;
|
||
|
||
var node = _firstCyclic;
|
||
while (node != null)
|
||
{
|
||
var next = node.Next;
|
||
// If the active node is being removed, jump it to the preceding
|
||
// non-cyclic node (or reset if there is none).
|
||
if (_currNode == node)
|
||
{
|
||
_currNode = node.Previous;
|
||
if (_currNode != null)
|
||
_framePosition = _currNode.Value.GetEndFramePosition();
|
||
else
|
||
_framePosition = 0.0;
|
||
}
|
||
_queue.Remove(node);
|
||
node = next;
|
||
}
|
||
|
||
_firstCyclic = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Move <see cref="_currNode"/> to the next node in the queue, or wrap
|
||
/// back to <see cref="_firstCyclic"/> when the queue is exhausted.
|
||
///
|
||
/// Implements <c>FUN_00525EB0</c> (Sequence::advance_to_next_animation).
|
||
/// The retail client walks a doubly-linked list; we mirror that with
|
||
/// LinkedList.Next plus the _firstCyclic wrap sentinel.
|
||
/// </summary>
|
||
private void AdvanceToNextAnimation()
|
||
{
|
||
if (_currNode == null) return;
|
||
|
||
LinkedListNode<AnimNode>? next = _currNode.Next;
|
||
|
||
if (next != null)
|
||
{
|
||
_currNode = next;
|
||
}
|
||
else if (_firstCyclic != null)
|
||
{
|
||
// Wrap to first cyclic node — this is the loop that keeps idle/walk
|
||
// animations playing forever.
|
||
_currNode = _firstCyclic;
|
||
}
|
||
// else: end of a finite non-looping sequence; stay on last node.
|
||
|
||
if (_currNode != null)
|
||
_framePosition = _currNode.Value.GetStartFramePosition();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the per-part blended transform from the current animation frame.
|
||
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using
|
||
/// the fractional part of _framePosition.
|
||
///
|
||
/// Uses the retail-client slerp (<see cref="SlerpRetailClient"/>) for
|
||
/// quaternion interpolation and linear lerp for position.
|
||
/// </summary>
|
||
private IReadOnlyList<PartTransform> BuildBlendedFrame()
|
||
{
|
||
int partCount = _setup.Parts.Count;
|
||
|
||
if (_currNode == null)
|
||
return BuildIdentityFrame(partCount);
|
||
|
||
var curr = _currNode.Value;
|
||
int numPartFrames = curr.Anim.PartFrames.Count;
|
||
|
||
// Clamp frameIndex to valid range.
|
||
int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame);
|
||
int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame);
|
||
rangeHi = Math.Min(rangeHi, numPartFrames - 1);
|
||
|
||
int frameIdx = (int)Math.Floor(_framePosition);
|
||
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
||
|
||
// Next frame for interpolation: step in the playback direction.
|
||
int nextIdx;
|
||
if (curr.Framerate >= 0.0)
|
||
{
|
||
nextIdx = frameIdx + 1;
|
||
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
||
nextIdx = rangeLo; // wrap forward
|
||
}
|
||
else
|
||
{
|
||
nextIdx = frameIdx - 1;
|
||
if (nextIdx < rangeLo)
|
||
nextIdx = rangeHi; // wrap backward
|
||
}
|
||
|
||
// Fractional blend weight (always in [0, 1]).
|
||
double rawT = _framePosition - Math.Floor(_framePosition);
|
||
float t = (float)Math.Clamp(rawT, 0.0, 1.0);
|
||
|
||
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
||
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
|
||
|
||
var result = new PartTransform[partCount];
|
||
for (int i = 0; i < partCount; i++)
|
||
{
|
||
if (i < f0Parts.Count)
|
||
{
|
||
var p0 = f0Parts[i];
|
||
var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
|
||
|
||
result[i] = new PartTransform(
|
||
Vector3.Lerp(p0.Origin, p1.Origin, t),
|
||
SlerpRetailClient(p0.Orientation, p1.Orientation, t));
|
||
}
|
||
else
|
||
{
|
||
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
|
||
{
|
||
var result = new PartTransform[partCount];
|
||
for (int i = 0; i < partCount; i++)
|
||
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>
|
||
/// (chunk_00530000.c:4799-4846):
|
||
/// <list type="number">
|
||
/// <item>Compute dot product of q1 and q2.</item>
|
||
/// <item>If dot < 0, negate q2 (choose the shorter arc).</item>
|
||
/// <item>If 1 - dot <= epsilon, fall back to (1-t)*q1 + t*q2 (linear).</item>
|
||
/// <item>Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).</item>
|
||
/// <item>Validate result lies in [0,1]²; if not, fall back to linear.</item>
|
||
/// </list>
|
||
/// The only difference from the standard formula is step 5: the retail
|
||
/// client validates that both blend weights are in [0,1] before using the
|
||
/// sin-based result; this handles degenerate inputs gracefully.
|
||
/// </summary>
|
||
public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t)
|
||
{
|
||
float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z;
|
||
|
||
// Step 2: choose the shorter arc.
|
||
Quaternion q2s;
|
||
if (dot < 0f)
|
||
{
|
||
dot = -dot;
|
||
q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W);
|
||
}
|
||
else
|
||
{
|
||
q2s = q2;
|
||
}
|
||
|
||
const float SlerpEpsilon = 1e-4f;
|
||
float w1, w2;
|
||
|
||
if (1f - dot <= SlerpEpsilon)
|
||
{
|
||
// Near-parallel: linear fallback (matches retail client's path).
|
||
w1 = 1f - t;
|
||
w2 = t;
|
||
}
|
||
else
|
||
{
|
||
float omega = MathF.Acos(dot);
|
||
float sinOmega = MathF.Sin(omega);
|
||
float invSin = 1f / sinOmega;
|
||
|
||
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
|
||
float candidate2 = MathF.Sin(t * omega) * invSin;
|
||
|
||
// Step 5: validate (retail client check: both weights in [0,1]).
|
||
if (candidate1 >= 0f && candidate1 <= 1f
|
||
&& candidate2 >= 0f && candidate2 <= 1f)
|
||
{
|
||
w1 = candidate1;
|
||
w2 = candidate2;
|
||
}
|
||
else
|
||
{
|
||
w1 = 1f - t;
|
||
w2 = t;
|
||
}
|
||
}
|
||
|
||
return new Quaternion(
|
||
w1 * q1.X + w2 * q2s.X,
|
||
w1 * q1.Y + w2 * q2s.Y,
|
||
w1 * q1.Z + w2 * q2s.Z,
|
||
w1 * q1.W + w2 * q2s.W);
|
||
}
|
||
}
|