feat(anim): integrate AnimationSequencer into GameWindow rendering loop

Wire the new AnimationSequencer into per-entity animation playback:

- Added `Sequencer` field to `AnimatedEntity`; populated at spawn time
  by loading the entity's MotionTable and calling SetCycle on the resolved
  idle (style, motion) so playback starts immediately.
- Added `_animLoader` (DatCollectionLoader) initialized alongside `_dats`
  so sequencer instances share a single animation loader.
- `TickAnimations`: when an entity has a sequencer, calls `seq.Advance(dt)`
  and reads back `PartTransform[]` instead of doing manual frame-index math
  and Quaternion.Slerp. Falls back to the Phase 6.5 manual slerp for entities
  whose MotionTable couldn't be loaded (missing dat / offline mode).
- `OnLiveMotionUpdated`: calls `sequencer.SetCycle(style, motion)` when
  available so motion changes prepend transition-link frames for smooth blending.
- `UpdatePlayerAnimation`: same — calls `sequencer.SetCycle(NonCombatStyleFull, cmd)`
  on motion changes; also creates a sequencer when the player entity is
  lazy-registered for the first time.

The existing manual-slerp fallback is kept verbatim so behavior is unchanged
for any entity that can't be backed by a sequencer. Build clean, 426 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 00:27:11 +02:00
parent f48f2745c4
commit e4dae3d217

View file

