diff --git a/engine/Age.Engine.Tests/AnimInterpolatorTests.cs b/engine/Age.Engine.Tests/AnimInterpolatorTests.cs
new file mode 100644
index 0000000..b91aa21
--- /dev/null
+++ b/engine/Age.Engine.Tests/AnimInterpolatorTests.cs
@@ -0,0 +1,57 @@
+using System.Linq;
+using Age.Engine.Model;
+using Xunit;
+
+public class AnimInterpolatorTests
+{
+ private static GfxState VisibleObj(long handle)
+ {
+ var g = new GfxState();
+ g.SetSurface(1, resId: 5, colorKey: -1);
+ g.BindDraw(handle, 1, 0, 0, 256, 64, 100, 100); // 256x64 sheet, base pos (100,100)
+ return g;
+ }
+
+ [Fact]
+ public void PingPong_FoldsAtHalfPeriod()
+ {
+ Assert.Equal(0, BlendMath.PingPong(now: 0, start: 0, period: 1000));
+ Assert.Equal(500, BlendMath.PingPong(now: 500, start: 0, period: 1000)); // peak at half
+ Assert.Equal(0, BlendMath.PingPong(now: 1000, start: 0, period: 1000)); // back to base
+ }
+
+ [Fact]
+ public void SrcRect_StaticCell_SelectsSubRect()
+ {
+ var g = VisibleObj(0x100);
+ g.SetSrcRect(0x100, gridW: 4, gridH: 1, cell: 2, period: 0); // static cell 2 of 4 across 256px
+ var ro = g.SnapshotVisibleObjects(0).Single();
+ Assert.Equal(128, ro.SrcX); // cell 2 * 64
+ Assert.Equal(64, ro.W);
+ }
+
+ [Fact]
+ public void SrcRect_Animated_PingPongsCellAcrossGrid()
+ {
+ var g = VisibleObj(0x100);
+ g.SetSrcRect(0x100, gridW: 4, gridH: 1, cell: 0, period: 1000);
+ g.SnapshotVisibleObjects(0); // seeds start=0
+ var half = g.SnapshotVisibleObjects(500).Single();
+ Assert.Equal(192, half.SrcX); // t=1 -> last cell (3) * 64
+ var back = g.SnapshotVisibleObjects(1000).Single();
+ Assert.Equal(0, back.SrcX); // ping-ponged back to cell 0
+ }
+
+ [Fact]
+ public void ColorAnim_PingPongsAlphaTowardTarget()
+ {
+ var g = VisibleObj(0x100);
+ // base = opaque white (no static color); target = alpha 0 (fade out) => pulses opaque<->transparent
+ g.SetColorAnim(0x100, period: 1000, target: GfxState.PackColor(0x00, 0x000000));
+ g.SnapshotVisibleObjects(0); // seeds start=0
+ var opaque = g.SnapshotVisibleObjects(0).Single();
+ Assert.Equal(255, opaque.Alpha); // t=0 -> base opaque
+ var peak = g.SnapshotVisibleObjects(500).Single();
+ Assert.Equal(0, peak.Alpha); // t=1 -> target alpha 0
+ }
+}
diff --git a/engine/Age.Engine/Model/BlendMath.cs b/engine/Age.Engine/Model/BlendMath.cs
index ff65d74..272a544 100644
--- a/engine/Age.Engine/Model/BlendMath.cs
+++ b/engine/Age.Engine/Model/BlendMath.cs
@@ -19,4 +19,15 @@ public static class BlendMath
public static (int A, int R, int G, int B) UnpackArgb(long packed)
=> ((int)((packed >> 24) & 0xff), (int)((packed >> 16) & 0xff),
(int)((packed >> 8) & 0xff), (int)(packed & 0xff));
+
+ /// Ping-pong (triangle-wave) progress of gfx_object_anim_interpolate: fold (now-start) mod
+ /// period at period/2 so the value ramps to the peak at half-period then back. Returns u in
+ /// [0, period/2]; the interpolation weight toward the target is u / (period/2).
+ public static long PingPong(long now, long start, long period)
+ {
+ if (period <= 0) return 0;
+ long u = ((now - start) % period + period) % period;
+ if (u >= period / 2) u = period - u;
+ return u;
+ }
}
diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs
index c7ca1a2..7556b7d 100644
--- a/engine/Age.Engine/Model/GfxState.cs
+++ b/engine/Age.Engine/Model/GfxState.cs
@@ -216,9 +216,16 @@ public sealed class GfxState
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
}
- /// Visible objects in ascending-handle order (= the engine's z-order), each with its source
- /// surface (resId/colorkey) resolved from its live source slot — for the host per-frame compositor.
- public IReadOnlyList SnapshotVisibleObjects()
+ /// Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
+ /// callers and existing tests.
+ public IReadOnlyList SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
+
+ /// Visible objects in ascending-handle order (= z-order), each with its source surface resolved
+ /// and its active anim channels interpolated at (the port of
+ /// gfx_object_anim_interpolate). Position is the base V24 (a direct transform, ops 0x22f/0x229). The
+ /// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the
+ /// alpha/tint. Channel Start fields seed to nowMs on first sight.
+ public IReadOnlyList SnapshotVisibleObjects(long nowMs)
{
lock (_lock)
{
@@ -229,14 +236,44 @@ public sealed class GfxState
if (!o.Visible) continue;
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L);
+ // ---- color: slice-A static base, then op-0x232 ping-pong toward the target ----
int alpha = 255; long tint = 0xFFFFFF; var blend = BlendKind.Opaque;
if (o.HasColor)
{
var (a, r, g, b) = BlendMath.UnpackArgb(o.Color);
alpha = a; tint = ((long)r << 16) | ((long)g << 8) | (long)b; blend = BlendKind.Alpha;
}
+ if (o.ColorAnim)
+ {
+ if (o.ColorStart < 0) o.ColorStart = nowMs;
+ double t = PingPongWeight(nowMs, o.ColorStart, o.ColorPeriod);
+ var (ta, tr, tg, tb) = BlendMath.UnpackArgb(o.ColorTarget);
+ var (ba, br, bg, bb) = (alpha, (int)((tint >> 16) & 0xff), (int)((tint >> 8) & 0xff), (int)(tint & 0xff));
+ alpha = (int)(ba + (ta - ba) * t);
+ tint = ((long)(br + (tr - br) * t) << 16) | ((long)(bg + (tg - bg) * t) << 8) | (long)(bb + (tb - bb) * t);
+ blend = BlendKind.Alpha;
+ }
- list.Add(new RenderObject(kv.Key, resId, ck, o.SrcRect.X, o.SrcRect.Y, o.SrcRect.W, o.SrcRect.H,
+ // ---- src-rect: pick the spritesheet cell (static or ping-ponged across the grid) ----
+ int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
+ if (o.SrcAnim && o.SrcGridW >= 1)
+ {
+ int cellW = (int)(o.SrcRect.W / o.SrcGridW);
+ int cellH = o.SrcGridH >= 1 ? (int)(o.SrcRect.H / o.SrcGridH) : o.SrcRect.H;
+ long cell = o.SrcCell;
+ if (o.SrcPeriod > 0)
+ {
+ if (o.SrcStart < 0) o.SrcStart = nowMs;
+ double t = PingPongWeight(nowMs, o.SrcStart, o.SrcPeriod);
+ cell = (long)System.Math.Round(t * (o.SrcGridW - 1));
+ }
+ long cols = o.SrcGridW;
+ srcX = o.SrcRect.X + (int)(cell % cols) * cellW;
+ srcY = o.SrcRect.Y + (int)(cell / cols) * cellH;
+ w = cellW; h = cellH;
+ }
+
+ list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y,
new AnimState(o.AnimEnabled, o.AnimNormalized,
o.AnimTarget.X, o.AnimTarget.Y, o.AnimTarget.Z,
@@ -247,6 +284,14 @@ public sealed class GfxState
}
}
+ /// Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period.
+ private static double PingPongWeight(long now, long start, long period)
+ {
+ if (period <= 0) return 0;
+ long half = period / 2; if (half <= 0) return 0;
+ return (double)BlendMath.PingPong(now, start, period) / half;
+ }
+
/// Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the
/// common (non-negative-sentinel) case. The alpha<0 / color<0 native-fetch path is deferred.
public static long PackColor(long alpha, long color)