Merge branch 'flow/m3te/P4.4' into m3te-plan
This commit is contained in:
52
board_layout.sx
Normal file
52
board_layout.sx
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
127
board_view.sx
127
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);
|
||||
}
|
||||
|
||||
BIN
goldens/p4_hud.png
Normal file
BIN
goldens/p4_hud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
9
main.sx
9
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));
|
||||
|
||||
1
tests/expected/hit_test.exit
Normal file
1
tests/expected/hit_test.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
5
tests/expected/hit_test.stdout
Normal file
5
tests/expected/hit_test.stdout
Normal file
@@ -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
|
||||
68
tests/hit_test.sx
Normal file
68
tests/hit_test.sx
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user