Visual-juice vibe-pass, FX-only — no logic/state changes, input gating still owned by BoardAnim.active. - board_fx.sx: bigger, punchier match bursts — peak size 1.95->2.50 cells, combo bonus 0.55->0.72, and the per-gem fx tints saturated a touch (low channel trimmed, dominant/mid lifted) so every burst pops as a brighter, more vivid candy colour. The hot per-pixel tint loop's hoisted locals are preserved (issue 0001). - gem_anim.sx: snappier clear pop — faster rise (0.30->0.18 of the window) to a bigger overshoot (CLEAR_POP_A 0.22->0.34) so the matched-gem clear reads as a candy snap. gem_pose's clear-pop invariants still hold. - main.sx: M3TE_FX=<n> deterministic match-FX capture hook, mirroring the M3TE_SELECT pattern. Commits the n-th currently-legal swap at startup via the normal plan_and_commit path and begins the move timeline + burst/popup FX; M3TE_ANIM_TIME pins the phase and the frame loop holds the move/FX frozen while pinned, so the burst + "+points" screenshot identically every run. A larger M3TE_ANIM_TIME captures the settled, FX-gone board. Startup- only and guarded, so normal play is untouched. - README.md: document the new M3TE_FX pin alongside the other capture hooks. - goldens/p6_fx_match.png: updated deterministic golden (iOS 26 sim, SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22) — the vertical red 3-match, burst region +1.4% mean luminance / 3.2:1 brighter:dimmer vs the same scene on the pre-juice constants. Gate: ios-sim build links, 19/19 logic tests green (incl. gem_pose t=0 rest).
262 lines
9.5 KiB
Plaintext
262 lines
9.5 KiB
Plaintext
// 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: 2.50;
|
|
FX_BURST_COMBO :f32: 0.72; // 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;
|
|
|
|
// 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: s64) -> 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 };
|
|
|
|
// 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
|
|
}
|