Add TUI signup flow, quit/help/standings modals, and UI refinements
- Add signup with invite code support, remove guest login
- Add quit confirmation (q), help screen (h), standings tab
- Unified footer: [h]elp [q]uit | action text | [tab] standings
- Amber card highlighting persists through entire initial flip phase
- Player box border only highlights on turn (green) or knock (red)
- Play area gold border only during player's actual turn
- Game end returns to lobby create/join instead of login screen
- Lobby reset_to_pre_room for replayability without reconnecting
- Dynamic opponent layout fits all in one row when terminal is wide enough
- Hole emoji (⛳) in status bar, branded title with suits on connect screen
- DECK label spacing, Hole terminology in scoreboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfe29bb665
commit
13e98d330a
@ -65,6 +65,28 @@ class GameClient:
|
|||||||
self._username = data["user"]["username"]
|
self._username = data["user"]["username"]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
async def register(
|
||||||
|
self, username: str, password: str, invite_code: str = "", email: str = ""
|
||||||
|
) -> dict:
|
||||||
|
"""Register a new account via HTTP and store JWT token."""
|
||||||
|
payload: dict = {"username": username, "password": password}
|
||||||
|
if invite_code:
|
||||||
|
payload["invite_code"] = invite_code
|
||||||
|
if email:
|
||||||
|
payload["email"] = email
|
||||||
|
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
f"{self.http_base}/api/auth/register",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = resp.json().get("detail", "Registration failed")
|
||||||
|
raise ConnectionError(detail)
|
||||||
|
data = resp.json()
|
||||||
|
self._token = data["token"]
|
||||||
|
self._username = data["user"]["username"]
|
||||||
|
return data
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Open WebSocket connection to the server."""
|
"""Open WebSocket connection to the server."""
|
||||||
self._ws = await websockets.connect(self.ws_url)
|
self._ws = await websockets.connect(self.ws_url)
|
||||||
|
|||||||
@ -1,70 +1,159 @@
|
|||||||
"""Connection screen: server URL + optional login form."""
|
"""Connection screen: login or sign up form."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Button, Input, Static
|
from textual.widgets import Button, Input, Static
|
||||||
|
|
||||||
|
_TITLE = (
|
||||||
|
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
||||||
|
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
||||||
|
"[bold #cc0000]♥[/bold #cc0000]"
|
||||||
|
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
||||||
|
"[bold #cc0000]♦[/bold #cc0000]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConnectScreen(Screen):
|
class ConnectScreen(Screen):
|
||||||
"""Initial screen for connecting to the server."""
|
"""Initial screen for logging in or signing up."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._mode: str = "login" # "login" or "signup"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Container(id="connect-container"):
|
with Container(id="connect-container"):
|
||||||
yield Static("GolfCards.club", id="connect-title")
|
yield Static(_TITLE, id="connect-title")
|
||||||
yield Static("Login (optional - leave blank to play as guest)")
|
|
||||||
|
# Login form
|
||||||
|
with Vertical(id="login-form"):
|
||||||
|
yield Static("Log in to play")
|
||||||
yield Input(placeholder="Username", id="input-username")
|
yield Input(placeholder="Username", id="input-username")
|
||||||
yield Input(placeholder="Password", password=True, id="input-password")
|
yield Input(placeholder="Password", password=True, id="input-password")
|
||||||
with Horizontal(id="connect-buttons"):
|
with Horizontal(id="connect-buttons"):
|
||||||
yield Button("Connect as Guest", id="btn-guest", variant="default")
|
yield Button("Login", id="btn-login", variant="primary")
|
||||||
yield Button("Login & Connect", id="btn-login", variant="primary")
|
yield Button(
|
||||||
|
"No account? [bold cyan]Sign Up[/bold cyan]",
|
||||||
|
id="btn-toggle-signup",
|
||||||
|
variant="default",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Signup form
|
||||||
|
with Vertical(id="signup-form"):
|
||||||
|
yield Static("Create an account")
|
||||||
|
yield Input(placeholder="Invite Code", id="input-invite-code")
|
||||||
|
yield Input(placeholder="Username", id="input-signup-username")
|
||||||
|
yield Input(placeholder="Email (optional)", id="input-signup-email")
|
||||||
|
yield Input(
|
||||||
|
placeholder="Password (min 8 chars)",
|
||||||
|
password=True,
|
||||||
|
id="input-signup-password",
|
||||||
|
)
|
||||||
|
with Horizontal(id="signup-buttons"):
|
||||||
|
yield Button("Sign Up", id="btn-signup", variant="primary")
|
||||||
|
yield Button(
|
||||||
|
"Have an account? [bold cyan]Log In[/bold cyan]",
|
||||||
|
id="btn-toggle-login",
|
||||||
|
variant="default",
|
||||||
|
)
|
||||||
|
|
||||||
yield Static("", id="connect-status")
|
yield Static("", id="connect-status")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._update_form_visibility()
|
||||||
|
|
||||||
|
def _update_form_visibility(self) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#login-form").display = self._mode == "login"
|
||||||
|
self.query_one("#signup-form").display = self._mode == "signup"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "btn-guest":
|
if event.button.id == "btn-login":
|
||||||
self._connect(login=False)
|
self._do_login()
|
||||||
elif event.button.id == "btn-login":
|
elif event.button.id == "btn-signup":
|
||||||
self._connect(login=True)
|
self._do_signup()
|
||||||
|
elif event.button.id == "btn-toggle-signup":
|
||||||
|
self._mode = "signup"
|
||||||
|
self._set_status("")
|
||||||
|
self._update_form_visibility()
|
||||||
|
elif event.button.id == "btn-toggle-login":
|
||||||
|
self._mode = "login"
|
||||||
|
self._set_status("")
|
||||||
|
self._update_form_visibility()
|
||||||
|
|
||||||
|
def key_escape(self) -> None:
|
||||||
|
"""Escape goes back to login if on signup form."""
|
||||||
|
if self._mode == "signup":
|
||||||
|
self._mode = "login"
|
||||||
|
self._set_status("")
|
||||||
|
self._update_form_visibility()
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
# Enter key in password field triggers login
|
|
||||||
if event.input.id == "input-password":
|
if event.input.id == "input-password":
|
||||||
self._connect(login=True)
|
self._do_login()
|
||||||
|
elif event.input.id == "input-signup-password":
|
||||||
|
self._do_signup()
|
||||||
|
|
||||||
def _connect(self, login: bool) -> None:
|
def _do_login(self) -> None:
|
||||||
self._set_status("Connecting...")
|
self._set_status("Logging in...")
|
||||||
self._disable_buttons()
|
self._disable_buttons()
|
||||||
self.run_worker(self._do_connect(login), exclusive=True)
|
self.run_worker(self._login_flow(), exclusive=True)
|
||||||
|
|
||||||
async def _do_connect(self, login: bool) -> None:
|
def _do_signup(self) -> None:
|
||||||
|
self._set_status("Signing up...")
|
||||||
|
self._disable_buttons()
|
||||||
|
self.run_worker(self._signup_flow(), exclusive=True)
|
||||||
|
|
||||||
|
async def _login_flow(self) -> None:
|
||||||
client = self.app.client
|
client = self.app.client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if login:
|
|
||||||
username = self.query_one("#input-username", Input).value.strip()
|
username = self.query_one("#input-username", Input).value.strip()
|
||||||
password = self.query_one("#input-password", Input).value
|
password = self.query_one("#input-password", Input).value
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
self._set_status("Username and password required")
|
self._set_status("Username and password required")
|
||||||
self._enable_buttons()
|
self._enable_buttons()
|
||||||
return
|
return
|
||||||
self._set_status("Logging in...")
|
|
||||||
await client.login(username, password)
|
await client.login(username, password)
|
||||||
self._set_status(f"Logged in as {client.username}")
|
self._set_status(f"Logged in as {client.username}")
|
||||||
|
await self._connect_ws()
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"[red]{e}[/red]")
|
||||||
|
self._enable_buttons()
|
||||||
|
|
||||||
self._set_status("Connecting to WebSocket...")
|
async def _signup_flow(self) -> None:
|
||||||
|
client = self.app.client
|
||||||
|
try:
|
||||||
|
invite = self.query_one("#input-invite-code", Input).value.strip()
|
||||||
|
username = self.query_one("#input-signup-username", Input).value.strip()
|
||||||
|
email = self.query_one("#input-signup-email", Input).value.strip()
|
||||||
|
password = self.query_one("#input-signup-password", Input).value
|
||||||
|
if not username or not password:
|
||||||
|
self._set_status("Username and password required")
|
||||||
|
self._enable_buttons()
|
||||||
|
return
|
||||||
|
if len(password) < 8:
|
||||||
|
self._set_status("Password must be at least 8 characters")
|
||||||
|
self._enable_buttons()
|
||||||
|
return
|
||||||
|
await client.register(username, password, invite_code=invite, email=email)
|
||||||
|
self._set_status(f"Account created! Welcome, {client.username}")
|
||||||
|
await self._connect_ws()
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"[red]{e}[/red]")
|
||||||
|
self._enable_buttons()
|
||||||
|
|
||||||
|
async def _connect_ws(self) -> None:
|
||||||
|
client = self.app.client
|
||||||
|
self._set_status("Connecting...")
|
||||||
await client.connect()
|
await client.connect()
|
||||||
self._set_status("Connected!")
|
self._set_status("Connected!")
|
||||||
|
|
||||||
# Move to lobby
|
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
from tui_client.screens.lobby import LobbyScreen
|
||||||
self.app.push_screen(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:
|
def _set_status(self, text: str) -> None:
|
||||||
self.query_one("#connect-status", Static).update(text)
|
self.query_one("#connect-status", Static).update(text)
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,8 @@ from __future__ import annotations
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal
|
from textual.containers import Container, Horizontal
|
||||||
from textual.events import Resize
|
from textual.events import Resize
|
||||||
from textual.screen import Screen
|
from textual.screen import ModalScreen, Screen
|
||||||
from textual.widgets import Static
|
from textual.widgets import Button, Static
|
||||||
|
|
||||||
from tui_client.models import GameState, PlayerData
|
from tui_client.models import GameState, PlayerData
|
||||||
from tui_client.widgets.hand import HandWidget
|
from tui_client.widgets.hand import HandWidget
|
||||||
@ -15,6 +15,130 @@ from tui_client.widgets.scoreboard import ScoreboardScreen
|
|||||||
from tui_client.widgets.status_bar import StatusBarWidget
|
from tui_client.widgets.status_bar import StatusBarWidget
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmQuitScreen(ModalScreen[bool]):
|
||||||
|
"""Modal confirmation for quitting/leaving a game."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("y", "confirm", "Yes"),
|
||||||
|
("n", "cancel", "No"),
|
||||||
|
("escape", "cancel", "Cancel"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._message = message
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="confirm-dialog"):
|
||||||
|
yield Static(self._message, id="confirm-message")
|
||||||
|
with Horizontal(id="confirm-buttons"):
|
||||||
|
yield Button("Yes [Y]", id="btn-yes", variant="error")
|
||||||
|
yield Button("No [N]", id="btn-no", variant="primary")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn-yes":
|
||||||
|
self.dismiss(True)
|
||||||
|
else:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
def action_confirm(self) -> None:
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
_HELP_TEXT = """\
|
||||||
|
[bold]Keyboard Commands[/bold]
|
||||||
|
|
||||||
|
[bold]Drawing[/bold]
|
||||||
|
\\[d] Draw from deck
|
||||||
|
\\[s] Pick from discard pile
|
||||||
|
|
||||||
|
[bold]Card Actions[/bold]
|
||||||
|
\\[1]-\\[6] Select card position
|
||||||
|
(flip, swap, or initial flip)
|
||||||
|
\\[x] Discard held card
|
||||||
|
\\[c] Cancel draw (from discard)
|
||||||
|
|
||||||
|
[bold]Special Actions[/bold]
|
||||||
|
\\[f] Flip a card (when enabled)
|
||||||
|
\\[p] Skip optional flip
|
||||||
|
\\[k] Knock early (when enabled)
|
||||||
|
|
||||||
|
[bold]Game Flow[/bold]
|
||||||
|
\\[n] Next hole
|
||||||
|
\\[tab] Standings
|
||||||
|
\\[q] Quit / leave game
|
||||||
|
\\[h] This help screen
|
||||||
|
|
||||||
|
[dim]Press any key or click to close[/dim]\
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StandingsScreen(ModalScreen):
|
||||||
|
"""Modal overlay showing current game standings."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close", "Close"),
|
||||||
|
("tab", "close", "Close"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, players: list, current_round: int, total_rounds: int) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._players = players
|
||||||
|
self._current_round = current_round
|
||||||
|
self._total_rounds = total_rounds
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="standings-dialog"):
|
||||||
|
yield Static(
|
||||||
|
f"[bold]Standings — Hole {self._current_round}/{self._total_rounds}[/bold]",
|
||||||
|
id="standings-title",
|
||||||
|
)
|
||||||
|
yield Static(self._build_table(), id="standings-body")
|
||||||
|
yield Static("[dim]Press any key to close[/dim]", id="standings-hint")
|
||||||
|
|
||||||
|
def _build_table(self) -> str:
|
||||||
|
sorted_players = sorted(self._players, key=lambda p: p.total_score)
|
||||||
|
lines = []
|
||||||
|
for i, p in enumerate(sorted_players, 1):
|
||||||
|
score_str = f"{p.total_score:>4}"
|
||||||
|
lines.append(f" {i}. {p.name:<16} {score_str}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
def on_click(self, event) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
def action_close(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
class HelpScreen(ModalScreen):
|
||||||
|
"""Modal help overlay showing all keyboard commands."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close", "Close"),
|
||||||
|
("h", "close", "Close"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="help-dialog"):
|
||||||
|
yield Static(_HELP_TEXT, id="help-text")
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
def on_click(self, event) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
def action_close(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
|
||||||
class GameScreen(Screen):
|
class GameScreen(Screen):
|
||||||
"""Main game board with card display and keyboard controls."""
|
"""Main game board with card display and keyboard controls."""
|
||||||
|
|
||||||
@ -33,6 +157,9 @@ class GameScreen(Screen):
|
|||||||
("p", "skip_flip", "Skip flip"),
|
("p", "skip_flip", "Skip flip"),
|
||||||
("k", "knock_early", "Knock early"),
|
("k", "knock_early", "Knock early"),
|
||||||
("n", "next_round", "Next round"),
|
("n", "next_round", "Next round"),
|
||||||
|
("q", "quit_game", "Quit game"),
|
||||||
|
("h", "show_help", "Help"),
|
||||||
|
("tab", "show_standings", "Standings"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, initial_state: dict, is_host: bool = False):
|
def __init__(self, initial_state: dict, is_host: bool = False):
|
||||||
@ -55,7 +182,10 @@ class GameScreen(Screen):
|
|||||||
yield PlayAreaWidget(id="play-area")
|
yield PlayAreaWidget(id="play-area")
|
||||||
yield Static("", id="local-hand-label")
|
yield Static("", id="local-hand-label")
|
||||||
yield HandWidget(id="local-hand")
|
yield HandWidget(id="local-hand")
|
||||||
yield Static("", id="action-bar")
|
with Horizontal(id="game-footer"):
|
||||||
|
yield Static("\\[h]elp \\[q]uit", id="footer-left")
|
||||||
|
yield Static("", id="footer-center")
|
||||||
|
yield Static("\\[tab] standings", id="footer-right")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._player_id = self.app.player_id or ""
|
self._player_id = self.app.player_id or ""
|
||||||
@ -97,12 +227,12 @@ class GameScreen(Screen):
|
|||||||
|
|
||||||
if source == "discard":
|
if source == "discard":
|
||||||
self._set_action(
|
self._set_action(
|
||||||
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel"
|
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True
|
||||||
)
|
)
|
||||||
self._set_keymap("[1-6] Swap [C] Cancel")
|
self._set_keymap("[1-6] Swap [C] Cancel")
|
||||||
else:
|
else:
|
||||||
self._set_action(
|
self._set_action(
|
||||||
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[x] to discard"
|
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True
|
||||||
)
|
)
|
||||||
self._set_keymap("[1-6] Swap [X] Discard")
|
self._set_keymap("[1-6] Swap [X] Discard")
|
||||||
|
|
||||||
@ -111,10 +241,10 @@ class GameScreen(Screen):
|
|||||||
optional = data.get("optional", False)
|
optional = data.get("optional", False)
|
||||||
self._can_flip_optional = optional
|
self._can_flip_optional = optional
|
||||||
if optional:
|
if optional:
|
||||||
self._set_action("Keyboard: Flip a card \[1] thru \[6] or \[p] to skip")
|
self._set_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True)
|
||||||
self._set_keymap("[1-6] Flip card [P] Skip")
|
self._set_keymap("[1-6] Flip card [P] Skip")
|
||||||
else:
|
else:
|
||||||
self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]")
|
self._set_action("Flip a face-down card \\[1] thru \\[6]", active=True)
|
||||||
self._set_keymap("[1-6] Flip card")
|
self._set_keymap("[1-6] Flip card")
|
||||||
|
|
||||||
def _handle_round_over(self, data: dict) -> None:
|
def _handle_round_over(self, data: dict) -> None:
|
||||||
@ -125,7 +255,7 @@ class GameScreen(Screen):
|
|||||||
self.app.push_screen(
|
self.app.push_screen(
|
||||||
ScoreboardScreen(
|
ScoreboardScreen(
|
||||||
scores=scores,
|
scores=scores,
|
||||||
title=f"Round {round_num} Complete",
|
title=f"Hole {round_num} Complete",
|
||||||
is_game_over=False,
|
is_game_over=False,
|
||||||
is_host=self._is_host,
|
is_host=self._is_host,
|
||||||
round_num=round_num,
|
round_num=round_num,
|
||||||
@ -172,6 +302,10 @@ class GameScreen(Screen):
|
|||||||
elif result == "lobby":
|
elif result == "lobby":
|
||||||
self.run_worker(self._send("leave_game"))
|
self.run_worker(self._send("leave_game"))
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
# Reset lobby back to create/join state
|
||||||
|
lobby = self.app.screen
|
||||||
|
if hasattr(lobby, "reset_to_pre_room"):
|
||||||
|
lobby.reset_to_pre_room()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Click handlers (from widget messages)
|
# Click handlers (from widget messages)
|
||||||
@ -256,9 +390,10 @@ class GameScreen(Screen):
|
|||||||
hand.update_player(
|
hand.update_player(
|
||||||
me,
|
me,
|
||||||
deck_colors=self._state.deck_colors,
|
deck_colors=self._state.deck_colors,
|
||||||
is_current_turn=(me.id == self._state.current_player_id),
|
is_current_turn=False,
|
||||||
is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"),
|
is_knocker=False,
|
||||||
is_dealer=(me.id == self._state.dealer_id),
|
is_dealer=(me.id == self._state.dealer_id),
|
||||||
|
highlight=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
needed = self._state.initial_flips
|
needed = self._state.initial_flips
|
||||||
@ -272,7 +407,7 @@ class GameScreen(Screen):
|
|||||||
self._initial_flip_positions = []
|
self._initial_flip_positions = []
|
||||||
else:
|
else:
|
||||||
self._set_action(
|
self._set_action(
|
||||||
f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})"
|
f"Choose {needed - selected} more card(s) to flip ({selected}/{needed})", active=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def _do_flip(self, pos: int) -> None:
|
def _do_flip(self, pos: int) -> None:
|
||||||
@ -299,7 +434,7 @@ class GameScreen(Screen):
|
|||||||
def action_flip_mode(self) -> None:
|
def action_flip_mode(self) -> None:
|
||||||
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
|
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
|
||||||
self._awaiting_flip = True
|
self._awaiting_flip = True
|
||||||
self._set_action("Flip mode: select a face-down card [1-6]")
|
self._set_action("Flip mode: select a face-down card [1-6]", active=True)
|
||||||
|
|
||||||
def action_skip_flip(self) -> None:
|
def action_skip_flip(self) -> None:
|
||||||
if self._awaiting_flip and self._can_flip_optional:
|
if self._awaiting_flip and self._can_flip_optional:
|
||||||
@ -317,9 +452,34 @@ class GameScreen(Screen):
|
|||||||
if self._is_host and self._state.phase == "round_over":
|
if self._is_host and self._state.phase == "round_over":
|
||||||
self.run_worker(self._send("next_round"))
|
self.run_worker(self._send("next_round"))
|
||||||
|
|
||||||
|
def action_show_help(self) -> None:
|
||||||
|
self.app.push_screen(HelpScreen())
|
||||||
|
|
||||||
|
def action_show_standings(self) -> None:
|
||||||
|
self.app.push_screen(StandingsScreen(
|
||||||
|
self._state.players,
|
||||||
|
self._state.current_round,
|
||||||
|
self._state.total_rounds,
|
||||||
|
))
|
||||||
|
|
||||||
|
def action_quit_game(self) -> None:
|
||||||
|
if self._is_host:
|
||||||
|
msg = "End the game for everyone?"
|
||||||
|
else:
|
||||||
|
msg = "Leave this game?"
|
||||||
|
self.app.push_screen(ConfirmQuitScreen(msg), callback=self._on_quit_confirm)
|
||||||
|
|
||||||
|
def _on_quit_confirm(self, confirmed: bool) -> None:
|
||||||
|
if confirmed:
|
||||||
|
self.action_leave_game()
|
||||||
|
|
||||||
def action_leave_game(self) -> None:
|
def action_leave_game(self) -> None:
|
||||||
self.run_worker(self._send("leave_game"))
|
self.run_worker(self._send("leave_game"))
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
# Reset lobby back to create/join state
|
||||||
|
lobby = self.app.screen
|
||||||
|
if hasattr(lobby, "reset_to_pre_room"):
|
||||||
|
lobby.reset_to_pre_room()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@ -340,9 +500,21 @@ class GameScreen(Screen):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._set_action(f"[red]Send error: {e}[/red]")
|
self._set_action(f"[red]Send error: {e}[/red]")
|
||||||
|
|
||||||
def _set_action(self, text: str) -> None:
|
def _set_action(self, text: str, active: bool = False) -> None:
|
||||||
|
import re
|
||||||
try:
|
try:
|
||||||
self.query_one("#action-bar", Static).update(text)
|
if active:
|
||||||
|
# Highlight bracketed keys and parenthesized counts in amber,
|
||||||
|
# rest in bold white
|
||||||
|
ac = "#ffaa00"
|
||||||
|
# Color \\[...] key hints and (...) counts
|
||||||
|
text = re.sub(
|
||||||
|
r"(\\?\[.*?\]|\([\d/]+\))",
|
||||||
|
rf"[bold {ac}]\1[/]",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
text = f"[bold white]{text}[/]"
|
||||||
|
self.query_one("#footer-center", Static).update(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -361,6 +533,8 @@ class GameScreen(Screen):
|
|||||||
# Play area
|
# Play area
|
||||||
play_area = self.query_one("#play-area", PlayAreaWidget)
|
play_area = self.query_one("#play-area", PlayAreaWidget)
|
||||||
play_area.update_state(state, local_player_id=self._player_id)
|
play_area.update_state(state, local_player_id=self._player_id)
|
||||||
|
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
|
||||||
|
play_area.set_class(is_active, "my-turn")
|
||||||
|
|
||||||
# Local player hand (in bordered box with turn/knocker indicators)
|
# Local player hand (in bordered box with turn/knocker indicators)
|
||||||
me = self._get_local_player()
|
me = self._get_local_player()
|
||||||
@ -368,12 +542,15 @@ class GameScreen(Screen):
|
|||||||
self.query_one("#local-hand-label", Static).update("")
|
self.query_one("#local-hand-label", Static).update("")
|
||||||
hand = self.query_one("#local-hand", HandWidget)
|
hand = self.query_one("#local-hand", HandWidget)
|
||||||
hand._is_local = True
|
hand._is_local = True
|
||||||
|
# During initial flip, don't show current_turn borders (no one is "taking a turn")
|
||||||
|
show_turn = not state.waiting_for_initial_flip and me.id == state.current_player_id
|
||||||
hand.update_player(
|
hand.update_player(
|
||||||
me,
|
me,
|
||||||
deck_colors=state.deck_colors,
|
deck_colors=state.deck_colors,
|
||||||
is_current_turn=(me.id == state.current_player_id),
|
is_current_turn=show_turn,
|
||||||
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
|
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
|
||||||
is_dealer=(me.id == state.dealer_id),
|
is_dealer=(me.id == state.dealer_id),
|
||||||
|
highlight=state.waiting_for_initial_flip,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
self.query_one("#local-hand-label", Static).update("")
|
||||||
@ -413,12 +590,13 @@ class GameScreen(Screen):
|
|||||||
cards, deck_colors=deck_colors, matched=matched,
|
cards, deck_colors=deck_colors, matched=matched,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
|
||||||
box = render_player_box(
|
box = render_player_box(
|
||||||
opp.name,
|
opp.name,
|
||||||
score=opp.score,
|
score=opp.score,
|
||||||
total_score=opp.total_score,
|
total_score=opp.total_score,
|
||||||
content_lines=card_lines,
|
content_lines=card_lines,
|
||||||
is_current_turn=(opp.id == state.current_player_id),
|
is_current_turn=opp_turn,
|
||||||
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
|
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
|
||||||
is_dealer=(opp.id == state.dealer_id),
|
is_dealer=(opp.id == state.dealer_id),
|
||||||
all_face_up=opp.all_face_up,
|
all_face_up=opp.all_face_up,
|
||||||
@ -426,17 +604,22 @@ class GameScreen(Screen):
|
|||||||
opp_blocks.append(box)
|
opp_blocks.append(box)
|
||||||
|
|
||||||
# Determine how many opponents fit per row
|
# Determine how many opponents fit per row
|
||||||
# Each box is ~21-24 chars wide
|
# Each box is ~21-24 chars wide; use actual widths for accuracy
|
||||||
opp_width = 22
|
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
||||||
if width < 80:
|
gap = " " if width < 120 else " "
|
||||||
per_row = 1
|
gap_len = len(gap)
|
||||||
gap = " "
|
|
||||||
elif width < 120:
|
# Greedily fit as many as possible in one row
|
||||||
gap = " "
|
per_row = 0
|
||||||
per_row = max(1, (width - 4) // (opp_width + len(gap)))
|
row_width = 0
|
||||||
|
for bw in box_widths:
|
||||||
|
needed_width = bw if per_row == 0 else gap_len + bw
|
||||||
|
if row_width + needed_width <= width:
|
||||||
|
row_width += needed_width
|
||||||
|
per_row += 1
|
||||||
else:
|
else:
|
||||||
gap = " "
|
break
|
||||||
per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap)))
|
per_row = max(1, per_row)
|
||||||
|
|
||||||
# Render in rows of per_row opponents
|
# Render in rows of per_row opponents
|
||||||
all_row_lines: list[str] = []
|
all_row_lines: list[str] = []
|
||||||
@ -469,15 +652,15 @@ class GameScreen(Screen):
|
|||||||
state = self._state
|
state = self._state
|
||||||
|
|
||||||
if state.phase in ("round_over", "game_over"):
|
if state.phase in ("round_over", "game_over"):
|
||||||
self._set_action("Keyboard: \[n]ext round")
|
self._set_action("\\[n]ext hole", active=True)
|
||||||
self._set_keymap("[N] Next round")
|
self._set_keymap("[N] Next hole")
|
||||||
return
|
return
|
||||||
|
|
||||||
if state.waiting_for_initial_flip:
|
if state.waiting_for_initial_flip:
|
||||||
needed = state.initial_flips
|
needed = state.initial_flips
|
||||||
selected = len(self._initial_flip_positions)
|
selected = len(self._initial_flip_positions)
|
||||||
self._set_action(
|
self._set_action(
|
||||||
f"Keyboard: Choose {needed} cards \[1] thru \[6] to flip ({selected}/{needed})"
|
f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True
|
||||||
)
|
)
|
||||||
self._set_keymap("[1-6] Select card")
|
self._set_keymap("[1-6] Select card")
|
||||||
return
|
return
|
||||||
@ -496,23 +679,23 @@ class GameScreen(Screen):
|
|||||||
if state.has_drawn_card:
|
if state.has_drawn_card:
|
||||||
keys = ["[1-6] Swap"]
|
keys = ["[1-6] Swap"]
|
||||||
if state.can_discard:
|
if state.can_discard:
|
||||||
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[x] to discard")
|
self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True)
|
||||||
keys.append("[X] Discard")
|
keys.append("[X] Discard")
|
||||||
else:
|
else:
|
||||||
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel")
|
self._set_action("Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True)
|
||||||
keys.append("[C] Cancel")
|
keys.append("[C] Cancel")
|
||||||
self._set_keymap(" ".join(keys))
|
self._set_keymap(" ".join(keys))
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = ["Choose \[d]eck or di\[s]card pile"]
|
parts = ["Choose \\[d]eck or di\\[s]card pile"]
|
||||||
keys = ["[D] Draw", "[S] Pick discard"]
|
keys = ["[D] Draw", "[S] Pick discard"]
|
||||||
if state.flip_as_action:
|
if state.flip_as_action:
|
||||||
parts.append("\[f]lip a card")
|
parts.append("\\[f]lip a card")
|
||||||
keys.append("[F] Flip")
|
keys.append("[F] Flip")
|
||||||
if state.knock_early:
|
if state.knock_early:
|
||||||
parts.append("\[k]nock early")
|
parts.append("\\[k]nock early")
|
||||||
keys.append("[K] Knock")
|
keys.append("[K] Knock")
|
||||||
self._set_action("Keyboard: " + " or ".join(parts))
|
self._set_action(" or ".join(parts), active=True)
|
||||||
self._set_keymap(" ".join(keys))
|
self._set_keymap(" ".join(keys))
|
||||||
|
|
||||||
def _set_keymap(self, text: str) -> None:
|
def _set_keymap(self, text: str) -> None:
|
||||||
|
|||||||
@ -83,7 +83,7 @@ class LobbyScreen(Screen):
|
|||||||
with Vertical(id="host-settings"):
|
with Vertical(id="host-settings"):
|
||||||
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
||||||
with Horizontal(classes="setting-row"):
|
with Horizontal(classes="setting-row"):
|
||||||
yield Label("Rounds")
|
yield Label("Holes")
|
||||||
yield Select(
|
yield Select(
|
||||||
[(str(v), v) for v in (1, 3, 9, 18)],
|
[(str(v), v) for v in (1, 3, 9, 18)],
|
||||||
value=9,
|
value=9,
|
||||||
@ -200,6 +200,23 @@ class LobbyScreen(Screen):
|
|||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
self._update_keymap()
|
self._update_keymap()
|
||||||
|
|
||||||
|
def reset_to_pre_room(self) -> None:
|
||||||
|
"""Reset lobby back to create/join state after leaving a game."""
|
||||||
|
self._room_code = None
|
||||||
|
self._player_id = None
|
||||||
|
self._is_host = False
|
||||||
|
self._players = []
|
||||||
|
self._in_room = False
|
||||||
|
self._set_room_info("")
|
||||||
|
self._set_status("")
|
||||||
|
try:
|
||||||
|
self.query_one("#input-room-code", Input).value = ""
|
||||||
|
self.query_one("#player-list", Static).update("")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._update_visibility()
|
||||||
|
self._update_keymap()
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
def _update_visibility(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.query_one("#pre-room").display = not self._in_room
|
self.query_one("#pre-room").display = not self._in_room
|
||||||
|
|||||||
@ -32,16 +32,32 @@ ConnectScreen {
|
|||||||
margin-bottom: 1;
|
margin-bottom: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect-buttons {
|
#login-form, #signup-form {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signup-form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connect-buttons, #signup-buttons {
|
||||||
height: 3;
|
height: 3;
|
||||||
align: center middle;
|
align: center middle;
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect-buttons Button {
|
#connect-buttons Button, #signup-buttons Button {
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-toggle-signup, #btn-toggle-login {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
#connect-status {
|
#connect-status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: $warning;
|
color: $warning;
|
||||||
@ -234,17 +250,11 @@ GameScreen {
|
|||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#action-bar {
|
#play-area.my-turn {
|
||||||
height: auto;
|
border: round #ffd700;
|
||||||
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 */
|
||||||
#local-hand-label {
|
#local-hand-label {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -292,3 +302,107 @@ GameScreen {
|
|||||||
align: center middle;
|
align: center middle;
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Confirm quit dialog */
|
||||||
|
ConfirmQuitScreen {
|
||||||
|
align: center middle;
|
||||||
|
background: $surface 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-dialog {
|
||||||
|
width: auto;
|
||||||
|
max-width: 48;
|
||||||
|
height: auto;
|
||||||
|
border: thick $error;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-message {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-buttons {
|
||||||
|
height: 3;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-buttons Button {
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game footer: [h]elp <action> [tab] standings [q]uit */
|
||||||
|
#game-footer {
|
||||||
|
height: 1;
|
||||||
|
dock: bottom;
|
||||||
|
background: $surface-darken-1;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer-left {
|
||||||
|
width: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer-center {
|
||||||
|
width: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer-right {
|
||||||
|
width: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help dialog */
|
||||||
|
HelpScreen {
|
||||||
|
align: center middle;
|
||||||
|
background: $surface 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-dialog {
|
||||||
|
width: auto;
|
||||||
|
max-width: 48;
|
||||||
|
height: auto;
|
||||||
|
border: thick $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standings dialog */
|
||||||
|
StandingsScreen {
|
||||||
|
align: center middle;
|
||||||
|
background: $surface 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#standings-dialog {
|
||||||
|
width: auto;
|
||||||
|
max-width: 48;
|
||||||
|
height: auto;
|
||||||
|
border: thick $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#standings-title {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#standings-body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#standings-hint {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ JOKER_COLOR = "#9b59b6" # purple
|
|||||||
BORDER_COLOR = "#888888" # card border
|
BORDER_COLOR = "#888888" # card border
|
||||||
EMPTY_COLOR = "#555555" # empty card slot
|
EMPTY_COLOR = "#555555" # empty card slot
|
||||||
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
|
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
|
||||||
|
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention
|
||||||
|
|
||||||
|
|
||||||
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
|
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
|
||||||
@ -41,9 +42,12 @@ def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -
|
|||||||
return BACK_COLORS.get(name, BACK_COLORS["red"])
|
return BACK_COLORS.get(name, BACK_COLORS["red"])
|
||||||
|
|
||||||
|
|
||||||
def _top_border(position: int | None, d: str, color: str) -> str:
|
def _top_border(position: int | None, d: str, color: str, highlight: bool = False) -> str:
|
||||||
"""Top border line, with position number replacing ┌ when present."""
|
"""Top border line, with position number replacing ┌ when present."""
|
||||||
if position is not None:
|
if position is not None:
|
||||||
|
if highlight:
|
||||||
|
hc = HIGHLIGHT_COLOR
|
||||||
|
return f"[bold {hc}]{position}[/][{d}{color}]───┐[/{d}{color}]"
|
||||||
return f"[{d}{color}]{position}───┐[/{d}{color}]"
|
return f"[{d}{color}]{position}───┐[/{d}{color}]"
|
||||||
return f"[{d}{color}]┌───┐[/{d}{color}]"
|
return f"[{d}{color}]┌───┐[/{d}{color}]"
|
||||||
|
|
||||||
@ -54,6 +58,7 @@ def render_card(
|
|||||||
position: int | None = None,
|
position: int | None = None,
|
||||||
deck_colors: list[str] | None = None,
|
deck_colors: list[str] | None = None,
|
||||||
dim: bool = False,
|
dim: bool = False,
|
||||||
|
highlight: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a card as a 4-line Rich-markup string.
|
"""Render a card as a 4-line Rich-markup string.
|
||||||
|
|
||||||
@ -64,7 +69,7 @@ def render_card(
|
|||||||
└───┘ └───┘ └───┘
|
└───┘ └───┘ └───┘
|
||||||
"""
|
"""
|
||||||
d = "dim " if dim else ""
|
d = "dim " if dim else ""
|
||||||
bc = BORDER_COLOR
|
bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR
|
||||||
|
|
||||||
# Empty slot
|
# Empty slot
|
||||||
if card is None:
|
if card is None:
|
||||||
@ -76,7 +81,7 @@ def render_card(
|
|||||||
f"[{d}{c}]└───┘[/{d}{c}]"
|
f"[{d}{c}]└───┘[/{d}{c}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
top = _top_border(position, d, bc)
|
top = _top_border(position, d, bc, highlight=highlight)
|
||||||
|
|
||||||
# Face-down card with colored back
|
# Face-down card with colored back
|
||||||
if not card.face_up:
|
if not card.face_up:
|
||||||
|
|||||||
@ -69,6 +69,7 @@ def _render_card_lines(
|
|||||||
is_local: bool = False,
|
is_local: bool = False,
|
||||||
deck_colors: list[str] | None = None,
|
deck_colors: list[str] | None = None,
|
||||||
matched: list[bool] | None = None,
|
matched: list[bool] | None = None,
|
||||||
|
highlight: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Render the 2x3 card grid as a list of text lines (no box).
|
"""Render the 2x3 card grid as a list of text lines (no box).
|
||||||
|
|
||||||
@ -94,6 +95,7 @@ def _render_card_lines(
|
|||||||
position=pos,
|
position=pos,
|
||||||
deck_colors=deck_colors,
|
deck_colors=deck_colors,
|
||||||
dim=matched[idx],
|
dim=matched[idx],
|
||||||
|
highlight=highlight,
|
||||||
)
|
)
|
||||||
card_lines = text.split("\n")
|
card_lines = text.split("\n")
|
||||||
while len(row_line_parts) < len(card_lines):
|
while len(row_line_parts) < len(card_lines):
|
||||||
@ -131,6 +133,8 @@ class HandWidget(Static):
|
|||||||
self._is_knocker: bool = False
|
self._is_knocker: bool = False
|
||||||
self._is_dealer: bool = False
|
self._is_dealer: bool = False
|
||||||
self._has_connector: bool = False
|
self._has_connector: bool = False
|
||||||
|
self._highlight: bool = False
|
||||||
|
self._box_width: int = 0
|
||||||
|
|
||||||
def update_player(
|
def update_player(
|
||||||
self,
|
self,
|
||||||
@ -140,6 +144,7 @@ class HandWidget(Static):
|
|||||||
is_current_turn: bool = False,
|
is_current_turn: bool = False,
|
||||||
is_knocker: bool = False,
|
is_knocker: bool = False,
|
||||||
is_dealer: bool = False,
|
is_dealer: bool = False,
|
||||||
|
highlight: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._player = player
|
self._player = player
|
||||||
if deck_colors is not None:
|
if deck_colors is not None:
|
||||||
@ -147,6 +152,7 @@ class HandWidget(Static):
|
|||||||
self._is_current_turn = is_current_turn
|
self._is_current_turn = is_current_turn
|
||||||
self._is_knocker = is_knocker
|
self._is_knocker = is_knocker
|
||||||
self._is_dealer = is_dealer
|
self._is_dealer = is_dealer
|
||||||
|
self._highlight = highlight
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@ -154,9 +160,14 @@ class HandWidget(Static):
|
|||||||
|
|
||||||
def on_click(self, event: Click) -> None:
|
def on_click(self, event: Click) -> None:
|
||||||
"""Map click coordinates to card position (0-5)."""
|
"""Map click coordinates to card position (0-5)."""
|
||||||
if not self._is_local:
|
if not self._is_local or not self._box_width:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# The content is centered in the widget — compute the x offset
|
||||||
|
x_offset = max(0, (self.size.width - self._box_width) // 2)
|
||||||
|
x = event.x - x_offset
|
||||||
|
y = event.y
|
||||||
|
|
||||||
# Box layout:
|
# Box layout:
|
||||||
# Line 0: top border
|
# Line 0: top border
|
||||||
# Lines 1-4: row 0 cards (4 lines each)
|
# Lines 1-4: row 0 cards (4 lines each)
|
||||||
@ -166,8 +177,6 @@ class HandWidget(Static):
|
|||||||
#
|
#
|
||||||
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
|
# 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)
|
# 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
|
# Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18
|
||||||
col = -1
|
col = -1
|
||||||
@ -202,7 +211,7 @@ class HandWidget(Static):
|
|||||||
self.update("")
|
self.update("")
|
||||||
return
|
return
|
||||||
|
|
||||||
from tui_client.widgets.player_box import render_player_box
|
from tui_client.widgets.player_box import _visible_len, render_player_box
|
||||||
|
|
||||||
cards = self._player.cards
|
cards = self._player.cards
|
||||||
matched = _check_column_match(cards)
|
matched = _check_column_match(cards)
|
||||||
@ -213,6 +222,7 @@ class HandWidget(Static):
|
|||||||
is_local=self._is_local,
|
is_local=self._is_local,
|
||||||
deck_colors=self._deck_colors,
|
deck_colors=self._deck_colors,
|
||||||
matched=matched,
|
matched=matched,
|
||||||
|
highlight=self._highlight,
|
||||||
)
|
)
|
||||||
|
|
||||||
box_lines = render_player_box(
|
box_lines = render_player_box(
|
||||||
@ -227,4 +237,8 @@ class HandWidget(Static):
|
|||||||
all_face_up=self._player.all_face_up,
|
all_face_up=self._player.all_face_up,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store box width for click coordinate mapping
|
||||||
|
if box_lines:
|
||||||
|
self._box_width = _visible_len(box_lines[0])
|
||||||
|
|
||||||
self.update("\n".join(box_lines))
|
self.update("\n".join(box_lines))
|
||||||
|
|||||||
@ -58,13 +58,15 @@ class PlayAreaWidget(Static):
|
|||||||
|
|
||||||
def on_click(self, event: Click) -> None:
|
def on_click(self, event: Click) -> None:
|
||||||
"""Map click to deck or discard column."""
|
"""Map click to deck or discard column."""
|
||||||
x = event.x
|
# Content is always 3 columns wide; account for centering within widget
|
||||||
# Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col)
|
content_width = 3 * _COL_WIDTH
|
||||||
if x < _COL_WIDTH:
|
x_offset = max(0, (self.content_size.width - content_width) // 2)
|
||||||
|
x = event.x - x_offset
|
||||||
|
|
||||||
|
# Layout: DECK (col 0..11) | HOLDING (col 12..23) | DISCARD (col 24..35)
|
||||||
|
if 0 <= x < _COL_WIDTH:
|
||||||
self.post_message(self.DeckClicked())
|
self.post_message(self.DeckClicked())
|
||||||
elif self._has_holding and x >= 2 * _COL_WIDTH:
|
elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
|
||||||
self.post_message(self.DiscardClicked())
|
|
||||||
elif not self._has_holding and x >= _COL_WIDTH:
|
|
||||||
self.post_message(self.DiscardClicked())
|
self.post_message(self.DiscardClicked())
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
def _refresh(self) -> None:
|
||||||
@ -114,7 +116,7 @@ class PlayAreaWidget(Static):
|
|||||||
lines.append(row)
|
lines.append(row)
|
||||||
|
|
||||||
# Labels row — always 3 columns
|
# Labels row — always 3 columns
|
||||||
deck_label = f"DECK:{state.deck_remaining}"
|
deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
|
||||||
discard_label = "DISCARD"
|
discard_label = "DISCARD"
|
||||||
label = _pad_center(deck_label, _COL_WIDTH)
|
label = _pad_center(deck_label, _COL_WIDTH)
|
||||||
if held_lines:
|
if held_lines:
|
||||||
|
|||||||
@ -9,7 +9,6 @@ _BORDER_NORMAL = "#555555"
|
|||||||
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn
|
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn
|
||||||
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
|
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
|
||||||
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out
|
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out
|
||||||
|
|
||||||
_NAME_COLOR = "#e0e0e0"
|
_NAME_COLOR = "#e0e0e0"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
scores: list[dict],
|
scores: list[dict],
|
||||||
title: str = "Round Over",
|
title: str = "Hole Over",
|
||||||
is_game_over: bool = False,
|
is_game_over: bool = False,
|
||||||
is_host: bool = False,
|
is_host: bool = False,
|
||||||
round_num: int = 1,
|
round_num: int = 1,
|
||||||
@ -44,7 +44,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
|||||||
table = self.query_one("#scoreboard-table", DataTable)
|
table = self.query_one("#scoreboard-table", DataTable)
|
||||||
|
|
||||||
if self._is_game_over:
|
if self._is_game_over:
|
||||||
table.add_columns("Rank", "Player", "Total", "Rounds Won")
|
table.add_columns("Rank", "Player", "Total", "Holes Won")
|
||||||
for i, s in enumerate(self._scores, 1):
|
for i, s in enumerate(self._scores, 1):
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
@ -53,7 +53,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
|||||||
str(s.get("rounds_won", 0)),
|
str(s.get("rounds_won", 0)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
table.add_columns("Player", "Round Score", "Total", "Rounds Won")
|
table.add_columns("Player", "Hole Score", "Total", "Holes Won")
|
||||||
for s in self._scores:
|
for s in self._scores:
|
||||||
table.add_row(
|
table.add_row(
|
||||||
s.get("name", "?"),
|
s.get("name", "?"),
|
||||||
|
|||||||
@ -34,15 +34,15 @@ class StatusBarWidget(Static):
|
|||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
# Round info
|
# Round info
|
||||||
parts.append(f"Round {state.current_round}/{state.total_rounds}")
|
parts.append(f"⛳ {state.current_round}/{state.total_rounds}")
|
||||||
|
|
||||||
# Phase
|
# Phase
|
||||||
phase_display = {
|
phase_display = {
|
||||||
"waiting": "Waiting",
|
"waiting": "Waiting",
|
||||||
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]",
|
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]",
|
||||||
"playing": "Playing",
|
"playing": "",
|
||||||
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
|
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
|
||||||
"round_over": "[white on #555555] Round Over [/white on #555555]",
|
"round_over": "[white on #555555] Hole Over [/white on #555555]",
|
||||||
"game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
|
"game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
|
||||||
}.get(state.phase, state.phase)
|
}.get(state.phase, state.phase)
|
||||||
parts.append(phase_display)
|
parts.append(phase_display)
|
||||||
@ -50,7 +50,7 @@ class StatusBarWidget(Static):
|
|||||||
# Turn info (skip during initial flip - it's misleading)
|
# 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 and state.players and state.phase != "initial_flip":
|
||||||
if state.current_player_id == self._player_id:
|
if state.current_player_id == self._player_id:
|
||||||
parts.append("[bold white on #2e7d32] YOUR TURN [/bold white on #2e7d32]")
|
parts.append("[bold #111111 on #ffd700] YOUR TURN! [/bold #111111 on #ffd700]")
|
||||||
else:
|
else:
|
||||||
for p in state.players:
|
for p in state.players:
|
||||||
if p.id == state.current_player_id:
|
if p.id == state.current_player_id:
|
||||||
@ -68,7 +68,7 @@ class StatusBarWidget(Static):
|
|||||||
if state.active_rules:
|
if state.active_rules:
|
||||||
parts.append(f"Rules: {', '.join(state.active_rules)}")
|
parts.append(f"Rules: {', '.join(state.active_rules)}")
|
||||||
|
|
||||||
text = " │ ".join(parts)
|
text = " │ ".join(p for p in parts if p)
|
||||||
if self._extra:
|
if self._extra:
|
||||||
text += f" {self._extra}"
|
text += f" {self._extra}"
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user