@ -21,6 +21,12 @@ public sealed class GameWindow : IDisposable
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
/// <summary>
/// Adapter so <see cref="AcDream.Core.Physics.AnimationSequencer"/> instances
/// can load Animation dats without knowing about DatCollection directly.
/// Initialized alongside <see cref="_dats"/> once the dat files are opened.
/// </summary>
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
private float _lastMouseX;
private float _lastMouseY;
private StaticMeshRenderer? _staticMesh;
@ -91,6 +97,16 @@ public sealed class GameWindow : IDisposable
/// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
/// <summary>
/// Phase B.3: full animation sequencer with transition-link support.
/// Non-null when a MotionTable was successfully loaded at spawn time.
/// When present, <see cref="TickAnimations"/> delegates to this instead
/// of performing the manual slerp, and motion changes call
/// <see cref="AcDream.Core.Physics.AnimationSequencer.SetCycle"/> so
/// transition frames blend smoothly.
/// </summary>
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
}
// Phase B.2: player movement mode.
@ -323,6 +339,7 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -822,6 +839,29 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
// Phase B.3: try to build an AnimationSequencer for smooth
// transition-link blending. Requires a MotionTable in the dats.
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
if (_animLoader is not null)
{
uint mtId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
var mtable = mtId != 0 ? _dats!.Get<DatReaderWriter.DBObjs.MotionTable>(mtId) : null;
if (mtable is not null)
{
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
// Prime the sequencer on the idle cycle so it starts playing
// immediately. Use the resolved style from the spawn motion state,
// defaulting to NonCombat (0x003D).
uint spawnStyle = stanceOverride.HasValue
? ((uint)stanceOverride.Value << 16)
: ((uint)mtable.DefaultStyle & 0xFFFF0000u);
uint spawnMotion = commandOverride.HasValue
? (uint)commandOverride.Value
: (uint)mtable.StyleDefaults.GetValueOrDefault(mtable.DefaultStyle);
sequencer.SetCycle(spawnStyle, spawnMotion);
}
}
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
@ -833,6 +873,7 @@ public sealed class GameWindow : IDisposable
Scale = scale,
PartTemplate = template,
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
}
@ -920,6 +961,18 @@ public sealed class GameWindow : IDisposable
if (!newCycleIsGood)
return;
// Phase B.3: if this entity has a sequencer, delegate the cycle switch
// to it so transition-link frames are prepended automatically. The
// sequencer's SetCycle fast-path will no-op if nothing actually changed.
if (ae.Sequencer is not null)
{
uint styleCmd = (uint)stance << 16;
uint motionCmd = command.HasValue ? (uint)command.Value : 0u;
ae.Sequencer.SetCycle(styleCmd, motionCmd);
// Keep ae.Animation/LowFrame/HighFrame in sync so the fallback
// path (and diagnostics) are accurate if the sequencer is ever removed.
}
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
@ -1825,104 +1878,154 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
/// [LowFrame..HighFrame] interval, then rebuild that entity's
/// MeshRefs from the new frame's per-part transforms. Static
/// entities (no AnimatedEntity record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call.
/// Phase 6.4 / B.3: advance every animated entity's playback by
/// <paramref name="dt"/> seconds, then rebuild that entity's MeshRefs
/// from the resulting per-part transforms. Static entities (no
/// AnimatedEntity record) are untouched.
///
/// When the entity has a <see cref="AcDream.Core.Physics.AnimationSequencer"/>
/// (Phase B.3), delegates frame advancement and blending to it — this gives
/// retail-accurate quaternion slerp and smooth transition-link blending on
/// motion changes. Falls back to the Phase 6.5 manual slerp for entities
/// whose MotionTable couldn't be loaded (e.g. offline mode or a missing dat).
/// </summary>
private void TickAnimations(float dt)
{
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
// Wrap into [LowFrame, HighFrame]. Use a guarded modulo so
// big dts (first frame after a stall) don't blow the loop.
if (ae.CurrFrame > ae.HighFrame)
// ── Phase B.3 path: sequencer-driven ────────────────────────────
if (ae.Sequencer is { } seq)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
{
ae.CurrFrame = ae.LowFrame;
}
var transforms = seq.Advance(dt);
int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
// Phase 6.5: blend between adjacent keyframes using the fractional
// part of CurrFrame so the animation is smooth at any framerate
// instead of snapping to integer frame indices.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame; // cycle wraps within [LowFrame, HighFrame]
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
for (int i = 0; i < partCount; i++)
{
// Slerp between the current and next keyframe per part.
// Out-of-range parts get an identity transform — defensive
// for setups whose part count exceeds the animation's bone
// count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (i < partFrames.Count)
for (int i = 0; i < partCount; i++)
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
var pt = i < transforms.Count ? transforms[i]
: new AcDream.Core.Physics.PartTransform(
System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity);
// Per-part default scale from the Setup, matching
// SetupMesh.Flatten's composition order: scale → rotate → translate.
var defaultScale = i < ae.Setup.DefaultScale.Count
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var partTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(pt.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(pt.Origin);
if (ae.Scale != 1.0f)
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation };
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.
var defaultScale = i < ae.Setup.DefaultScale.Count
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var partTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(frame.Origin);
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
ae.Entity.MeshRefs = newMeshRefs;
continue;
}
ae.Entity.MeshRefs = newMeshRefs;
// ── Phase 6.4 / 6.5 fallback path: manual slerp ─────────────────
{
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
// Wrap into [LowFrame, HighFrame]. Use a guarded modulo so
// big dts (first frame after a stall) don't blow the loop.
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
{
ae.CurrFrame = ae.LowFrame;
}
// Phase 6.5: blend between adjacent keyframes using the fractional
// part of CurrFrame so the animation is smooth at any framerate
// instead of snapping to integer frame indices.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame; // cycle wraps within [LowFrame, HighFrame]
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
for (int i = 0; i < partCount; i++)
{
// Slerp between the current and next keyframe per part.
// Out-of-range parts get an identity transform — defensive
// for setups whose part count exceeds the animation's bone
// count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (i < partFrames.Count)
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation };
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.
var defaultScale = i < ae.Setup.DefaultScale.Count
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var partTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(frame.Origin);
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
}
ae.Entity.MeshRefs = newMeshRefs;
}
}
}
@ -1968,6 +2071,16 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < pe.MeshRefs.Count; i++)
template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides);
// Phase B.3: try to attach an AnimationSequencer for the player.
AcDream.Core.Physics.AnimationSequencer? playerSeq = null;
if (_animLoader is not null)
{
uint mtId = _playerMotionTableId ?? (uint)setup.DefaultMotionTable;
var mtable = mtId != 0 ? _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtId) : null;
if (mtable is not null)
playerSeq = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
}
ae = new AnimatedEntity
{
Entity = pe,
@ -1979,6 +2092,7 @@ public sealed class GameWindow : IDisposable
Scale = 1f,
PartTemplate = template,
CurrFrame = 0f,
Sequencer = playerSeq,
};
_animatedEntities[pe.Id] = ae;
}
@ -1998,6 +2112,16 @@ public sealed class GameWindow : IDisposable
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
// Phase B.3: if the entity has a sequencer, route the motion change
// through it so transition-link frames blend smoothly. NonCombat
// stance is 0x003D0000 in full style-command form.
if (ae.Sequencer is not null)
{
const uint NonCombatStyleFull = 0x003D0000u;
uint motionCmd = (uint)cmdOverride;
ae.Sequencer.SetCycle(NonCombatStyleFull, motionCmd);
}
ae.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame);
ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1);