From b9ca284f90c0199b614b3e4826889080f49f291c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 8 Jul 2026 22:05:00 -0400 Subject: [PATCH] feat: port anim interpolator (ping-pong spritesheet cell + color glow) on nowMs BlendMath.PingPong + SnapshotVisibleObjects(nowMs); no-arg overload keeps headless callers deterministic (parity held, 76/76 + sweep 284/13). Co-Authored-By: Claude Opus 4.8 --- .../Age.Engine.Tests/AnimInterpolatorTests.cs | 57 +++++++++++++++++++ engine/Age.Engine/Model/BlendMath.cs | 11 ++++ engine/Age.Engine/Model/GfxState.cs | 53 +++++++++++++++-- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 engine/Age.Engine.Tests/AnimInterpolatorTests.cs 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)