P6.2: score popups & match FX (sx, iOS sim)

Add a purely-visual, transient juice layer over a committed move — score
popups + a tinted particle/flash burst at the clears — with no change to the
model, score, moves, or settled board.

- assets/fx/particle.png: key the painted transparency checkerboard out of the
  provided particle art to real alpha (8-connected border flood fill +
  smooth luminance falloff that preserves the soft glow), downscaled to a
  256x256 RGBA white sparkle. tools/key_particle.sx is the reproducible tool.
- board_fx.sx: BoardFx (live particle bursts + one "+points" popup) and
  BoardFxAssets. The engine image path samples texture*white (no draw-time
  tint), so the white sprite is tinted per gem colour at LOAD time into one
  texture per colour; a burst animates by scale (grow -> shrink) and the soft
  texture edges carry the fade. Combos (cascade depth > 1) burst bigger and the
  popup is larger + gold. All driven by delta_time and self-pruning.
- board_anim.sx: AnimMove carries the model's cascade.awarded so the popup
  shows the real payout without re-deriving any scoring in the view.
- board_view.sx / main.sx: wire BoardFx + the tinted assets, tick each frame,
  spawn on a legal commit, and render bursts (clipped to the grid) under the
  popups (drawn on top). Input-lock (BoardAnim.active) is untouched; FX never
  gate input and may outlast the move slightly before vanishing.

Goldens (iPhone-17-class sim, iOS 26): p6_fx.png (combo: gold "+480" + bursts
mid-cascade), p6_fx_match.png (single match: "+30" + red burst), p6_fx_after.png
(settled board, FX fully gone). Gate: ios-sim build links, 15/15 logic tests
green (scoring/cascade goldens unchanged).
This commit is contained in:
swipelab
2026-06-05 02:18:55 +03:00
parent 907de09372
commit c2548aa854
9 changed files with 581 additions and 1 deletions

