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.
238 lines
7.9 KiB
Plaintext
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;
|
|
}
|