diff --git a/board_layout.sx b/board_layout.sx new file mode 100644 index 0000000..6235517 --- /dev/null +++ b/board_layout.sx @@ -0,0 +1,52 @@ +// Pure geometry of the on-screen board: where the centered 8×8 grid sits inside +// a frame, and the two-way mapping between cells and screen points. Owns no +// rendering and pulls in NO GL/stb imports, so the touch→cell mapping is +// unit-testable headless. BoardView composes this for layout + hit-testing, and +// P5's swap input reuses `point_to_cell` to resolve a tap to a swap endpoint. +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "board.sx"; + +BoardLayout :: struct { + cell_size: f32; + origin: Point; + + // Center a square 8×8 grid inside the safe-area-inset region of `frame`. + compute :: (self: *BoardLayout, frame: Frame, safe: EdgeInsets) { + avail := frame.inset(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: *BoardLayout, 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 + ) + } + + // Inverse of `cell_frame`: map a view-local point to the grid cell under it, + // or null when the point falls outside the 8×8 grid. The `< 0.0` guards run + // BEFORE the truncating cast, since casting a small negative float rounds + // toward zero into a valid index. Uses the SAME origin / cell_size `compute` + // produced, so a tap resolves to exactly the cell drawn under the finger. + point_to_cell :: (self: *BoardLayout, p: Point) -> ?Cell { + if self.cell_size <= 0.0 { return null; } + fx := (p.x - self.origin.x) / self.cell_size; + fy := (p.y - self.origin.y) / self.cell_size; + if fx < 0.0 or fy < 0.0 { return null; } + col : s64 = xx fx; + row : s64 = xx fy; + if col >= BOARD_COLS or row >= BOARD_ROWS { return null; } + Cell.{ col = col, row = row } + } +} diff --git a/board_view.sx b/board_view.sx index 90449c7..8c50168 100644 --- a/board_view.sx +++ b/board_view.sx @@ -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); +} diff --git a/goldens/p4_hud.png b/goldens/p4_hud.png new file mode 100644 index 0000000..83b3bee Binary files /dev/null and b/goldens/p4_hud.png differ diff --git a/main.sx b/main.sx index 911a038..cbc3574 100644 --- a/main.sx +++ b/main.sx @@ -40,10 +40,14 @@ g_metal_gpu : *MetalGPU = null; g_board : *Board = null; g_assets : *BoardAssets = null; +// Current cell selection (P4.4). Heap-allocated so it survives BoardView's +// per-frame rebuild; a tap hit-tests a cell and toggles this. +g_sel : *BoardSelection = null; + // Rebuilt each frame inside the pipeline's arena; carries the current safe-area // insets so the grid stays inside the notch / home-indicator region. build_ui :: () -> View { - BoardView.{ board = g_board, assets = g_assets, safe = g_safe_insets } + BoardView.{ board = g_board, assets = g_assets, sel = g_sel, safe = g_safe_insets } } frame :: () { @@ -137,6 +141,9 @@ main :: () -> void { g_assets.init(); g_assets.load(g_pipeline.gpu); + g_sel = xx context.allocator.alloc(size_of(BoardSelection)); + g_sel.init(); + g_pipeline.set_body(closure(build_ui)); g_plat.run_frame_loop(closure(frame)); diff --git a/tests/expected/hit_test.exit b/tests/expected/hit_test.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/hit_test.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/hit_test.stdout b/tests/expected/hit_test.stdout new file mode 100644 index 0000000..d2559bb --- /dev/null +++ b/tests/expected/hit_test.stdout @@ -0,0 +1,5 @@ +grid origin (100,0) cell 75 +ok: 64/64 cell centers round-trip +corner maps to (3,5) +ok: 0 off-board taps resolved to a cell +ok: hit-test mapping is the inverse of the layout diff --git a/tests/hit_test.sx b/tests/hit_test.sx new file mode 100644 index 0000000..f4c586c --- /dev/null +++ b/tests/hit_test.sx @@ -0,0 +1,68 @@ +// Hit-test golden (P4.4): lock the touch→cell mapping `BoardLayout.point_to_cell` +// as the exact inverse of `cell_frame`. The two are written independently — one +// multiplies a cell index by cell_size, the other divides a point by cell_size +// and truncates — so round-tripping every cell center back to its own cell is a +// real check, not a tautology. BoardView and P5's swap input both reuse this +// mapping, so a drift here would silently send taps/swaps to the wrong cell. +// +// Imports BoardLayout (no GL/stb), not BoardView, so it links headless. It also +// avoids tests/test.sx, whose modules/process.sx → modules/trace.sx pulls in a +// second `Frame` struct that collides with the UI `Frame`. 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"; + +main :: () -> s32 { + // 800×600 with no safe inset → a 600px square grid, cell 75, centered: the + // grid origin lands at (100, 0). Integer math keeps the dump deterministic. + lay : BoardLayout = ---; + lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); + + print("grid origin ({},{}) cell {}\n", + cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size); + + fails : s64 = 0; + + // Every cell center must map back to its own cell. + hits : s64 = 0; + for 0..BOARD_ROWS: (row) { + for 0..BOARD_COLS: (col) { + cf := lay.cell_frame(col, row); + center := Point.{ x = cf.mid_x(), y = cf.mid_y() }; + if h := lay.point_to_cell(center) { + if h.col == col and h.row == row { hits += 1; } + } + } + } + if hits != BOARD_CELLS { fails += 1; } + print("ok: {}/{} cell centers round-trip\n", hits, BOARD_CELLS); + + // A cell's top-left corner belongs to that cell (the leading edge is + // inclusive), so corner-of-(3,5) resolves to (3,5). + corner := Point.{ x = lay.origin.x + 3.0 * lay.cell_size, y = lay.origin.y + 5.0 * lay.cell_size }; + corner_col : s64 = -1; + corner_row : s64 = -1; + if h := lay.point_to_cell(corner) { corner_col = h.col; corner_row = h.row; } + if corner_col != 3 or corner_row != 5 { fails += 1; } + print("corner maps to ({},{})\n", corner_col, corner_row); + + // Off-board taps reject (null): left of, above, and right of the grid. None + // should resolve to a cell, so the on-board count must stay 0. + off_left := Point.{ x = lay.origin.x - 5.0, y = lay.origin.y + 10.0 }; + off_above := Point.{ x = lay.origin.x + 10.0, y = lay.origin.y - 5.0 }; + off_right := Point.{ x = lay.origin.x + 8.0 * lay.cell_size + 1.0, y = lay.origin.y + 10.0 }; + on_board : s64 = 0; + if h := lay.point_to_cell(off_left) { on_board += 1; print("off_left hit ({},{})\n", h.col, h.row); } + if h := lay.point_to_cell(off_above) { on_board += 1; print("off_above hit ({},{})\n", h.col, h.row); } + if h := lay.point_to_cell(off_right) { on_board += 1; print("off_right hit ({},{})\n", h.col, h.row); } + if on_board != 0 { fails += 1; } + print("ok: {} off-board taps resolved to a cell\n", on_board); + + if fails == 0 { + print("ok: hit-test mapping is the inverse of the layout\n"); + return 0; + } + print("FAIL: {} hit-test checks failed\n", fails); + return 1; +}