Files
m3te/tools/key_particle.sx
swipelab 31d1012806 shed local vendors: stb + kb_text_shape + file_utils now ship with sx
The local vendors/ copies existed because the old modules/ffi/stb*.sx
resolved C paths CWD-relative, forcing every consumer to carry
identically-named copies. sx now ships these as proper library vendors
(#import "vendors/<name>/<name>.sx"), so the copies and the retired
ffi module imports both go. Verified: sx build --target ios-sim
bundles M3te.app; tools/run_tests.sh 23/23.
2026-06-12 18:35:12 +03:00

238 lines
7.9 KiB
Plaintext

// 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;
}