feat(core+app): Phase 6.4 — per-frame animation playback (breathing/idle cycles)

Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.

MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.

WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.

GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.

OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 19:08:08 +02:00
parent b96167e066
commit f0fa067566
3 changed files with 222 additions and 10 deletions

View file

@ -50,12 +50,65 @@ public static class MotionResolver
/// — pass that here when present so character outfits/states use the
/// correct table.
/// </param>
/// <summary>
/// Per-entity idle-cycle playback descriptor returned by
/// <see cref="GetIdleCycle"/>. Holds the resolved Animation plus the
/// frame range and framerate from the MotionTable's AnimData entry.
/// Phase 6.4 uses this to drive per-frame playback (so creatures
/// breathe instead of being frozen on a single frame).
/// </summary>
public sealed record IdleCycle(
Animation Animation,
int LowFrame,
int HighFrame,
float Framerate);
/// <summary>
/// Same resolution algorithm as <see cref="GetIdleFrame"/> but
/// returns the full cycle metadata so the caller can advance frames
/// over time. Returns null if any link in the chain is missing.
/// </summary>
public static IdleCycle? GetIdleCycle(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (anim, ad) = resolved.Value;
return new IdleCycle(anim, ad.LowFrame, ad.HighFrame, ad.Framerate);
}
public static AnimationFrame? GetIdleFrame(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (animation, animData) = resolved.Value;
int frameIdx = animData.LowFrame;
if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count)
frameIdx = 0;
return animation.PartFrames[frameIdx];
}
/// <summary>
/// Shared cycle-resolution path for both <see cref="GetIdleFrame"/>
/// and <see cref="GetIdleCycle"/>. Returns the loaded Animation +
/// the AnimData that describes its frame range and framerate, or
/// null if any link in the chain is missing.
/// </summary>
private static (Animation, AnimData)? ResolveIdleCycleInternal(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride,
ushort? stanceOverride,
ushort? commandOverride)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(dats);
@ -127,7 +180,7 @@ public static class MotionResolver
var animData = motionData.Anims[0];
// Step 3: load the Animation and grab the first frame.
// Load the Animation referenced by the cycle.
uint animId = (uint)animData.AnimId;
if (animId == 0) return null;
@ -135,12 +188,6 @@ public static class MotionResolver
if (animation is null) return null;
if (animation.PartFrames.Count == 0) return null;
// animData.LowFrame is the start frame index of the cycle within the
// animation. Clamp defensively in case it's out of range.
int frameIdx = animData.LowFrame;
if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count)
frameIdx = 0;
return animation.PartFrames[frameIdx];
return (animation, animData);
}
}