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"]
|
||||
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:
|
||||
"""Open WebSocket connection to the server."""
|
||||
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 textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.screen import Screen
|
||||
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):
|
||||
"""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:
|
||||
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(_TITLE, id="connect-title")
|
||||
|
||||
# Login form
|
||||
with Vertical(id="login-form"):
|
||||
yield Static("Log in to play")
|
||||
yield Input(placeholder="Username", id="input-username")
|
||||
yield Input(placeholder="Password", password=True, id="input-password")
|
||||
with Horizontal(id="connect-buttons"):
|
||||
yield Button("Login", 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")
|
||||
|
||||
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:
|
||||
if event.button.id == "btn-guest":
|
||||
self._connect(login=False)
|
||||
elif event.button.id == "btn-login":
|
||||
self._connect(login=True)
|
||||
if event.button.id == "btn-login":
|
||||
self._do_login()
|
||||
elif event.button.id == "btn-signup":
|
||||
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:
|
||||
# Enter key in password field triggers login
|
||||
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:
|
||||
self._set_status("Connecting...")
|
||||
def _do_login(self) -> None:
|
||||
self._set_status("Logging in...")
|
||||
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
|
||||
|
||||
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())
|
||||
|
||||
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
|
||||
await client.login(username, password)
|
||||
self._set_status(f"Logged in as {client.username}")
|
||||
await self._connect_ws()
|
||||
except Exception as e:
|
||||
self._set_status(f"Error: {e}")
|
||||
self._set_status(f"[red]{e}[/red]")
|
||||
self._enable_buttons()
|
||||
|
||||
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()
|
||||
self._set_status("Connected!")
|
||||
from tui_client.screens.lobby import LobbyScreen
|
||||
self.app.push_screen(LobbyScreen())
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.query_one("#connect-status", Static).update(text)
|
||||
|
||||
|
||||
@ -5,8 +5,8 @@ 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 textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
from tui_client.models import GameState, PlayerData
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Main game board with card display and keyboard controls."""
|
||||
|
||||
@ -33,6 +157,9 @@ class GameScreen(Screen):
|
||||
("p", "skip_flip", "Skip flip"),
|
||||
("k", "knock_early", "Knock early"),
|
||||
("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):
|
||||
@ -55,7 +182,10 @@ class GameScreen(Screen):
|
||||
yield PlayAreaWidget(id="play-area")
|
||||
yield Static("", id="local-hand-label")
|
||||
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:
|
||||
self._player_id = self.app.player_id or ""
|
||||
@ -97,12 +227,12 @@ class GameScreen(Screen):
|
||||
|
||||
if source == "discard":
|
||||
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")
|
||||
else:
|
||||
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")
|
||||
|
||||
@ -111,10 +241,10 @@ class GameScreen(Screen):
|
||||
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_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True)
|
||||
self._set_keymap("[1-6] Flip card [P] Skip")
|
||||
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")
|
||||
|
||||
def _handle_round_over(self, data: dict) -> None:
|
||||
@ -125,7 +255,7 @@ class GameScreen(Screen):
|
||||
self.app.push_screen(
|
||||
ScoreboardScreen(
|
||||
scores=scores,
|
||||
title=f"Round {round_num} Complete",
|
||||
title=f"Hole {round_num} Complete",
|
||||
is_game_over=False,
|
||||
is_host=self._is_host,
|
||||
round_num=round_num,
|
||||
@ -172,6 +302,10 @@ class GameScreen(Screen):
|
||||
elif result == "lobby":
|
||||
self.run_worker(self._send("leave_game"))
|
||||
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)
|
||||
@ -256,9 +390,10 @@ class GameScreen(Screen):
|
||||
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_current_turn=False,
|
||||
is_knocker=False,
|
||||
is_dealer=(me.id == self._state.dealer_id),
|
||||
highlight=True,
|
||||
)
|
||||
|
||||
needed = self._state.initial_flips
|
||||
@ -272,7 +407,7 @@ class GameScreen(Screen):
|
||||
self._initial_flip_positions = []
|
||||
else:
|
||||
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:
|
||||
@ -299,7 +434,7 @@ class GameScreen(Screen):
|
||||
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]")
|
||||
self._set_action("Flip mode: select a face-down card [1-6]", active=True)
|
||||
|
||||
def action_skip_flip(self) -> None:
|
||||
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":
|
||||
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:
|
||||
self.run_worker(self._send("leave_game"))
|
||||
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
|
||||
@ -340,9 +500,21 @@ class GameScreen(Screen):
|
||||
except Exception as e:
|
||||
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:
|
||||
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:
|
||||
pass
|
||||
|
||||
@ -361,6 +533,8 @@ class GameScreen(Screen):
|
||||
# Play area
|
||||
play_area = self.query_one("#play-area", PlayAreaWidget)
|
||||
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)
|
||||
me = self._get_local_player()
|
||||
@ -368,12 +542,15 @@ class GameScreen(Screen):
|
||||
self.query_one("#local-hand-label", Static).update("")
|
||||
hand = self.query_one("#local-hand", HandWidget)
|
||||
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(
|
||||
me,
|
||||
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_dealer=(me.id == state.dealer_id),
|
||||
highlight=state.waiting_for_initial_flip,
|
||||
)
|
||||
else:
|
||||
self.query_one("#local-hand-label", Static).update("")
|
||||
@ -413,12 +590,13 @@ class GameScreen(Screen):
|
||||
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(
|
||||
opp.name,
|
||||
score=opp.score,
|
||||
total_score=opp.total_score,
|
||||
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_dealer=(opp.id == state.dealer_id),
|
||||
all_face_up=opp.all_face_up,
|
||||
@ -426,17 +604,22 @@ class GameScreen(Screen):
|
||||
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)))
|
||||
# Each box is ~21-24 chars wide; use actual widths for accuracy
|
||||
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
||||
gap = " " if width < 120 else " "
|
||||
gap_len = len(gap)
|
||||
|
||||
# Greedily fit as many as possible in one row
|
||||
per_row = 0
|
||||
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:
|
||||
break
|
||||
per_row = max(1, per_row)
|
||||
|
||||
# Render in rows of per_row opponents
|
||||
all_row_lines: list[str] = []
|
||||
@ -469,15 +652,15 @@ class GameScreen(Screen):
|
||||
state = self._state
|
||||
|
||||
if state.phase in ("round_over", "game_over"):
|
||||
self._set_action("Keyboard: \[n]ext round")
|
||||
self._set_keymap("[N] Next round")
|
||||
self._set_action("\\[n]ext hole", active=True)
|
||||
self._set_keymap("[N] Next hole")
|
||||
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})"
|
||||
f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True
|
||||
)
|
||||
self._set_keymap("[1-6] Select card")
|
||||
return
|
||||
@ -496,23 +679,23 @@ class GameScreen(Screen):
|
||||
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")
|
||||
self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True)
|
||||
keys.append("[X] Discard")
|
||||
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")
|
||||
self._set_keymap(" ".join(keys))
|
||||
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"]
|
||||
if state.flip_as_action:
|
||||
parts.append("\[f]lip a card")
|
||||
parts.append("\\[f]lip a card")
|
||||
keys.append("[F] Flip")
|
||||
if state.knock_early:
|
||||
parts.append("\[k]nock early")
|
||||
parts.append("\\[k]nock early")
|
||||
keys.append("[K] Knock")
|
||||
self._set_action("Keyboard: " + " or ".join(parts))
|
||||
self._set_action(" or ".join(parts), active=True)
|
||||
self._set_keymap(" ".join(keys))
|
||||
|
||||
def _set_keymap(self, text: str) -> None:
|
||||
|
||||
@ -83,7 +83,7 @@ class LobbyScreen(Screen):
|
||||
with Vertical(id="host-settings"):
|
||||
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
||||
with Horizontal(classes="setting-row"):
|
||||
yield Label("Rounds")
|
||||
yield Label("Holes")
|
||||
yield Select(
|
||||
[(str(v), v) for v in (1, 3, 9, 18)],
|
||||
value=9,
|
||||
@ -200,6 +200,23 @@ class LobbyScreen(Screen):
|
||||
self._update_visibility()
|
||||
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:
|
||||
try:
|
||||
self.query_one("#pre-room").display = not self._in_room
|
||||
|
||||
@ -32,16 +32,32 @@ ConnectScreen {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#connect-buttons {
|
||||
#login-form, #signup-form {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#signup-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#connect-buttons, #signup-buttons {
|
||||
height: 3;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#connect-buttons Button {
|
||||
#connect-buttons Button, #signup-buttons Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#btn-toggle-signup, #btn-toggle-login {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
#connect-status {
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
@ -234,17 +250,11 @@ GameScreen {
|
||||
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;
|
||||
#play-area.my-turn {
|
||||
border: round #ffd700;
|
||||
}
|
||||
|
||||
|
||||
/* Local hand label */
|
||||
#local-hand-label {
|
||||
text-align: center;
|
||||
@ -292,3 +302,107 @@ GameScreen {
|
||||
align: center middle;
|
||||
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
|
||||
EMPTY_COLOR = "#555555" # empty card slot
|
||||
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:
|
||||
@ -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"])
|
||||
|
||||
|
||||
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."""
|
||||
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}]┌───┐[/{d}{color}]"
|
||||
|
||||
@ -54,6 +58,7 @@ def render_card(
|
||||
position: int | None = None,
|
||||
deck_colors: list[str] | None = None,
|
||||
dim: bool = False,
|
||||
highlight: bool = False,
|
||||
) -> str:
|
||||
"""Render a card as a 4-line Rich-markup string.
|
||||
|
||||
@ -64,7 +69,7 @@ def render_card(
|
||||
└───┘ └───┘ └───┘
|
||||
"""
|
||||
d = "dim " if dim else ""
|
||||
bc = BORDER_COLOR
|
||||
bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR
|
||||
|
||||
# Empty slot
|
||||
if card is None:
|
||||
@ -76,7 +81,7 @@ def render_card(
|
||||
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
|
||||
if not card.face_up:
|
||||
|
||||
@ -69,6 +69,7 @@ def _render_card_lines(
|
||||
is_local: bool = False,
|
||||
deck_colors: list[str] | None = None,
|
||||
matched: list[bool] | None = None,
|
||||
highlight: bool = False,
|
||||
) -> list[str]:
|
||||
"""Render the 2x3 card grid as a list of text lines (no box).
|
||||
|
||||
@ -94,6 +95,7 @@ def _render_card_lines(
|
||||
position=pos,
|
||||
deck_colors=deck_colors,
|
||||
dim=matched[idx],
|
||||
highlight=highlight,
|
||||
)
|
||||
card_lines = text.split("\n")
|
||||
while len(row_line_parts) < len(card_lines):
|
||||
@ -131,6 +133,8 @@ class HandWidget(Static):
|
||||
self._is_knocker: bool = False
|
||||
self._is_dealer: bool = False
|
||||
self._has_connector: bool = False
|
||||
self._highlight: bool = False
|
||||
self._box_width: int = 0
|
||||
|
||||
def update_player(
|
||||
self,
|
||||
@ -140,6 +144,7 @@ class HandWidget(Static):
|
||||
is_current_turn: bool = False,
|
||||
is_knocker: bool = False,
|
||||
is_dealer: bool = False,
|
||||
highlight: bool = False,
|
||||
) -> None:
|
||||
self._player = player
|
||||
if deck_colors is not None:
|
||||
@ -147,6 +152,7 @@ class HandWidget(Static):
|
||||
self._is_current_turn = is_current_turn
|
||||
self._is_knocker = is_knocker
|
||||
self._is_dealer = is_dealer
|
||||
self._highlight = highlight
|
||||
self._refresh()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
@ -154,9 +160,14 @@ class HandWidget(Static):
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Map click coordinates to card position (0-5)."""
|
||||
if not self._is_local:
|
||||
if not self._is_local or not self._box_width:
|
||||
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:
|
||||
# Line 0: top border
|
||||
# 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)
|
||||
|
||||
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
|
||||
@ -202,7 +211,7 @@ class HandWidget(Static):
|
||||
self.update("")
|
||||
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
|
||||
matched = _check_column_match(cards)
|
||||
@ -213,6 +222,7 @@ class HandWidget(Static):
|
||||
is_local=self._is_local,
|
||||
deck_colors=self._deck_colors,
|
||||
matched=matched,
|
||||
highlight=self._highlight,
|
||||
)
|
||||
|
||||
box_lines = render_player_box(
|
||||
@ -227,4 +237,8 @@ class HandWidget(Static):
|
||||
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))
|
||||
|
||||
@ -58,13 +58,15 @@ class PlayAreaWidget(Static):
|
||||
|
||||
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:
|
||||
# Content is always 3 columns wide; account for centering within widget
|
||||
content_width = 3 * _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())
|
||||
elif self._has_holding and x >= 2 * _COL_WIDTH:
|
||||
self.post_message(self.DiscardClicked())
|
||||
elif not self._has_holding and x >= _COL_WIDTH:
|
||||
elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
|
||||
self.post_message(self.DiscardClicked())
|
||||
|
||||
def _refresh(self) -> None:
|
||||
@ -114,7 +116,7 @@ class PlayAreaWidget(Static):
|
||||
lines.append(row)
|
||||
|
||||
# Labels row — always 3 columns
|
||||
deck_label = f"DECK:{state.deck_remaining}"
|
||||
deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
|
||||
discard_label = "DISCARD"
|
||||
label = _pad_center(deck_label, _COL_WIDTH)
|
||||
if held_lines:
|
||||
|
||||
@ -9,7 +9,6 @@ _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"
|
||||
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
||||
def __init__(
|
||||
self,
|
||||
scores: list[dict],
|
||||
title: str = "Round Over",
|
||||
title: str = "Hole Over",
|
||||
is_game_over: bool = False,
|
||||
is_host: bool = False,
|
||||
round_num: int = 1,
|
||||
@ -44,7 +44,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
||||
table = self.query_one("#scoreboard-table", DataTable)
|
||||
|
||||
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):
|
||||
table.add_row(
|
||||
str(i),
|
||||
@ -53,7 +53,7 @@ class ScoreboardScreen(ModalScreen[str]):
|
||||
str(s.get("rounds_won", 0)),
|
||||
)
|
||||
else:
|
||||
table.add_columns("Player", "Round Score", "Total", "Rounds Won")
|
||||
table.add_columns("Player", "Hole Score", "Total", "Holes Won")
|
||||
for s in self._scores:
|
||||
table.add_row(
|
||||
s.get("name", "?"),
|
||||
|
||||
@ -34,15 +34,15 @@ class StatusBarWidget(Static):
|
||||
parts = []
|
||||
|
||||
# Round info
|
||||
parts.append(f"Round {state.current_round}/{state.total_rounds}")
|
||||
parts.append(f"⛳ {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",
|
||||
"playing": "",
|
||||
"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]",
|
||||
}.get(state.phase, state.phase)
|
||||
parts.append(phase_display)
|
||||
@ -50,7 +50,7 @@ class StatusBarWidget(Static):
|
||||
# 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]")
|
||||
parts.append("[bold #111111 on #ffd700] YOUR TURN! [/bold #111111 on #ffd700]")
|
||||
else:
|
||||
for p in state.players:
|
||||
if p.id == state.current_player_id:
|
||||
@ -68,7 +68,7 @@ class StatusBarWidget(Static):
|
||||
if 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:
|
||||
text += f" {self._extra}"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user