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:
BIN
assets/fx/particle.png
Normal file
BIN
assets/fx/particle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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
259
board_fx.sx
Normal 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
|
||||
}
|
||||
@@ -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
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
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
BIN
goldens/p6_fx_match.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
18
main.sx
18
main.sx
@@ -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
237
tools/key_particle.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user