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 CameraController? _cameraController;
private IMouse? _capturedMouse; private IMouse? _capturedMouse;
private DatCollection? _dats; 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 _lastMouseX;
private float _lastMouseY; private float _lastMouseY;
private StaticMeshRenderer? _staticMesh; private StaticMeshRenderer? _staticMesh;
@ -91,6 +97,16 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate; public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] 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. // Phase B.2: player movement mode.
@ -323,6 +339,7 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged; _cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read); _dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
uint centerLandblockId = 0xA9B4FFFFu; uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); 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++) for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides); 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 _animatedEntities[entity.Id] = new AnimatedEntity
{ {
Entity = entity, Entity = entity,
@ -833,6 +873,7 @@ public sealed class GameWindow : IDisposable
Scale = scale, Scale = scale,
PartTemplate = template, PartTemplate = template,
CurrFrame = idleCycle.LowFrame, CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
}; };
} }
@ -920,6 +961,18 @@ public sealed class GameWindow : IDisposable
if (!newCycleIsGood) if (!newCycleIsGood)
return; 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.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame); ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1); ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
@ -1825,18 +1878,67 @@ public sealed class GameWindow : IDisposable
} }
/// <summary> /// <summary>
/// Phase 6.4: advance every animated entity's frame counter by /// Phase 6.4 / B.3: advance every animated entity's playback by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's /// <paramref name="dt"/> seconds, then rebuild that entity's MeshRefs
/// [LowFrame..HighFrame] interval, then rebuild that entity's /// from the resulting per-part transforms. Static entities (no
/// MeshRefs from the new frame's per-part transforms. Static /// AnimatedEntity record) are untouched.
/// entities (no AnimatedEntity record) are untouched. The static ///
/// renderer reads the new MeshRefs on the next Draw call. /// 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> /// </summary>
private void TickAnimations(float dt) private void TickAnimations(float dt)
{ {
foreach (var kv in _animatedEntities) foreach (var kv in _animatedEntities)
{ {
var ae = kv.Value; var ae = kv.Value;
// ── Phase B.3 path: sequencer-driven ────────────────────────────
if (ae.Sequencer is { } seq)
{
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);
for (int i = 0; i < partCount; i++)
{
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,
});
}
ae.Entity.MeshRefs = newMeshRefs;
continue;
}
// ── Phase 6.4 / 6.5 fallback path: manual slerp ─────────────────
{
int span = ae.HighFrame - ae.LowFrame; int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue; if (span <= 0) continue;
@ -1925,6 +2027,7 @@ public sealed class GameWindow : IDisposable
ae.Entity.MeshRefs = newMeshRefs; ae.Entity.MeshRefs = newMeshRefs;
} }
} }
}
/// <summary> /// <summary>
/// Phase B.2: switch the locally-controlled player entity's animation cycle /// Phase B.2: switch the locally-controlled player entity's animation cycle
@ -1968,6 +2071,16 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < pe.MeshRefs.Count; i++) for (int i = 0; i < pe.MeshRefs.Count; i++)
template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides); 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 ae = new AnimatedEntity
{ {
Entity = pe, Entity = pe,
@ -1979,6 +2092,7 @@ public sealed class GameWindow : IDisposable
Scale = 1f, Scale = 1f,
PartTemplate = template, PartTemplate = template,
CurrFrame = 0f, CurrFrame = 0f,
Sequencer = playerSeq,
}; };
_animatedEntities[pe.Id] = ae; _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; 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.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame); ae.LowFrame = Math.Max(0, cycle.LowFrame);
ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1);