// 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/ffi/opengl.sx"; #import "vendors/stb_image/stb_image.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, growing in two ways so deeper cascades read as // more exciting: a per-move DEPTH boost lifts every burst of a deep cascade from // its first round (`FX_BURST_DEPTH` per combo level, escalating in lockstep with // the cascade SFX), and a per-round bump grows the later rounds within a move // (`FX_BURST_COMBO`, capped). Sizes are in CELL units (1.0 == one grid cell). FX_BURST_LIFE :f32: 0.70; FX_BURST_BASE :f32: 2.50; FX_BURST_COMBO :f32: 0.72; // extra peak size per round index within a move (capped) FX_BURST_DEPTH :f32: 0.45; // extra peak size per cascade combo level (whole move) // Popup timing/motion. Rises ~1.2 cells over its life and fades out. A combo // (depth > 1) popup is gold and grows one step per combo level, topped by a // `COMBO xN` label naming the cascade depth; both escalate in lockstep with the // cascade SFX cue (see `fx_combo_level`). 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; FX_POPUP_COMBO_STEP :f32: 8.0; // extra popup font per combo level past the base FX_COMBO_LABEL_RATIO :f32: 0.55; // `COMBO xN` label font as a fraction of the +points font FX_COMBO_LABEL_GAP :f32: 0.12; // gap (cell units) between the label and +points // Vivid candy tints so a soft glow reads brightly over the dark board, in gem // order (red, orange, yellow, green, blue, purple). Saturated a touch past the // pastel — the low channel is trimmed while the dominant/mid channel is lifted — // so every burst pops as a punchier colour without losing luminance. fx_tint :: (i: i64) -> Color { if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; } if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; } if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; } if i == 3 { return Color.{ r = 112, g = 250, b = 112, a = 255 }; } if i == 4 { return Color.{ r = 96, g = 192, b = 255, a = 255 }; } Color.{ r = 224, g = 124, 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 }; // base gold (depth 2) FX_POPUP_COMBO_HOT :: Color.{ r = 255, g = 248, b = 214, a = 255 }; // hot near-white gold (deepest) // Cascade depth (`mv.rounds.len`) -> combo emphasis level. Mirrors audio.sx's // `cascade_cue_index` clamp EXACTLY (depth <= 1 -> 0, depth >= 5 -> the max), so // the on-screen combo emphasis (popup size/colour + burst boost) steps up in // lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the // equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx). FX_COMBO_MAX_LEVEL :: 4; // == audio.sx COMBO_CLIPS - 1 fx_combo_level :: (depth: i64) -> i64 { if depth <= 1 { return 0; } if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; } depth - 1 } // Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1) // uses the plain size; a combo starts at the base combo size and grows one step // per combo level past the first, clamped at the deepest level. fx_popup_font :: (depth: i64) -> f32 { if depth <= 1 { return FX_POPUP_FONT; } FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1) } // Popup colour for a cascade `depth` rounds deep: white for a single clear, else // the gold lerped toward a hot near-white as the cascade deepens. fx_popup_color :: (depth: i64) -> Color { if depth <= 1 { return FX_POPUP_COLOR; } t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1); Color.{ r = fx_lerp_u8(FX_POPUP_COMBO_COLOR.r, FX_POPUP_COMBO_HOT.r, t), g = fx_lerp_u8(FX_POPUP_COMBO_COLOR.g, FX_POPUP_COMBO_HOT.g, t), b = fx_lerp_u8(FX_POPUP_COMBO_COLOR.b, FX_POPUP_COMBO_HOT.b, t), a = 255, } } fx_lerp_u8 :: (lo: u8, hi: u8, t: f32) -> u8 { cast(u8) (cast(f32) lo + (cast(f32) hi - cast(f32) lo) * t) } // 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: i32, h: i32, 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 : i32 = 0; h : i32 = 0; ch : i32 = 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(i64) w * cast(i64) h; buf : [*]u8 = xx context.allocator.alloc_bytes(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 : i64 = 0; o : i64 = 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: i64; 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. `depth` is the cascade depth (`mv.rounds.len`): it // drives the combo styling at render time (gold size step + `COMBO xN` label for // depth > 1). 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: i64; depth: i64; delay: f32; age: f32; life: f32; } // 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; } // Whole-move depth boost: a deeper cascade makes every burst bigger from // its first round, escalating in lockstep with the cascade SFX cue. depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len); 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 := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2); // Stagger each burst's START by its gem's clear-ripple rank so the // bursts ripple in lockstep with the staggered pops (P18.2) instead of // one simultaneous flash. The round's audio cue still fires once at t0. span := clear_diag_span(@rd.matched); for 0..BOARD_CELLS (idx) { if rd.matched.cells[idx] { g := rd.before[idx]; if g != .empty { col := idx % BOARD_COLS; row := idx / BOARD_COLS; rdelay := CLEAR_STAGGER_MAX * clear_rank(span, col, row) * CLEAR_ANIM_DUR; self.particles.append(FxParticle.{ col = cast(f32) col + 0.5, row = cast(f32) row + 0.5, tint = cast(i64) g, delay = t0 + rdelay, 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 : i64 = 0; sr : i64 = 0; cnt : i64 = 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, depth = mv.rounds.len, delay = SWAP_ANIM_DUR, age = 0.0, life = FX_POPUP_LIFE, }); } // 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 : i64 = 0; i : i64 = 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 }