P5.2: swipe commits legal swap / reverts illegal (sx, iOS sim)
Wire touch input into the model in BoardView.handle_event. A press records the drag start (new DragInput, heap-allocated so it survives the per-frame BoardView rebuild between mouse_down and mouse_up); the release resolves the gesture against the same layout it was drawn with. A swipe — start→end mapped by swipe_intent to an adjacent-swap intent — is fed straight into commit_swap: a legal swap applies, cascades (clear→collapse→refill), accrues score and spends a move; an illegal one reverts, no move. A sub-threshold / off-board drag carries no intent and falls back to the tap behaviour (toggle/clear selection). The next frame re-renders board + HUD from the model. Reuses swipe.sx + board_layout.sx + commit_swap unchanged — this is wiring, not new legality/cascade logic. tests/swipe_commit.sx (new golden) drives the full path on the seeded board (SEED 1337): a rightward swipe (0,0)->(1,0) is illegal (two reds) and reverts byte-for-byte with no score/move; (5,4)->(6,4) is legal, completes R,R,R on row 4, awards 30, spends one move. Sim evidence (iPhone 17, iOS 26.0): goldens/p5_swap_before.png (SCORE 0, MOVES 30/30) and goldens/p5_swap_after.png (SCORE 30, MOVES 29/30) bracket a real idb-injected swipe at (276,475)->(327,475) pt = cell (5,4)->(6,4); the three reds clear and the board matches the model's resolved state.
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
#import "modules/ui/font.sx";
|
#import "modules/ui/font.sx";
|
||||||
#import "board.sx";
|
#import "board.sx";
|
||||||
#import "board_layout.sx";
|
#import "board_layout.sx";
|
||||||
|
#import "swipe.sx";
|
||||||
|
|
||||||
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
BoardSelection :: struct {
|
||||||
active: bool;
|
active: bool;
|
||||||
cell: Cell;
|
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 {
|
BoardView :: struct {
|
||||||
board: *Board;
|
board: *Board;
|
||||||
assets: *BoardAssets;
|
assets: *BoardAssets;
|
||||||
sel: *BoardSelection;
|
sel: *BoardSelection;
|
||||||
|
drag: *DragInput;
|
||||||
safe: EdgeInsets;
|
safe: EdgeInsets;
|
||||||
|
|
||||||
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
|
// 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);
|
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 {
|
handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool {
|
||||||
self.compute_layout(frame);
|
self.compute_layout(frame);
|
||||||
if event.* == {
|
if event.* == {
|
||||||
case .mouse_down: (d) {
|
case .mouse_down: (d) {
|
||||||
if hit := self.layout.point_to_cell(d.position) {
|
self.drag.begin(d.position);
|
||||||
self.sel.toggle(hit);
|
return true;
|
||||||
} else {
|
}
|
||||||
|
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();
|
self.sel.clear();
|
||||||
|
} else {
|
||||||
|
if hit := self.layout.point_to_cell(start) {
|
||||||
|
self.sel.toggle(hit);
|
||||||
|
} else {
|
||||||
|
self.sel.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
goldens/p5_swap_after.png
Normal file
BIN
goldens/p5_swap_after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
BIN
goldens/p5_swap_before.png
Normal file
BIN
goldens/p5_swap_before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
10
main.sx
10
main.sx
@@ -44,10 +44,15 @@ g_assets : *BoardAssets = null;
|
|||||||
// per-frame rebuild; a tap hit-tests a cell and toggles this.
|
// per-frame rebuild; a tap hit-tests a cell and toggles this.
|
||||||
g_sel : *BoardSelection = null;
|
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
|
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
|
||||||
// insets so the grid stays inside the notch / home-indicator region.
|
// insets so the grid stays inside the notch / home-indicator region.
|
||||||
build_ui :: () -> View {
|
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 :: () {
|
frame :: () {
|
||||||
@@ -144,6 +149,9 @@ main :: () -> void {
|
|||||||
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
|
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
|
||||||
g_sel.init();
|
g_sel.init();
|
||||||
|
|
||||||
|
g_drag = xx context.allocator.alloc(size_of(DragInput));
|
||||||
|
g_drag.init();
|
||||||
|
|
||||||
g_pipeline.set_body(closure(build_ui));
|
g_pipeline.set_body(closure(build_ui));
|
||||||
|
|
||||||
g_plat.run_frame_loop(closure(frame));
|
g_plat.run_frame_loop(closure(frame));
|
||||||
|
|||||||
1
tests/expected/swipe_commit.exit
Normal file
1
tests/expected/swipe_commit.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
24
tests/expected/swipe_commit.stdout
Normal file
24
tests/expected/swipe_commit.stdout
Normal file
@@ -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
|
||||||
104
tests/swipe_commit.sx
Normal file
104
tests/swipe_commit.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user