// 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); }