diff --git a/README.md b/README.md index 28df468..aa3f4bd 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,34 @@ env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \ With no variable set the game runs fully live (the clock advances by `delta_time`). `tests/gem_pose.sx` locks the `t==0`-rest invariant headlessly. + +### Level-state capture (P7.2) + +The win/lose banner and restart button are driven by the model's `level_status` +(score vs. goal vs. move budget). Three more env hooks force a terminal status +(or a restart) so the banner / restart states can be screenshot deterministically +without scripting a winning swipe — combine them with `M3TE_ANIM_TIME` to pin the +idle clock: + +- `M3TE_TARGET=` overrides the per-level score goal. `0` makes the fresh board + read **won** immediately (`score 0 ≥ goal 0`). +- `M3TE_MOVE_LIMIT=` overrides the move budget. `0` makes it read **lost** + (budget spent below the goal). +- `M3TE_RESTART=` runs `board.restart` after the overrides, capturing + the fresh `in_progress` board the restart button produces. + +```bash +# Win banner + restart over the board: goldens/p7_win.png +env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \ + xcrun simctl launch booted co.swipelab.m3te +# Lose banner ("OUT OF MOVES") + restart: goldens/p7_lose.png +env SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \ + xcrun simctl launch booted co.swipelab.m3te +# Fresh in_progress board after restart: goldens/p7_restart.png +env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_RESTART=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \ + xcrun simctl launch booted co.swipelab.m3te +``` + +While a banner is up the board freezes (only the restart button is live, per +P7.1's finished-level rule); `tests/banner_layout.sx` locks the restart button's +rect ↔ hit-test round-trip headlessly. diff --git a/board_layout.sx b/board_layout.sx index 6235517..c250856 100644 --- a/board_layout.sx +++ b/board_layout.sx @@ -49,4 +49,47 @@ BoardLayout :: struct { if col >= BOARD_COLS or row >= BOARD_ROWS { return null; } Cell.{ col = col, row = row } } + + // Frame of the whole 8×8 grid (origin + cols/rows × cell_size). The banner + // and its dimming overlay are sized off this so they cover exactly the board. + grid_frame :: (self: *BoardLayout) -> Frame { + Frame.make( + self.origin.x, self.origin.y, + self.cell_size * cast(f32) BOARD_COLS, + self.cell_size * cast(f32) BOARD_ROWS + ) + } + + // Win/lose banner geometry (P7.2): an overlay panel centered over the board + // grid, with the title band and the restart button inside it. Derived purely + // from the SAME grid layout the gems use, so the restart hit-test in + // BoardView.handle_event lands on exactly the button BoardView draws. The + // headless banner_layout test locks the button-rect ↔ hit-test round-trip. + banner :: (self: *BoardLayout) -> BannerLayout { + grid := self.grid_frame(); + cx := grid.mid_x(); + cy := grid.mid_y(); + + panel_w := grid.size.width * 0.84; + panel_h := grid.size.height * 0.44; + panel := Frame.make(cx - panel_w * 0.5, cy - panel_h * 0.5, panel_w, panel_h); + + title := Frame.make(panel.origin.x, panel.origin.y + panel_h * 0.18, panel_w, panel_h * 0.30); + + btn_w := panel_w * 0.60; + btn_h := panel_h * 0.24; + btn_y := panel.origin.y + panel_h - btn_h - panel_h * 0.16; + button := Frame.make(cx - btn_w * 0.5, btn_y, btn_w, btn_h); + + BannerLayout.{ panel = panel, title = title, button = button } + } +} + +// Resolved rectangles of the win/lose banner: the centered `panel`, the `title` +// band where the win/lose headline is centered, and the restart `button` rect +// (also the hit-test target). All in the same view-local space as BoardLayout. +BannerLayout :: struct { + panel: Frame; + title: Frame; + button: Frame; } diff --git a/board_view.sx b/board_view.sx index 069f75d..0c27434 100644 --- a/board_view.sx +++ b/board_view.sx @@ -40,6 +40,19 @@ HUD_LINE_GAP :f32: 6.0; HUD_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 }; HUD_PANEL :: Color.{ r = 12, g = 14, b = 22, a = 185 }; +// Win/lose banner (P7.2): a dim over the board, an opaque panel, the win/lose +// headline, and a restart button. Built from text + rects only — the engine's +// image path can't tint/fade at draw time (issue 0002), but rects and text DO +// honour colour + alpha, so the whole overlay is drawn with them. +BANNER_DIM :: Color.{ r = 6, g = 8, b = 14, a = 188 }; +BANNER_PANEL :: Color.{ r = 20, g = 24, b = 38, a = 240 }; +BANNER_WIN_TEXT :: Color.{ r = 120, g = 240, b = 150, a = 255 }; +BANNER_LOSE_TEXT :: Color.{ r = 255, g = 120, b = 110, a = 255 }; +BANNER_BTN :: Color.{ r = 64, g = 132, b = 224, a = 255 }; +BANNER_BTN_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 }; +BANNER_TITLE_FONT :f32: 52.0; +BANNER_BTN_FONT :f32: 30.0; + // UV sub-rect of one gem column, spanning the sheet's full height. GemUV :: struct { uv_min: Point; @@ -181,6 +194,9 @@ BoardView :: struct { fxassets: *BoardFxAssets; motion: *GemMotion; safe: EdgeInsets; + // Seed for `restart`: the same fixed seed main seeded the board with, so the + // restart button reproduces the identical starting level. + seed: s64; // Where the grid sits + the touch↔cell mapping. Recomputed each render / // event from the current frame so the hit-test matches what was drawn. @@ -430,6 +446,68 @@ BoardView :: struct { self.draw_gem(ctx, gf, cast(s64) g); } } + + // Whether the win/lose banner is up: the level is over AND any in-flight move + // animation has settled, so a winning/losing cascade plays to completion + // before the banner covers the board. Board input stays frozen the whole time + // the level is terminal (see handle_event), independent of this. + banner_up :: (self: *BoardView) -> bool { + if level_status(self.board) == .in_progress { return false; } + self.anim == null or !self.anim.active + } + + // Win/lose overlay (P7.2): dim the board, draw the centered panel, the + // win/lose headline, and the restart button — all text + rects so colour and + // alpha are honoured. The button rect comes from the shared BannerLayout, so + // it sits exactly where handle_event hit-tests the restart tap. + render_banner :: (self: *BoardView, ctx: *RenderContext, status: Status) { + ctx.add_rect(self.layout.grid_frame(), BANNER_DIM); + + bl := self.layout.banner(); + ctx.add_rounded_rect(bl.panel, BANNER_PANEL, 18.0); + + title := if status == .won then "YOU WIN!" else "OUT OF MOVES"; + tcol := if status == .won then BANNER_WIN_TEXT else BANNER_LOSE_TEXT; + tfont := fit_font(title, BANNER_TITLE_FONT, bl.title.size.width); + tsz := measure_text(title, tfont); + ctx.add_text( + Frame.make(bl.title.mid_x() - tsz.width * 0.5, bl.title.mid_y() - tsz.height * 0.5, tsz.width, tsz.height), + title, tfont, tcol + ); + + ctx.add_rounded_rect(bl.button, BANNER_BTN, 12.0); + btxt := "PLAY AGAIN"; + bfont := fit_font(btxt, BANNER_BTN_FONT, bl.button.size.width * 0.86); + bsz := measure_text(btxt, bfont); + ctx.add_text( + Frame.make(bl.button.mid_x() - bsz.width * 0.5, bl.button.mid_y() - bsz.height * 0.5, bsz.width, bsz.height), + btxt, bfont, BANNER_BTN_TEXT + ); + } + + // Restart action behind the banner's button: reseed the SAME starting level + // through the model (board.restart) and drop every transient view layer + // (selection, in-flight drag, move animation, FX, and the per-gem landing + // bounce) so the board returns to a clean, resting in_progress state. Without + // the motion reset a restart fired right after a terminal cascade would carry + // that move's landing squash onto the freshly seeded board. + do_restart :: (self: *BoardView) { + self.board.restart(self.seed); + self.sel.clear(); + self.drag.clear(); + if self.anim != null { self.anim.init(); } + if self.fx != null { self.fx.clear(); } + self.motion.reset_landings(); + } +} + +// Scale `base` font size down so `text` fits within `max_w` (measure_text scales +// linearly with font size, so one division lands it). Never scales up — a short +// headline keeps its size; only an over-wide one shrinks to fit the panel. +fit_font :: (text: string, base: f32, max_w: f32) -> f32 { + sz := measure_text(text, base); + if sz.width <= max_w or sz.width <= 0.0 { return base; } + base * max_w / sz.width } impl View for BoardView { @@ -489,6 +567,13 @@ impl View for BoardView { // self-pruning, so they vanish once the move settles. self.render_fx_particles(ctx); self.render_fx_popups(ctx); + + // 6. Win/lose banner over everything, once the level is over and the + // final cascade has settled. Status comes from the model (P7.1); the + // view never recomputes win/lose. + if self.banner_up() { + self.render_banner(ctx, level_status(self.board)); + } } // Touch input. A press records the drag start; the release resolves the @@ -500,6 +585,24 @@ impl View for BoardView { // behaviour: toggle the selection on the pressed cell, or clear it off-board. handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool { self.compute_layout(frame); + + // A finished level (won/lost) freezes board input: swipes/taps on cells + // are ignored. Status comes from the model (P7.1) — never recomputed + // here. Once the banner is up its restart button is the only live target; + // a tap inside it reseeds a fresh level through board.restart. + if level_status(self.board) != .in_progress { + if event.* == { + case .mouse_down: (d) { return true; } + case .mouse_up: (d) { + if self.banner_up() and self.layout.banner().button.contains(d.position) { + self.do_restart(); + } + return true; + } + } + return false; + } + if event.* == { case .mouse_down: (d) { // Gate input at gesture START: while a move animation is in @@ -536,13 +639,13 @@ impl View for BoardView { } } -// Draw the HUD card — current score and remaining moves (out of the move limit) -// — centered horizontally in the top of `avail`, the safe-area-inset region the -// grid is centered in. Reads live model state, so it tracks score/moves as the -// game progresses. A translucent panel sits behind the text for legibility over -// the board art. +// Draw the HUD card — current score against the per-level goal and the remaining +// moves (out of the move limit) — centered horizontally in the top of `avail`, +// the safe-area-inset region the grid is centered in. Reads live model state +// (score, target_score, moves), so it tracks the goal progress as the game runs. +// A translucent panel sits behind the text for legibility over the board art. render_hud :: (ctx: *RenderContext, board: *Board, avail: Frame) { - score_str := format("SCORE {}", board.score); + score_str := format("SCORE {} / {}", board.score, board.target_score); moves_str := format("MOVES {}/{}", board.moves_remaining(), board.move_limit); score_sz := measure_text(score_str, HUD_FONT); diff --git a/gem_anim.sx b/gem_anim.sx index 1cfca42..6cdead0 100644 --- a/gem_anim.sx +++ b/gem_anim.sx @@ -103,6 +103,14 @@ GemMotion :: struct { init :: (self: *GemMotion) { self.clock = 0.0; self.pinned = false; + self.reset_landings(); + } + + // Drop every landing stamp back to the never-landed sentinel so no cell + // carries a squash-bounce. `restart` calls this so a reseeded board starts at + // its resting pose instead of replaying the prior move's landing wobble; the + // idle clock keeps running, so the always-on idle simply resumes from rest. + reset_landings :: (self: *GemMotion) { for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; } } diff --git a/goldens/p7_lose.png b/goldens/p7_lose.png new file mode 100644 index 0000000..b2eeaee Binary files /dev/null and b/goldens/p7_lose.png differ diff --git a/goldens/p7_restart.png b/goldens/p7_restart.png new file mode 100644 index 0000000..7c938a4 Binary files /dev/null and b/goldens/p7_restart.png differ diff --git a/goldens/p7_win.png b/goldens/p7_win.png new file mode 100644 index 0000000..a240486 Binary files /dev/null and b/goldens/p7_win.png differ diff --git a/main.sx b/main.sx index 0c7f6b5..953bfea 100644 --- a/main.sx +++ b/main.sx @@ -82,7 +82,7 @@ g_anim_prev_active : bool = false; // 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, fx = g_fx, fxassets = g_fxassets, motion = g_motion, 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, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED } } // Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot @@ -300,6 +300,18 @@ main :: () -> void { } } + // Level-state capture hooks (P7.2): override the goal / move budget so a + // terminal status can be screenshot without scripting a swipe. M3TE_TARGET=0 + // makes the fresh board read WON immediately (score 0 ≥ goal 0); + // M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With + // M3TE_RESTART set non-zero the board is then restart()-ed, capturing the + // fresh in_progress board the restart button produces. + if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); } + if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); } + if rs := read_env("M3TE_RESTART") { + if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); } + } + g_pipeline.set_body(closure(build_ui)); g_plat.run_frame_loop(closure(frame)); diff --git a/tests/banner_layout.sx b/tests/banner_layout.sx new file mode 100644 index 0000000..4c8a2e1 --- /dev/null +++ b/tests/banner_layout.sx @@ -0,0 +1,81 @@ +// Banner / restart hit-test golden (P7.2): lock the win/lose banner geometry and +// the restart-button hit-test that `BoardView.handle_event` relies on. The button +// rect is derived from the SAME grid layout the gems use, and a finished level +// freezes every board-cell tap, so the button is the only live target — if its +// rect drifted from what `render_banner` draws, a tap on the visible button would +// miss and the level could never be restarted. This test checks, headlessly: +// +// * the button is centered over the grid and sits fully inside the panel, +// * the button centre hit-tests INTO the button (tap → restart), +// * an off-button tap (a board corner) hit-tests OUT (frozen, no restart). +// +// Imports BoardLayout (no GL/stb), not BoardView, so it links headless — same +// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero +// exit code (the runner checks exit code AND stdout). +#import "modules/std.sx"; +#import "board.sx"; +#import "board_layout.sx"; + +irect :: (f: Frame) -> string { + format("({},{},{},{})", + cast(s64) f.origin.x, cast(s64) f.origin.y, + cast(s64) f.size.width, cast(s64) f.size.height) +} + +main :: () -> s32 { + // 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the + // same layout tests/hit_test.sx pins, so the numbers are checkable by hand. + lay : BoardLayout = ---; + lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); + + grid := lay.grid_frame(); + bl := lay.banner(); + print("grid {}\n", irect(grid)); + print("panel {}\n", irect(bl.panel)); + print("title {}\n", irect(bl.title)); + print("button {}\n", irect(bl.button)); + + fails : s64 = 0; + + // The button is horizontally centered on the grid (centred banner). + bcx := bl.button.mid_x(); + if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; } + print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x()); + + // The whole button sits inside the panel — its four corners are contained, + // so it can never spill outside the drawn card. + bx0 := bl.button.origin.x; by0 := bl.button.origin.y; + bx1 := bl.button.max_x(); by1 := bl.button.max_y(); + corners_in : s64 = 0; + if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; } + if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; } + if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; } + if bl.panel.contains(Point.{ x = bx1, y = by1 }) { corners_in += 1; } + if corners_in != 4 { fails += 1; } + print("button corners inside panel: {}/4\n", corners_in); + + // A tap on the button centre restarts (hit-test true); the panel itself is + // also contained, so the centre is unambiguously on the button. + center := Point.{ x = bl.button.mid_x(), y = bl.button.mid_y() }; + hit_center := bl.button.contains(center); + if !hit_center { fails += 1; } + print("button center hit: {}\n", hit_center); + + // Off-button taps that a finished level must NOT treat as restart: the grid's + // top-left corner cell centre, and a point just outside the panel. Neither is + // in the button, so each leaves the level frozen. + corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 }; + outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() }; + off_hits : s64 = 0; + if bl.button.contains(corner_cell) { off_hits += 1; } + if bl.button.contains(outside) { off_hits += 1; } + if off_hits != 0 { fails += 1; } + print("off-button taps that hit the button: {}\n", off_hits); + + if fails == 0 { + print("ok: restart button hit-test matches the drawn banner\n"); + return 0; + } + print("FAIL: {} banner hit-test checks failed\n", fails); + return 1; +} diff --git a/tests/expected/banner_layout.exit b/tests/expected/banner_layout.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/banner_layout.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/banner_layout.stdout b/tests/expected/banner_layout.stdout new file mode 100644 index 0000000..164c3e3 --- /dev/null +++ b/tests/expected/banner_layout.stdout @@ -0,0 +1,9 @@ +grid (100,0,600,600) +panel (148,168,503,264) +title (148,215,503,79) +button (248,326,302,63) +button mid_x 400 grid mid_x 400 +button corners inside panel: 4/4 +button center hit: true +off-button taps that hit the button: 0 +ok: restart button hit-test matches the drawn banner diff --git a/tests/expected/gem_pose.stdout b/tests/expected/gem_pose.stdout index c9af136..390066a 100644 --- a/tests/expected/gem_pose.stdout +++ b/tests/expected/gem_pose.stdout @@ -10,4 +10,6 @@ land_start_rest true land_end_rest true land_mid_wobbles true clear_start_full true clear_end_gone true clear_overshoots true == gem motion land bookkeeping == motion_init true motion_no_land true motion_fresh_land true +== gem motion restart resets landings == +restart_pre_squashing true restart_post_rest true restart_clock_kept true ok: per-gem animation rests at t=0 and stays bounded diff --git a/tests/gem_pose.sx b/tests/gem_pose.sx index 9a7c480..29e26fe 100644 --- a/tests/gem_pose.sx +++ b/tests/gem_pose.sx @@ -96,6 +96,33 @@ main :: () -> s32 { if !no_land { fails += 1; } if !fresh_land { fails += 1; } + // 7. Restart resets the landing bounce. The restart button (BoardView. + // do_restart) reseeds the model AND must clear the per-gem landing state, + // or a restart fired right after a terminal cascade carries that move's + // squash onto the freshly seeded board. reset_landings is the factored + // reset do_restart calls: a cell stamped a moment ago is mid-squash, and + // after reset_landings every cell is back at rest — while the idle clock + // is deliberately left running (idle resumes from its normal phase). + print("== gem motion restart resets landings ==\n"); + r : GemMotion = ---; + r.init(); + r.clock = 5.0; + r.stamp_land(3); + r.stamp_land(42); + r.clock = 5.08; // 0.08s past impact: well inside LAND_DUR, so mid-squash. + pre_squashing := fabs(land_squash(r.land_local(3))) > 0.01 + and fabs(land_squash(r.land_local(42))) > 0.01; + r.reset_landings(); + post_rest := land_squash(r.land_local(3)) == 0.0 + and land_squash(r.land_local(42)) == 0.0 + and land_squash(r.land_local(0)) == 0.0; + clock_kept := r.clock == 5.08; + print("restart_pre_squashing {} restart_post_rest {} restart_clock_kept {}\n", + pre_squashing, post_rest, clock_kept); + if !pre_squashing { fails += 1; } + if !post_rest { fails += 1; } + if !clock_kept { fails += 1; } + if fails == 0 { print("ok: per-gem animation rests at t=0 and stays bounded\n"); return 0;