From 24f585ebfd9aedf001bbbf960eda089ff04aef2c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 23:48:23 -0400 Subject: [PATCH] feat(gfx): Godot per-object alpha tween + alpha-aware blit + --shot-settle Retained-object animation channel wired into the compositor: wall-clock tween of the anim channel over the GLOBAL clock (0x238), opacity from the 3rd vec component, applied via an alpha-aware BlitLayer. Non-regressing (SC0000 opening page 2 pixel- identical at settle 3 and 300; selftest OK). Added --shot-settle to capture mid-tween. NOTE: this drives RETAINED-object animations; the opening AE* explosion (AE001D/ AE002B/AE003B) is an immediate-mode slot-0 frame sequence paced by sleep/the render loop, which our instant execution burst collapses -> needs frame-pacing (scene- coroutine backlog), not this per-object channel. Co-Authored-By: Claude Opus 4.8 --- godot/Main.cs | 77 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/godot/Main.cs b/godot/Main.cs index 1a06155..abd1c21 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -27,6 +27,8 @@ public partial class Main : Godot.Control private int _shotPage = 1; // --shot-page : which page to capture (default 1) private volatile int _pageCount; private int _shotSettle; + private int _shotSettleTarget = 3; // --shot-settle : settle N frames before grabbing (to + // capture mid-tween — the anim clock keeps running while parked) private bool _shotDone; public override void _Ready() @@ -84,6 +86,7 @@ public partial class Main : Godot.Control if (userArgs[i] == "--scene" && i + 1 < userArgs.Length) scene = userArgs[i + 1]; if (userArgs[i] == "--shot" && i + 1 < userArgs.Length) _shotPath = userArgs[i + 1]; if (userArgs[i] == "--shot-page" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _shotPage); + if (userArgs[i] == "--shot-settle" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _shotSettleTarget); if (userArgs[i] == "--seed" && i + 1 < userArgs.Length) { var kv = userArgs[i + 1].Split('='); @@ -129,11 +132,12 @@ public partial class Main : Godot.Control public override void _Process(double delta) { + _lastDelta = delta; if (!_selftest && _vm != null) Recomposite(); // retained per-frame compositor (surface+object model) // --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it. if (_shotPath != null && !_shotDone && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) { - if (++_shotSettle >= 3) + if (++_shotSettle >= _shotSettleTarget) { _shotDone = true; var img = GetViewport().GetTexture().GetImage(); @@ -168,22 +172,65 @@ public partial class Main : Godot.Control // ---- retained per-frame compositor (main thread, from _Process) ---- // Clear the screen and composite the VM's current VISIBLE gfx objects in ascending-handle order (= the // engine's z-order), each blitting its live surface's rect at its position. Surfaces are cached by BMP - // path (this runs every frame). Alpha/colorkey/animation come in later phases (objects opaque here). + // path (this runs every frame). Animated objects tween over the global anim-clock (0x238); their opacity + // is applied by the alpha-aware BlitLayer. See docs/engine-re.md "sprite transform / ANIMATION cluster". private readonly System.Collections.Generic.Dictionary _imgCache = new(); + // Per-handle wall-clock tween of the animation channel. The engine's clock (op 0x238) is a GLOBAL, + // non-blocking clock; the host advances it here while the VM is parked at wait-for-input. Opacity comes + // from the 3rd anim vec component (TZ), data-driven from the opening (100=full, 0=clear). + private sealed class TweenState + { + public long Generation = long.MinValue; + public bool Initialized; + public double Elapsed, Duration; + public double StartA, TargetA, CurrentA = 1.0; + } + private readonly System.Collections.Generic.Dictionary _tweens = new(); + private long _lastClockGen = long.MinValue; + private double _lastDelta; + private const double GameTickSeconds = 1.0 / 60.0; // anim-clock ticks -> seconds (game runs ~60fps) + private void Recomposite() { _screen.Fill(new Color(0, 0, 0, 0)); + long clockGen = _vm.Gfx.AnimClockGeneration; + double clockDur = System.Math.Max(1, _vm.Gfx.AnimClockDurationTicks) * GameTickSeconds; + bool clockReset = clockGen != _lastClockGen; + _lastClockGen = clockGen; foreach (var v in _vm.Gfx.SnapshotVisibleObjects()) // already ascending-handle = z-order { if (v.SurfaceResId == 0) continue; // render-target/blank surface (no file) — later phase var bmp = _host.ResolveResIdTexture(v.SurfaceResId); - if (bmp != null) BlitLayer(bmp, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY); + if (bmp == null) continue; + BlitLayer(bmp, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, AlphaFor(v, clockReset, clockDur)); } _screenTex.Update(_screen); } - private void BlitLayer(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY) + // Current opacity for a visible object: 1.0 unless it has an active anim channel, in which case tween the + // 3rd vec component (TZ, ~percent) over the global clock. Start opaque on first sight so a CG never + // begins invisible (the safe direction); re-arm whenever the object's or the clock's generation bumps. + private float AlphaFor(Age.Engine.Model.RenderObject v, bool clockReset, double clockDur) + { + if (!v.Anim.Enabled) return 1f; + var tw = _tweens.TryGetValue(v.Handle, out var t) ? t : (_tweens[v.Handle] = new TweenState()); + double targetA = System.Math.Clamp(v.Anim.TZ / 100.0, 0, 1); + if (clockReset || tw.Generation != v.Anim.Generation) + { + tw.Generation = v.Anim.Generation; + tw.Elapsed = 0; tw.Duration = clockDur; + tw.StartA = tw.Initialized ? tw.CurrentA : 1.0; // hold previous on-screen alpha; first sight opaque + tw.TargetA = targetA; + tw.Initialized = true; + } + tw.Elapsed += _lastDelta; + double p = tw.Duration > 0 ? System.Math.Clamp(tw.Elapsed / tw.Duration, 0, 1) : 1; + tw.CurrentA = tw.StartA + (tw.TargetA - tw.StartA) * p; + return (float)tw.CurrentA; + } + + private void BlitLayer(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY, float alpha = 1f) { if (!_imgCache.TryGetValue(bmpPath, out var src)) { @@ -200,7 +247,27 @@ public partial class Main : Godot.Control sw = System.Math.Min(sw, src.GetWidth() - srcX); sh = System.Math.Min(sh, src.GetHeight() - srcY); if (sw <= 0 || sh <= 0) return; - _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY)); + if (alpha >= 0.999f) // fast opaque path (unchanged behaviour for non-animating objects) + { + _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY)); + return; + } + // Alpha composite over the raw RGBA byte buffer: out = src*(sa) + dst*(1-sa), sa = srcAlpha * objAlpha. + byte[] dst = _screen.GetData(); byte[] ss = src.GetData(); + int dw = _screen.GetWidth(), sfw = src.GetWidth(); + int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255); + for (int y = 0; y < sh; y++) + for (int x = 0; x < sw; x++) + { + int dxp = dstX + x, dyp = dstY + y; + if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= _screen.GetHeight()) continue; + int di = (dyp * dw + dxp) * 4; + int si = ((srcY + y) * sfw + (srcX + x)) * 4; + int sa = ss[si + 3] * ia / 255; + for (int c = 0; c < 3; c++) dst[di + c] = (byte)((ss[si + c] * sa + dst[di + c] * (255 - sa)) / 255); + dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + sa); + } + _screen.SetData(dw, _screen.GetHeight(), false, _screen.GetFormat(), dst); } // Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line.