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

240 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()