// 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 "vendors/stb_image/stb_image.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(i64) r, cast(i64) g), cast(i64) b); lo := min(min(cast(i64) r, cast(i64) g), cast(i64) 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: i64, bg: [*]u8, lum: [*]i64, src: [*]u8, stack: [*]i64, sp: *i64, lim: i64) { 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 :: () -> i32 { w : i32 = 0; h : i32 = 0; ch : i32 = 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(i64) w; H := cast(i64) h; N := W * H; print("loaded {}x{} ({} src channels)\n", w, h, ch); // Hoisted working locals (see codegen note above). y : i64 = 0; x : i64 = 0; i : i64 = 0; p : i64 = 0; r : i64 = 0; g : i64 = 0; b : i64 = 0; l : i64 = 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 : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64)); c_lo : i64 = 255; c_hi : i64 = 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_bytes(N); memset(xx bg, 0, N); stack : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64)); sp : i64 = 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 : i64 = 0; cy : i64 = 0; dx : i64 = 0; dy : i64 = 0; nx : i64 = 0; ny : i64 = 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_bytes(N * size_of(f32)); kept : i64 = 0; n_bg : i64 = 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_bytes(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 : i64 = 0; tx : i64 = 0; x0 : i64 = 0; x1 : i64 = 0; y0 : i64 = 0; y1 : i64 = 0; sum : f32 = 0.0; cnt : i64 = 0; sy : i64 = 0; sx : i64 = 0; av : f32 = 0.0; o : i64 = 0; ty = 0; while ty < OUT_DIM { tx = 0; while tx < OUT_DIM { x0 = cast(i64) (cast(f32) tx * sxf); x1 = cast(i64) (cast(f32) (tx + 1) * sxf); y0 = cast(i64) (cast(f32) ty * syf); y1 = cast(i64) (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; }