diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 283497f..e9168ba 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -21,6 +21,12 @@ public sealed class GameWindow : IDisposable
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
+ ///
+ /// Adapter so instances
+ /// can load Animation dats without knowing about DatCollection directly.
+ /// Initialized alongside once the dat files are opened.
+ ///
+ private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
private float _lastMouseX;
private float _lastMouseY;
private StaticMeshRenderer? _staticMesh;
@@ -91,6 +97,16 @@ public sealed class GameWindow : IDisposable
///
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
+
+ ///
+ /// Phase B.3: full animation sequencer with transition-link support.
+ /// Non-null when a MotionTable was successfully loaded at spawn time.
+ /// When present, delegates to this instead
+ /// of performing the manual slerp, and motion changes call
+ /// so
+ /// transition frames blend smoothly.
+ ///
+ 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(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
}
///
- /// Phase 6.4: advance every animated entity's frame counter by
- /// * 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
+ /// 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
+ /// (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).
///
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(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(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(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(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);