fix(animation): close #61 + smooth stop from backward/sidestep-left/turn-left

Two related AnimationSequencer fixes for visible animation glitches at
motion-cycle boundaries.

1. Link-tail blend hold (closes #61). BuildBlendedFrame was wrapping
   nextIdx unconditionally to rangeLo at the high-frame boundary —
   correct for looping cyclic nodes (idle/run/walk loops), wrong for
   one-shot links and action overlays. During the ~30 ms fractional
   tail before the sequencer transitions to the next queue node, the
   blend mixed frame[end] with frame[0], producing a one-frame flash
   through the anim's starting pose. Symptoms: door swing-open flap
   (frame 0 = closed pose) and player run-stop twitch (frame 0 =
   mid-stride). Fix: gate the wrap on curr.IsLooping; non-looping
   nodes hold the boundary frame until AdvanceToNextAnimation fires.

2. Stop-anim direction fallback. Stopping from WalkBackward /
   SideStepLeft / TurnLeft hit a null linkData from GetLink (the dat
   authors a single forward/right stop link and reuses it for both
   directions). SetCycle then enqueued only the Ready cycle, snapping
   straight to idle with no leg-settle blend. Fix: when the primary
   GetLink lookup is null, retry with the substate's low byte remapped
   to its forward/right peer (0x06→0x05, 0x10→0x0F, 0x0E→0x0D).

Both fixes are pinned by new regression tests in
AnimationSequencerTests that fail against the prior code (Y=5.02 for
the link tail wrap → frame 0 blend; Y=0 for the backward stop snapping
to Ready cycle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-18 15:16:20 +02:00
parent 6c4f6be1b4
commit 9f069e14c9
2 changed files with 149 additions and 2 deletions

View file

@ -479,6 +479,29 @@ public sealed class AnimationSequencer
? null
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
// Stop-anim fallback: dat-authored leg-settle / turn-stop links are
// keyed under the FORWARD/RIGHT variant only. Stopping from
// WalkBackward / SideStepLeft / TurnLeft hits a null linkData and
// would visibly snap to Ready. The settle anim is direction-agnostic
// (legs come to standing the same way regardless of which way you
// were walking), so retry GetLink with the substate's low-byte
// remapped to its forward/right peer.
if (linkData is null && !skipTransitionLink && CurrentMotion != 0)
{
uint substateLow = CurrentMotion & 0xFFu;
uint adjustedSubstate = substateLow switch
{
0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward
0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight
0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight
_ => CurrentMotion,
};
if (adjustedSubstate != CurrentMotion)
{
linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
}
}
// 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);
@ -1419,18 +1442,24 @@ public sealed class AnimationSequencer
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
// Next frame for interpolation: step in the playback direction.
// Wrap to opposite end ONLY for looping cyclic nodes. For one-shot
// nodes (link transitions, action overlays), hold the boundary
// frame instead — otherwise the fractional tail of the anim
// blends frame[end] with frame[0], producing a brief flash through
// the anim's starting pose at the link→cycle boundary (issue #61:
// door swing-open flap; run-stop twitch).
int nextIdx;
if (curr.Framerate >= 0.0)
{
nextIdx = frameIdx + 1;
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
nextIdx = rangeLo; // wrap forward
nextIdx = curr.IsLooping ? rangeLo : frameIdx;
}
else
{
nextIdx = frameIdx - 1;
if (nextIdx < rangeLo)
nextIdx = rangeHi; // wrap backward
nextIdx = curr.IsLooping ? rangeHi : frameIdx;
}
// Fractional blend weight (always in [0, 1]).