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()
|