using System; using System.Numerics; using DatReaderWriter.DBObjs; namespace AcDream.Core.Physics; /// /// Retail's simplest animation-clip playback shape: advance a frame position /// at a fixed framerate and wrap it back into [LowFrame, HighFrame], /// then linearly interpolate one part's origin/orientation between the two /// bracketing frames. This is the effect of /// CPhysicsObj::set_sequence_animation (0x0050F6F0) when called /// with a constant DID and a nonzero framerate and no further motion-command /// traffic — e.g. gmCG3DView::StartAnimation (0x004EE600), /// which plays the chargen preview's idle DID at a flat 30 fps with no /// transitional blending. /// /// /// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as /// an inline, App-layer-only implementation for the "legacy" (no /// ) NPC idle-cycle path — /// LiveEntityAnimationPresenter.Present's non-sequencer branch /// (CurrFrame += legacyAdvanceSeconds * Framerate with the same /// modulo wrap) and its private TryResolvePartFrame helper (the same /// frame-bracket lerp/slerp). That call site has a live entity, a /// LiveEntityRuntime membership, and per-tick elapsed time supplied by /// the render loop; the chargen preview has none of that (there is no live /// entity — character creation hasn't happened yet), so it cannot reuse that /// class directly. Rather than re-typing the same formula a second time, /// this Core, pure, unit-testable class is the shared primitive: the /// chargen preview (AcDream.App.Rendering.ChargenPreviewAnimator) /// consumes it directly, and it is safe for a future pass to redirect /// LiveEntityAnimationPresenter's inline copy through it as a /// behavior-preserving mechanical follow-up (not done here — that file is /// live, heavily tested production entity-rendering code with zero relation /// to this preview-only feature, so touching it is out of this slice's /// blast radius by design, not oversight). Tracked as /// docs/ISSUES.md #403 so the follow-up has an owner. /// /// public static class RetailAnimationCyclePlayback { /// /// Advances by elapsedSeconds * framerate /// and wraps it back into [lowFrame, highFrame] with the SAME modulo /// shape LiveEntityAnimationPresenter.Present's legacy branch uses /// (over % (span + 1), not a plain clamp — a frame position that /// overshoots the end by more than one span wraps around more than once /// rather than sticking at the boundary, matching a long stall/resume). /// Returns unchanged for a degenerate cycle /// ( <= ), a /// non-positive , or a non-positive /// . /// public static float Advance( float currFrame, int lowFrame, int highFrame, float framerate, float elapsedSeconds) { int span = highFrame - lowFrame; if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f) return currFrame; float next = currFrame + elapsedSeconds * framerate; if (next > highFrame) { float over = next - lowFrame; next = lowFrame + (over % (span + 1)); } else if (next < lowFrame) { next = lowFrame; } return next; } /// /// Resolves part 's origin/orientation at /// by linearly interpolating (lerp origin, /// slerp orientation) between the frame at floor(currFrame) and /// the next frame in the cycle (wrapping +1 /// back to ). Returns false — with /// default outputs — when is outside /// the bracketing frame's part list, matching /// LiveEntityAnimationPresenter.TryResolvePartFrame's no- /// sequence-frames branch exactly. /// public static bool TryInterpolatePart( Animation animation, float currFrame, int lowFrame, int highFrame, int partIndex, out Vector3 origin, out Quaternion orientation) { ArgumentNullException.ThrowIfNull(animation); int frameIndex = (int)MathF.Floor(currFrame); if (frameIndex < lowFrame || frameIndex > highFrame || frameIndex >= animation.PartFrames.Count) frameIndex = lowFrame; int nextIndex = frameIndex + 1; if (nextIndex > highFrame || nextIndex >= animation.PartFrames.Count) nextIndex = lowFrame; float t = Math.Clamp(currFrame - frameIndex, 0f, 1f); var frames = animation.PartFrames[frameIndex].Frames; var nextFrames = animation.PartFrames[nextIndex].Frames; if (partIndex < frames.Count) { var first = frames[partIndex]; var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; origin = Vector3.Lerp(first.Origin, next.Origin, t); orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); return true; } origin = default; orientation = default; return false; } }