""" This file is part of the SplendidBear Websites' projects. Copyright (c) 2026 @ www.splendidbear.org For the full copyright and license information, please view the LICENSE file that was distributed with this source code. """ from __future__ import annotations from collections.abc import Callable from typing import Any import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk, GdkPixbuf from mineseeker import assets from mineseeker.constants import ( GRID_ROWS, GRID_COLS, CELL_SIZE, PLAYER_RED, PLAYER_BLUE, bomb_pos_image ) from mineseeker.state.game_state import GameState, Cell # --------------------------------------------------------------------------- # Bomb diamond radius (mirrors JS bombRadius() / PHP getBombRadius()) # --------------------------------------------------------------------------- def _bomb_cells(row: int, col: int) -> list[tuple[int, int]]: """Return all cells within the 5×5 diamond centred at (row, col).""" result = [] for dr in range(-2, 3): for dc in range(-2, 3): if abs(dr) + abs(dc) <= 2: r, c = row + dr, col + dc if 0 <= r < GRID_ROWS and 0 <= c < GRID_COLS: result.append((r, c)) return result def _bomb_pos(dr: int, dc: int) -> tuple[str, str]: """Map (delta_row, delta_col) to (horizontal, vertical) overlay names.""" h = "top" if dr < 0 else ("bottom" if dr > 0 else "middle") v = "left" if dc < 0 else ("right" if dc > 0 else "center") return h, v # --------------------------------------------------------------------------- # GridWidget # --------------------------------------------------------------------------- class GridWidget(Gtk.DrawingArea): """ 16×16 minesweeper grid rendered with Cairo + GdkPixbuf tile images. Signals emitted (via callbacks, not GObject signals for simplicity): on_cell_click(row, col, bomb_mode) — user clicked a cell """ def __init__(self, on_cell_click: Callable[[int, int, bool], None]) -> None: super().__init__() self._on_cell_click = on_cell_click self._state: GameState | None = None self._bomb_mode: bool = False self._hover: tuple[int, int] | None = None # (row, col) under cursor width = CELL_SIZE * GRID_COLS height = CELL_SIZE * GRID_ROWS self.set_content_width(width) self.set_content_height(height) self.set_draw_func(self._draw) # Click gesture click = Gtk.GestureClick() click.connect("pressed", self._on_pressed) self.add_controller(click) # Motion controller for bomb hover preview motion = Gtk.EventControllerMotion() motion.connect("motion", self._on_motion) motion.connect("leave", self._on_leave) self.add_controller(motion) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def set_state(self, state: GameState) -> None: self._state = state self.queue_draw() def set_bomb_mode(self, active: bool) -> None: self._bomb_mode = active self.queue_draw() def refresh(self) -> None: self.queue_draw() # ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------ def _draw(self, area, cr, width, height) -> None: if self._state is None: return for r in range(GRID_ROWS): for c in range(GRID_COLS): cell = self._state.cells[r][c] x = c * CELL_SIZE y = r * CELL_SIZE self._draw_cell(cr, x, y, cell, r, c) # Bomb hover diamond overlay if self._bomb_mode and self._hover: hr, hc = self._hover for (br, bc) in _bomb_cells(hr, hc): dr, dc = br - hr, bc - hc h_pos, v_pos = _bomb_pos(dr, dc) img_name = bomb_pos_image(h_pos, v_pos) pixbuf = assets.get_image(img_name) if pixbuf: self._paint_pixbuf(cr, bc * CELL_SIZE, br * CELL_SIZE, pixbuf) def _draw_cell(self, cr, x: int, y: int, cell: Cell, row: int, col: int) -> None: cs = CELL_SIZE if cell.state == "hidden": # Wave background wave_name = f"bg-wave-{cell.wave}-outbg.png" pixbuf = assets.get_image(wave_name) or assets.get_image("bg-wave-1-outbg.png") if pixbuf: self._paint_pixbuf(cr, x, y, pixbuf) else: # Fallback: solid dark tile cr.set_source_rgb(0.15, 0.15, 0.25) cr.rectangle(x, y, cs, cs) cr.fill() elif cell.state == "safe": # Light tile with number cr.set_source_rgb(0.85, 0.85, 0.85) cr.rectangle(x, y, cs, cs) cr.fill() # Draw thin border cr.set_source_rgb(0.6, 0.6, 0.6) cr.set_line_width(0.5) cr.rectangle(x + 0.5, y + 0.5, cs - 1, cs - 1) cr.stroke() if cell.value and cell.value != 0: self._draw_number(cr, x, y, cs, int(cell.value)) elif cell.state == "mine": # Mine flag — show the appropriate player flag color = cell.owner or "red" flag_name = f"bg-flag-{color}-outbg.png" pixbuf = assets.get_image(flag_name) if pixbuf: self._paint_pixbuf(cr, x, y, pixbuf) else: # Fallback colour if color == "red": cr.set_source_rgb(0.8, 0.1, 0.1) else: cr.set_source_rgb(0.1, 0.3, 0.9) cr.rectangle(x, y, cs, cs) cr.fill() # Last-step highlight overlay if cell.is_last: color = cell.owner or (self._state.turn if self._state else "red") last_name = f"bg-last-{color}-outbg.png" pixbuf = assets.get_image(last_name) if pixbuf: self._paint_pixbuf(cr, x, y, pixbuf) # Target overlay on hover (non-bomb) if not self._bomb_mode and self._hover == (row, col): pixbuf = assets.get_image("bg-target-outbg.png") if pixbuf: self._paint_pixbuf(cr, x, y, pixbuf) @staticmethod def _paint_pixbuf(cr, x: int, y: int, pixbuf: GdkPixbuf.Pixbuf) -> None: Gdk.cairo_set_source_pixbuf(cr, pixbuf, x, y) cr.paint() # Number colours matching standard minesweeper conventions _NUM_COLOURS = { 1: (0.0, 0.0, 1.0), 2: (0.0, 0.5, 0.0), 3: (1.0, 0.0, 0.0), 4: (0.0, 0.0, 0.5), 5: (0.5, 0.0, 0.0), 6: (0.0, 0.5, 0.5), 7: (0.0, 0.0, 0.0), 8: (0.5, 0.5, 0.5), } def _draw_number(self, cr, x: int, y: int, cs: int, value: int) -> None: r, g, b = self._NUM_COLOURS.get(value, (0, 0, 0)) cr.set_source_rgb(r, g, b) cr.select_font_face("Sans", 0, 1) # normal, bold cr.set_font_size(cs * 0.55) text = str(value) ext = cr.text_extents(text) tx = x + (cs - ext.width) / 2 - ext.x_bearing ty = y + (cs + ext.height) / 2 - ext.y_bearing - ext.height cr.move_to(tx, ty) cr.show_text(text) # ------------------------------------------------------------------ # Input handlers # ------------------------------------------------------------------ def _cell_at(self, px: float, py: float) -> tuple[int, int] | None: col = int(px // CELL_SIZE) row = int(py // CELL_SIZE) if 0 <= row < GRID_ROWS and 0 <= col < GRID_COLS: return row, col return None def _on_pressed(self, gesture, n_press, x, y) -> None: pos = self._cell_at(x, y) if pos and self._state and not self._state.finished: self._on_cell_click(pos[0], pos[1], self._bomb_mode) def _on_motion(self, controller, x, y) -> None: pos = self._cell_at(x, y) if pos != self._hover: self._hover = pos self.queue_draw() def _on_leave(self, controller) -> None: if self._hover is not None: self._hover = None self.queue_draw()