diff --git a/board_view.sx b/board_view.sx index 8c50168..68a8fb5 100644 --- a/board_view.sx +++ b/board_view.sx @@ -17,6 +17,7 @@ #import "modules/ui/font.sx"; #import "board.sx"; #import "board_layout.sx"; +#import "swipe.sx"; // Fraction of a cell each gem occupies; the remainder is margin so a gem sits // inside its cell tile rather than touching the tile's edges. @@ -112,7 +113,8 @@ load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 { // Which board cell the player has currently selected, if any. Lives behind a // pointer (heap-allocated in main) because BoardView is a value rebuilt every // frame from `build_ui`, so the view itself cannot carry state across frames. -// Selection only — P5 turns a selected→adjacent tap into a swap. +// A tap toggles this highlight; a swipe commits a swap (see DragInput) and +// clears it. BoardSelection :: struct { active: bool; cell: Cell; @@ -138,10 +140,35 @@ BoardSelection :: struct { } } +// Tracks an in-progress touch drag between its press and release so a swipe can +// be resolved on lift: the press records the start point, and release maps +// start→end through `swipe_intent` to an adjacent-swap intent. Heap-allocated +// (like BoardSelection) so it survives BoardView's per-frame rebuild between the +// down (touchesBegan → mouse_down) and up (touchesEnded → mouse_up) events. +DragInput :: struct { + active: bool; + start: Point; + + init :: (self: *DragInput) { + self.active = false; + self.start = Point.{ x = 0.0, y = 0.0 }; + } + + begin :: (self: *DragInput, p: Point) { + self.active = true; + self.start = p; + } + + clear :: (self: *DragInput) { + self.active = false; + } +} + BoardView :: struct { board: *Board; assets: *BoardAssets; sel: *BoardSelection; + drag: *DragInput; safe: EdgeInsets; // Where the grid sits + the touch↔cell mapping. Recomputed each render / @@ -209,14 +236,33 @@ impl View for BoardView { render_hud(ctx, self.board, avail); } + // Touch input. A press records the drag start; the release resolves the + // gesture against the SAME layout it was drawn with. A swipe (start→end maps + // to an adjacent-swap intent) is fed straight into `commit_swap`: a legal + // swap applies, cascades, scores and spends a move, an illegal one reverts — + // either way the next frame re-renders the board + HUD from the model. A + // sub-threshold / off-board drag carries no intent and falls back to the tap + // 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); if event.* == { case .mouse_down: (d) { - if hit := self.layout.point_to_cell(d.position) { - self.sel.toggle(hit); - } else { + self.drag.begin(d.position); + return true; + } + case .mouse_up: (d) { + if !self.drag.active { return false; } + start := self.drag.start; + self.drag.clear(); + if intent := swipe_intent(@self.layout, start, d.position) { + commit_swap(self.board, intent.a, intent.b); self.sel.clear(); + } else { + if hit := self.layout.point_to_cell(start) { + self.sel.toggle(hit); + } else { + self.sel.clear(); + } } return true; } diff --git a/goldens/p5_swap_after.png b/goldens/p5_swap_after.png new file mode 100644 index 0000000..9dcc8db Binary files /dev/null and b/goldens/p5_swap_after.png differ diff --git a/goldens/p5_swap_before.png b/goldens/p5_swap_before.png new file mode 100644 index 0000000..122b9ce Binary files /dev/null and b/goldens/p5_swap_before.png differ diff --git a/main.sx b/main.sx index cbc3574..f42600d 100644 --- a/main.sx +++ b/main.sx @@ -44,10 +44,15 @@ g_assets : *BoardAssets = null; // per-frame rebuild; a tap hit-tests a cell and toggles this. g_sel : *BoardSelection = null; +// In-progress touch drag (P5.2). Heap-allocated for the same reason: the press +// and release that bracket a swipe land on different per-frame BoardView values, +// so the drag start must persist between them. +g_drag : *DragInput = 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, safe = g_safe_insets } + BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, safe = g_safe_insets } } frame :: () { @@ -144,6 +149,9 @@ main :: () -> void { g_sel = xx context.allocator.alloc(size_of(BoardSelection)); g_sel.init(); + g_drag = xx context.allocator.alloc(size_of(DragInput)); + g_drag.init(); + g_pipeline.set_body(closure(build_ui)); g_plat.run_frame_loop(closure(frame)); diff --git a/tests/expected/swipe_commit.exit b/tests/expected/swipe_commit.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/swipe_commit.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/swipe_commit.stdout b/tests/expected/swipe_commit.stdout new file mode 100644 index 0000000..984f4b1 --- /dev/null +++ b/tests/expected/swipe_commit.stdout @@ -0,0 +1,24 @@ +== illegal swipe reverts == +RRPPOGRG +PGPOPRRO +YYBBYRYB +GBYYRGGP +OGBRRORY +BYRRPRBG +YOYYROBB +OROBPPRB +intent (0,0)->(1,0) +legal false awarded 0 score 0 moves_made 0 moves_remaining 30 +== legal swipe commits == +intent (5,4)->(6,4) +legal true depth 1 awarded 30 score 30 moves_made 1 moves_remaining 29 +after: +RRPBORRG +PGPPOGRO +YYBOPRYB +GBYBYRGP +OGBYRGOY +BYRRPRBG +YOYYROBB +OROBPPRB +ok: swipe reverts illegal, commits legal diff --git a/tests/swipe_commit.sx b/tests/swipe_commit.sx new file mode 100644 index 0000000..908e151 --- /dev/null +++ b/tests/swipe_commit.sx @@ -0,0 +1,104 @@ +// Swipe→commit wiring golden (P5.2): prove the full input-to-model path P5.2 +// adds — a touch drag resolved by `swipe_intent` and fed straight into +// `commit_swap` — on the SAME seeded board the iOS app renders (SEED 1337). +// Feeds SYNTHETIC down/up screen positions built from a BoardLayout, resolves +// the swap intent, then commits it, asserting: +// - an ILLEGAL swipe ((0,0)->(1,0): two reds → no match) reverts: the board is +// byte-for-byte unchanged, score stays 0, and no move is spent; +// - a known LEGAL swipe ((5,4)->(6,4): brings R into (5,4), completing R,R,R +// across cols 3-5 of row 4) commits: the board changes, score accrues, and +// exactly one move is spent. +// No rendering, no model reach-around — it calls exactly what BoardView.handle_event +// calls. Links headless like tests/swipe_intent.sx; avoids tests/test.sx because +// its trace.sx pulls in a second `Frame` that collides with the UI one. 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"; +#import "swipe.sx"; + +SEED :: 1337; + +cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point { + cf := lay.cell_frame(col, row); + Point.{ x = cf.mid_x(), y = cf.mid_y() } +} + +boards_equal :: (x: *Board, y: *Board) -> bool { + for 0..BOARD_CELLS: (i) { + if !(x.cells[i] == y.cells[i]) { return false; } + } + true +} + +main :: () -> s32 { + // 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A + // 60px drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis. + lay : BoardLayout = ---; + lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); + D : f32 = 60.0; + + fails : s64 = 0; + + // ── ILLEGAL swipe reverts ────────────────────────────────────────────── + // (0,0) and (1,0) are both red on the seed board, so swapping them forms no + // match. The rightward drag must resolve to exactly this pair, and + // commit_swap must reject it — board untouched, score 0, no move spent. + print("== illegal swipe reverts ==\n"); + bi : Board = ---; + bi.init(SEED); + before : Board = bi; + out(board_dump(@bi)); + + a_il := cell_center(@lay, 0, 0); + if s := swipe_intent(@lay, a_il, Point.{ x = a_il.x + D, y = a_il.y }) { + print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row); + if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; } + mv := commit_swap(@bi, s.a, s.b); + print("legal {} awarded {} score {} moves_made {} moves_remaining {}\n", + mv.legal, mv.cascade.awarded, bi.score, bi.moves_made, mv.moves_remaining); + if mv.legal { fails += 1; } + if !boards_equal(@before, @bi) { fails += 1; } + if bi.score != 0 { fails += 1; } + if bi.moves_made != 0 { fails += 1; } + if mv.moves_remaining != bi.move_limit { fails += 1; } + } else { + print("intent none\n"); + fails += 1; + } + + // ── LEGAL swipe commits ──────────────────────────────────────────────── + // (5,4)->(6,4): the rightward swipe brings R into (5,4), completing R,R,R + // across cols 3-5 of row 4. commit_swap applies it, resolves the cascade + // (score accrues into Board.score) and spends one move; the board changes. + print("== legal swipe commits ==\n"); + bl : Board = ---; + bl.init(SEED); + pre : Board = bl; + + a_le := cell_center(@lay, 5, 4); + if s := swipe_intent(@lay, a_le, Point.{ x = a_le.x + D, y = a_le.y }) { + print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row); + if !(s.a.col == 5 and s.a.row == 4 and s.b.col == 6 and s.b.row == 4) { fails += 1; } + mv := commit_swap(@bl, s.a, s.b); + print("legal {} depth {} awarded {} score {} moves_made {} moves_remaining {}\n", + mv.legal, mv.cascade.depth, mv.cascade.awarded, bl.score, bl.moves_made, mv.moves_remaining); + if !mv.legal { fails += 1; } + if boards_equal(@pre, @bl) { fails += 1; } + if !(bl.score > 0) { fails += 1; } + if bl.moves_made != 1 { fails += 1; } + if mv.moves_remaining != bl.move_limit - 1 { fails += 1; } + out("after:\n"); + out(board_dump(@bl)); + } else { + print("intent none\n"); + fails += 1; + } + + if fails == 0 { + print("ok: swipe reverts illegal, commits legal\n"); + return 0; + } + print("FAIL: {} swipe-commit checks failed\n", fails); + return 1; +}