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

18
main.sx
View File

@@ -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));