Private
Public Access
1
0
Files
MineSeeker/gtk-client/mineseeker/ui/grid_widget.py

240 lines
8.3 KiB
Python
Raw Normal View History

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