acdream/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs
Erik 2388fe7aa7 docs: CC6b-PRE re-review residuals R1/R2 + cross-branch renumbering
R1: the unsound elided-ctor-byte argument survived at its canonical
citation site (ChargenPreviewEntityBuilder's class doc, which the two
corrected docs point at) and in the ledger row's Deliverables column,
which contradicted its own review-status column. Both now carry the real
evidence: InitializePage @0x0047FDD0 writes an explicit m_bZoomedIn = 0
at 0x004802C3.

R2: the verified 180-degree initial heading (m_fCurHeading = 180f at
0x00480235 + SetPlayerHeading at 0x0048023F, cross-confirmed at
gmBarberUI::PostInit and the summary page) now has a durable home in the
CC6b-mount OWED list — without it the mount half ships a character
facing away from the camera.

Merge prep: the branch-local TS-82 renumbered to TS-84 (the CC4 branch
independently allocated TS-82 and landed first) and the branch-local
ISSUES #402 renumbered to #403 (same collision, same rule), with the
Core doc reference updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 19:39:16 +02:00

124 lines
5.4 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 exact advance-with-wrap-then-lerp/slerp algorithm already exists as
/// an inline, App-layer-only implementation for the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path —
/// <c>LiveEntityAnimationPresenter.Present</c>'s non-sequencer branch
/// (<c>CurrFrame += legacyAdvanceSeconds * Framerate</c> with the same
/// modulo wrap) and its private <c>TryResolvePartFrame</c> helper (the same
/// frame-bracket lerp/slerp). That call site 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 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 (<c>AcDream.App.Rendering.ChargenPreviewAnimator</c>)
/// consumes it directly, and it is safe for a future pass to redirect
/// <c>LiveEntityAnimationPresenter</c>'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
/// <c>docs/ISSUES.md</c> #403 so the follow-up has an owner.
/// </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 <c>LiveEntityAnimationPresenter.Present</c>'s legacy branch uses
/// (<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"/>), a
/// non-positive <paramref name="framerate"/>, or a non-positive
/// <paramref name="elapsedSeconds"/>.
/// </summary>
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;
}
/// <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
/// <c>LiveEntityAnimationPresenter.TryResolvePartFrame</c>'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;
}
}