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);