Private
Public Access
1
0

new: dev: initialize the GTK client #11

This commit is contained in:
2026-04-28 08:28:51 +02:00
parent 199bb7e525
commit 6484133199
29 changed files with 2788 additions and 0 deletions

View File

View File

@@ -0,0 +1,109 @@
"""
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
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.ui.login_page import LoginPage
from mineseeker.ui.totp_page import TotpPage
from mineseeker.ui.lobby_page import LobbyPage
from mineseeker.ui.game_page import GamePage
class AppWindow(Adw.ApplicationWindow):
"""
Main application window containing a Gtk.Stack that navigates between:
- "login" : LoginPage
- "totp" : TotpPage
- "lobby" : LobbyPage
- "game" : GamePage (replaced on each new game)
"""
def __init__(self, application: Adw.Application) -> None:
super().__init__(application=application)
self.set_title("MineSeeker")
self.set_default_size(980, 680)
# Stack — child names serve as page IDs
self._stack = Gtk.Stack()
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self._stack.set_transition_duration(200)
# Build pages
self._login_page = LoginPage(on_success=self._on_login_success, on_guest=self._on_guest)
self._totp_page = TotpPage(on_success=self._on_totp_success, on_back=self._show_login)
self._lobby_page = LobbyPage(on_game_start=self._on_game_start)
self._game_page: GamePage | None = None
self._stack.add_named(self._login_page, "login")
self._stack.add_named(self._totp_page, "totp")
self._stack.add_named(self._lobby_page, "lobby")
# Wrap in a NavigationView-style container using Adw.ToolbarView
toolbar_view = Adw.ToolbarView()
header = Adw.HeaderBar()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self._stack)
self.set_content(toolbar_view)
# Start on the login page
self._stack.set_visible_child_name("login")
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def _show_login(self) -> None:
self._stack.set_visible_child_name("login")
def _show_totp(self) -> None:
self._stack.set_visible_child_name("totp")
def _show_lobby(self) -> None:
self._lobby_page.refresh()
self._stack.set_visible_child_name("lobby")
# ------------------------------------------------------------------
# Callbacks from child pages
# ------------------------------------------------------------------
def _on_login_success(self, needs_totp: bool) -> None:
if needs_totp:
self._show_totp()
else:
self._show_lobby()
def _on_guest(self) -> None:
self._show_lobby()
def _on_totp_success(self) -> None:
self._show_lobby()
def _on_game_start(self, game_assoc: str, mercure_jwt: str, color: str) -> None:
"""Replace or create the GamePage and switch to it."""
# Remove previous game page if present
if self._game_page is not None:
self._stack.remove(self._game_page)
self._game_page = GamePage(
game_assoc=game_assoc,
mercure_jwt=mercure_jwt,
color=color,
on_leave=self._on_game_leave,
)
self._stack.add_named(self._game_page, "game")
self._stack.set_visible_child_name("game")
def _on_game_leave(self) -> None:
self._show_lobby()

View File

@@ -0,0 +1,96 @@
"""
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
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.state.game_state import BonusStats
from mineseeker.constants import BONUS_LABELS
class BonusDialog(Adw.Dialog):
"""Modal dialog displaying bonus stats for both players."""
def __init__(
self,
parent: Gtk.Widget,
red_name: str,
blue_name: str,
red_points: float,
blue_points: float,
red_stats: BonusStats,
blue_stats: BonusStats,
) -> None:
super().__init__()
self.set_title("Bonus Statistics")
self.set_content_width(480)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
header = Adw.HeaderBar()
box.append(header)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content.set_margin_top(16)
content.set_margin_bottom(16)
content.set_margin_start(16)
content.set_margin_end(16)
# Totals row
totals = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
totals.add_css_class("card")
red_total = Gtk.Label(label=f"{red_name}: {red_points:.1f} pts")
red_total.add_css_class("red-player")
red_total.set_hexpand(True)
red_total.set_xalign(0)
red_total.set_margin_start(12)
red_total.set_margin_top(8)
red_total.set_margin_bottom(8)
totals.append(red_total)
blue_total = Gtk.Label(label=f"{blue_name}: {blue_points:.1f} pts")
blue_total.add_css_class("blue-player")
blue_total.set_hexpand(True)
blue_total.set_xalign(1)
blue_total.set_margin_end(12)
totals.append(blue_total)
content.append(totals)
# Per-stat rows
group = Adw.PreferencesGroup(title="Breakdown")
stat_fields = [
("blind_hits", red_stats.blind_hits, blue_stats.blind_hits),
("chain_best", red_stats.chain_best, blue_stats.chain_best),
("last_mine_hits",red_stats.last_mine_hits,blue_stats.last_mine_hits),
("edge_mines", red_stats.edge_mines, blue_stats.edge_mines),
("biggest_reveal",red_stats.biggest_reveal,blue_stats.biggest_reveal),
]
key_map = {
"blind_hits": "blindHits",
"chain_best": "chainBest",
"last_mine_hits": "lastMineHits",
"edge_mines": "edgeMines",
"biggest_reveal": "biggestReveal",
}
for field_name, rv, bv in stat_fields:
label = BONUS_LABELS.get(key_map[field_name], field_name)
row = Adw.ActionRow(title=label)
row.set_subtitle(f"Red: {rv} Blue: {bv}")
group.add(row)
content.append(group)
box.append(content)
self.set_child(box)
self.present(parent)

