Files
m3te/board_view.sx
swipelab 9ed98c73d2 P4.4: selection highlight + score/moves HUD (sx, iOS sim)
Tap a gem to select it: BoardView hit-tests the touch to a grid cell and
draws a bright rim + translucent fill over it; tapping the same cell clears
the selection, tapping another moves it, tapping off-board clears it.
Selection only — no swap (that's P5). The HUD renders the live score and
remaining moves (out of the move limit) in the Lato font on a translucent
card above the grid.

The touch→cell geometry is factored into a pure BoardLayout (no GL/stb
imports) that BoardView composes and P5 will reuse for swap endpoints.
tests/hit_test.sx locks point_to_cell as the exact inverse of cell_frame
(every cell center round-trips; off-board taps reject) — headless because
BoardLayout pulls no C imports. goldens/p4_hud.png captures the scene after
a real idb tap at (201,437)pt: the HUD plus a yellow selection rim on the
red gem at cell (col 4, row 3).
2026-06-05 00:00:48 +03:00

253 lines
9.3 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";
// 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.
// Selection only — P5 turns a selected→adjacent tap into a swap.
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;
}
}
}
BoardView :: struct {
board: *Board;
assets: *BoardAssets;
sel: *BoardSelection;
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);
}
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.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);
}