diff --git a/README.md b/README.md index 7f2e351..07a0d45 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ deterministically — combine it with `M3TE_ANIM_TIME` to freeze the phase: The legal-swap order is the fixed enumeration in `tests/expected/swap_legality.stdout` (row-major, right-before-down). For seed 1337, `M3TE_FX=3` is the vertical red -3-match used by the golden. +3-match used by the golden, and `M3TE_FX=11` is a **depth-5 cascade** (the deepest +on this seed) used to capture the escalated combo emphasis (next section). ```bash # Punchy match burst + "+30" popup, pinned mid-clear: goldens/p6_fx_match.png @@ -144,3 +145,30 @@ env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \ env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \ xcrun simctl launch booted co.swipelab.m3te ``` + +### Escalating combo emphasis (P11.2) + +The combo FX escalates with cascade depth (`mv.rounds.len`), the SAME depth the +cascade SFX (`play_cascade`) steps up on: a deeper cascade gets a bigger, hotter- +gold `+points` popup topped by a `COMBO xN` label, and bursts that grow from the +first round. The depth→emphasis clamp (`fx_combo_level`) mirrors the cascade cue's +`cascade_cue_index` exactly (depth ≤ 1 → floor, depth ≥ 5 → ceiling); the +equivalence is locked headlessly by `tests/fx_combo.sx`. + +Capture it with the same `M3TE_FX` hook — `M3TE_FX=11` is a depth-5 cascade on +seed 1337, contrasted against the depth-1 single clear `M3TE_FX=3`: + +```bash +# Escalated COMBO x5 + gold "+1050" + bigger burst: goldens/p11_combo_deep.png +env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \ + xcrun simctl launch booted co.swipelab.m3te +# Single clear for contrast — plain white "+30", no COMBO label (goldens/p6_fx_match.png): +env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \ + xcrun simctl launch booted co.swipelab.m3te +# Deep cascade at a later phase — all combo FX gone over the settled board (no golden): +env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \ + xcrun simctl launch booted co.swipelab.m3te +``` + +The combo emphasis is purely visual and self-pruning: it never gates input +(`BoardAnim.active` owns gating) and never touches board / score / move state. diff --git a/board_fx.sx b/board_fx.sx index d95455e..f397912 100644 --- a/board_fx.sx +++ b/board_fx.sx @@ -22,18 +22,27 @@ #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). +// 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 cascade depth (capped) +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 larger and gold. +// 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 @@ -49,7 +58,45 @@ fx_tint :: (i: s64) -> Color { } 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 }; +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: s64) -> s64 { + 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: s64) -> 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: s64) -> 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 @@ -129,17 +176,19 @@ FxParticle :: struct { } // 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. +// 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: s64; + depth: s64; delay: f32; age: f32; life: f32; - combo: bool; } // Live FX state for the in-flight move. Heap-allocated (like BoardAnim) so it @@ -167,10 +216,13 @@ BoardFx :: struct { 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 := FX_BURST_COMBO * cast(f32) min(k, 2); + extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2); for 0..BOARD_CELLS: (idx) { if rd.matched.cells[idx] { g := rd.before[idx]; @@ -206,10 +258,10 @@ BoardFx :: struct { 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, - combo = mv.rounds.len > 1, }); } diff --git a/board_view.sx b/board_view.sx index 906cc48..f6bbbd6 100644 --- a/board_view.sx +++ b/board_view.sx @@ -428,6 +428,9 @@ BoardView :: struct { // 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. + // A combo (depth > 1) escalates with cascade depth: gold and larger, topped + // by a `COMBO xN` label naming the depth — the same depth the cascade SFX + // escalates on — so deeper cascades read as more exciting. render_fx_popups :: (self: *BoardView, ctx: *RenderContext) { if self.fx == null or self.fx.popups.len == 0 { return; } cs := self.layout.cell_size; @@ -436,8 +439,8 @@ BoardView :: struct { 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; + font := fx_popup_font(q.depth); + base := fx_popup_color(q.depth); 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); @@ -447,6 +450,16 @@ BoardView :: struct { Frame.make(cx - sz.width * 0.5, cy - sz.height * 0.5, sz.width, sz.height), txt, font, col ); + if q.depth > 1 { + lfont := font * FX_COMBO_LABEL_RATIO; + ltxt := format("COMBO x{}", q.depth); + lsz := measure_text(ltxt, lfont); + lcy := cy - sz.height * 0.5 - cs * FX_COMBO_LABEL_GAP - lsz.height * 0.5; + ctx.add_text( + Frame.make(cx - lsz.width * 0.5, lcy - lsz.height * 0.5, lsz.width, lsz.height), + ltxt, lfont, col + ); + } } } } diff --git a/goldens/p11_combo_deep.png b/goldens/p11_combo_deep.png new file mode 100644 index 0000000..93355b9 Binary files /dev/null and b/goldens/p11_combo_deep.png differ diff --git a/tests/expected/fx_combo.exit b/tests/expected/fx_combo.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/fx_combo.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/fx_combo.stdout b/tests/expected/fx_combo.stdout new file mode 100644 index 0000000..c213f61 --- /dev/null +++ b/tests/expected/fx_combo.stdout @@ -0,0 +1,12 @@ +== combo emphasis selection (depth -> fx level / popup font) == +depth 0 -> level 0 font 34.000000 combo false +depth 1 -> level 0 font 34.000000 combo false +depth 2 -> level 1 font 48.000000 combo true +depth 3 -> level 2 font 56.000000 combo true +depth 4 -> level 3 font 64.000000 combo true +depth 5 -> level 4 font 72.000000 combo true +depth 6 -> level 4 font 72.000000 combo true +depth 7 -> level 4 font 72.000000 combo true +depth 8 -> level 4 font 72.000000 combo true +depth 9 -> level 4 font 72.000000 combo true +ok: combo emphasis clamps into level 0..4 in lockstep with the cascade cue diff --git a/tests/fx_combo.sx b/tests/fx_combo.sx new file mode 100644 index 0000000..56de877 --- /dev/null +++ b/tests/fx_combo.sx @@ -0,0 +1,57 @@ +// P11.2 — Combo-emphasis selection snapshot: prove the cascade-depth → combo- +// emphasis mapping is a PURE, headless clamp that steps up in LOCKSTEP with the +// cascade SFX cue. The on-screen combo emphasis (`board_fx.sx`'s `fx_combo_level`, +// which drives the popup size/colour step + the burst depth boost) must use the +// SAME depth→level clamp the audio path uses (`audio.sx`'s `cascade_cue_index`): +// depth <= 1 pins to the floor, depth >= 5 to the ceiling, stepping up +// monotonically between — so a deeper cascade always looks AND sounds more +// escalated. `expect_level` below is exactly the index column locked by +// `tests/expected/cascade_cue.stdout`, so any drift on EITHER side breaks a +// snapshot. (audio.sx isn't imported here: its AudioToolbox `#foreign` symbols +// can't link into the same headless binary as board_fx.sx's GL imports.) The +// derived popup-font table is locked by this test's own committed snapshot. +#import "modules/std.sx"; +#import "board_fx.sx"; + +main :: () -> s32 { + print("== combo emphasis selection (depth -> fx level / popup font) ==\n"); + + // The cascade-cue index per depth 0..9, copied from cascade_cue.stdout. The + // FX level must equal this entry for entry — the audio/visual lockstep. + expect_level : [10]s64 = .{ 0, 0, 1, 2, 3, 4, 4, 4, 4, 4 }; + + prev : s64 = -1; + for 0..10: (depth) { + lvl := fx_combo_level(depth); + font := fx_popup_font(depth); + combo := depth > 1; + print("depth {} -> level {} font {} combo {}\n", depth, lvl, font, combo); + if lvl < prev { print("FAIL: fx level decreased at depth {}\n", depth); return 1; } + if lvl != expect_level[depth] { + print("FAIL: fx level {} != cascade cue index {} at depth {}\n", + lvl, expect_level[depth], depth); + return 1; + } + prev = lvl; + } + + // Explicit clamp boundaries, independent of the loop above. + if fx_combo_level(0) != 0 { print("FAIL: depth 0 not clamped to floor\n"); return 1; } + if fx_combo_level(1) != 0 { print("FAIL: depth 1 not clamped to floor\n"); return 1; } + if fx_combo_level(5) != FX_COMBO_MAX_LEVEL { print("FAIL: depth 5 not at ceiling\n"); return 1; } + if fx_combo_level(9) != FX_COMBO_MAX_LEVEL { print("FAIL: deep cascade not clamped to ceiling\n"); return 1; } + + // A single clear (depth 1) keeps the plain popup font; a combo is strictly + // larger and the font never shrinks as the cascade deepens. + if fx_popup_font(1) != FX_POPUP_FONT { print("FAIL: single-clear popup not plain font\n"); return 1; } + pf : f32 = 0.0; + for 2..10: (depth) { + f := fx_popup_font(depth); + if f <= FX_POPUP_FONT { print("FAIL: combo popup not larger than plain at depth {}\n", depth); return 1; } + if depth > 2 and f < pf { print("FAIL: popup font shrank at depth {}\n", depth); return 1; } + pf = f; + } + + print("ok: combo emphasis clamps into level 0..{} in lockstep with the cascade cue\n", FX_COMBO_MAX_LEVEL); + return 0; +}