View File

@@ -0,0 +1,477 @@
"""
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
import threading
import time
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api import game as game_api
from mineseeker.api.sse import SseListener
from mineseeker import assets
from mineseeker.constants import HEARTBEAT_INTERVAL_MS, WIN_THRESHOLD, PLAYER_RED, PLAYER_BLUE
from mineseeker.state.game_state import GameState
from mineseeker.state import session as session_mod
from mineseeker.ui.grid_widget import GridWidget
from mineseeker.ui.player_panel import PlayerPanel
from mineseeker.ui.bonus_dialog import BonusDialog
from mineseeker.ui.result_overlay import ResultOverlay
class GamePage(Gtk.Overlay):
"""
Full game screen.
Layout:
[RedPanel] [GridWidget] [BluePanel]
An Overlay places the ResultOverlay on top when the game ends.
"""
def __init__(
self,
game_assoc: str,
mercure_jwt: str,
color: str,
on_leave: Callable[[], None],
) -> None:
super().__init__()
self._game_assoc = game_assoc
self._mercure_jwt = mercure_jwt
self._color = color # "red" | "blue" | "" (determined by subscribe)
self._on_leave = on_leave
self._state = GameState()
self._bomb_mode = False
self._step_start: float = time.monotonic()
self._heartbeat_source: int | None = None
# --- Layout ---
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
main_box.set_hexpand(True)
main_box.set_vexpand(True)
# Red player panel (left)
self._red_panel = PlayerPanel(
color=PLAYER_RED,
is_local=(color == PLAYER_RED),
on_bomb_toggle=self._on_bomb_toggle,
on_resign=self._on_resign,
)
main_box.append(self._red_panel)
# Centre column: status bar + grid
centre = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
centre.set_hexpand(True)
centre.set_vexpand(True)
# Status / turn label
self._status_label = Gtk.Label(label="Connecting…")
self._status_label.add_css_class("dim-label")
self._status_label.set_margin_top(8)
self._status_label.set_margin_bottom(8)
centre.append(self._status_label)
# Grid in a scrolled window so it never clips on small screens
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_hexpand(True)
scrolled.set_vexpand(True)
self._grid = GridWidget(on_cell_click=self._on_cell_click)
scrolled.set_child(self._grid)
centre.append(scrolled)
# Bonus button row
bonus_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
bonus_box.set_halign(Gtk.Align.CENTER)
bonus_box.set_margin_top(6)
bonus_box.set_margin_bottom(6)
bonus_btn = Gtk.Button(label="Bonus Stats")
bonus_btn.add_css_class("flat")
bonus_btn.connect("clicked", self._show_bonus_dialog)
bonus_box.append(bonus_btn)
leave_btn = Gtk.Button(label="Leave")
leave_btn.add_css_class("flat")
leave_btn.connect("clicked", self._on_leave_clicked)
bonus_box.append(leave_btn)
centre.append(bonus_box)
main_box.append(centre)
# Blue player panel (right)
self._blue_panel = PlayerPanel(
color=PLAYER_BLUE,
is_local=(color == PLAYER_BLUE),
on_bomb_toggle=self._on_bomb_toggle,
on_resign=self._on_resign,
)
main_box.append(self._blue_panel)
# Result overlay
self._result_overlay = ResultOverlay(
on_play_again=self._on_play_again,
on_lobby=self._on_lobby,
)
self.set_child(main_box)
self.add_overlay(self._result_overlay)
# Start async init
threading.Thread(target=self._init_game, daemon=True).start()
# ------------------------------------------------------------------
# Game initialisation
# ------------------------------------------------------------------
def _init_game(self) -> None:
"""Connect, start SSE, join the channel, start/restore the game."""
try:
# 1. Fetch existing game state
connect_data = game_api.connect(self._game_assoc)
GLib.idle_add(self._apply_connect_data, connect_data)
# 2. Start the SSE listener
self._sse = SseListener(
game_assoc=self._game_assoc,
mercure_jwt=self._mercure_jwt,
on_subscribe=self._on_subscribe,
on_unsubscribe=self._on_unsubscribe,
on_topic=self._on_topic,
on_challenge=self._on_challenge,
on_challenge_response=self._on_challenge_response,
on_heartbeat=self._on_heartbeat,
)
self._sse.start()
# 3. Join (announces presence via Mercure)
game_api.join(self._game_assoc)
# 4. If no existing game, create the grid
if not connect_data.get("users"):
game_api.start(self._game_assoc)
# 5. Start heartbeat
GLib.idle_add(self._start_heartbeat)
except Exception as e:
GLib.idle_add(self._set_status, f"Error: {e}")
def _apply_connect_data(self, data: dict) -> bool:
self._state.apply_connect(data)
self._refresh_panels()
self._grid.set_state(self._state)
return GLib.SOURCE_REMOVE
# ------------------------------------------------------------------
# SSE handlers (called on GTK main thread via GLib.idle_add)
# ------------------------------------------------------------------
def _on_subscribe(self, payload: dict) -> None:
"""Two players connected → start game."""
users = payload.get("users", {})
user_cnt = payload.get("userCnt", 0)
# Determine our colour if not yet assigned
if not self._color:
sess = session_mod.get()
my_name = sess.username
if my_name == users.get("blue") or my_name == users.get("blueAnon"):
self._color = PLAYER_BLUE
else:
self._color = PLAYER_RED
sess.color = self._color
# Update player names from subscribe payload
self._state.red.name = users.get("red", "")
self._state.red.anon_name = users.get("redAnon", "")
self._state.blue.name = users.get("blue", "")
self._state.blue.anon_name = users.get("blueAnon", "")
if user_cnt == 2:
self._set_status("Game started!")
assets.play_sound("starting")
else:
self._set_status("Waiting for opponent…")
self._refresh_panels()
def _on_unsubscribe(self, payload: dict) -> None:
self._set_status("Opponent left the game.")
def _on_topic(self, payload: dict) -> None:
"""A step was made — apply it and refresh."""
data = payload.get("data", {})
if not data:
return
player = data.get("player", "")
is_mine = data.get("revealedCells") and any(
rc.get("value") == "m" for rc in data["revealedCells"]
)
# Play sounds
if data.get("resign"):
assets.play_sound("won")
elif is_mine:
my_state = self._state.red if player == PLAYER_RED else self._state.blue
if my_state.mines > 20:
assets.play_sound("warning")
else:
assets.play_sound("mine")
else:
assets.play_sound("click")
self._state.apply_step(data)
self._grid.refresh()
self._refresh_panels()
if self._state.finished:
self._show_result()
# uuid from server
if data.get("uuid"):
session_mod.get().game_assoc = data["uuid"]
def _on_challenge(self, payload: dict) -> None:
"""Incoming challenge — show accept/decline dialog."""
challenger_name = payload.get("challengerName", "Someone")
challenger_assoc = payload.get("challengerGameAssoc", "")
GLib.idle_add(self._show_challenge_dialog, challenger_name, challenger_assoc)
def _on_challenge_response(self, payload: dict) -> None:
if payload.get("accepted"):
# Switch to the new game assoc
new_assoc = payload.get("targetGameAssoc", "")
if new_assoc:
GLib.idle_add(self._redirect_to_game, new_assoc)
def _on_heartbeat(self, payload: dict) -> None:
# Heartbeat from opponent received — game is live
pass
# ------------------------------------------------------------------
# Cell click / resign
# ------------------------------------------------------------------
def _on_cell_click(self, row: int, col: int, bomb_mode: bool) -> None:
if self._state.finished:
return
if self._state.turn != self._color:
return # not our turn
elapsed = time.monotonic() - self._step_start
self._step_start = time.monotonic()
threading.Thread(
target=self._send_step,
args=(row, col, bomb_mode, elapsed),
daemon=True,
).start()
def _send_step(self, row: int, col: int, bomb: bool, elapsed: float) -> None:
try:
result = game_api.step(
game_assoc=self._game_assoc,
coords=[row, col],
player=self._color,
bomb=bomb,
resign=None,
step_elapsed=elapsed,
)
GLib.idle_add(self._apply_step_result, result)
except Exception as e:
GLib.idle_add(self._set_status, f"Step error: {e}")
def _apply_step_result(self, data: dict) -> bool:
self._state.apply_step(data)
self._grid.refresh()
self._refresh_panels()
if self._bomb_mode:
self._bomb_mode = False
self._grid.set_bomb_mode(False)
local_panel = self._red_panel if self._color == PLAYER_RED else self._blue_panel
local_panel.reset_bomb_toggle()
if self._state.finished:
self._show_result()
return GLib.SOURCE_REMOVE
def _on_resign(self) -> None:
threading.Thread(target=self._send_resign, daemon=True).start()
def _send_resign(self) -> None:
try:
result = game_api.step(
game_assoc=self._game_assoc,
coords=[0, 0],
player=self._color,
bomb=False,
resign=self._color,
step_elapsed=0,
)
GLib.idle_add(self._apply_step_result, result)
except Exception:
pass
# ------------------------------------------------------------------
# Bomb toggle
# ------------------------------------------------------------------
def _on_bomb_toggle(self, active: bool) -> None:
self._bomb_mode = active
self._grid.set_bomb_mode(active)
# ------------------------------------------------------------------
# Heartbeat
# ------------------------------------------------------------------
def _start_heartbeat(self) -> bool:
interval_s = HEARTBEAT_INTERVAL_MS / 1000.0
self._heartbeat_source = GLib.timeout_add(
HEARTBEAT_INTERVAL_MS,
self._send_heartbeat,
)
return GLib.SOURCE_REMOVE
def _send_heartbeat(self) -> bool:
if self._color:
threading.Thread(
target=game_api.heartbeat,
args=(self._game_assoc, self._color),
daemon=True,
).start()
return GLib.SOURCE_CONTINUE # repeat
# ------------------------------------------------------------------
# Result / game over
# ------------------------------------------------------------------
def _show_result(self) -> None:
assets.play_sound("won")
self._result_overlay.show_result(
winner=self._state.winner,
resigned=self._state.resigned,
local_color=self._color,
red_mines=self._state.red.mines,
blue_mines=self._state.blue.mines,
red_name=self._state.red.display_name,
blue_name=self._state.blue.display_name,
)
self._stop_heartbeat()
def _stop_heartbeat(self) -> None:
if self._heartbeat_source is not None:
GLib.source_remove(self._heartbeat_source)
self._heartbeat_source = None
# ------------------------------------------------------------------
# Navigation callbacks
# ------------------------------------------------------------------
def _on_play_again(self) -> None:
self._leave_game()
self._on_leave()
def _on_lobby(self) -> None:
self._leave_game()
self._on_leave()
def _on_leave_clicked(self, *_) -> None:
self._leave_game()
self._on_leave()
def _leave_game(self) -> None:
self._stop_heartbeat()
if hasattr(self, "_sse"):
self._sse.stop()
threading.Thread(
target=game_api.leave, args=(self._game_assoc,), daemon=True
).start()
def _redirect_to_game(self, new_assoc: str) -> bool:
# Challenge accepted — leave current and open new game page
self._leave_game()
self._on_leave()
return GLib.SOURCE_REMOVE
# ------------------------------------------------------------------
# Challenge dialog
# ------------------------------------------------------------------
def _show_challenge_dialog(self, challenger_name: str, challenger_assoc: str) -> bool:
dialog = Adw.AlertDialog(
heading=f"Challenge from {challenger_name}",
body="Do you accept the challenge?",
)
dialog.add_response("decline", "Decline")
dialog.add_response("accept", "Accept")
dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED)
dialog.connect(
"response",
lambda d, resp: self._on_challenge_response_dialog(resp, challenger_assoc),
)
dialog.present(self)
return GLib.SOURCE_REMOVE
def _on_challenge_response_dialog(self, response: str, challenger_assoc: str) -> None:
accepted = response == "accept"
threading.Thread(
target=game_api.challenge_respond,
args=(challenger_assoc, accepted, self._game_assoc),
daemon=True,
).start()
# ------------------------------------------------------------------
# Bonus dialog
# ------------------------------------------------------------------
def _show_bonus_dialog(self, *_) -> None:
BonusDialog(
parent=self,
red_name=self._state.red.display_name,
blue_name=self._state.blue.display_name,
red_points=self._state.red.bonus_points,
blue_points=self._state.blue.bonus_points,
red_stats=self._state.red.bonus_stats,
blue_stats=self._state.blue.bonus_stats,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _refresh_panels(self) -> None:
is_red_turn = self._state.turn == PLAYER_RED
self._red_panel.update(self._state.red, is_red_turn)
self._blue_panel.update(self._state.blue, not is_red_turn)
self._grid.set_state(self._state)
self._update_status_label()
def _update_status_label(self) -> None:
if self._state.finished:
self._status_label.set_label("Game over")
elif not self._color:
self._status_label.set_label("Connecting…")
elif self._state.turn == self._color:
self._status_label.set_label("Your turn")
else:
opponent = self._state.blue if self._color == PLAYER_RED else self._state.red
self._status_label.set_label(f"{opponent.display_name}'s turn")
def _set_status(self, message: str) -> bool:
self._status_label.set_label(message)
return GLib.SOURCE_REMOVE

View File

@@ -0,0 +1,239 @@
"""
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()

