Add TUI lobby settings, clickable cards, and UI polish
- Lobby: collapsible Game Settings, House Rules, Deck Style sections - Lobby: CPU profile picker via [+], random CPU via [?], remove via [-] - Lobby: all settings (rounds, decks, flip mode, house rules, deck colors) sent to server on start_game instead of hardcoded defaults - Game: clickable cards (hand positions, deck, discard pile) - Game: immediate visual feedback on initial card flips - Game: action bar shows escaped keyboard hints (Keyboard: Choose [d]eck...) - Game: play area uses fixed-width rounded box instead of horizontal lines - Game: position numbers on card top-left corner (replacing ┌) on all states - Game: deck color preview swatches next to style dropdown - Fix opponent box height mismatch when match connectors present - Rebrand to GolfCards.club - Add spacing between status bar/opponents and above local hand Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e601c3eac4
commit
bfe29bb665
20
tui_client/pyproject.toml
Normal file
20
tui_client/pyproject.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "golf-tui"
|
||||
version = "0.1.0"
|
||||
description = "Terminal client for the Golf card game"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"textual>=0.47.0",
|
||||
"websockets>=12.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
golf-tui = "tui_client.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
1
tui_client/src/tui_client/__init__.py
Normal file
1
tui_client/src/tui_client/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""TUI client for the Golf card game."""
|
||||
59
tui_client/src/tui_client/__main__.py
Normal file
59
tui_client/src/tui_client/__main__.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Entry point: python -m tui_client [--server HOST] [--no-tls]
|
||||
|
||||
Reads defaults from ~/.config/golf-tui.conf (create with --save-config).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from tui_client.config import load_config, save_config, CONFIG_PATH
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
|
||||
parser = argparse.ArgumentParser(description="Golf Card Game TUI Client")
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
default=cfg.get("server", "golfcards.club"),
|
||||
help=f"Server host[:port] (default: {cfg.get('server', 'golfcards.club')})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-tls",
|
||||
action="store_true",
|
||||
default=cfg.get("tls", "true").lower() != "true",
|
||||
help="Use ws:// and http:// instead of wss:// and https://",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging to tui_debug.log",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-config",
|
||||
action="store_true",
|
||||
help=f"Save current options as defaults to {CONFIG_PATH}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.save_config:
|
||||
save_config({
|
||||
"server": args.server,
|
||||
"tls": str(not args.no_tls).lower(),
|
||||
})
|
||||
print(f"Config saved to {CONFIG_PATH}")
|
||||
print(f" server = {args.server}")
|
||||
print(f" tls = {str(not args.no_tls).lower()}")
|
||||
return
|
||||
|
||||
if args.debug:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG, filename="tui_debug.log")
|
||||
|
||||
from tui_client.app import GolfApp
|
||||
app = GolfApp(server=args.server, use_tls=not args.no_tls)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
115
tui_client/src/tui_client/app.py
Normal file
115
tui_client/src/tui_client/app.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Main Textual App for the Golf TUI client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.client import GameClient
|
||||
|
||||
|
||||
class ServerMessage(Message):
|
||||
"""A message received from the game server."""
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
super().__init__()
|
||||
self.msg_type: str = data.get("type", "")
|
||||
self.data: dict = data
|
||||
|
||||
|
||||
class KeymapBar(Static):
|
||||
"""Bottom bar showing available keys for the current context."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
KeymapBar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: #1a1a2e;
|
||||
color: #888888;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class GolfApp(App):
|
||||
"""Golf Card Game TUI Application."""
|
||||
|
||||
TITLE = "GolfCards.club"
|
||||
CSS_PATH = "styles.tcss"
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "esc_pressed", ""),
|
||||
]
|
||||
|
||||
def __init__(self, server: str, use_tls: bool = True):
|
||||
super().__init__()
|
||||
self.client = GameClient(server, use_tls)
|
||||
self.client._app = self
|
||||
self.player_id: str | None = None
|
||||
self._last_escape: float = 0.0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield KeymapBar(id="keymap-bar")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
from tui_client.screens.connect import ConnectScreen
|
||||
self.push_screen(ConnectScreen())
|
||||
self._update_keymap()
|
||||
|
||||
def on_screen_resume(self) -> None:
|
||||
self._update_keymap()
|
||||
|
||||
def post_server_message(self, data: dict) -> None:
|
||||
"""Called from GameClient listener to inject server messages."""
|
||||
msg = ServerMessage(data)
|
||||
self.call_later(self._route_server_message, msg)
|
||||
|
||||
def _route_server_message(self, msg: ServerMessage) -> None:
|
||||
"""Forward a server message to the active screen."""
|
||||
screen = self.screen
|
||||
handler = getattr(screen, "on_server_message", None)
|
||||
if handler:
|
||||
handler(msg)
|
||||
|
||||
def action_esc_pressed(self) -> None:
|
||||
"""Double-escape to hard quit. Single escape does nothing."""
|
||||
now = time.monotonic()
|
||||
if now - self._last_escape < 0.5:
|
||||
self.exit()
|
||||
else:
|
||||
self._last_escape = now
|
||||
|
||||
def _update_keymap(self) -> None:
|
||||
"""Update the keymap bar based on current screen."""
|
||||
screen_name = self.screen.__class__.__name__
|
||||
keymap = getattr(self.screen, "KEYMAP_HINT", None)
|
||||
if keymap:
|
||||
text = keymap
|
||||
elif screen_name == "ConnectScreen":
|
||||
text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit"
|
||||
elif screen_name == "LobbyScreen":
|
||||
text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit"
|
||||
else:
|
||||
text = "[Esc Esc] Quit"
|
||||
try:
|
||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_keymap(self, text: str) -> None:
|
||||
"""Allow screens to update the keymap bar dynamically.
|
||||
|
||||
Always appends [Esc Esc] Quit on the right for discoverability.
|
||||
"""
|
||||
if "[Esc Esc]" not in text:
|
||||
text = f"{text} [Esc Esc] Quit"
|
||||
try:
|
||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
await self.client.disconnect()
|
||||
114
tui_client/src/tui_client/client.py
Normal file
114
tui_client/src/tui_client/client.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""WebSocket + HTTP networking for the TUI client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GameClient:
|
||||
"""Handles HTTP auth and WebSocket game communication."""
|
||||
|
||||
def __init__(self, host: str, use_tls: bool = True):
|
||||
self.host = host
|
||||
self.use_tls = use_tls
|
||||
self._token: Optional[str] = None
|
||||
self._ws: Optional[ClientConnection] = None
|
||||
self._listener_task: Optional[asyncio.Task] = None
|
||||
self._app = None # Set by GolfApp
|
||||
self._username: Optional[str] = None
|
||||
|
||||
@property
|
||||
def http_base(self) -> str:
|
||||
scheme = "https" if self.use_tls else "http"
|
||||
return f"{scheme}://{self.host}"
|
||||
|
||||
@property
|
||||
def ws_url(self) -> str:
|
||||
scheme = "wss" if self.use_tls else "ws"
|
||||
url = f"{scheme}://{self.host}/ws"
|
||||
if self._token:
|
||||
url += f"?token={self._token}"
|
||||
return url
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self._token is not None
|
||||
|
||||
@property
|
||||
def username(self) -> Optional[str]:
|
||||
return self._username
|
||||
|
||||
async def login(self, username: str, password: str) -> dict:
|
||||
"""Login via HTTP and store JWT token.
|
||||
|
||||
Returns the response dict on success, raises on failure.
|
||||
"""
|
||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
||||
resp = await http.post(
|
||||
f"{self.http_base}/api/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
detail = resp.json().get("detail", "Login failed")
|
||||
raise ConnectionError(detail)
|
||||
data = resp.json()
|
||||
self._token = data["token"]
|
||||
self._username = data["user"]["username"]
|
||||
return data
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Open WebSocket connection to the server."""
|
||||
self._ws = await websockets.connect(self.ws_url)
|
||||
self._listener_task = asyncio.create_task(self._listen())
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close WebSocket connection."""
|
||||
if self._listener_task:
|
||||
self._listener_task.cancel()
|
||||
try:
|
||||
await self._listener_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._listener_task = None
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
async def send(self, msg_type: str, **kwargs) -> None:
|
||||
"""Send a JSON message over WebSocket."""
|
||||
if not self._ws:
|
||||
raise ConnectionError("Not connected")
|
||||
msg = {"type": msg_type, **kwargs}
|
||||
logger.debug(f"TX: {msg}")
|
||||
await self._ws.send(json.dumps(msg))
|
||||
|
||||
async def _listen(self) -> None:
|
||||
"""Background task: read messages from WebSocket and post to app."""
|
||||
try:
|
||||
async for raw in self._ws:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
logger.debug(f"RX: {data.get('type', '?')}")
|
||||
if self._app:
|
||||
self._app.post_server_message(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Non-JSON message: {raw[:100]}")
|
||||
except websockets.ConnectionClosed as e:
|
||||
logger.info(f"WebSocket closed: {e}")
|
||||
if self._app:
|
||||
self._app.post_server_message({"type": "connection_closed", "reason": str(e)})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket listener error: {e}")
|
||||
if self._app:
|
||||
self._app.post_server_message({"type": "connection_error", "reason": str(e)})
|
||||
41
tui_client/src/tui_client/config.py
Normal file
41
tui_client/src/tui_client/config.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""User configuration for the TUI client.
|
||||
|
||||
Config file: ~/.config/golf-tui.conf
|
||||
|
||||
Example contents:
|
||||
server = golfcards.club
|
||||
tls = true
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = Path(os.environ.get("GOLF_TUI_CONFIG", "~/.config/golf-tui.conf")).expanduser()
|
||||
|
||||
DEFAULTS = {
|
||||
"server": "golfcards.club",
|
||||
"tls": "true",
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict[str, str]:
|
||||
"""Load config from file, falling back to defaults."""
|
||||
cfg = dict(DEFAULTS)
|
||||
if CONFIG_PATH.exists():
|
||||
for line in CONFIG_PATH.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
cfg[key.strip().lower()] = value.strip()
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: dict[str, str]) -> None:
|
||||
"""Write config to file."""
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{k} = {v}" for k, v in sorted(cfg.items())]
|
||||
CONFIG_PATH.write_text("\n".join(lines) + "\n")
|
||||
133
tui_client/src/tui_client/models.py
Normal file
133
tui_client/src/tui_client/models.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Data models for the TUI client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardData:
|
||||
"""A single card as received from the server."""
|
||||
|
||||
suit: Optional[str] = None # "hearts", "diamonds", "clubs", "spades"
|
||||
rank: Optional[str] = None # "A", "2".."10", "J", "Q", "K", "★"
|
||||
face_up: bool = False
|
||||
deck_id: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> CardData:
|
||||
return cls(
|
||||
suit=d.get("suit"),
|
||||
rank=d.get("rank"),
|
||||
face_up=d.get("face_up", False),
|
||||
deck_id=d.get("deck_id"),
|
||||
)
|
||||
|
||||
@property
|
||||
def display_suit(self) -> str:
|
||||
"""Unicode suit symbol."""
|
||||
return {
|
||||
"hearts": "\u2665",
|
||||
"diamonds": "\u2666",
|
||||
"clubs": "\u2663",
|
||||
"spades": "\u2660",
|
||||
}.get(self.suit or "", "")
|
||||
|
||||
@property
|
||||
def display_rank(self) -> str:
|
||||
if self.rank == "10":
|
||||
return "10"
|
||||
return self.rank or ""
|
||||
|
||||
@property
|
||||
def is_red(self) -> bool:
|
||||
return self.suit in ("hearts", "diamonds")
|
||||
|
||||
@property
|
||||
def is_joker(self) -> bool:
|
||||
return self.rank == "\u2605"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerData:
|
||||
"""A player as received in game state."""
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
cards: list[CardData] = field(default_factory=list)
|
||||
score: Optional[int] = None
|
||||
total_score: int = 0
|
||||
rounds_won: int = 0
|
||||
all_face_up: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> PlayerData:
|
||||
return cls(
|
||||
id=d.get("id", ""),
|
||||
name=d.get("name", ""),
|
||||
cards=[CardData.from_dict(c) for c in d.get("cards", [])],
|
||||
score=d.get("score"),
|
||||
total_score=d.get("total_score", 0),
|
||||
rounds_won=d.get("rounds_won", 0),
|
||||
all_face_up=d.get("all_face_up", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""Full game state from the server."""
|
||||
|
||||
phase: str = "waiting"
|
||||
players: list[PlayerData] = field(default_factory=list)
|
||||
current_player_id: Optional[str] = None
|
||||
dealer_id: Optional[str] = None
|
||||
discard_top: Optional[CardData] = None
|
||||
deck_remaining: int = 0
|
||||
current_round: int = 1
|
||||
total_rounds: int = 1
|
||||
has_drawn_card: bool = False
|
||||
drawn_card: Optional[CardData] = None
|
||||
drawn_player_id: Optional[str] = None
|
||||
can_discard: bool = True
|
||||
waiting_for_initial_flip: bool = False
|
||||
initial_flips: int = 2
|
||||
flip_on_discard: bool = False
|
||||
flip_mode: str = "never"
|
||||
flip_is_optional: bool = False
|
||||
flip_as_action: bool = False
|
||||
knock_early: bool = False
|
||||
finisher_id: Optional[str] = None
|
||||
card_values: dict = field(default_factory=dict)
|
||||
active_rules: list = field(default_factory=list)
|
||||
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> GameState:
|
||||
discard = d.get("discard_top")
|
||||
drawn = d.get("drawn_card")
|
||||
return cls(
|
||||
phase=d.get("phase", "waiting"),
|
||||
players=[PlayerData.from_dict(p) for p in d.get("players", [])],
|
||||
current_player_id=d.get("current_player_id"),
|
||||
dealer_id=d.get("dealer_id"),
|
||||
discard_top=CardData.from_dict(discard) if discard else None,
|
||||
deck_remaining=d.get("deck_remaining", 0),
|
||||
current_round=d.get("current_round", 1),
|
||||
total_rounds=d.get("total_rounds", 1),
|
||||
has_drawn_card=d.get("has_drawn_card", False),
|
||||
drawn_card=CardData.from_dict(drawn) if drawn else None,
|
||||
drawn_player_id=d.get("drawn_player_id"),
|
||||
can_discard=d.get("can_discard", True),
|
||||
waiting_for_initial_flip=d.get("waiting_for_initial_flip", False),
|
||||
initial_flips=d.get("initial_flips", 2),
|
||||
flip_on_discard=d.get("flip_on_discard", False),
|
||||
flip_mode=d.get("flip_mode", "never"),
|
||||
flip_is_optional=d.get("flip_is_optional", False),
|
||||
flip_as_action=d.get("flip_as_action", False),
|
||||
knock_early=d.get("knock_early", False),
|
||||
finisher_id=d.get("finisher_id"),
|
||||
card_values=d.get("card_values", {}),
|
||||
active_rules=d.get("active_rules", []),
|
||||
deck_colors=d.get("deck_colors", ["red", "blue", "gold"]),
|
||||
)
|
||||
1
tui_client/src/tui_client/screens/__init__.py
Normal file
1
tui_client/src/tui_client/screens/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Screen modules for the TUI client."""
|
||||
77
tui_client/src/tui_client/screens/connect.py
Normal file
77
tui_client/src/tui_client/screens/connect.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""Connection screen: server URL + optional login form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Input, Static
|
||||
|
||||
|
||||
class ConnectScreen(Screen):
|
||||
"""Initial screen for connecting to the server."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="connect-container"):
|
||||
yield Static("GolfCards.club", id="connect-title")
|
||||
yield Static("Login (optional - leave blank to play as guest)")
|
||||
yield Input(placeholder="Username", id="input-username")
|
||||
yield Input(placeholder="Password", password=True, id="input-password")
|
||||
with Horizontal(id="connect-buttons"):
|
||||
yield Button("Connect as Guest", id="btn-guest", variant="default")
|
||||
yield Button("Login & Connect", id="btn-login", variant="primary")
|
||||
yield Static("", id="connect-status")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-guest":
|
||||
self._connect(login=False)
|
||||
elif event.button.id == "btn-login":
|
||||
self._connect(login=True)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
# Enter key in password field triggers login
|
||||
if event.input.id == "input-password":
|
||||
self._connect(login=True)
|
||||
|
||||
def _connect(self, login: bool) -> None:
|
||||
self._set_status("Connecting...")
|
||||
self._disable_buttons()
|
||||
self.run_worker(self._do_connect(login), exclusive=True)
|
||||
|
||||
async def _do_connect(self, login: bool) -> None:
|
||||
client = self.app.client
|
||||
|
||||
try:
|
||||
if login:
|
||||
username = self.query_one("#input-username", Input).value.strip()
|
||||
password = self.query_one("#input-password", Input).value
|
||||
if not username or not password:
|
||||
self._set_status("Username and password required")
|
||||
self._enable_buttons()
|
||||
return
|
||||
self._set_status("Logging in...")
|
||||
await client.login(username, password)
|
||||
self._set_status(f"Logged in as {client.username}")
|
||||
|
||||
self._set_status("Connecting to WebSocket...")
|
||||
await client.connect()
|
||||
self._set_status("Connected!")
|
||||
|
||||
# Move to lobby
|
||||
from tui_client.screens.lobby import LobbyScreen
|
||||
self.app.push_screen(LobbyScreen())
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"Error: {e}")
|
||||
self._enable_buttons()
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.query_one("#connect-status", Static).update(text)
|
||||
|
||||
def _disable_buttons(self) -> None:
|
||||
for btn in self.query("Button"):
|
||||
btn.disabled = True
|
||||
|
||||
def _enable_buttons(self) -> None:
|
||||
for btn in self.query("Button"):
|
||||
btn.disabled = False
|
||||
522
tui_client/src/tui_client/screens/game.py
Normal file
522
tui_client/src/tui_client/screens/game.py
Normal file
@ -0,0 +1,522 @@
|
||||
"""Main game board screen with keyboard actions and message dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.events import Resize
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.models import GameState, PlayerData
|
||||
from tui_client.widgets.hand import HandWidget
|
||||
from tui_client.widgets.play_area import PlayAreaWidget
|
||||
from tui_client.widgets.scoreboard import ScoreboardScreen
|
||||
from tui_client.widgets.status_bar import StatusBarWidget
|
||||
|
||||
|
||||
class GameScreen(Screen):
|
||||
"""Main game board with card display and keyboard controls."""
|
||||
|
||||
BINDINGS = [
|
||||
("d", "draw_deck", "Draw from deck"),
|
||||
("s", "pick_discard", "Pick from discard"),
|
||||
("1", "select_1", "Position 1"),
|
||||
("2", "select_2", "Position 2"),
|
||||
("3", "select_3", "Position 3"),
|
||||
("4", "select_4", "Position 4"),
|
||||
("5", "select_5", "Position 5"),
|
||||
("6", "select_6", "Position 6"),
|
||||
("x", "discard_held", "Discard held card"),
|
||||
("c", "cancel_draw", "Cancel draw"),
|
||||
("f", "flip_mode", "Flip card"),
|
||||
("p", "skip_flip", "Skip flip"),
|
||||
("k", "knock_early", "Knock early"),
|
||||
("n", "next_round", "Next round"),
|
||||
]
|
||||
|
||||
def __init__(self, initial_state: dict, is_host: bool = False):
|
||||
super().__init__()
|
||||
self._state = GameState.from_dict(initial_state)
|
||||
self._is_host = is_host
|
||||
self._player_id: str = ""
|
||||
self._awaiting_flip = False
|
||||
self._awaiting_initial_flip = False
|
||||
self._initial_flip_positions: list[int] = []
|
||||
self._can_flip_optional = False
|
||||
self._term_width: int = 80
|
||||
self._term_height: int = 24
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield StatusBarWidget(id="status-bar")
|
||||
with Container(id="game-content"):
|
||||
yield Static("", id="opponents-area")
|
||||
with Horizontal(id="play-area-row"):
|
||||
yield PlayAreaWidget(id="play-area")
|
||||
yield Static("", id="local-hand-label")
|
||||
yield HandWidget(id="local-hand")
|
||||
yield Static("", id="action-bar")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._player_id = self.app.player_id or ""
|
||||
self._term_width = self.app.size.width
|
||||
self._term_height = self.app.size.height
|
||||
self._full_refresh()
|
||||
|
||||
def on_resize(self, event: Resize) -> None:
|
||||
self._term_width = event.size.width
|
||||
self._term_height = event.size.height
|
||||
self._full_refresh()
|
||||
|
||||
def on_server_message(self, event) -> None:
|
||||
"""Dispatch server messages to handlers."""
|
||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
||||
if handler:
|
||||
handler(event.data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Server message handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_game_state(self, data: dict) -> None:
|
||||
state_data = data.get("game_state", data)
|
||||
self._state = GameState.from_dict(state_data)
|
||||
self._full_refresh()
|
||||
|
||||
def _handle_your_turn(self, data: dict) -> None:
|
||||
self._awaiting_flip = False
|
||||
self._refresh_action_bar()
|
||||
|
||||
def _handle_card_drawn(self, data: dict) -> None:
|
||||
from tui_client.models import CardData
|
||||
|
||||
card = CardData.from_dict(data.get("card", {}))
|
||||
source = data.get("source", "deck")
|
||||
rank = card.display_rank
|
||||
suit = card.display_suit
|
||||
|
||||
if source == "discard":
|
||||
self._set_action(
|
||||
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel"
|
||||
)
|
||||
self._set_keymap("[1-6] Swap [C] Cancel")
|
||||
else:
|
||||
self._set_action(
|
||||
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[x] to discard"
|
||||
)
|
||||
self._set_keymap("[1-6] Swap [X] Discard")
|
||||
|
||||
def _handle_can_flip(self, data: dict) -> None:
|
||||
self._awaiting_flip = True
|
||||
optional = data.get("optional", False)
|
||||
self._can_flip_optional = optional
|
||||
if optional:
|
||||
self._set_action("Keyboard: Flip a card \[1] thru \[6] or \[p] to skip")
|
||||
self._set_keymap("[1-6] Flip card [P] Skip")
|
||||
else:
|
||||
self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]")
|
||||
self._set_keymap("[1-6] Flip card")
|
||||
|
||||
def _handle_round_over(self, data: dict) -> None:
|
||||
scores = data.get("scores", [])
|
||||
round_num = data.get("round", 1)
|
||||
total_rounds = data.get("total_rounds", 1)
|
||||
|
||||
self.app.push_screen(
|
||||
ScoreboardScreen(
|
||||
scores=scores,
|
||||
title=f"Round {round_num} Complete",
|
||||
is_game_over=False,
|
||||
is_host=self._is_host,
|
||||
round_num=round_num,
|
||||
total_rounds=total_rounds,
|
||||
),
|
||||
callback=self._on_scoreboard_dismiss,
|
||||
)
|
||||
|
||||
def _handle_game_over(self, data: dict) -> None:
|
||||
scores = data.get("final_scores", [])
|
||||
|
||||
self.app.push_screen(
|
||||
ScoreboardScreen(
|
||||
scores=scores,
|
||||
title="Game Over!",
|
||||
is_game_over=True,
|
||||
is_host=self._is_host,
|
||||
),
|
||||
callback=self._on_scoreboard_dismiss,
|
||||
)
|
||||
|
||||
def _handle_round_started(self, data: dict) -> None:
|
||||
state_data = data.get("game_state", data)
|
||||
self._state = GameState.from_dict(state_data)
|
||||
self._awaiting_flip = False
|
||||
self._awaiting_initial_flip = False
|
||||
self._initial_flip_positions = []
|
||||
self._full_refresh()
|
||||
|
||||
def _handle_game_ended(self, data: dict) -> None:
|
||||
reason = data.get("reason", "Game ended")
|
||||
self._set_action(f"{reason}. Press Escape to return to lobby.")
|
||||
|
||||
def _handle_error(self, data: dict) -> None:
|
||||
msg = data.get("message", "Unknown error")
|
||||
self._set_action(f"[red]Error: {msg}[/red]")
|
||||
|
||||
def _handle_connection_closed(self, data: dict) -> None:
|
||||
self._set_action("[red]Connection lost.[/red]")
|
||||
|
||||
def _on_scoreboard_dismiss(self, result: str | None) -> None:
|
||||
if result == "next_round":
|
||||
self.run_worker(self._send("next_round"))
|
||||
elif result == "lobby":
|
||||
self.run_worker(self._send("leave_game"))
|
||||
self.app.pop_screen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Click handlers (from widget messages)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_hand_widget_card_clicked(self, event: HandWidget.CardClicked) -> None:
|
||||
"""Handle click on a card in the local hand."""
|
||||
self._select_position(event.position)
|
||||
|
||||
def on_play_area_widget_deck_clicked(self, event: PlayAreaWidget.DeckClicked) -> None:
|
||||
"""Handle click on the deck."""
|
||||
self.action_draw_deck()
|
||||
|
||||
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
|
||||
"""Handle click on the discard pile."""
|
||||
self.action_pick_discard()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keyboard actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_draw_deck(self) -> None:
|
||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
||||
return
|
||||
self.run_worker(self._send("draw", source="deck"))
|
||||
|
||||
def action_pick_discard(self) -> None:
|
||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
||||
return
|
||||
if not self._state.discard_top:
|
||||
return
|
||||
self.run_worker(self._send("draw", source="discard"))
|
||||
|
||||
def action_select_1(self) -> None:
|
||||
self._select_position(0)
|
||||
|
||||
def action_select_2(self) -> None:
|
||||
self._select_position(1)
|
||||
|
||||
def action_select_3(self) -> None:
|
||||
self._select_position(2)
|
||||
|
||||
def action_select_4(self) -> None:
|
||||
self._select_position(3)
|
||||
|
||||
def action_select_5(self) -> None:
|
||||
self._select_position(4)
|
||||
|
||||
def action_select_6(self) -> None:
|
||||
self._select_position(5)
|
||||
|
||||
def _select_position(self, pos: int) -> None:
|
||||
# Initial flip phase
|
||||
if self._state.waiting_for_initial_flip:
|
||||
self._handle_initial_flip_select(pos)
|
||||
return
|
||||
|
||||
# Flip after discard
|
||||
if self._awaiting_flip:
|
||||
self._do_flip(pos)
|
||||
return
|
||||
|
||||
# Swap with held card
|
||||
if self._state.has_drawn_card and self._is_my_turn():
|
||||
self.run_worker(self._send("swap", position=pos))
|
||||
return
|
||||
|
||||
def _handle_initial_flip_select(self, pos: int) -> None:
|
||||
if pos in self._initial_flip_positions:
|
||||
return # already selected
|
||||
# Reject already face-up cards
|
||||
me = self._get_local_player()
|
||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
||||
return
|
||||
|
||||
self._initial_flip_positions.append(pos)
|
||||
|
||||
# Immediately show the card as face-up locally for visual feedback
|
||||
if me and pos < len(me.cards):
|
||||
me.cards[pos].face_up = True
|
||||
hand = self.query_one("#local-hand", HandWidget)
|
||||
hand.update_player(
|
||||
me,
|
||||
deck_colors=self._state.deck_colors,
|
||||
is_current_turn=(me.id == self._state.current_player_id),
|
||||
is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"),
|
||||
is_dealer=(me.id == self._state.dealer_id),
|
||||
)
|
||||
|
||||
needed = self._state.initial_flips
|
||||
selected = len(self._initial_flip_positions)
|
||||
|
||||
if selected >= needed:
|
||||
self.run_worker(
|
||||
self._send("flip_initial", positions=self._initial_flip_positions)
|
||||
)
|
||||
self._awaiting_initial_flip = False
|
||||
self._initial_flip_positions = []
|
||||
else:
|
||||
self._set_action(
|
||||
f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})"
|
||||
)
|
||||
|
||||
def _do_flip(self, pos: int) -> None:
|
||||
me = self._get_local_player()
|
||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
||||
self._set_action("That card is already face-up! Pick a face-down card.")
|
||||
return
|
||||
self.run_worker(self._send("flip_card", position=pos))
|
||||
self._awaiting_flip = False
|
||||
|
||||
def action_discard_held(self) -> None:
|
||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
||||
return
|
||||
if not self._state.can_discard:
|
||||
self._set_action("Can't discard a card drawn from discard. Swap or cancel.")
|
||||
return
|
||||
self.run_worker(self._send("discard"))
|
||||
|
||||
def action_cancel_draw(self) -> None:
|
||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
||||
return
|
||||
self.run_worker(self._send("cancel_draw"))
|
||||
|
||||
def action_flip_mode(self) -> None:
|
||||
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
|
||||
self._awaiting_flip = True
|
||||
self._set_action("Flip mode: select a face-down card [1-6]")
|
||||
|
||||
def action_skip_flip(self) -> None:
|
||||
if self._awaiting_flip and self._can_flip_optional:
|
||||
self.run_worker(self._send("skip_flip"))
|
||||
self._awaiting_flip = False
|
||||
|
||||
def action_knock_early(self) -> None:
|
||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
||||
return
|
||||
if not self._state.knock_early:
|
||||
return
|
||||
self.run_worker(self._send("knock_early"))
|
||||
|
||||
def action_next_round(self) -> None:
|
||||
if self._is_host and self._state.phase == "round_over":
|
||||
self.run_worker(self._send("next_round"))
|
||||
|
||||
def action_leave_game(self) -> None:
|
||||
self.run_worker(self._send("leave_game"))
|
||||
self.app.pop_screen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_my_turn(self) -> bool:
|
||||
return self._state.current_player_id == self._player_id
|
||||
|
||||
def _get_local_player(self) -> PlayerData | None:
|
||||
for p in self._state.players:
|
||||
if p.id == self._player_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
||||
try:
|
||||
await self.app.client.send(msg_type, **kwargs)
|
||||
except Exception as e:
|
||||
self._set_action(f"[red]Send error: {e}[/red]")
|
||||
|
||||
def _set_action(self, text: str) -> None:
|
||||
try:
|
||||
self.query_one("#action-bar", Static).update(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _full_refresh(self) -> None:
|
||||
"""Refresh all widgets from current game state."""
|
||||
state = self._state
|
||||
|
||||
# Status bar
|
||||
status = self.query_one("#status-bar", StatusBarWidget)
|
||||
status.update_state(state, self._player_id)
|
||||
|
||||
# Play area
|
||||
play_area = self.query_one("#play-area", PlayAreaWidget)
|
||||
play_area.update_state(state, local_player_id=self._player_id)
|
||||
|
||||
# Local player hand (in bordered box with turn/knocker indicators)
|
||||
me = self._get_local_player()
|
||||
if me:
|
||||
self.query_one("#local-hand-label", Static).update("")
|
||||
hand = self.query_one("#local-hand", HandWidget)
|
||||
hand._is_local = True
|
||||
hand.update_player(
|
||||
me,
|
||||
deck_colors=state.deck_colors,
|
||||
is_current_turn=(me.id == state.current_player_id),
|
||||
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
|
||||
is_dealer=(me.id == state.dealer_id),
|
||||
)
|
||||
else:
|
||||
self.query_one("#local-hand-label", Static).update("")
|
||||
|
||||
# Opponents - bordered boxes in a single Static
|
||||
opponents = [p for p in state.players if p.id != self._player_id]
|
||||
self._render_opponents(opponents)
|
||||
|
||||
# Action bar
|
||||
self._refresh_action_bar()
|
||||
|
||||
def _render_opponents(self, opponents: list[PlayerData]) -> None:
|
||||
"""Render all opponent hands as bordered boxes into the opponents area.
|
||||
|
||||
Adapts layout based on terminal width:
|
||||
- Narrow (<80): stack opponents vertically
|
||||
- Medium (80-119): 2-3 side-by-side with moderate spacing
|
||||
- Wide (120+): all side-by-side with generous spacing
|
||||
"""
|
||||
if not opponents:
|
||||
self.query_one("#opponents-area", Static).update("")
|
||||
return
|
||||
|
||||
from tui_client.widgets.hand import _check_column_match, _render_card_lines
|
||||
from tui_client.widgets.player_box import _visible_len, render_player_box
|
||||
|
||||
state = self._state
|
||||
deck_colors = state.deck_colors
|
||||
width = self._term_width
|
||||
|
||||
# Build each opponent's boxed display
|
||||
opp_blocks: list[list[str]] = []
|
||||
for opp in opponents:
|
||||
cards = opp.cards
|
||||
matched = _check_column_match(cards)
|
||||
card_lines = _render_card_lines(
|
||||
cards, deck_colors=deck_colors, matched=matched,
|
||||
)
|
||||
|
||||
box = render_player_box(
|
||||
opp.name,
|
||||
score=opp.score,
|
||||
total_score=opp.total_score,
|
||||
content_lines=card_lines,
|
||||
is_current_turn=(opp.id == state.current_player_id),
|
||||
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
|
||||
is_dealer=(opp.id == state.dealer_id),
|
||||
all_face_up=opp.all_face_up,
|
||||
)
|
||||
opp_blocks.append(box)
|
||||
|
||||
# Determine how many opponents fit per row
|
||||
# Each box is ~21-24 chars wide
|
||||
opp_width = 22
|
||||
if width < 80:
|
||||
per_row = 1
|
||||
gap = " "
|
||||
elif width < 120:
|
||||
gap = " "
|
||||
per_row = max(1, (width - 4) // (opp_width + len(gap)))
|
||||
else:
|
||||
gap = " "
|
||||
per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap)))
|
||||
|
||||
# Render in rows of per_row opponents
|
||||
all_row_lines: list[str] = []
|
||||
for chunk_start in range(0, len(opp_blocks), per_row):
|
||||
chunk = opp_blocks[chunk_start : chunk_start + per_row]
|
||||
|
||||
if len(chunk) == 1:
|
||||
all_row_lines.extend(chunk[0])
|
||||
else:
|
||||
max_height = max(len(b) for b in chunk)
|
||||
# Pad shorter blocks with spaces matching each block's visible width
|
||||
for b in chunk:
|
||||
if b:
|
||||
pad_width = _visible_len(b[0])
|
||||
else:
|
||||
pad_width = 0
|
||||
while len(b) < max_height:
|
||||
b.append(" " * pad_width)
|
||||
for row_idx in range(max_height):
|
||||
parts = [b[row_idx] for b in chunk]
|
||||
all_row_lines.append(gap.join(parts))
|
||||
|
||||
if chunk_start + per_row < len(opp_blocks):
|
||||
all_row_lines.append("")
|
||||
|
||||
self.query_one("#opponents-area", Static).update("\n".join(all_row_lines))
|
||||
|
||||
def _refresh_action_bar(self) -> None:
|
||||
"""Update action bar and keymap based on current game state."""
|
||||
state = self._state
|
||||
|
||||
if state.phase in ("round_over", "game_over"):
|
||||
self._set_action("Keyboard: \[n]ext round")
|
||||
self._set_keymap("[N] Next round")
|
||||
return
|
||||
|
||||
if state.waiting_for_initial_flip:
|
||||
needed = state.initial_flips
|
||||
selected = len(self._initial_flip_positions)
|
||||
self._set_action(
|
||||
f"Keyboard: Choose {needed} cards \[1] thru \[6] to flip ({selected}/{needed})"
|
||||
)
|
||||
self._set_keymap("[1-6] Select card")
|
||||
return
|
||||
|
||||
if not self._is_my_turn():
|
||||
if state.current_player_id:
|
||||
for p in state.players:
|
||||
if p.id == state.current_player_id:
|
||||
self._set_action(f"Waiting for {p.name}...")
|
||||
self._set_keymap("Waiting...")
|
||||
return
|
||||
self._set_action("Waiting...")
|
||||
self._set_keymap("Waiting...")
|
||||
return
|
||||
|
||||
if state.has_drawn_card:
|
||||
keys = ["[1-6] Swap"]
|
||||
if state.can_discard:
|
||||
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[x] to discard")
|
||||
keys.append("[X] Discard")
|
||||
else:
|
||||
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel")
|
||||
keys.append("[C] Cancel")
|
||||
self._set_keymap(" ".join(keys))
|
||||
return
|
||||
|
||||
parts = ["Choose \[d]eck or di\[s]card pile"]
|
||||
keys = ["[D] Draw", "[S] Pick discard"]
|
||||
if state.flip_as_action:
|
||||
parts.append("\[f]lip a card")
|
||||
keys.append("[F] Flip")
|
||||
if state.knock_early:
|
||||
parts.append("\[k]nock early")
|
||||
keys.append("[K] Knock")
|
||||
self._set_action("Keyboard: " + " or ".join(parts))
|
||||
self._set_keymap(" ".join(keys))
|
||||
|
||||
def _set_keymap(self, text: str) -> None:
|
||||
try:
|
||||
self.app.set_keymap(text)
|
||||
except Exception:
|
||||
pass
|
||||
445
tui_client/src/tui_client/screens/lobby.py
Normal file
445
tui_client/src/tui_client/screens/lobby.py
Normal file
@ -0,0 +1,445 @@
|
||||
"""Lobby screen: create/join room, add CPUs, configure, start game."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
Label,
|
||||
OptionList,
|
||||
Select,
|
||||
Static,
|
||||
Switch,
|
||||
)
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
DECK_PRESETS = {
|
||||
"classic": ["red", "blue", "gold"],
|
||||
"ninja": ["green", "purple", "orange"],
|
||||
"ocean": ["blue", "teal", "cyan"],
|
||||
"forest": ["green", "gold", "brown"],
|
||||
"sunset": ["orange", "red", "purple"],
|
||||
"berry": ["purple", "pink", "red"],
|
||||
"neon": ["pink", "cyan", "green"],
|
||||
"royal": ["purple", "gold", "red"],
|
||||
"earth": ["brown", "green", "gold"],
|
||||
"all-red": ["red", "red", "red"],
|
||||
"all-blue": ["blue", "blue", "blue"],
|
||||
"all-green": ["green", "green", "green"],
|
||||
}
|
||||
|
||||
|
||||
class LobbyScreen(Screen):
|
||||
"""Room creation, joining, and pre-game configuration."""
|
||||
|
||||
BINDINGS = [
|
||||
("plus_sign", "add_cpu", "Add CPU"),
|
||||
("equals_sign", "add_cpu", "Add CPU"),
|
||||
("hyphen_minus", "remove_cpu", "Remove CPU"),
|
||||
("enter", "start_or_create", "Start/Create"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._room_code: str | None = None
|
||||
self._player_id: str | None = None
|
||||
self._is_host: bool = False
|
||||
self._players: list[dict] = []
|
||||
self._in_room: bool = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="lobby-container"):
|
||||
yield Static("[bold]GolfCards.club[/bold]", id="lobby-title")
|
||||
yield Static("", id="room-info")
|
||||
|
||||
# Pre-room: join/create
|
||||
with Vertical(id="pre-room"):
|
||||
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code")
|
||||
with Horizontal(id="pre-room-buttons"):
|
||||
yield Button("Create Room", id="btn-create", variant="primary")
|
||||
yield Button("Join Room", id="btn-join", variant="default")
|
||||
|
||||
# In-room: player list + controls + settings
|
||||
with Vertical(id="in-room"):
|
||||
yield Static("[bold]Players[/bold]", id="player-list-label")
|
||||
yield Static("", id="player-list")
|
||||
|
||||
# CPU controls: compact [+] [-]
|
||||
with Horizontal(id="cpu-controls"):
|
||||
yield Label("CPU:", id="cpu-label")
|
||||
yield Button("+", id="btn-cpu-add", variant="default")
|
||||
yield Button("−", id="btn-cpu-remove", variant="warning")
|
||||
yield Button("?", id="btn-cpu-random", variant="default")
|
||||
|
||||
# CPU profile picker (hidden by default)
|
||||
yield OptionList(id="cpu-profile-list")
|
||||
|
||||
# Host settings (collapsible sections)
|
||||
with Vertical(id="host-settings"):
|
||||
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Rounds")
|
||||
yield Select(
|
||||
[(str(v), v) for v in (1, 3, 9, 18)],
|
||||
value=9,
|
||||
id="sel-rounds",
|
||||
allow_blank=False,
|
||||
)
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Decks")
|
||||
yield Select(
|
||||
[(str(v), v) for v in (1, 2, 3)],
|
||||
value=1,
|
||||
id="sel-decks",
|
||||
allow_blank=False,
|
||||
)
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Initial Flips")
|
||||
yield Select(
|
||||
[(str(v), v) for v in (0, 1, 2)],
|
||||
value=2,
|
||||
id="sel-initial-flips",
|
||||
allow_blank=False,
|
||||
)
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Flip Mode")
|
||||
yield Select(
|
||||
[("Never", "never"), ("Always", "always"), ("Endgame", "endgame")],
|
||||
value="never",
|
||||
id="sel-flip-mode",
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
with Collapsible(title="House Rules", collapsed=True, id="coll-rules"):
|
||||
# Joker variant
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Jokers")
|
||||
yield Select(
|
||||
[
|
||||
("None", "none"),
|
||||
("Standard (−2)", "standard"),
|
||||
("Lucky Swing (−5)", "lucky_swing"),
|
||||
("Eagle Eye (+2/−4)", "eagle_eye"),
|
||||
],
|
||||
value="none",
|
||||
id="sel-jokers",
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
# Scoring rules
|
||||
yield Static("[bold]Scoring[/bold]", classes="rules-header")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Super Kings (K = −2)")
|
||||
yield Switch(id="sw-super_kings")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Ten Penny (10 = 1)")
|
||||
yield Switch(id="sw-ten_penny")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("One-Eyed Jacks (J♥/J♠ = 0)")
|
||||
yield Switch(id="sw-one_eyed_jacks")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Negative Pairs Keep Value")
|
||||
yield Switch(id="sw-negative_pairs_keep_value")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Four of a Kind (−20)")
|
||||
yield Switch(id="sw-four_of_a_kind")
|
||||
|
||||
# Knock & Endgame
|
||||
yield Static("[bold]Knock & Endgame[/bold]", classes="rules-header")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Knock Penalty (+10)")
|
||||
yield Switch(id="sw-knock_penalty")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Knock Bonus (−5)")
|
||||
yield Switch(id="sw-knock_bonus")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Knock Early")
|
||||
yield Switch(id="sw-knock_early")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Flip as Action")
|
||||
yield Switch(id="sw-flip_as_action")
|
||||
|
||||
# Bonuses & Penalties
|
||||
yield Static("[bold]Bonuses & Penalties[/bold]", classes="rules-header")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Underdog Bonus (−3)")
|
||||
yield Switch(id="sw-underdog_bonus")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Tied Shame (+5)")
|
||||
yield Switch(id="sw-tied_shame")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Blackjack (21→0)")
|
||||
yield Switch(id="sw-blackjack")
|
||||
with Horizontal(classes="rule-row"):
|
||||
yield Label("Wolfpack")
|
||||
yield Switch(id="sw-wolfpack")
|
||||
|
||||
with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"):
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Select(
|
||||
[(name.replace("-", " ").title(), name) for name in DECK_PRESETS],
|
||||
value="classic",
|
||||
id="sel-deck-style",
|
||||
allow_blank=False,
|
||||
)
|
||||
yield Static(
|
||||
self._render_deck_preview("classic"),
|
||||
id="deck-preview",
|
||||
)
|
||||
|
||||
yield Button("Start Game", id="btn-start", variant="success")
|
||||
|
||||
yield Static("", id="lobby-status")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_visibility()
|
||||
self._update_keymap()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
try:
|
||||
self.query_one("#pre-room").display = not self._in_room
|
||||
self.query_one("#in-room").display = self._in_room
|
||||
# Host-only controls
|
||||
self.query_one("#cpu-controls").display = self._in_room and self._is_host
|
||||
self.query_one("#host-settings").display = self._in_room and self._is_host
|
||||
self.query_one("#btn-start").display = self._in_room and self._is_host
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_keymap(self) -> None:
|
||||
try:
|
||||
if self._in_room and self._is_host:
|
||||
self.app.set_keymap("[+] Add CPU [−] Remove CPU [Enter] Start Game [Esc Esc] Quit")
|
||||
elif self._in_room:
|
||||
self.app.set_keymap("Waiting for host to start... [Esc Esc] Quit")
|
||||
else:
|
||||
self.app.set_keymap("[Tab] Navigate [Enter] Create/Join [Esc Esc] Quit")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-create":
|
||||
self._create_room()
|
||||
elif event.button.id == "btn-join":
|
||||
self._join_room()
|
||||
elif event.button.id == "btn-cpu-add":
|
||||
self._show_cpu_picker()
|
||||
elif event.button.id == "btn-cpu-remove":
|
||||
self._remove_cpu()
|
||||
elif event.button.id == "btn-cpu-random":
|
||||
self._add_random_cpu()
|
||||
elif event.button.id == "btn-start":
|
||||
self._start_game()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "input-room-code":
|
||||
code = event.value.strip()
|
||||
if code:
|
||||
self._join_room()
|
||||
else:
|
||||
self._create_room()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if event.option_list.id == "cpu-profile-list":
|
||||
profile_name = str(event.option.id) if event.option.id else ""
|
||||
self.run_worker(self._send("add_cpu", profile_name=profile_name))
|
||||
event.option_list.display = False
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
if event.select.id == "sel-deck-style" and event.value is not None:
|
||||
try:
|
||||
preview = self.query_one("#deck-preview", Static)
|
||||
preview.update(self._render_deck_preview(str(event.value)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_add_cpu(self) -> None:
|
||||
if self._in_room and self._is_host:
|
||||
self._show_cpu_picker()
|
||||
|
||||
def action_remove_cpu(self) -> None:
|
||||
if self._in_room and self._is_host:
|
||||
self._remove_cpu()
|
||||
|
||||
def action_start_or_create(self) -> None:
|
||||
if self._in_room and self._is_host:
|
||||
self._start_game()
|
||||
elif not self._in_room:
|
||||
code = self.query_one("#input-room-code", Input).value.strip()
|
||||
if code:
|
||||
self._join_room()
|
||||
else:
|
||||
self._create_room()
|
||||
|
||||
def _create_room(self) -> None:
|
||||
player_name = self.app.client.username or "Player"
|
||||
self.run_worker(self._send("create_room", player_name=player_name))
|
||||
|
||||
def _join_room(self) -> None:
|
||||
code = self.query_one("#input-room-code", Input).value.strip().upper()
|
||||
if not code:
|
||||
self._set_status("Enter a room code to join")
|
||||
return
|
||||
player_name = self.app.client.username or "Player"
|
||||
self.run_worker(self._send("join_room", room_code=code, player_name=player_name))
|
||||
|
||||
@staticmethod
|
||||
def _render_deck_preview(preset_name: str) -> str:
|
||||
"""Render mini card-back swatches for a deck color preset."""
|
||||
from tui_client.widgets.card import BACK_COLORS
|
||||
|
||||
colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
|
||||
# Show unique colors only (e.g. all-red shows one wider swatch)
|
||||
seen: list[str] = []
|
||||
for c in colors:
|
||||
if c not in seen:
|
||||
seen.append(c)
|
||||
|
||||
parts: list[str] = []
|
||||
for color_name in seen:
|
||||
hex_color = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
||||
parts.append(f"[{hex_color}]░░░[/]")
|
||||
return " ".join(parts)
|
||||
|
||||
def _add_random_cpu(self) -> None:
|
||||
"""Add a random CPU (server picks the profile)."""
|
||||
self.run_worker(self._send("add_cpu"))
|
||||
|
||||
def _show_cpu_picker(self) -> None:
|
||||
"""Request CPU profiles from server and show picker."""
|
||||
self.run_worker(self._send("get_cpu_profiles"))
|
||||
|
||||
def _handle_cpu_profiles(self, data: dict) -> None:
|
||||
"""Populate and show the CPU profile option list."""
|
||||
profiles = data.get("profiles", [])
|
||||
option_list = self.query_one("#cpu-profile-list", OptionList)
|
||||
option_list.clear_options()
|
||||
for p in profiles:
|
||||
name = p.get("name", "?")
|
||||
style = p.get("style", "")
|
||||
option_list.add_option(Option(f"{name} — {style}", id=name))
|
||||
option_list.display = True
|
||||
option_list.focus()
|
||||
|
||||
def _remove_cpu(self) -> None:
|
||||
self.run_worker(self._send("remove_cpu"))
|
||||
|
||||
def _collect_settings(self) -> dict:
|
||||
"""Read all Select/Switch values and return kwargs for start_game."""
|
||||
settings: dict = {}
|
||||
|
||||
try:
|
||||
settings["rounds"] = self.query_one("#sel-rounds", Select).value
|
||||
settings["decks"] = self.query_one("#sel-decks", Select).value
|
||||
settings["initial_flips"] = self.query_one("#sel-initial-flips", Select).value
|
||||
settings["flip_mode"] = self.query_one("#sel-flip-mode", Select).value
|
||||
except Exception:
|
||||
settings.setdefault("rounds", 9)
|
||||
settings.setdefault("decks", 1)
|
||||
settings.setdefault("initial_flips", 2)
|
||||
settings.setdefault("flip_mode", "never")
|
||||
|
||||
# Joker variant → booleans
|
||||
try:
|
||||
joker_mode = self.query_one("#sel-jokers", Select).value
|
||||
except Exception:
|
||||
joker_mode = "none"
|
||||
|
||||
settings["use_jokers"] = joker_mode != "none"
|
||||
settings["lucky_swing"] = joker_mode == "lucky_swing"
|
||||
settings["eagle_eye"] = joker_mode == "eagle_eye"
|
||||
|
||||
# Boolean house rules from switches
|
||||
rule_ids = [
|
||||
"super_kings", "ten_penny", "one_eyed_jacks",
|
||||
"negative_pairs_keep_value", "four_of_a_kind",
|
||||
"knock_penalty", "knock_bonus", "knock_early", "flip_as_action",
|
||||
"underdog_bonus", "tied_shame", "blackjack", "wolfpack",
|
||||
]
|
||||
for rule_id in rule_ids:
|
||||
try:
|
||||
settings[rule_id] = self.query_one(f"#sw-{rule_id}", Switch).value
|
||||
except Exception:
|
||||
settings[rule_id] = False
|
||||
|
||||
# Deck colors from preset
|
||||
try:
|
||||
preset = self.query_one("#sel-deck-style", Select).value
|
||||
settings["deck_colors"] = DECK_PRESETS.get(preset, ["red", "blue", "gold"])
|
||||
except Exception:
|
||||
settings["deck_colors"] = ["red", "blue", "gold"]
|
||||
|
||||
return settings
|
||||
|
||||
def _start_game(self) -> None:
|
||||
self._set_status("Starting game...")
|
||||
settings = self._collect_settings()
|
||||
self.run_worker(self._send("start_game", **settings))
|
||||
|
||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
||||
try:
|
||||
await self.app.client.send(msg_type, **kwargs)
|
||||
except Exception as e:
|
||||
self._set_status(f"Error: {e}")
|
||||
|
||||
def on_server_message(self, event) -> None:
|
||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
||||
if handler:
|
||||
handler(event.data)
|
||||
|
||||
def _handle_room_created(self, data: dict) -> None:
|
||||
self._room_code = data.get("room_code", "")
|
||||
self._player_id = data.get("player_id", "")
|
||||
self.app.player_id = self._player_id
|
||||
self._is_host = True
|
||||
self._in_room = True
|
||||
self._set_room_info(f"Room [bold]{self._room_code}[/bold] (You are host)")
|
||||
self._set_status("Add CPU opponents, then start when ready.")
|
||||
self._update_visibility()
|
||||
self._update_keymap()
|
||||
|
||||
def _handle_room_joined(self, data: dict) -> None:
|
||||
self._room_code = data.get("room_code", "")
|
||||
self._player_id = data.get("player_id", "")
|
||||
self.app.player_id = self._player_id
|
||||
self._in_room = True
|
||||
self._set_room_info(f"Room [bold]{self._room_code}[/bold]")
|
||||
self._set_status("Waiting for host to start the game.")
|
||||
self._update_visibility()
|
||||
self._update_keymap()
|
||||
|
||||
def _handle_player_joined(self, data: dict) -> None:
|
||||
self._players = data.get("players", [])
|
||||
self._refresh_player_list()
|
||||
|
||||
def _handle_game_started(self, data: dict) -> None:
|
||||
from tui_client.screens.game import GameScreen
|
||||
game_state = data.get("game_state", {})
|
||||
self.app.push_screen(GameScreen(game_state, self._is_host))
|
||||
|
||||
def _handle_error(self, data: dict) -> None:
|
||||
self._set_status(f"[red]Error: {data.get('message', 'Unknown error')}[/red]")
|
||||
|
||||
def _refresh_player_list(self) -> None:
|
||||
lines = []
|
||||
for i, p in enumerate(self._players, 1):
|
||||
name = p.get("name", "?")
|
||||
tags = []
|
||||
if p.get("is_host"):
|
||||
tags.append("[bold cyan]Host[/bold cyan]")
|
||||
if p.get("is_cpu"):
|
||||
tags.append("[yellow]CPU[/yellow]")
|
||||
suffix = f" {' '.join(tags)}" if tags else ""
|
||||
lines.append(f" {i}. {name}{suffix}")
|
||||
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
|
||||
|
||||
def _set_room_info(self, text: str) -> None:
|
||||
self.query_one("#room-info", Static).update(text)
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.query_one("#lobby-status", Static).update(text)
|
||||
294
tui_client/src/tui_client/styles.tcss
Normal file
294
tui_client/src/tui_client/styles.tcss
Normal file
@ -0,0 +1,294 @@
|
||||
/* Base app styles */
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
/* Connect screen */
|
||||
ConnectScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#connect-container {
|
||||
width: 80%;
|
||||
max-width: 64;
|
||||
min-width: 40;
|
||||
height: auto;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#connect-container Static {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#connect-title {
|
||||
text-style: bold;
|
||||
color: $text;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#connect-container Input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#connect-buttons {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#connect-buttons Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#connect-status {
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
margin-top: 1;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
/* Lobby screen */
|
||||
LobbyScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#lobby-container {
|
||||
width: 80%;
|
||||
max-width: 72;
|
||||
min-width: 40;
|
||||
height: auto;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#lobby-title {
|
||||
text-style: bold;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#room-info {
|
||||
text-align: center;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* Pre-room: join/create controls */
|
||||
#pre-room {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#input-room-code {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#pre-room-buttons {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#pre-room-buttons Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
/* In-room: player list + controls */
|
||||
#in-room {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#player-list-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#player-list {
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
max-height: 12;
|
||||
border: tall $primary;
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* CPU controls: compact [+] [-] */
|
||||
#cpu-controls {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#cpu-controls Button {
|
||||
min-width: 5;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#cpu-label {
|
||||
padding: 1 1 0 0;
|
||||
}
|
||||
|
||||
#cpu-profile-list {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
border: tall $accent;
|
||||
margin-bottom: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Host settings */
|
||||
#host-settings {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
height: 3;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.setting-row Label {
|
||||
width: 1fr;
|
||||
padding: 1 1 0 0;
|
||||
}
|
||||
|
||||
.setting-row Select {
|
||||
width: 24;
|
||||
}
|
||||
|
||||
#deck-preview {
|
||||
width: auto;
|
||||
padding: 1 1 0 1;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
height: 3;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.rule-row Label {
|
||||
width: 1fr;
|
||||
padding: 1 1 0 0;
|
||||
}
|
||||
|
||||
.rule-row Switch {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
margin-top: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#btn-start {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#lobby-status {
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
/* Game screen */
|
||||
GameScreen {
|
||||
align: center top;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#game-content {
|
||||
width: 100%;
|
||||
max-width: 120;
|
||||
height: 100%;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
dock: top;
|
||||
background: $primary;
|
||||
color: $text;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
#opponents-area {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
padding: 1 2 0 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#play-area-row {
|
||||
height: auto;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#play-area {
|
||||
height: auto;
|
||||
width: auto;
|
||||
padding: 0 2;
|
||||
border: round $primary-lighten-2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#action-bar {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
max-height: 3;
|
||||
dock: bottom;
|
||||
background: $surface-darken-1;
|
||||
padding: 0 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
/* Local hand label */
|
||||
#local-hand-label {
|
||||
text-align: center;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
/* Local hand widget */
|
||||
#local-hand {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
/* Scoreboard overlay */
|
||||
#scoreboard-overlay {
|
||||
align: center middle;
|
||||
background: $surface 80%;
|
||||
}
|
||||
|
||||
#scoreboard-container {
|
||||
width: 80%;
|
||||
max-width: 64;
|
||||
min-width: 40;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#scoreboard-title {
|
||||
text-style: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#scoreboard-table {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#scoreboard-buttons {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
1
tui_client/src/tui_client/widgets/__init__.py
Normal file
1
tui_client/src/tui_client/widgets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Widget modules for the TUI client."""
|
||||
161
tui_client/src/tui_client/widgets/card.py
Normal file
161
tui_client/src/tui_client/widgets/card.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""Single card widget using Unicode box-drawing with Rich color markup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.models import CardData
|
||||
|
||||
|
||||
# Web UI card back colors mapped to terminal hex equivalents
|
||||
BACK_COLORS: dict[str, str] = {
|
||||
"red": "#c41e3a",
|
||||
"blue": "#2e5cb8",
|
||||
"green": "#228b22",
|
||||
"gold": "#daa520",
|
||||
"purple": "#6a0dad",
|
||||
"teal": "#008b8b",
|
||||
"pink": "#db7093",
|
||||
"slate": "#4a5568",
|
||||
"orange": "#e67e22",
|
||||
"cyan": "#00bcd4",
|
||||
"brown": "#8b4513",
|
||||
"yellow": "#daa520",
|
||||
}
|
||||
|
||||
# Face-up card text colors (matching web UI)
|
||||
SUIT_RED = "#c0392b" # hearts, diamonds
|
||||
SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg)
|
||||
JOKER_COLOR = "#9b59b6" # purple
|
||||
BORDER_COLOR = "#888888" # card border
|
||||
EMPTY_COLOR = "#555555" # empty card slot
|
||||
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
|
||||
|
||||
|
||||
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
|
||||
"""Get the hex color for a face-down card's back based on deck_id."""
|
||||
if deck_colors and card.deck_id is not None and card.deck_id < len(deck_colors):
|
||||
name = deck_colors[card.deck_id]
|
||||
else:
|
||||
name = "red"
|
||||
return BACK_COLORS.get(name, BACK_COLORS["red"])
|
||||
|
||||
|
||||
def _top_border(position: int | None, d: str, color: str) -> str:
|
||||
"""Top border line, with position number replacing ┌ when present."""
|
||||
if position is not None:
|
||||
return f"[{d}{color}]{position}───┐[/{d}{color}]"
|
||||
return f"[{d}{color}]┌───┐[/{d}{color}]"
|
||||
|
||||
|
||||
def render_card(
|
||||
card: CardData | None,
|
||||
selected: bool = False,
|
||||
position: int | None = None,
|
||||
deck_colors: list[str] | None = None,
|
||||
dim: bool = False,
|
||||
) -> str:
|
||||
"""Render a card as a 4-line Rich-markup string.
|
||||
|
||||
Face-up: Face-down: Empty:
|
||||
┌───┐ 1───┐ ┌───┐
|
||||
│ A │ │░░░│ │ │
|
||||
│ ♠ │ │░░░│ │ │
|
||||
└───┘ └───┘ └───┘
|
||||
"""
|
||||
d = "dim " if dim else ""
|
||||
bc = BORDER_COLOR
|
||||
|
||||
# Empty slot
|
||||
if card is None:
|
||||
c = EMPTY_COLOR
|
||||
return (
|
||||
f"[{d}{c}]┌───┐[/{d}{c}]\n"
|
||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
||||
f"[{d}{c}]└───┘[/{d}{c}]"
|
||||
)
|
||||
|
||||
top = _top_border(position, d, bc)
|
||||
|
||||
# Face-down card with colored back
|
||||
if not card.face_up:
|
||||
back = _back_color_for_card(card, deck_colors)
|
||||
return (
|
||||
f"{top}\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]└───┘[/{d}{bc}]"
|
||||
)
|
||||
|
||||
# Joker
|
||||
if card.is_joker:
|
||||
jc = JOKER_COLOR
|
||||
icon = "🐉" if card.suit == "hearts" else "👹"
|
||||
return (
|
||||
f"{top}\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]└───┘[/{d}{bc}]"
|
||||
)
|
||||
|
||||
# Face-up normal card
|
||||
fc = SUIT_RED if card.is_red else SUIT_BLACK
|
||||
rank = card.display_rank
|
||||
suit = card.display_suit
|
||||
rank_line = f"{rank:^3}"
|
||||
suit_line = f"{suit:^3}"
|
||||
|
||||
return (
|
||||
f"{top}\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{suit_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||
f"[{d}{bc}]└───┘[/{d}{bc}]"
|
||||
)
|
||||
|
||||
|
||||
class CardWidget(Static):
|
||||
"""A single card display widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
card: CardData | None = None,
|
||||
selected: bool = False,
|
||||
position: int | None = None,
|
||||
matched: bool = False,
|
||||
deck_colors: list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._card = card
|
||||
self._selected = selected
|
||||
self._position = position
|
||||
self._matched = matched
|
||||
self._deck_colors = deck_colors
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh_display()
|
||||
|
||||
def update_card(
|
||||
self,
|
||||
card: CardData | None,
|
||||
selected: bool = False,
|
||||
matched: bool = False,
|
||||
deck_colors: list[str] | None = None,
|
||||
) -> None:
|
||||
self._card = card
|
||||
self._selected = selected
|
||||
self._matched = matched
|
||||
if deck_colors is not None:
|
||||
self._deck_colors = deck_colors
|
||||
self._refresh_display()
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
text = render_card(
|
||||
self._card,
|
||||
self._selected,
|
||||
self._position,
|
||||
deck_colors=self._deck_colors,
|
||||
dim=self._matched,
|
||||
)
|
||||
self.update(text)
|
||||
230
tui_client/src/tui_client/widgets/hand.py
Normal file
230
tui_client/src/tui_client/widgets/hand.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""2x3 card grid for one player's hand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.events import Click
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.models import CardData, PlayerData
|
||||
from tui_client.widgets.card import render_card
|
||||
|
||||
# Color for the match connector lines (muted green — pairs cancel to 0)
|
||||
_MATCH_COLOR = "#6a9955"
|
||||
|
||||
|
||||
def _check_column_match(cards: list[CardData]) -> list[bool]:
|
||||
"""Check which cards are in matched columns (both face-up, same rank).
|
||||
|
||||
Cards layout: [0][1][2]
|
||||
[3][4][5]
|
||||
Columns: (0,3), (1,4), (2,5)
|
||||
"""
|
||||
matched = [False] * 6
|
||||
if len(cards) < 6:
|
||||
return matched
|
||||
|
||||
for col in range(3):
|
||||
top = cards[col]
|
||||
bot = cards[col + 3]
|
||||
if (
|
||||
top.face_up
|
||||
and bot.face_up
|
||||
and top.rank is not None
|
||||
and top.rank == bot.rank
|
||||
):
|
||||
matched[col] = True
|
||||
matched[col + 3] = True
|
||||
return matched
|
||||
|
||||
|
||||
def _build_match_connector(matched: list[bool]) -> str | None:
|
||||
"""Build a connector line with ║ ║ under each matched column.
|
||||
|
||||
Card layout: 5-char card, 1-char gap, repeated.
|
||||
Positions: 01234 5 6789A B CDEFG
|
||||
card0 card1 card2
|
||||
|
||||
For each matched column, place ║ at offsets 1 and 3 within the card span.
|
||||
"""
|
||||
has_any = matched[0] or matched[1] or matched[2]
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
# Build a 17-char line (3 cards * 5 + 2 gaps)
|
||||
chars = list(" " * 17)
|
||||
for col in range(3):
|
||||
if matched[col]: # top card matched implies column matched
|
||||
base = col * 6 # card start position (0, 6, 12)
|
||||
chars[base + 1] = "║"
|
||||
chars[base + 3] = "║"
|
||||
|
||||
line = "".join(chars)
|
||||
return f"[{_MATCH_COLOR}]{line}[/]"
|
||||
|
||||
|
||||
def _render_card_lines(
|
||||
cards: list[CardData],
|
||||
*,
|
||||
is_local: bool = False,
|
||||
deck_colors: list[str] | None = None,
|
||||
matched: list[bool] | None = None,
|
||||
) -> list[str]:
|
||||
"""Render the 2x3 card grid as a list of text lines (no box).
|
||||
|
||||
Inserts a connector line with ║ ║ between rows for matched columns.
|
||||
"""
|
||||
if matched is None:
|
||||
matched = _check_column_match(cards)
|
||||
lines: list[str] = []
|
||||
for row_idx, row_start in enumerate((0, 3)):
|
||||
# Insert connector between row 0 and row 1
|
||||
if row_idx == 1:
|
||||
connector = _build_match_connector(matched)
|
||||
if connector:
|
||||
lines.append(connector)
|
||||
|
||||
row_line_parts: list[list[str]] = []
|
||||
for i in range(3):
|
||||
idx = row_start + i
|
||||
card = cards[idx] if idx < len(cards) else None
|
||||
pos = idx + 1 if is_local else None
|
||||
text = render_card(
|
||||
card,
|
||||
position=pos,
|
||||
deck_colors=deck_colors,
|
||||
dim=matched[idx],
|
||||
)
|
||||
card_lines = text.split("\n")
|
||||
while len(row_line_parts) < len(card_lines):
|
||||
row_line_parts.append([])
|
||||
for ln_idx, ln in enumerate(card_lines):
|
||||
row_line_parts[ln_idx].append(ln)
|
||||
for parts in row_line_parts:
|
||||
lines.append(" ".join(parts))
|
||||
return lines
|
||||
|
||||
|
||||
class HandWidget(Static):
|
||||
"""Displays a player's 2x3 card grid as rich text, wrapped in a player box."""
|
||||
|
||||
class CardClicked(Message):
|
||||
"""Posted when a card position is clicked in the local hand."""
|
||||
|
||||
def __init__(self, position: int) -> None:
|
||||
super().__init__()
|
||||
self.position = position
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: PlayerData | None = None,
|
||||
is_local: bool = False,
|
||||
deck_colors: list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._player = player
|
||||
self._is_local = is_local
|
||||
self._deck_colors = deck_colors
|
||||
# State flags for the player box
|
||||
self._is_current_turn: bool = False
|
||||
self._is_knocker: bool = False
|
||||
self._is_dealer: bool = False
|
||||
self._has_connector: bool = False
|
||||
|
||||
def update_player(
|
||||
self,
|
||||
player: PlayerData,
|
||||
deck_colors: list[str] | None = None,
|
||||
*,
|
||||
is_current_turn: bool = False,
|
||||
is_knocker: bool = False,
|
||||
is_dealer: bool = False,
|
||||
) -> None:
|
||||
self._player = player
|
||||
if deck_colors is not None:
|
||||
self._deck_colors = deck_colors
|
||||
self._is_current_turn = is_current_turn
|
||||
self._is_knocker = is_knocker
|
||||
self._is_dealer = is_dealer
|
||||
self._refresh()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh()
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Map click coordinates to card position (0-5)."""
|
||||
if not self._is_local:
|
||||
return
|
||||
|
||||
# Box layout:
|
||||
# Line 0: top border
|
||||
# Lines 1-4: row 0 cards (4 lines each)
|
||||
# Line 5 (optional): match connector
|
||||
# Lines 5-8 or 6-9: row 1 cards
|
||||
# Last line: bottom border
|
||||
#
|
||||
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
|
||||
|
||||
x, y = event.x, event.y
|
||||
|
||||
# Determine column from x (content starts at x=2 inside box)
|
||||
# Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18
|
||||
col = -1
|
||||
if 2 <= x <= 6:
|
||||
col = 0
|
||||
elif 8 <= x <= 12:
|
||||
col = 1
|
||||
elif 14 <= x <= 18:
|
||||
col = 2
|
||||
|
||||
if col < 0:
|
||||
return
|
||||
|
||||
# Determine row from y
|
||||
# y=0: top border, y=1..4: row 0 cards, then optional connector, then row 1
|
||||
row = -1
|
||||
if 1 <= y <= 4:
|
||||
row = 0
|
||||
else:
|
||||
row1_start = 6 if self._has_connector else 5
|
||||
if row1_start <= y <= row1_start + 3:
|
||||
row = 1
|
||||
|
||||
if row < 0:
|
||||
return
|
||||
|
||||
position = row * 3 + col
|
||||
self.post_message(self.CardClicked(position))
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self._player or not self._player.cards:
|
||||
self.update("")
|
||||
return
|
||||
|
||||
from tui_client.widgets.player_box import render_player_box
|
||||
|
||||
cards = self._player.cards
|
||||
matched = _check_column_match(cards)
|
||||
self._has_connector = any(matched[:3])
|
||||
|
||||
card_lines = _render_card_lines(
|
||||
cards,
|
||||
is_local=self._is_local,
|
||||
deck_colors=self._deck_colors,
|
||||
matched=matched,
|
||||
)
|
||||
|
||||
box_lines = render_player_box(
|
||||
self._player.name,
|
||||
score=self._player.score,
|
||||
total_score=self._player.total_score,
|
||||
content_lines=card_lines,
|
||||
is_current_turn=self._is_current_turn,
|
||||
is_knocker=self._is_knocker,
|
||||
is_dealer=self._is_dealer,
|
||||
is_local=self._is_local,
|
||||
all_face_up=self._player.all_face_up,
|
||||
)
|
||||
|
||||
self.update("\n".join(box_lines))
|
||||
128
tui_client/src/tui_client/widgets/play_area.py
Normal file
128
tui_client/src/tui_client/widgets/play_area.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Deck + discard + held card area."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import replace
|
||||
|
||||
from textual.events import Click
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.models import CardData, GameState
|
||||
from tui_client.widgets.card import render_card
|
||||
|
||||
# Fixed column width for each card section (card is 5 wide)
|
||||
_COL_WIDTH = 12
|
||||
|
||||
# Lime green for the held card highlight
|
||||
_HOLDING_COLOR = "#80ff00"
|
||||
|
||||
|
||||
def _pad_center(text: str, width: int) -> str:
|
||||
"""Center-pad a plain or Rich-markup string to *width* visible chars."""
|
||||
visible = re.sub(r"\[.*?\]", "", text)
|
||||
pad = max(0, width - len(visible))
|
||||
left = pad // 2
|
||||
right = pad - left
|
||||
return " " * left + text + " " * right
|
||||
|
||||
|
||||
class PlayAreaWidget(Static):
|
||||
"""Displays the deck, discard pile, and held card.
|
||||
|
||||
Layout order: DECK [HOLDING] DISCARD
|
||||
HOLDING only appears when the player has drawn a card.
|
||||
"""
|
||||
|
||||
class DeckClicked(Message):
|
||||
"""Posted when the deck is clicked."""
|
||||
|
||||
class DiscardClicked(Message):
|
||||
"""Posted when the discard pile is clicked."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._state: GameState | None = None
|
||||
self._local_player_id: str = ""
|
||||
self._has_holding: bool = False
|
||||
|
||||
def update_state(self, state: GameState, local_player_id: str = "") -> None:
|
||||
self._state = state
|
||||
if local_player_id:
|
||||
self._local_player_id = local_player_id
|
||||
self._refresh()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh()
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Map click to deck or discard column."""
|
||||
x = event.x
|
||||
# Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col)
|
||||
if x < _COL_WIDTH:
|
||||
self.post_message(self.DeckClicked())
|
||||
elif self._has_holding and x >= 2 * _COL_WIDTH:
|
||||
self.post_message(self.DiscardClicked())
|
||||
elif not self._has_holding and x >= _COL_WIDTH:
|
||||
self.post_message(self.DiscardClicked())
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self._state:
|
||||
self.update("")
|
||||
return
|
||||
|
||||
state = self._state
|
||||
|
||||
# Deck card (face-down)
|
||||
deck_card = CardData(face_up=False, deck_id=0)
|
||||
deck_text = render_card(deck_card, deck_colors=state.deck_colors)
|
||||
deck_lines = deck_text.split("\n")
|
||||
|
||||
# Discard card
|
||||
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors)
|
||||
discard_lines = discard_text.split("\n")
|
||||
|
||||
# Held card — force face_up so render_card shows the face
|
||||
# Only show when it's the local player holding
|
||||
held_lines = None
|
||||
local_holding = (
|
||||
state.has_drawn_card
|
||||
and state.drawn_card
|
||||
and state.drawn_player_id == self._local_player_id
|
||||
)
|
||||
if local_holding:
|
||||
revealed = replace(state.drawn_card, face_up=True)
|
||||
held_text = render_card(revealed, deck_colors=state.deck_colors)
|
||||
held_lines = held_text.split("\n")
|
||||
|
||||
self._has_holding = held_lines is not None
|
||||
|
||||
# Always render 3 columns so the box stays a fixed width
|
||||
num_card_lines = max(len(deck_lines), len(discard_lines))
|
||||
lines = []
|
||||
for i in range(num_card_lines):
|
||||
d = deck_lines[i] if i < len(deck_lines) else " "
|
||||
c = discard_lines[i] if i < len(discard_lines) else " "
|
||||
row = _pad_center(d, _COL_WIDTH)
|
||||
if held_lines:
|
||||
h = held_lines[i] if i < len(held_lines) else " "
|
||||
row += _pad_center(h, _COL_WIDTH)
|
||||
else:
|
||||
row += " " * _COL_WIDTH
|
||||
row += _pad_center(c, _COL_WIDTH)
|
||||
lines.append(row)
|
||||
|
||||
# Labels row — always 3 columns
|
||||
deck_label = f"DECK:{state.deck_remaining}"
|
||||
discard_label = "DISCARD"
|
||||
label = _pad_center(deck_label, _COL_WIDTH)
|
||||
if held_lines:
|
||||
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
|
||||
label += _pad_center(holding_label, _COL_WIDTH)
|
||||
else:
|
||||
label += " " * _COL_WIDTH
|
||||
label += _pad_center(discard_label, _COL_WIDTH)
|
||||
lines.append(label)
|
||||
|
||||
self.update("\n".join(lines))
|
||||
115
tui_client/src/tui_client/widgets/player_box.py
Normal file
115
tui_client/src/tui_client/widgets/player_box.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Bordered player container with name, score, and state indicators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Border colors matching web UI palette
|
||||
_BORDER_NORMAL = "#555555"
|
||||
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn
|
||||
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
|
||||
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out
|
||||
|
||||
_NAME_COLOR = "#e0e0e0"
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Length of text with Rich markup tags stripped."""
|
||||
return len(re.sub(r"\[.*?\]", "", text))
|
||||
|
||||
|
||||
def render_player_box(
|
||||
name: str,
|
||||
score: int | None,
|
||||
total_score: int,
|
||||
content_lines: list[str],
|
||||
*,
|
||||
is_current_turn: bool = False,
|
||||
is_knocker: bool = False,
|
||||
is_dealer: bool = False,
|
||||
is_local: bool = False,
|
||||
all_face_up: bool = False,
|
||||
) -> list[str]:
|
||||
"""Render a bordered player container with name/score header.
|
||||
|
||||
Every line in the returned list has the same visible width (``box_width``).
|
||||
|
||||
Layout::
|
||||
|
||||
╭─ Name ────── 15 ─╮
|
||||
│ ┌───┐ ┌───┐ ┌───┐│
|
||||
│ │ A │ │░░░│ │ 7 ││
|
||||
│ │ ♠ │ │░░░│ │ ♦ ││
|
||||
│ └───┘ └───┘ └───┘│
|
||||
│ ┌───┐ ┌───┐ ┌───┐│
|
||||
│ │ 4 │ │ 5 │ │ Q ││
|
||||
│ │ ♣ │ │ ♥ │ │ ♦ ││
|
||||
│ └───┘ └───┘ └───┘│
|
||||
╰──────────────────╯
|
||||
"""
|
||||
# Pick border color based on state
|
||||
if is_knocker:
|
||||
bc = _BORDER_KNOCKER
|
||||
elif is_current_turn and is_local:
|
||||
bc = _BORDER_TURN_LOCAL
|
||||
elif is_current_turn:
|
||||
bc = _BORDER_TURN_OPPONENT
|
||||
else:
|
||||
bc = _BORDER_NORMAL
|
||||
|
||||
# Build display name
|
||||
display_name = name
|
||||
if is_dealer:
|
||||
display_name = f"Ⓓ {display_name}"
|
||||
if all_face_up:
|
||||
display_name += " ✓"
|
||||
if is_knocker:
|
||||
display_name += " OUT"
|
||||
|
||||
# Score text
|
||||
score_text = f"{score}" if score is not None else f"{total_score}"
|
||||
|
||||
# Compute box width. Every line is exactly box_width visible chars.
|
||||
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
|
||||
max_vis = max((_visible_len(line) for line in content_lines), default=17)
|
||||
name_part = f" {display_name} "
|
||||
score_part = f" {score_text} "
|
||||
# Top row: ╭─ <name_part> <fill> <score_part> ─╮
|
||||
# = 4 + len(name_part) + fill + len(score_part)
|
||||
min_top = 4 + len(name_part) + 1 + len(score_part) # fill>=1
|
||||
box_width = max(max_vis + 4, 21, min_top)
|
||||
|
||||
# Possibly truncate name if it still doesn't fit
|
||||
fill_len = box_width - 4 - len(name_part) - len(score_part)
|
||||
if fill_len < 1:
|
||||
max_name = box_width - 4 - len(score_part) - 4
|
||||
display_name = display_name[: max(3, max_name)] + "…"
|
||||
name_part = f" {display_name} "
|
||||
fill_len = box_width - 4 - len(name_part) - len(score_part)
|
||||
|
||||
fill = "─" * max(1, fill_len)
|
||||
|
||||
# Top border
|
||||
top = (
|
||||
f"[{bc}]╭─[/]"
|
||||
f"[bold {_NAME_COLOR}]{name_part}[/]"
|
||||
f"[{bc}]{fill}[/]"
|
||||
f"[bold]{score_part}[/]"
|
||||
f"[{bc}]─╮[/]"
|
||||
)
|
||||
|
||||
result = [top]
|
||||
|
||||
# Content lines
|
||||
inner = box_width - 2 # chars between │ and │
|
||||
for line in content_lines:
|
||||
vis_len = _visible_len(line)
|
||||
right_pad = max(0, inner - 1 - vis_len)
|
||||
result.append(
|
||||
f"[{bc}]│[/] {line}{' ' * right_pad}[{bc}]│[/]"
|
||||
)
|
||||
|
||||
# Bottom border
|
||||
result.append(f"[{bc}]╰{'─' * inner}╯[/]")
|
||||
|
||||
return result
|
||||
69
tui_client/src/tui_client/widgets/scoreboard.py
Normal file
69
tui_client/src/tui_client/widgets/scoreboard.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Scoreboard overlay for round/game over."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Static
|
||||
|
||||
|
||||
class ScoreboardScreen(ModalScreen[str]):
|
||||
"""Modal overlay showing round or game scores."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scores: list[dict],
|
||||
title: str = "Round Over",
|
||||
is_game_over: bool = False,
|
||||
is_host: bool = False,
|
||||
round_num: int = 1,
|
||||
total_rounds: int = 1,
|
||||
):
|
||||
super().__init__()
|
||||
self._scores = scores
|
||||
self._title = title
|
||||
self._is_game_over = is_game_over
|
||||
self._is_host = is_host
|
||||
self._round_num = round_num
|
||||
self._total_rounds = total_rounds
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="scoreboard-container"):
|
||||
yield Static(self._title, id="scoreboard-title")
|
||||
yield DataTable(id="scoreboard-table")
|
||||
with Horizontal(id="scoreboard-buttons"):
|
||||
if self._is_game_over:
|
||||
yield Button("Back to Lobby", id="btn-lobby", variant="primary")
|
||||
elif self._is_host:
|
||||
yield Button("Next Round", id="btn-next-round", variant="primary")
|
||||
else:
|
||||
yield Button("Waiting for host...", id="btn-waiting", disabled=True)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#scoreboard-table", DataTable)
|
||||
|
||||
if self._is_game_over:
|
||||
table.add_columns("Rank", "Player", "Total", "Rounds Won")
|
||||
for i, s in enumerate(self._scores, 1):
|
||||
table.add_row(
|
||||
str(i),
|
||||
s.get("name", "?"),
|
||||
str(s.get("total", 0)),
|
||||
str(s.get("rounds_won", 0)),
|
||||
)
|
||||
else:
|
||||
table.add_columns("Player", "Round Score", "Total", "Rounds Won")
|
||||
for s in self._scores:
|
||||
table.add_row(
|
||||
s.get("name", "?"),
|
||||
str(s.get("score", 0)),
|
||||
str(s.get("total", 0)),
|
||||
str(s.get("rounds_won", 0)),
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-next-round":
|
||||
self.dismiss("next_round")
|
||||
elif event.button.id == "btn-lobby":
|
||||
self.dismiss("lobby")
|
||||
75
tui_client/src/tui_client/widgets/status_bar.py
Normal file
75
tui_client/src/tui_client/widgets/status_bar.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Status bar showing phase, turn info, and action prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
from tui_client.models import GameState
|
||||
|
||||
|
||||
class StatusBarWidget(Static):
|
||||
"""Top status bar with round, phase, and turn info."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._state: GameState | None = None
|
||||
self._player_id: str | None = None
|
||||
self._extra: str = ""
|
||||
|
||||
def update_state(self, state: GameState, player_id: str | None = None) -> None:
|
||||
self._state = state
|
||||
self._player_id = player_id
|
||||
self._refresh()
|
||||
|
||||
def set_extra(self, text: str) -> None:
|
||||
self._extra = text
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self._state:
|
||||
self.update("Connecting...")
|
||||
return
|
||||
|
||||
state = self._state
|
||||
parts = []
|
||||
|
||||
# Round info
|
||||
parts.append(f"Round {state.current_round}/{state.total_rounds}")
|
||||
|
||||
# Phase
|
||||
phase_display = {
|
||||
"waiting": "Waiting",
|
||||
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]",
|
||||
"playing": "Playing",
|
||||
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
|
||||
"round_over": "[white on #555555] Round Over [/white on #555555]",
|
||||
"game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
|
||||
}.get(state.phase, state.phase)
|
||||
parts.append(phase_display)
|
||||
|
||||
# Turn info (skip during initial flip - it's misleading)
|
||||
if state.current_player_id and state.players and state.phase != "initial_flip":
|
||||
if state.current_player_id == self._player_id:
|
||||
parts.append("[bold white on #2e7d32] YOUR TURN [/bold white on #2e7d32]")
|
||||
else:
|
||||
for p in state.players:
|
||||
if p.id == state.current_player_id:
|
||||
parts.append(f"[white on #555555] {p.name}'s Turn [/white on #555555]")
|
||||
break
|
||||
|
||||
# Finisher indicator
|
||||
if state.finisher_id:
|
||||
for p in state.players:
|
||||
if p.id == state.finisher_id:
|
||||
parts.append(f"[bold white on #b8860b] {p.name} finished! [/bold white on #b8860b]")
|
||||
break
|
||||
|
||||
# Active rules
|
||||
if state.active_rules:
|
||||
parts.append(f"Rules: {', '.join(state.active_rules)}")
|
||||
|
||||
text = " │ ".join(parts)
|
||||
if self._extra:
|
||||
text += f" {self._extra}"
|
||||
|
||||
self.update(text)
|
||||
Loading…
Reference in New Issue
Block a user