acdream/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs
Erik f7aa8e0eb7
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
fix: complete retail parity stability pass
2026-08-28 20:01:39 +02:00

111 lines
4.6 KiB
C#

using System;
using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail's simplest animation-clip playback shape: advance a frame position
/// at a fixed framerate and wrap it back into <c>[LowFrame, HighFrame]</c>,
/// then linearly interpolate one part's origin/orientation between the two
/// bracketing frames. This is the effect of
/// <c>CPhysicsObj::set_sequence_animation</c> (<c>0x0050F6F0</c>) when called
/// with a constant DID and a nonzero framerate and no further motion-command
/// traffic — e.g. <c>gmCG3DView::StartAnimation</c> (<c>0x004EE600</c>),
/// which plays the chargen preview's idle DID at a flat 30 fps with no
/// transitional blending.
///
/// <para>
/// This is the shared implementation used by both the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path and the chargen
/// preview. The live presenter has a live entity, a
/// <c>LiveEntityRuntime</c> 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 neither consumer can
/// own the primitive. Keeping it here prevents the two paths from drifting.
/// </para>
/// </summary>
public static class RetailAnimationCyclePlayback
{
/// <summary>
/// Advances <paramref name="currFrame"/> by <c>elapsedSeconds * framerate</c>
/// and wraps it back into <c>[lowFrame, highFrame]</c> with the SAME modulo
/// shape retail playback and the live presenter's legacy branch use
/// (<c>over % (span + 1)</c>, 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 <paramref name="currFrame"/> unchanged for a degenerate cycle
/// (<paramref name="highFrame"/> &lt;= <paramref name="lowFrame"/>) or a
/// non-positive <paramref name="elapsedSeconds"/>. A negative framerate
/// advances backward and clamps at the low frame, matching the former
/// live-presenter implementation exactly.
/// </summary>
public static float Advance(
float currFrame,
int lowFrame,
int highFrame,
float framerate,
float elapsedSeconds)
{
int span = highFrame - lowFrame;
if (span <= 0 || 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;
}
/// <summary>
/// Resolves part <paramref name="partIndex"/>'s origin/orientation at
/// <paramref name="currFrame"/> by linearly interpolating (lerp origin,
/// slerp orientation) between the frame at <c>floor(currFrame)</c> and
/// the next frame in the cycle (wrapping <paramref name="highFrame"/>+1
/// back to <paramref name="lowFrame"/>). Returns <c>false</c> — with
/// <c>default</c> outputs — when <paramref name="partIndex"/> is outside
/// the bracketing frame's part list, matching
/// the live presenter's no-sequence-frames branch exactly.
/// </summary>
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;
}
}