View File

@@ -0,0 +1,185 @@
"""
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
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api import game as game_api
from mineseeker import assets
from mineseeker.state import session as session_mod
class LobbyPage(Gtk.Box):
"""
Lobby screen — shows waiting players and a "New Game" button.
Flow:
- "New Game" → fetch token → start game → on_game_start()
- Click a waiting player → challenge them → on_game_start() when accepted
"""
def __init__(self, on_game_start: Callable[[str, str, str], None]) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_game_start = on_game_start
self._waiting: list[dict] = []
# Header bar action area
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
header_box.set_margin_top(12)
header_box.set_margin_bottom(12)
header_box.set_margin_start(16)
header_box.set_margin_end(16)
title = Gtk.Label(label="Lobby")
title.add_css_class("title-2")
title.set_hexpand(True)
title.set_xalign(0)
header_box.append(title)
self._refresh_btn = Gtk.Button(label="Refresh")
self._refresh_btn.connect("clicked", lambda *_: self.refresh())
header_box.append(self._refresh_btn)
new_game_btn = Gtk.Button(label="New Game")
new_game_btn.add_css_class("suggested-action")
new_game_btn.connect("clicked", self._on_new_game)
header_box.append(new_game_btn)
self.append(header_box)
self.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
# Waiting players list
scrolled = Gtk.ScrolledWindow()
scrolled.set_vexpand(True)
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self._list_box = Gtk.ListBox()
self._list_box.set_selection_mode(Gtk.SelectionMode.NONE)
self._list_box.add_css_class("boxed-list")
self._list_box.set_margin_top(12)
self._list_box.set_margin_bottom(12)
self._list_box.set_margin_start(16)
self._list_box.set_margin_end(16)
scrolled.set_child(self._list_box)
self.append(scrolled)
self._status_label = Gtk.Label(label="No players waiting.")
self._status_label.add_css_class("dim-label")
self._status_label.set_margin_top(24)
self._status_label.set_visible(True)
self.append(self._status_label)
def refresh(self) -> None:
"""Fetch waiting players list from the server."""
self._refresh_btn.set_sensitive(False)
threading.Thread(target=self._do_refresh, daemon=True).start()
def _do_refresh(self) -> None:
try:
waiting = game_api.waiting()
GLib.idle_add(self._update_list, waiting)
except Exception:
GLib.idle_add(self._refresh_btn.set_sensitive, True)
def _update_list(self, waiting: list[dict]) -> bool:
self._waiting = waiting
# Clear existing rows
while True:
row = self._list_box.get_first_child()
if row is None:
break
self._list_box.remove(row)
my_assoc = session_mod.get().game_assoc
for player in waiting:
if player["gameAssoc"] == my_assoc:
continue # don't show ourselves
row = self._make_player_row(player)
self._list_box.append(row)
has_players = bool([p for p in waiting if p.get("gameAssoc") != my_assoc])
self._status_label.set_visible(not has_players)
self._refresh_btn.set_sensitive(True)
return GLib.SOURCE_REMOVE
def _make_player_row(self, player: dict) -> Adw.ActionRow:
row = Adw.ActionRow()
row.set_title(player.get("name", "Guest"))
row.set_subtitle(f"Waiting since {player.get('since', '')[:19].replace('T', ' ')}")
challenge_btn = Gtk.Button(label="Challenge")
challenge_btn.add_css_class("flat")
challenge_btn.set_valign(Gtk.Align.CENTER)
challenge_btn.connect(
"clicked",
lambda _btn, p=player: self._on_challenge(p),
)
row.add_suffix(challenge_btn)
return row
def _on_new_game(self, *_) -> None:
threading.Thread(target=self._do_new_game, daemon=True).start()
def _do_new_game(self) -> None:
try:
token_data = game_api.fetch_token()
game_assoc = token_data["gameAssoc"]
mercure_jwt = token_data["mercureJwt"]
sess = session_mod.get()
sess.game_assoc = game_assoc
sess.mercure_jwt = mercure_jwt
sess.color = "red" # first player always red
# Load images while we wait for an opponent
assets.load_images()
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "red")
except Exception as e:
GLib.idle_add(self._show_error_toast, str(e))
def _on_challenge(self, player: dict) -> None:
threading.Thread(
target=self._do_challenge, args=(player,), daemon=True
).start()
def _do_challenge(self, player: dict) -> None:
try:
token_data = game_api.fetch_token()
game_assoc = token_data["gameAssoc"]
mercure_jwt = token_data["mercureJwt"]
sess = session_mod.get()
sess.game_assoc = game_assoc
sess.mercure_jwt = mercure_jwt
game_api.challenge(
target_game_assoc=player["gameAssoc"],
challenger_game_assoc=game_assoc,
)
assets.load_images()
# GamePage will determine color from subscribe payload
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "")
except Exception as e:
GLib.idle_add(self._show_error_toast, str(e))
def _show_error_toast(self, message: str) -> bool:
# Find the nearest Adw.ToastOverlay ancestor if available, otherwise print
print(f"[LobbyPage] Error: {message}")
return GLib.SOURCE_REMOVE

View File

@@ -0,0 +1,146 @@
"""
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
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api.auth import login, login_as_guest, TotpRequired, AuthError
from mineseeker import assets
class LoginPage(Gtk.Box):
"""Username + password login form with a 'Play as Guest' option."""
def __init__(
self,
on_success: Callable[[bool], None],
on_guest: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_success = on_success
self._on_guest = on_guest
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
clamp = Adw.Clamp()
clamp.set_maximum_size(360)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
inner.set_margin_top(32)
inner.set_margin_bottom(32)
inner.set_margin_start(16)
inner.set_margin_end(16)
# Title
title = Gtk.Label(label="MineSeeker")
title.add_css_class("title-1")
inner.append(title)
subtitle = Gtk.Label(label="Sign in to play")
subtitle.add_css_class("dim-label")
inner.append(subtitle)
# Credentials group
group = Adw.PreferencesGroup()
self._username_row = Adw.EntryRow(title="Username")
group.add(self._username_row)
self._password_row = Adw.PasswordEntryRow(title="Password")
self._password_row.connect("entry-activated", self._on_login_clicked)
group.add(self._password_row)
inner.append(group)
# Error label
self._error_label = Gtk.Label(label="")
self._error_label.add_css_class("error")
self._error_label.set_visible(False)
inner.append(self._error_label)
# Login button
self._login_btn = Gtk.Button(label="Sign In")
self._login_btn.add_css_class("suggested-action")
self._login_btn.add_css_class("pill")
self._login_btn.connect("clicked", self._on_login_clicked)
inner.append(self._login_btn)
# Separator
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
inner.append(sep)
# Guest button
guest_btn = Gtk.Button(label="Play as Guest")
guest_btn.add_css_class("pill")
guest_btn.connect("clicked", self._on_guest_clicked)
inner.append(guest_btn)
clamp.set_child(inner)
self.append(clamp)
def _set_busy(self, busy: bool) -> None:
self._login_btn.set_sensitive(not busy)
self._username_row.set_sensitive(not busy)
self._password_row.set_sensitive(not busy)
if busy:
self._error_label.set_visible(False)
def _show_error(self, message: str) -> None:
self._error_label.set_label(message)
self._error_label.set_visible(True)
def _on_login_clicked(self, *_) -> None:
username = self._username_row.get_text().strip()
password = self._password_row.get_text()
if not username or not password:
self._show_error("Please enter username and password.")
return
self._set_busy(True)
threading.Thread(
target=self._do_login, args=(username, password), daemon=True
).start()
def _do_login(self, username: str, password: str) -> None:
try:
login(username, password)
# Load assets after successful authentication
assets.load_sounds()
GLib.idle_add(self._on_success, False)
except TotpRequired:
assets.load_sounds()
GLib.idle_add(self._on_success, True)
except AuthError as e:
GLib.idle_add(self._handle_auth_error, str(e))
except Exception as e:
GLib.idle_add(self._handle_auth_error, f"Connection error: {e}")
def _handle_auth_error(self, message: str) -> bool:
self._set_busy(False)
self._show_error(message)
return GLib.SOURCE_REMOVE
def _on_guest_clicked(self, *_) -> None:
self._set_busy(True)
threading.Thread(target=self._do_guest, daemon=True).start()
def _do_guest(self) -> None:
try:
login_as_guest()
assets.load_sounds()
GLib.idle_add(self._on_guest)
except Exception as e:
GLib.idle_add(self._handle_auth_error, f"Could not start guest session: {e}")

View File

@@ -0,0 +1,116 @@
"""
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
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.state.game_state import PlayerState
from mineseeker.constants import WIN_THRESHOLD
class PlayerPanel(Gtk.Box):
"""
Vertical sidebar panel showing one player's info:
- Name + colour indicator
- Mine count (e.g. "12 / 26")
- Bonus points
- Bomb toggle button
- Resign button (only for the local player)
"""
def __init__(
self,
color: str,
is_local: bool,
on_bomb_toggle: Callable[[bool], None],
on_resign: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._color = color
self._is_local = is_local
self._on_bomb_toggle = on_bomb_toggle
self._on_resign = on_resign
self._bomb_active = False
self.set_margin_top(12)
self.set_margin_bottom(12)
self.set_margin_start(12)
self.set_margin_end(12)
self.set_valign(Gtk.Align.START)
# Colour dot + name
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
dot = Gtk.Label(label="")
dot.add_css_class("red-player" if color == "red" else "blue-player")
name_box.append(dot)
self._name_label = Gtk.Label(label="Waiting…")
self._name_label.add_css_class("title-4")
self._name_label.set_ellipsize(3) # PANGO_ELLIPSIZE_END
name_box.append(self._name_label)
self.append(name_box)
# Mine count
self._mine_label = Gtk.Label(label=f"0 / {WIN_THRESHOLD}")
self._mine_label.add_css_class("title-2")
self.append(self._mine_label)
# Bonus points
self._bonus_label = Gtk.Label(label="Bonus: 0")
self._bonus_label.add_css_class("dim-label")
self.append(self._bonus_label)
# Bomb button — only meaningful for local player
if is_local:
self._bomb_btn = Gtk.ToggleButton(label="Bomb")
self._bomb_btn.set_sensitive(False)
self._bomb_btn.connect("toggled", self._on_bomb_toggled)
self.append(self._bomb_btn)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
self.append(sep)
resign_btn = Gtk.Button(label="Resign")
resign_btn.add_css_class("destructive-action")
resign_btn.connect("clicked", lambda *_: self._on_resign())
self.append(resign_btn)
# ------------------------------------------------------------------
# Update from state
# ------------------------------------------------------------------
def update(self, player: PlayerState, is_turn: bool) -> None:
self._name_label.set_label(player.display_name)
self._mine_label.set_label(f"{player.mines} / {WIN_THRESHOLD}")
self._bonus_label.set_label(f"Bonus: {player.bonus_points:.1f}")
if self._is_local and hasattr(self, "_bomb_btn"):
can_use = player.bomb_enabled and not player.bomb_used and is_turn
self._bomb_btn.set_sensitive(can_use)
if player.bomb_used:
self._bomb_btn.set_label("Bomb Used")
def set_bomb_enabled(self, enabled: bool) -> None:
if self._is_local and hasattr(self, "_bomb_btn"):
self._bomb_btn.set_sensitive(enabled)
def reset_bomb_toggle(self) -> None:
"""Deactivate the bomb toggle (after a bomb move is sent)."""
if self._is_local and hasattr(self, "_bomb_btn"):
self._bomb_btn.set_active(False)
def _on_bomb_toggled(self, btn: Gtk.ToggleButton) -> None:
self._bomb_active = btn.get_active()
self._on_bomb_toggle(self._bomb_active)

View File

@@ -0,0 +1,102 @@
"""
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
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
class ResultOverlay(Gtk.Box):
"""
Translucent overlay shown at game end.
Displays the winner, final scores, and action buttons.
"""
def __init__(
self,
on_play_again: Callable[[], None],
on_lobby: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=16)
self._on_play_again = on_play_again
self._on_lobby = on_lobby
self.set_visible(False)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
self.set_margin_top(16)
self.set_margin_bottom(16)
self.set_margin_start(16)
self.set_margin_end(16)
self.add_css_class("card")
self._title_label = Gtk.Label(label="")
self._title_label.add_css_class("title-1")
self.append(self._title_label)
self._subtitle_label = Gtk.Label(label="")
self._subtitle_label.add_css_class("title-3")
self.append(self._subtitle_label)
self._score_label = Gtk.Label(label="")
self._score_label.add_css_class("dim-label")
self.append(self._score_label)
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
btn_box.set_halign(Gtk.Align.CENTER)
play_again_btn = Gtk.Button(label="Play Again")
play_again_btn.add_css_class("suggested-action")
play_again_btn.add_css_class("pill")
play_again_btn.connect("clicked", lambda *_: self._on_play_again())
btn_box.append(play_again_btn)
lobby_btn = Gtk.Button(label="Back to Lobby")
lobby_btn.add_css_class("pill")
lobby_btn.connect("clicked", lambda *_: self._on_lobby())
btn_box.append(lobby_btn)
self.append(btn_box)
def show_result(
self,
winner: str | None,
resigned: str | None,
local_color: str,
red_mines: int,
blue_mines: int,
red_name: str,
blue_name: str,
) -> None:
if resigned:
loser_name = red_name if resigned == "red" else blue_name
self._title_label.set_label("Resignation")
self._subtitle_label.set_label(f"{loser_name} resigned.")
elif winner == "draw" or winner is None:
self._title_label.set_label("Draw!")
self._subtitle_label.set_label("Equal mines — it's a draw.")
elif winner == local_color:
self._title_label.set_label("You Win!")
self._subtitle_label.set_label("Congratulations!")
else:
self._title_label.set_label("You Lose")
self._subtitle_label.set_label("Better luck next time.")
self._score_label.set_label(
f"{red_name}: {red_mines} mines · {blue_name}: {blue_mines} mines"
)
self.set_visible(True)
def hide_result(self) -> None:
self.set_visible(False)

View File

@@ -0,0 +1,112 @@
"""
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
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api.auth import submit_totp, AuthError
class TotpPage(Gtk.Box):
"""6-digit TOTP code entry shown after a successful password login."""
def __init__(
self,
on_success: Callable[[], None],
on_back: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_success = on_success
self._on_back = on_back
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
clamp = Adw.Clamp()
clamp.set_maximum_size(360)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
inner.set_margin_top(32)
inner.set_margin_bottom(32)
inner.set_margin_start(16)
inner.set_margin_end(16)
title = Gtk.Label(label="Two-Factor Authentication")
title.add_css_class("title-2")
inner.append(title)
subtitle = Gtk.Label(label="Enter the 6-digit code from your authenticator app.")
subtitle.set_wrap(True)
subtitle.add_css_class("dim-label")
inner.append(subtitle)
group = Adw.PreferencesGroup()
self._code_row = Adw.EntryRow(title="Authentication Code")
self._code_row.set_input_purpose(Gtk.InputPurpose.DIGITS)
self._code_row.connect("entry-activated", self._on_verify_clicked)
group.add(self._code_row)
inner.append(group)
self._error_label = Gtk.Label(label="")
self._error_label.add_css_class("error")
self._error_label.set_visible(False)
inner.append(self._error_label)
self._verify_btn = Gtk.Button(label="Verify")
self._verify_btn.add_css_class("suggested-action")
self._verify_btn.add_css_class("pill")
self._verify_btn.connect("clicked", self._on_verify_clicked)
inner.append(self._verify_btn)
back_btn = Gtk.Button(label="Back to Login")
back_btn.add_css_class("pill")
back_btn.connect("clicked", lambda *_: self._on_back())
inner.append(back_btn)
clamp.set_child(inner)
self.append(clamp)
def _set_busy(self, busy: bool) -> None:
self._verify_btn.set_sensitive(not busy)
self._code_row.set_sensitive(not busy)
if busy:
self._error_label.set_visible(False)
def _show_error(self, message: str) -> None:
self._error_label.set_label(message)
self._error_label.set_visible(True)
def _on_verify_clicked(self, *_) -> None:
code = self._code_row.get_text().strip()
if len(code) != 6 or not code.isdigit():
self._show_error("Code must be exactly 6 digits.")
return
self._set_busy(True)
threading.Thread(target=self._do_verify, args=(code,), daemon=True).start()
def _do_verify(self, code: str) -> None:
try:
submit_totp(code)
GLib.idle_add(self._on_success)
except AuthError as e:
GLib.idle_add(self._handle_error, str(e))
except Exception as e:
GLib.idle_add(self._handle_error, f"Connection error: {e}")
def _handle_error(self, message: str) -> bool:
self._set_busy(False)
self._show_error(message)
return GLib.SOURCE_REMOVE