BIN
assets/fx/particle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -46,6 +46,8 @@ AnimRound :: struct {
// a legal swap has >=1 round and `final` is the settled board; an illegal swap
// has zero rounds, `pre == final`, and the view plays a slide-and-return. `a`/`b`
// are the swapped cells; `pre` is the board before the swap (the slide's start).
// `awarded` carries the model's own payout for this move (cascade.awarded) so the
// score-popup FX (P6.2) shows the real number without re-deriving any scoring.
AnimMove :: struct {
legal: bool;
a: Cell;
@@ -53,6 +55,7 @@ AnimMove :: struct {
pre: [BOARD_CELLS]Gem;
rounds: List(AnimRound);
final: [BOARD_CELLS]Gem;
awarded: s64;
}
// Commit the player's swap authoritatively AND record its visual timeline. The
@@ -65,6 +68,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move.b = b;
move.rounds = List(AnimRound).{};
move.pre = board.cells;
move.awarded = 0;
// Snapshot the entire model state (cells + RNG + score + moves) before the
// commit so the replay below is bit-identical to what commit_swap does.
@@ -72,6 +76,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
mv := commit_swap(board, a, b);
move.legal = mv.legal;
move.awarded = mv.cascade.awarded;
if !mv.legal {
move.final = board.cells;
return move;

259
board_fx.sx Normal file
View File

@@ -0,0 +1,259 @@
// Match FX & score popups (P6.2) — a PURELY VISUAL, transient layer played over
// a committed move. It never touches the model: it reads the recorded AnimMove
// (per-round matched cells + the model's own awarded points) and spawns short-
// lived particle bursts at the cleared cells plus one floating "+points" popup,
// all driven by the frame loop's delta_time. Everything is gone shortly after
// the move settles, and none of it gates input (that stays on BoardAnim.active).
//
// The provided art (assets/fx/particle.png) is a WHITE soft-glow sparkle; the
// engine's image path can't tint or fade a texture at draw time (it samples
// texture*white), so the white sprite is tinted per gem/combo colour HERE at
// load time into one texture per colour, and a burst animates by SCALE (grow →
// shrink to nothing) rather than alpha — the soft texture edges carry the fade.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
// Burst timing/size. A burst fires when its round's gems start clearing and
// lingers a touch into the fall; combos (cascade rounds past the first) burst
// bigger. Sizes are in CELL units (1.0 == one grid cell).
FX_BURST_LIFE :f32: 0.70;
FX_BURST_BASE :f32: 1.95;
FX_BURST_COMBO :f32: 0.55; // extra peak size per cascade depth (capped)
// Popup timing/motion. Rises ~1.2 cells over its life and fades out; a combo
// (depth > 1) popup is larger and gold.
FX_POPUP_LIFE :f32: 1.40;
FX_POPUP_RISE :f32: 1.2;
FX_POPUP_FONT :f32: 34.0;
FX_POPUP_COMBO_FONT :f32: 48.0;
// Bright, slightly pastel tints so a soft glow reads over the dark board, in gem
// order (red, orange, yellow, green, blue, purple).
fx_tint :: (i: s64) -> Color {
if i == 0 { return Color.{ r = 255, g = 86, b = 86, a = 255 }; }
if i == 1 { return Color.{ r = 255, g = 158, b = 64, a = 255 }; }
if i == 2 { return Color.{ r = 255, g = 234, b = 96, a = 255 }; }
if i == 3 { return Color.{ r = 120, g = 240, b = 120, a = 255 }; }
if i == 4 { return Color.{ r = 110, g = 184, b = 255, a = 255 }; }
Color.{ r = 206, g = 132, b = 255, a = 255 }
}
FX_POPUP_COLOR :: Color.{ r = 255, g = 255, b = 255, a = 255 };
FX_POPUP_COMBO_COLOR :: Color.{ r = 255, g = 222, b = 130, a = 255 };
// Upload an RGBA buffer as a texture, returning its handle. Mirrors
// board_view.load_texture's upload half but takes an in-memory buffer (the
// per-colour tinted particle) instead of a file path.
upload_rgba :: (pixels: [*]u8, w: s32, h: s32, gpu: ?GPU) -> u32 {
if gpu != null {
return xx gpu.create_texture(w, h, .rgba8, xx pixels);
}
tex : u32 = 0;
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
tex
}
// Loads the white particle once and bakes one tinted copy per colour. The white
// source's RGB is uniform, so a tint is just (tint.rgb, source.alpha) per pixel.
BoardFxAssets :: struct {
tex: [GEM_COUNT]u32;
loaded: bool;
init :: (self: *BoardFxAssets) {
for 0..GEM_COUNT: (t) { self.tex[t] = 0; }
self.loaded = false;
}
load :: (self: *BoardFxAssets, gpu: ?GPU) {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
if xx src == 0 {
out("WARNING: could not load assets/fx/particle.png\n");
self.loaded = false;
return;
}
n := cast(s64) w * cast(s64) h;
buf : [*]u8 = xx context.allocator.alloc(n * 4);
// Loop locals are hoisted: a block-scoped local declared inside a body
// that runs hundreds of thousands of times grows the stack per iteration
// (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars.
i : s64 = 0;
o : s64 = 0;
for 0..GEM_COUNT: (t) {
col := fx_tint(t);
i = 0;
while i < n {
o = i * 4;
buf[o] = col.r;
buf[o+1] = col.g;
buf[o+2] = col.b;
buf[o+3] = src[o+3];
i += 1;
}
self.tex[t] = upload_rgba(buf, w, h, gpu);
}
stbi_image_free(xx src);
self.loaded = true;
}
}
// A live burst: a soft glow centred on a board cell that grows then shrinks to
// nothing. `tint` indexes BoardFxAssets.tex; `delay` holds it invisible until
// its round's clear begins; `peak` is the peak size in cell units.
FxParticle :: struct {
col: f32;
row: f32;
tint: s64;
delay: f32;
age: f32;
life: f32;
peak: f32;
}
// A floating "+points" popup anchored at the initial clear's centroid, rising
// and fading over its life. `combo` selects the larger gold styling. Stores the
// raw points (not a formatted string): the label is built at render time in the
// frame's arena, so nothing allocated here has to outlive the spawning event.
FxPopup :: struct {
col: f32;
row: f32;
points: s64;
delay: f32;
age: f32;
life: f32;
combo: bool;
}
// Live FX state for the in-flight move. Heap-allocated (like BoardAnim) so it
// survives BoardView's per-frame rebuild; `tick` ages the FX and prunes the
// dead, and BoardView draws what is live.
BoardFx :: struct {
particles: List(FxParticle);
popups: List(FxPopup);
init :: (self: *BoardFx) {
self.particles = List(FxParticle).{};
self.popups = List(FxPopup).{};
}
clear :: (self: *BoardFx) {
self.particles.len = 0;
self.popups.len = 0;
}
// Spawn the FX for a committed legal move: a coloured burst at every cleared
// cell of every cascade round (timed to its clear), plus one popup showing
// the model's awarded points at the first round's centroid. Illegal moves
// (no clears, no award) spawn nothing.
begin :: (self: *BoardFx, mv: *AnimMove) {
self.clear();
if !mv.legal or mv.rounds.len == 0 { return; }
for 0..mv.rounds.len: (k) {
rd := @mv.rounds.items[k];
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
extra := FX_BURST_COMBO * cast(f32) min(k, 2);
for 0..BOARD_CELLS: (idx) {
if rd.matched.cells[idx] {
g := rd.before[idx];
if g != .empty {
self.particles.append(FxParticle.{
col = cast(f32) (idx % BOARD_COLS) + 0.5,
row = cast(f32) (idx / BOARD_COLS) + 0.5,
tint = cast(s64) g,
delay = t0,
age = 0.0,
life = FX_BURST_LIFE,
peak = FX_BURST_BASE + extra,
});
}
}
}
}
// One popup for the whole move at the first clear's centroid.
rd0 := @mv.rounds.items[0];
sc : s64 = 0;
sr : s64 = 0;
cnt : s64 = 0;
for 0..BOARD_CELLS: (idx) {
if rd0.matched.cells[idx] {
sc += idx % BOARD_COLS;
sr += idx / BOARD_COLS;
cnt += 1;
}
}
if cnt == 0 { return; }
self.popups.append(FxPopup.{
col = cast(f32) sc / cast(f32) cnt + 0.5,
row = cast(f32) sr / cast(f32) cnt + 0.5,
points = mv.awarded,
delay = SWAP_ANIM_DUR,
age = 0.0,
life = FX_POPUP_LIFE,
combo = mv.rounds.len > 1,
});
}
// Advance every live FX by `dt` and drop those past their lifetime. Kept
// simple: compact each list in place by overwriting dead entries.
tick :: (self: *BoardFx, dt: f32) {
w : s64 = 0;
i : s64 = 0;
while i < self.particles.len {
p := self.particles.items[i];
p.age += dt;
if p.age < p.delay + p.life {
self.particles.items[w] = p;
w += 1;
}
i += 1;
}
self.particles.len = w;
w = 0;
i = 0;
while i < self.popups.len {
q := self.popups.items[i];
q.age += dt;
if q.age < q.delay + q.life {
self.popups.items[w] = q;
w += 1;
}
i += 1;
}
self.popups.len = w;
}
}
// Burst size envelope over local progress 0..1: a fast rise to a peak then a
// fade back to zero, so a burst pops in and shrinks out (no alpha needed). 0
// outside [0,1].
fx_pop_env :: (t: f32) -> f32 {
if t <= 0.0 or t >= 1.0 { return 0.0; }
sin(PI * sqrt(t))
}
// Popup fade over local progress 0..1: full then ease-out to transparent.
fx_popup_fade :: (t: f32) -> f32 {
if t <= 0.0 { return 1.0; }
if t >= 1.0 { return 0.0; }
u := 1.0 - t;
u * u
}

View File

@@ -18,6 +18,7 @@
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "swipe.sx";
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
@@ -171,6 +172,8 @@ BoardView :: struct {
sel: *BoardSelection;
drag: *DragInput;
anim: *BoardAnim;
fx: *BoardFx;
fxassets: *BoardFxAssets;
safe: EdgeInsets;
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
@@ -319,6 +322,59 @@ BoardView :: struct {
}
}
// Transient match FX (P6.2): coloured glow bursts at the cleared cells,
// clipped to the grid so a burst's glow never bleeds over the HUD. Each
// burst grows then shrinks to nothing; the soft texture carries the fade.
render_fx_particles :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fxassets == null or !self.fxassets.loaded { return; }
if self.fx.particles.len == 0 { return; }
cs := self.layout.cell_size;
grid := Frame.make(
self.layout.origin.x, self.layout.origin.y,
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
for 0..self.fx.particles.len: (i) {
p := self.fx.particles.items[i];
lt := (p.age - p.delay) / p.life;
env := fx_pop_env(lt);
if env > 0.0 {
size := env * p.peak * cs;
cx := self.layout.origin.x + p.col * cs;
cy := self.layout.origin.y + p.row * cs;
gf := Frame.make(cx - size * 0.5, cy - size * 0.5, size, size);
ctx.add_image(gf, self.fxassets.tex[p.tint]);
}
}
ctx.pop_clip();
}
// Floating "+points" popups: rise and fade above the initial clear. Drawn
// unclipped (over everything) so the number stays legible as it lifts off
// the grid. The text path honours the colour's alpha, so these truly fade.
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fx.popups.len == 0 { return; }
cs := self.layout.cell_size;
for 0..self.fx.popups.len: (i) {
q := self.fx.popups.items[i];
lt := (q.age - q.delay) / q.life;
if lt >= 0.0 {
fade := fx_popup_fade(lt);
font := if q.combo then FX_POPUP_COMBO_FONT else FX_POPUP_FONT;
base := if q.combo then FX_POPUP_COMBO_COLOR else FX_POPUP_COLOR;
col := Color.{ r = base.r, g = base.g, b = base.b, a = cast(u8) (fade * 255.0) };
txt := format("+{}", q.points);
sz := measure_text(txt, font);
cx := self.layout.origin.x + q.col * cs;
cy := self.layout.origin.y + (q.row - lt * FX_POPUP_RISE) * cs;
ctx.add_text(
Frame.make(cx - sz.width * 0.5, cy - sz.height * 0.5, sz.width, sz.height),
txt, font, col
);
}
}
}
// Fall segment: every gem of the round's settled board slides from its source
// row (above the board for refills) down to its destination cell.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
@@ -387,6 +443,12 @@ impl View for BoardView {
// 4. HUD card with score + remaining moves, in the band above the grid.
avail := frame.inset(self.safe);
render_hud(ctx, self.board, avail);
// 5. Transient match FX over the board: coloured bursts at the cleared
// cells, then the floating "+points" popup on top. Purely visual and
// self-pruning, so they vanish once the move settles.
self.render_fx_particles(ctx);
self.render_fx_popups(ctx);
}
// Touch input. A press records the drag start; the release resolves the
@@ -416,6 +478,7 @@ impl View for BoardView {
if intent := swipe_intent(@self.layout, start, d.position) {
mv := plan_and_commit(self.board, intent.a, intent.b);
if self.anim != null { self.anim.begin(mv); }
if self.fx != null { self.fx.begin(@mv); }
self.sel.clear();
} else {
if hit := self.layout.point_to_cell(start) {

BIN
goldens/p6_fx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

BIN
goldens/p6_fx_after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

BIN
goldens/p6_fx_match.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

18
main.sx
View File

@@ -16,6 +16,7 @@
#import "board.sx";
#import "board_view.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#run configure_build();
@@ -56,10 +57,17 @@ g_drag : *DragInput = null;
// frames, so the timeline state must persist across BoardView's per-frame rebuild.
g_anim : *BoardAnim = null;
// Transient match FX + score popups (P6.2). Heap-allocated like the animation:
// a committed move spawns short-lived bursts/popups that play out (and prune
// themselves) over many later frames. `g_fxassets` holds the per-colour tinted
// particle textures, loaded once. Purely visual; neither gates input.
g_fx : *BoardFx = null;
g_fxassets : *BoardFxAssets = null;
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View {
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, safe = g_safe_insets }
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, safe = g_safe_insets }
}
frame :: () {
@@ -87,6 +95,7 @@ frame :: () {
// Advance the in-flight move animation by this frame's delta before rendering,
// so the board view draws the timeline slice for the current wall-clock time.
if g_anim != null { g_anim.tick(g_delta_time); }
if g_fx != null { g_fx.tick(g_delta_time); }
inline if OS == .ios {
// Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
@@ -167,6 +176,13 @@ main :: () -> void {
g_anim = xx context.allocator.alloc(size_of(BoardAnim));
g_anim.init();
g_fx = xx context.allocator.alloc(size_of(BoardFx));
g_fx.init();
g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
g_fxassets.init();
g_fxassets.load(g_pipeline.gpu);
g_pipeline.set_body(closure(build_ui));
g_plat.run_frame_loop(closure(frame));

237
tools/key_particle.sx Normal file
View File

@@ -0,0 +1,237 @@
// One-off asset-prep tool (not part of the game build): key the painted
// transparency checkerboard out of the provided particle art and emit a clean
// white RGBA sprite the engine tints per gem/combo colour at load time.
//
// Source: /Users/agra/Downloads/m3te_particle.png — 1254x1254 RGB (NO alpha):
// a white 4-point sparkle + soft radial glow painted over a gray transparency
// checkerboard. The foreground is strictly BRIGHTER than the checker, so the
// key is luminance-driven: an 8-connected flood fill from the border removes the
// edge-connected checker (same technique as the P4.1 gem / P4.2 cell keys), and
// the surviving glow's alpha ramps smoothly from the lightest checker shade up
// to pure white — preserving the soft falloff. RGB is forced white everywhere so
// a per-gem tint multiply yields a clean coloured glow.
//
// Output: assets/fx/particle.png — 256x256 RGBA (area-averaged downscale).
// Run from the repo root: /Users/agra/projects/sx/zig-out/bin/sx run tools/key_particle.sx
//
// NOTE (sx codegen): a block-scoped local declared INSIDE a loop body leaks
// stack per iteration, so a megapixel loop overflows. Every working local here
// is therefore hoisted above its loop and only assigned inside. The game itself
// never hits this — its loops run over 64 board cells, not millions of pixels.
#import "modules/std.sx";
#import "modules/math";
#import "modules/stb.sx";
SRC_PATH :: "/Users/agra/Downloads/m3te_particle.png";
OUT_PATH :: "assets/fx/particle.png";
OUT_DIM :: 256;
// Flood-fill predicate slack: a pixel is "checker" if it is near-neutral gray
// and no brighter than the lightest checker shade plus this margin. The glow
// core sits well above it, so the fill stops at the sparkle.
GRAY_TOL :: 24; // max channel spread still considered neutral gray
LUM_MARGIN :: 4; // lum headroom above the light checker shade
is_gray :: (r: u8, g: u8, b: u8) -> bool {
hi := max(max(cast(s64) r, cast(s64) g), cast(s64) b);
lo := min(min(cast(s64) r, cast(s64) g), cast(s64) b);
hi - lo <= GRAY_TOL
}
// Mark pixel `i` as removed background and queue it, if it is unvisited checker
// (near-neutral gray no brighter than the light checker shade + margin).
fd_seed :: (i: s64, bg: [*]u8, lum: [*]s64, src: [*]u8, stack: [*]s64, sp: *s64, lim: s64) {
if bg[i] != 0 { return; }
p := i * 4;
if lum[i] <= lim and is_gray(src[p], src[p+1], src[p+2]) {
bg[i] = 1;
stack[sp.*] = i;
sp.* += 1;
}
}
main :: () -> s32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
src : [*]u8 = xx stbi_load(SRC_PATH, @w, @h, @ch, 4);
if xx src == 0 {
print("FATAL: could not load {}\n", SRC_PATH);
return 1;
}
W := cast(s64) w;
H := cast(s64) h;
N := W * H;
print("loaded {}x{} ({} src channels)\n", w, h, ch);
// Hoisted working locals (see codegen note above).
y : s64 = 0;
x : s64 = 0;
i : s64 = 0;
p : s64 = 0;
r : s64 = 0;
g : s64 = 0;
b : s64 = 0;
l : s64 = 0;
// Per-pixel luminance, plus the checker shades read off the border ring
// (the border is pure checker — the glow never reaches the corners).
lum : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
c_lo : s64 = 255;
c_hi : s64 = 0;
y = 0;
while y < H {
x = 0;
while x < W {
i = y * W + x;
p = i * 4;
r = xx src[p];
g = xx src[p+1];
b = xx src[p+2];
l = (r + g + b) / 3;
lum[i] = l;
if x == 0 or y == 0 or x == W - 1 or y == H - 1 {
if l < c_lo { c_lo = l; }
if l > c_hi { c_hi = l; }
}
x += 1;
}
y += 1;
}
print("checker shades: lo={} hi={}\n", c_lo, c_hi);
// 8-connected flood fill of the edge-connected checker, seeded from every
// border pixel. `bg[i]==1` marks a removed (transparent) background pixel.
bg : [*]u8 = xx context.allocator.alloc(N);
memset(xx bg, 0, N);
stack : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
sp : s64 = 0;
checker_lim := c_hi + LUM_MARGIN;
x = 0;
while x < W {
fd_seed(x, bg, lum, src, stack, @sp, checker_lim);
fd_seed((H - 1) * W + x, bg, lum, src, stack, @sp, checker_lim);
x += 1;
}
y = 0;
while y < H {
fd_seed(y * W, bg, lum, src, stack, @sp, checker_lim);
fd_seed(y * W + (W - 1), bg, lum, src, stack, @sp, checker_lim);
y += 1;
}
cx : s64 = 0;
cy : s64 = 0;
dx : s64 = 0;
dy : s64 = 0;
nx : s64 = 0;
ny : s64 = 0;
while sp > 0 {
sp -= 1;
i = stack[sp];
cx = i % W;
cy = i / W;
dy = -1;
while dy <= 1 {
dx = -1;
while dx <= 1 {
if dx != 0 or dy != 0 {
nx = cx + dx;
ny = cy + dy;
if nx >= 0 and nx < W and ny >= 0 and ny < H {
fd_seed(ny * W + nx, bg, lum, src, stack, @sp, checker_lim);
}
}
dx += 1;
}
dy += 1;
}
}
// Full-res alpha: background → 0; everything else ramps from the lightest
// checker shade up to pure white, giving the glow its smooth falloff.
denom := cast(f32) (255 - c_hi);
if denom < 1.0 { denom = 1.0; }
alpha : [*]f32 = xx context.allocator.alloc(N * size_of(f32));
kept : s64 = 0;
n_bg : s64 = 0;
a : f32 = 0.0;
i = 0;
while i < N {
if bg[i] == 1 {
alpha[i] = 0.0;
n_bg += 1;
} else {
a = (cast(f32) (lum[i] - c_hi)) / denom;
a = clamp(a, 0.0, 1.0);
alpha[i] = a;
if a > 0.0 { kept += 1; }
}
i += 1;
}
print("flood removed {} bg px; kept {} glow px (of {})\n", n_bg, kept, N);
// Area-averaged downscale to OUT_DIM. RGB stays white; only the averaged
// alpha carries the sprite, so no premultiply is needed (white*cov == white).
out_px : [*]u8 = xx context.allocator.alloc(OUT_DIM * OUT_DIM * 4);
sxf := cast(f32) W / cast(f32) OUT_DIM;
syf := cast(f32) H / cast(f32) OUT_DIM;
max_a : f32 = 0.0;
ty : s64 = 0;
tx : s64 = 0;
x0 : s64 = 0;
x1 : s64 = 0;
y0 : s64 = 0;
y1 : s64 = 0;
sum : f32 = 0.0;
cnt : s64 = 0;
sy : s64 = 0;
sx : s64 = 0;
av : f32 = 0.0;
o : s64 = 0;
ty = 0;
while ty < OUT_DIM {
tx = 0;
while tx < OUT_DIM {
x0 = cast(s64) (cast(f32) tx * sxf);
x1 = cast(s64) (cast(f32) (tx + 1) * sxf);
y0 = cast(s64) (cast(f32) ty * syf);
y1 = cast(s64) (cast(f32) (ty + 1) * syf);
if x1 <= x0 { x1 = x0 + 1; }
if y1 <= y0 { y1 = y0 + 1; }
sum = 0.0;
cnt = 0;
sy = y0;
while sy < y1 {
sx = x0;
while sx < x1 {
sum += alpha[sy * W + sx];
cnt += 1;
sx += 1;
}
sy += 1;
}
av = sum / cast(f32) cnt;
if av > max_a { max_a = av; }
o = (ty * OUT_DIM + tx) * 4;
out_px[o] = 255;
out_px[o+1] = 255;
out_px[o+2] = 255;
out_px[o+3] = cast(u8) (clamp(av, 0.0, 1.0) * 255.0);
tx += 1;
}
ty += 1;
}
cc := (OUT_DIM / 2 * OUT_DIM + OUT_DIM / 2) * 4;
print("downscaled max alpha={} centre alpha={}\n", max_a, out_px[cc + 3]);
ok := stbi_write_png(OUT_PATH, OUT_DIM, OUT_DIM, 4, xx out_px, OUT_DIM * 4);
if ok == 0 {
print("FATAL: stbi_write_png failed for {}\n", OUT_PATH);
return 1;
}
stbi_image_free(xx src);
print("wrote {} ({}x{} RGBA)\n", OUT_PATH, OUT_DIM, OUT_DIM);
return 0;
}