Files
m3te/board_view.sx
swipelab e5df37523f 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.
2026-06-05 00:32:40 +03:00

299 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// BoardView (P4.3) — render the seeded match-3 board with real gem sprites.
//
// Modeled on game/chess/board_view.sx: a `View` that lays out an 8×8 grid and
// draws tiles/sprites through RenderContext.add_image / add_image_uv, sampling
// the gem sprite sheet by UV column. The background image fills the whole view;
// the grid is a centered square inside the safe-area inset.
#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 "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#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.
GEM_FILL_FRAC :f32: 0.84;
// Selection overlay: a translucent warm fill plus a bright opaque rim around the
// chosen cell. `add_stroked_rect` draws the rim in its FILL color (the renderer
// ignores the separate stroke color), so SELECT_RIM is passed as the fill.
SELECT_FILL :: Color.{ r = 255, g = 240, b = 120, a = 70 };
SELECT_RIM :: Color.{ r = 255, g = 228, b = 60, a = 255 };
// HUD: a translucent card with the score and remaining moves, in the loaded Lato
// font. Placed in the empty band above the centered grid (inside the safe area).
HUD_FONT :f32: 34.0;
HUD_PAD :f32: 14.0;
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 };
// UV sub-rect of one gem column, spanning the sheet's full height.
GemUV :: struct {
uv_min: Point;
uv_max: Point;
}
// Loads and holds the three board textures (background, cell tile, gem sheet)
// and maps a gem index to its column UV. Modeled on chess's ChessPieces.
BoardAssets :: struct {
bg_tex: u32;
cell_tex: u32;
gems_tex: u32;
cell_u: f32;
loaded: bool;
init :: (self: *BoardAssets) {
self.bg_tex = 0;
self.cell_tex = 0;
self.gems_tex = 0;
// gems.png is GEM_COUNT columns wide and one row tall, so a gem's UV
// column IS its gem index (0=red … 5=purple); cell_u is one column wide.
self.cell_u = 1.0 / cast(f32) GEM_COUNT;
self.loaded = false;
}
load :: (self: *BoardAssets, gpu: ?GPU) {
self.bg_tex = load_texture("assets/board/background.png", gpu);
self.cell_tex = load_texture("assets/board/cell.png", gpu);
self.gems_tex = load_texture("assets/gems/gems.png", gpu);
self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
}
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
u0 : f32 = xx index * self.cell_u;
GemUV.{
uv_min = Point.{ x = u0, y = 0.0 },
uv_max = Point.{ x = u0 + self.cell_u, y = 1.0 }
}
}
}
// Decode an RGBA image and upload it as a texture, returning the handle (0 on
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4);
if pixels == null {
out("WARNING: could not load texture: ");
out(path);
out("\n");
return 0;
}
tex : u32 = 0;
if gpu != null {
tex = gpu.create_texture(w, h, .rgba8, xx pixels);
} else {
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);
}
stbi_image_free(pixels);
return tex;
}
// 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.
// A tap toggles this highlight; a swipe commits a swap (see DragInput) and
// clears it.
BoardSelection :: struct {
active: bool;
cell: Cell;
init :: (self: *BoardSelection) {
self.active = false;
self.cell = Cell.{ col = 0, row = 0 };
}
clear :: (self: *BoardSelection) {
self.active = false;
}
// Tapping a cell selects it; tapping the cell already selected clears the
// selection, so a tap toggles its own cell and moves it to any other.
toggle :: (self: *BoardSelection, c: Cell) {
if self.active and self.cell.col == c.col and self.cell.row == c.row {
self.active = false;
} else {
self.active = true;
self.cell = c;
}
}
}
// 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 /
// event from the current frame so the hit-test matches what was drawn.
layout: BoardLayout;
compute_layout :: (self: *BoardView, frame: Frame) {
self.layout.compute(frame, self.safe);
}
}
impl View for BoardView {
size_that_fits :: (self: *BoardView, proposal: ProposedSize) -> Size {
Size.{ width = proposal.width ?? 0.0, height = proposal.height ?? 0.0 }
}
layout :: (self: *BoardView, bounds: Frame) {
self.compute_layout(bounds);
}
render :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
self.compute_layout(frame);
// 1. Background image fills the whole view, behind the grid.
if self.assets.bg_tex != 0 {
ctx.add_image(frame, self.assets.bg_tex);
}
// 2. One cell tile per board cell, then its gem sampled by index column.
gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
cf := self.layout.cell_frame(col, row);
if self.assets.cell_tex != 0 {
ctx.add_image(cf, self.assets.cell_tex);
}
g := self.board.at(col, row);
if g != .empty and self.assets.gems_tex != 0 {
uv := self.assets.gem_uv(cast(s64) g);
gf := Frame.make(
cf.origin.x + gem_inset,
cf.origin.y + gem_inset,
gem_dim,
gem_dim
);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
}
}
}
// 3. Selection overlay on the chosen cell: a translucent fill under a
// bright rim, drawn over the whole grid so it reads as a highlight.
if self.sel != null and self.sel.active {
cf := self.layout.cell_frame(self.sel.cell.col, self.sel.cell.row);
ctx.add_rect(cf, SELECT_FILL);
rim_w := max(2.0, self.layout.cell_size * 0.06);
ctx.add_stroked_rect(cf, SELECT_RIM, SELECT_RIM, rim_w, self.layout.cell_size * 0.14);
}
// 4. HUD card with score + remaining moves, in the band above the grid.
avail := frame.inset(self.safe);
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) {
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;
}
}
false
}
}
// 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.
render_hud :: (ctx: *RenderContext, board: *Board, avail: Frame) {
score_str := format("SCORE {}", board.score);
moves_str := format("MOVES {}/{}", board.moves_remaining(), board.move_limit);
score_sz := measure_text(score_str, HUD_FONT);
moves_sz := measure_text(moves_str, HUD_FONT);
text_w := max(score_sz.width, moves_sz.width);
panel_w := text_w + HUD_PAD * 2.0;
panel_h := score_sz.height + HUD_LINE_GAP + moves_sz.height + HUD_PAD * 2.0;
panel_x := avail.origin.x + (avail.size.width - panel_w) * 0.5;
panel_y := avail.origin.y + HUD_PAD;
ctx.add_rounded_rect(Frame.make(panel_x, panel_y, panel_w, panel_h), HUD_PANEL, 12.0);
tx := panel_x + HUD_PAD;
ty := panel_y + HUD_PAD;
ctx.add_text(Frame.make(tx, ty, score_sz.width, score_sz.height), score_str, HUD_FONT, HUD_TEXT);
ty += score_sz.height + HUD_LINE_GAP;
ctx.add_text(Frame.make(tx, ty, moves_sz.width, moves_sz.height), moves_str, HUD_FONT, HUD_TEXT);
}