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).
This commit is contained in:
swipelab
2026-06-05 00:00:48 +03:00
parent 3cd1ef1585
commit 9ed98c73d2
7 changed files with 237 additions and 25 deletions

View File

@@ -14,12 +14,28 @@
#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;
@@ -93,34 +109,47 @@ load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
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;
cell_size: f32;
origin: Point;
// 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;
// Center a square 8×8 grid inside the safe-area-inset region of `frame`.
compute_layout :: (self: *BoardView, frame: Frame) {
avail := frame.inset(self.safe);
cols : f32 = xx BOARD_COLS;
board_dim := min(avail.size.width, avail.size.height);
self.cell_size = board_dim / cols;
total := self.cell_size * cols;
self.origin = Point.{
x = avail.origin.x + (avail.size.width - total) * 0.5,
y = avail.origin.y + (avail.size.height - total) * 0.5
};
}
cell_frame :: (self: *BoardView, col: s64, row: s64) -> Frame {
Frame.make(
self.origin.x + xx col * self.cell_size,
self.origin.y + xx row * self.cell_size,
self.cell_size,
self.cell_size
)
self.layout.compute(frame, self.safe);
}
}
@@ -142,11 +171,11 @@ impl View for BoardView {
}
// 2. One cell tile per board cell, then its gem sampled by index column.
gem_inset := self.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.cell_size * GEM_FILL_FRAC;
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.cell_frame(col, row);
cf := self.layout.cell_frame(col, row);
if self.assets.cell_tex != 0 {
ctx.add_image(cf, self.assets.cell_tex);
@@ -165,9 +194,59 @@ impl View for BoardView {
}
}
}
// 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);
}