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

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) {