new: dev: initialize the GTK client #11
This commit is contained in:
0
gtk-client/mineseeker/ui/__init__.py
Normal file
0
gtk-client/mineseeker/ui/__init__.py
Normal file
109
gtk-client/mineseeker/ui/app_window.py
Normal file
109
gtk-client/mineseeker/ui/app_window.py
Normal 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()
|
||||
96
gtk-client/mineseeker/ui/bonus_dialog.py
Normal file
96
gtk-client/mineseeker/ui/bonus_dialog.py
Normal 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)
|
||||
477
gtk-client/mineseeker/ui/game_page.py
Normal file
477
gtk-client/mineseeker/ui/game_page.py
Normal 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
|
||||
239
gtk-client/mineseeker/ui/grid_widget.py
Normal file
239
gtk-client/mineseeker/ui/grid_widget.py
Normal 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()
|
||||
185
gtk-client/mineseeker/ui/lobby_page.py
Normal file
185
gtk-client/mineseeker/ui/lobby_page.py
Normal 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
|
||||
146
gtk-client/mineseeker/ui/login_page.py
Normal file
146
gtk-client/mineseeker/ui/login_page.py
Normal 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}")
|
||||
116
gtk-client/mineseeker/ui/player_panel.py
Normal file
116
gtk-client/mineseeker/ui/player_panel.py
Normal 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)
|
||||
102
gtk-client/mineseeker/ui/result_overlay.py
Normal file
102
gtk-client/mineseeker/ui/result_overlay.py
Normal 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)
|
||||
112
gtk-client/mineseeker/ui/totp_page.py
Normal file
112
gtk-client/mineseeker/ui/totp_page.py
Normal 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
|
||||
Reference in New Issue
Block a user