240 lines
8.3 KiB
Python
240 lines
8.3 KiB
Python
"""
|
||
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()
|