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.
299 lines
11 KiB
Plaintext
299 lines
11 KiB
Plaintext
// 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);
|
||
}
|