Add session persistence, splash screen, and TUI polish
Save JWT token to ~/.config/golfcards/session.json after login so subsequent launches skip the login screen when the session is still valid. A new splash screen shows the token check status (SUCCESS / NONE FOUND / EXPIRED) before routing to lobby or login. Also: move OUT indicator to player box bottom border, remove checkmark, center scoreboard overlay, use alternating shade blocks (▓▒▓/▒▓▒) for card backs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
67d06d9799
commit
b1d3aa7b77
@ -55,8 +55,8 @@ class GolfApp(App):
|
|||||||
yield KeymapBar(id="keymap-bar")
|
yield KeymapBar(id="keymap-bar")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
from tui_client.screens.connect import ConnectScreen
|
from tui_client.screens.splash import SplashScreen
|
||||||
self.push_screen(ConnectScreen())
|
self.push_screen(SplashScreen())
|
||||||
self._update_keymap()
|
self._update_keymap()
|
||||||
|
|
||||||
def on_screen_resume(self) -> None:
|
def on_screen_resume(self) -> None:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -13,6 +14,9 @@ from websockets.asyncio.client import ClientConnection
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SESSION_DIR = Path.home() / ".config" / "golfcards"
|
||||||
|
_SESSION_FILE = _SESSION_DIR / "session.json"
|
||||||
|
|
||||||
|
|
||||||
class GameClient:
|
class GameClient:
|
||||||
"""Handles HTTP auth and WebSocket game communication."""
|
"""Handles HTTP auth and WebSocket game communication."""
|
||||||
@ -47,6 +51,62 @@ class GameClient:
|
|||||||
def username(self) -> Optional[str]:
|
def username(self) -> Optional[str]:
|
||||||
return self._username
|
return self._username
|
||||||
|
|
||||||
|
def save_session(self) -> None:
|
||||||
|
"""Persist token and server info to disk."""
|
||||||
|
if not self._token:
|
||||||
|
return
|
||||||
|
_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"host": self.host,
|
||||||
|
"use_tls": self.use_tls,
|
||||||
|
"token": self._token,
|
||||||
|
"username": self._username,
|
||||||
|
}
|
||||||
|
_SESSION_FILE.write_text(json.dumps(data))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_session() -> dict | None:
|
||||||
|
"""Load saved session from disk, or None if not found."""
|
||||||
|
if not _SESSION_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(_SESSION_FILE.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_session() -> None:
|
||||||
|
"""Delete saved session file."""
|
||||||
|
try:
|
||||||
|
_SESSION_FILE.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def verify_token(self) -> bool:
|
||||||
|
"""Check if the current token is still valid via /api/auth/me."""
|
||||||
|
if not self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
||||||
|
resp = await http.get(
|
||||||
|
f"{self.http_base}/api/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {self._token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
self._username = data.get("username", self._username)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def restore_session(self, session: dict) -> None:
|
||||||
|
"""Restore client state from a saved session dict."""
|
||||||
|
self.host = session["host"]
|
||||||
|
self.use_tls = session["use_tls"]
|
||||||
|
self._token = session["token"]
|
||||||
|
self._username = session.get("username")
|
||||||
|
|
||||||
async def login(self, username: str, password: str) -> dict:
|
async def login(self, username: str, password: str) -> dict:
|
||||||
"""Login via HTTP and store JWT token.
|
"""Login via HTTP and store JWT token.
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,29 @@ class PlayerData:
|
|||||||
rounds_won: int = 0
|
rounds_won: int = 0
|
||||||
all_face_up: bool = False
|
all_face_up: bool = False
|
||||||
|
|
||||||
|
# Standard card values for visible score calculation
|
||||||
|
_CARD_VALUES = {
|
||||||
|
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6,
|
||||||
|
'7': 7, '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible_score(self) -> int:
|
||||||
|
"""Compute score from face-up cards, zeroing matched columns."""
|
||||||
|
if len(self.cards) < 6:
|
||||||
|
return 0
|
||||||
|
values = [0] * 6
|
||||||
|
for i, c in enumerate(self.cards):
|
||||||
|
if c.face_up and c.rank:
|
||||||
|
values[i] = self._CARD_VALUES.get(c.rank, 0)
|
||||||
|
# Zero out matched columns (same rank, both face-up)
|
||||||
|
for col in range(3):
|
||||||
|
top, bot = self.cards[col], self.cards[col + 3]
|
||||||
|
if top.face_up and bot.face_up and top.rank and top.rank == bot.rank:
|
||||||
|
values[col] = 0
|
||||||
|
values[col + 3] = 0
|
||||||
|
return sum(values)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict) -> PlayerData:
|
def from_dict(cls, d: dict) -> PlayerData:
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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 = (
|
_TITLE = (
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
||||||
@ -61,8 +62,13 @@ class ConnectScreen(Screen):
|
|||||||
|
|
||||||
yield Static("", id="connect-status")
|
yield Static("", id="connect-status")
|
||||||
|
|
||||||
|
with Horizontal(classes="screen-footer"):
|
||||||
|
yield Static("", id="connect-footer-left", classes="screen-footer-left")
|
||||||
|
yield Static("\\[esc]\\[esc] quit", id="connect-footer-right", classes="screen-footer-right")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._update_form_visibility()
|
self._update_form_visibility()
|
||||||
|
self._update_footer()
|
||||||
|
|
||||||
def _update_form_visibility(self) -> None:
|
def _update_form_visibility(self) -> None:
|
||||||
try:
|
try:
|
||||||
@ -70,6 +76,17 @@ class ConnectScreen(Screen):
|
|||||||
self.query_one("#signup-form").display = self._mode == "signup"
|
self.query_one("#signup-form").display = self._mode == "signup"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._update_footer()
|
||||||
|
|
||||||
|
def _update_footer(self) -> None:
|
||||||
|
try:
|
||||||
|
left = self.query_one("#connect-footer-left", Static)
|
||||||
|
if self._mode == "signup":
|
||||||
|
left.update("\\[esc] back")
|
||||||
|
else:
|
||||||
|
left.update("")
|
||||||
|
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-login":
|
if event.button.id == "btn-login":
|
||||||
@ -150,6 +167,7 @@ class ConnectScreen(Screen):
|
|||||||
client = self.app.client
|
client = self.app.client
|
||||||
self._set_status("Connecting...")
|
self._set_status("Connecting...")
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
client.save_session()
|
||||||
self._set_status("Connected!")
|
self._set_status("Connected!")
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
from tui_client.screens.lobby import LobbyScreen
|
||||||
self.app.push_screen(LobbyScreen())
|
self.app.push_screen(LobbyScreen())
|
||||||
|
|||||||
@ -172,6 +172,8 @@ class GameScreen(Screen):
|
|||||||
self._initial_flip_positions: list[int] = []
|
self._initial_flip_positions: list[int] = []
|
||||||
self._can_flip_optional = False
|
self._can_flip_optional = False
|
||||||
self._term_width: int = 80
|
self._term_width: int = 80
|
||||||
|
self._swap_flash: dict[str, int] = {} # player_id -> position of last swap
|
||||||
|
self._discard_flash: bool = False # discard pile just changed
|
||||||
self._term_height: int = 24
|
self._term_height: int = 24
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
@ -210,7 +212,10 @@ class GameScreen(Screen):
|
|||||||
|
|
||||||
def _handle_game_state(self, data: dict) -> None:
|
def _handle_game_state(self, data: dict) -> None:
|
||||||
state_data = data.get("game_state", data)
|
state_data = data.get("game_state", data)
|
||||||
self._state = GameState.from_dict(state_data)
|
old_state = self._state
|
||||||
|
new_state = GameState.from_dict(state_data)
|
||||||
|
self._detect_swaps(old_state, new_state)
|
||||||
|
self._state = new_state
|
||||||
self._full_refresh()
|
self._full_refresh()
|
||||||
|
|
||||||
def _handle_your_turn(self, data: dict) -> None:
|
def _handle_your_turn(self, data: dict) -> None:
|
||||||
@ -320,8 +325,14 @@ class GameScreen(Screen):
|
|||||||
self.action_draw_deck()
|
self.action_draw_deck()
|
||||||
|
|
||||||
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
|
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
|
||||||
"""Handle click on the discard pile."""
|
"""Handle click on the discard pile.
|
||||||
self.action_pick_discard()
|
|
||||||
|
If holding a card, discard it. Otherwise, draw from discard.
|
||||||
|
"""
|
||||||
|
if self._state and self._state.has_drawn_card:
|
||||||
|
self.action_discard_held()
|
||||||
|
else:
|
||||||
|
self.action_pick_discard()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Keyboard actions
|
# Keyboard actions
|
||||||
@ -481,6 +492,46 @@ class GameScreen(Screen):
|
|||||||
if hasattr(lobby, "reset_to_pre_room"):
|
if hasattr(lobby, "reset_to_pre_room"):
|
||||||
lobby.reset_to_pre_room()
|
lobby.reset_to_pre_room()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Swap/discard detection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _detect_swaps(self, old: GameState, new: GameState) -> None:
|
||||||
|
"""Compare old and new state to find which card positions changed."""
|
||||||
|
if not old or not new or not old.players or not new.players:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_map = {p.id: p for p in old.players}
|
||||||
|
for np in new.players:
|
||||||
|
op = old_map.get(np.id)
|
||||||
|
if not op:
|
||||||
|
continue
|
||||||
|
for i, (oc, nc) in enumerate(zip(op.cards, np.cards)):
|
||||||
|
# Card changed: was face-down and now face-up with different rank,
|
||||||
|
# or rank/suit changed
|
||||||
|
if oc.rank != nc.rank or oc.suit != nc.suit:
|
||||||
|
if nc.face_up:
|
||||||
|
self._swap_flash[np.id] = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Detect discard change (new discard top differs from old)
|
||||||
|
if old.discard_top and new.discard_top:
|
||||||
|
if (old.discard_top.rank != new.discard_top.rank or
|
||||||
|
old.discard_top.suit != new.discard_top.suit):
|
||||||
|
self._discard_flash = True
|
||||||
|
elif not old.discard_top and new.discard_top:
|
||||||
|
self._discard_flash = True
|
||||||
|
|
||||||
|
# Schedule flash clear after 2 seconds
|
||||||
|
if self._swap_flash or self._discard_flash:
|
||||||
|
self.set_timer(1.0, self._clear_flash)
|
||||||
|
|
||||||
|
def _clear_flash(self) -> None:
|
||||||
|
"""Clear swap/discard flash highlights and re-render."""
|
||||||
|
self._swap_flash.clear()
|
||||||
|
self._discard_flash = False
|
||||||
|
self._full_refresh()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -532,7 +583,7 @@ 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, discard_flash=self._discard_flash)
|
||||||
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
|
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
|
||||||
play_area.set_class(is_active, "my-turn")
|
play_area.set_class(is_active, "my-turn")
|
||||||
|
|
||||||
@ -551,6 +602,7 @@ class GameScreen(Screen):
|
|||||||
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,
|
highlight=state.waiting_for_initial_flip,
|
||||||
|
flash_position=self._swap_flash.get(me.id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
self.query_one("#local-hand-label", Static).update("")
|
||||||
@ -588,25 +640,32 @@ class GameScreen(Screen):
|
|||||||
matched = _check_column_match(cards)
|
matched = _check_column_match(cards)
|
||||||
card_lines = _render_card_lines(
|
card_lines = _render_card_lines(
|
||||||
cards, deck_colors=deck_colors, matched=matched,
|
cards, deck_colors=deck_colors, matched=matched,
|
||||||
|
flash_position=self._swap_flash.get(opp.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
|
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
|
||||||
|
display_score = opp.score if opp.score is not None else opp.visible_score
|
||||||
box = render_player_box(
|
box = render_player_box(
|
||||||
opp.name,
|
opp.name,
|
||||||
score=opp.score,
|
score=display_score,
|
||||||
total_score=opp.total_score,
|
total_score=opp.total_score,
|
||||||
content_lines=card_lines,
|
content_lines=card_lines,
|
||||||
is_current_turn=opp_turn,
|
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,
|
|
||||||
)
|
)
|
||||||
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; use actual widths for accuracy
|
# Account for padding on the opponents-area widget (2 chars each side)
|
||||||
|
try:
|
||||||
|
opp_widget = self.query_one("#opponents-area", Static)
|
||||||
|
avail_width = opp_widget.content_size.width or (width - 4)
|
||||||
|
except Exception:
|
||||||
|
avail_width = width - 4
|
||||||
|
|
||||||
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
||||||
gap = " " if width < 120 else " "
|
gap = " " if avail_width < 120 else " "
|
||||||
gap_len = len(gap)
|
gap_len = len(gap)
|
||||||
|
|
||||||
# Greedily fit as many as possible in one row
|
# Greedily fit as many as possible in one row
|
||||||
@ -614,7 +673,7 @@ class GameScreen(Screen):
|
|||||||
row_width = 0
|
row_width = 0
|
||||||
for bw in box_widths:
|
for bw in box_widths:
|
||||||
needed_width = bw if per_row == 0 else gap_len + bw
|
needed_width = bw if per_row == 0 else gap_len + bw
|
||||||
if row_width + needed_width <= width:
|
if row_width + needed_width <= avail_width:
|
||||||
row_width += needed_width
|
row_width += needed_width
|
||||||
per_row += 1
|
per_row += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -196,9 +196,14 @@ class LobbyScreen(Screen):
|
|||||||
|
|
||||||
yield Static("", id="lobby-status")
|
yield Static("", id="lobby-status")
|
||||||
|
|
||||||
|
with Horizontal(classes="screen-footer"): # Outside lobby-container
|
||||||
|
yield Static("\\[esc] back", id="lobby-footer-left", classes="screen-footer-left")
|
||||||
|
yield Static("\\[esc]\\[esc] quit", id="lobby-footer-right", classes="screen-footer-right")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
self._update_keymap()
|
self._update_keymap()
|
||||||
|
self._update_footer()
|
||||||
|
|
||||||
def reset_to_pre_room(self) -> None:
|
def reset_to_pre_room(self) -> None:
|
||||||
"""Reset lobby back to create/join state after leaving a game."""
|
"""Reset lobby back to create/join state after leaving a game."""
|
||||||
@ -228,7 +233,18 @@ class LobbyScreen(Screen):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _update_footer(self) -> None:
|
||||||
|
try:
|
||||||
|
left = self.query_one("#lobby-footer-left", Static)
|
||||||
|
if self._in_room:
|
||||||
|
left.update("\\[esc] leave")
|
||||||
|
else:
|
||||||
|
left.update("\\[esc] back")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _update_keymap(self) -> None:
|
def _update_keymap(self) -> None:
|
||||||
|
self._update_footer()
|
||||||
try:
|
try:
|
||||||
if self._in_room and self._is_host:
|
if self._in_room and self._is_host:
|
||||||
self.app.set_keymap("[Esc] Leave [+] Add CPU [−] Remove [Enter] Start [Esc][Esc] Quit")
|
self.app.set_keymap("[Esc] Leave [+] Add CPU [−] Remove [Enter] Start [Esc][Esc] Quit")
|
||||||
@ -441,6 +457,7 @@ class LobbyScreen(Screen):
|
|||||||
def _handle_player_joined(self, data: dict) -> None:
|
def _handle_player_joined(self, data: dict) -> None:
|
||||||
self._players = data.get("players", [])
|
self._players = data.get("players", [])
|
||||||
self._refresh_player_list()
|
self._refresh_player_list()
|
||||||
|
self._auto_adjust_decks()
|
||||||
|
|
||||||
def _handle_game_started(self, data: dict) -> None:
|
def _handle_game_started(self, data: dict) -> None:
|
||||||
from tui_client.screens.game import GameScreen
|
from tui_client.screens.game import GameScreen
|
||||||
@ -463,6 +480,19 @@ class LobbyScreen(Screen):
|
|||||||
lines.append(f" {i}. {name}{suffix}")
|
lines.append(f" {i}. {name}{suffix}")
|
||||||
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
|
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
|
||||||
|
|
||||||
|
def _auto_adjust_decks(self) -> None:
|
||||||
|
"""Auto-set decks to 2 when more than 3 players."""
|
||||||
|
if not self._is_host:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sel = self.query_one("#sel-decks", Select)
|
||||||
|
if len(self._players) > 3 and sel.value == 1:
|
||||||
|
sel.value = 2
|
||||||
|
elif len(self._players) <= 3 and sel.value == 2:
|
||||||
|
sel.value = 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _set_room_info(self, text: str) -> None:
|
def _set_room_info(self, text: str) -> None:
|
||||||
self.query_one("#room-info", Static).update(text)
|
self.query_one("#room-info", Static).update(text)
|
||||||
|
|
||||||
|
|||||||
74
tui_client/src/tui_client/screens/splash.py
Normal file
74
tui_client/src/tui_client/screens/splash.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Splash screen: check for saved session token before showing login."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
_TITLE = (
|
||||||
|
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
||||||
|
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
||||||
|
"[bold #cc0000]♥[/bold #cc0000]"
|
||||||
|
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
||||||
|
"[bold #cc0000]♦[/bold #cc0000]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SplashScreen(Screen):
|
||||||
|
"""Shows session check status, then routes to lobby or login."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="connect-container"):
|
||||||
|
yield Static(_TITLE, id="connect-title")
|
||||||
|
yield Static("", id="splash-status")
|
||||||
|
|
||||||
|
with Horizontal(classes="screen-footer"):
|
||||||
|
yield Static("", classes="screen-footer-left")
|
||||||
|
yield Static("\\[esc]\\[esc] quit", classes="screen-footer-right")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.run_worker(self._check_session(), exclusive=True)
|
||||||
|
|
||||||
|
async def _check_session(self) -> None:
|
||||||
|
from tui_client.client import GameClient
|
||||||
|
|
||||||
|
status = self.query_one("#splash-status", Static)
|
||||||
|
status.update("Checking for session token...")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
session = GameClient.load_session()
|
||||||
|
if not session:
|
||||||
|
status.update("Checking for session token... [bold yellow]NONE FOUND[/bold yellow]")
|
||||||
|
await asyncio.sleep(0.8)
|
||||||
|
self._go_to_login()
|
||||||
|
return
|
||||||
|
|
||||||
|
client = self.app.client
|
||||||
|
client.restore_session(session)
|
||||||
|
if await client.verify_token():
|
||||||
|
status.update(f"Checking for session token... [bold green]SUCCESS[/bold green]")
|
||||||
|
await asyncio.sleep(0.8)
|
||||||
|
await self._go_to_lobby()
|
||||||
|
else:
|
||||||
|
GameClient.clear_session()
|
||||||
|
status.update("Checking for session token... [bold red]EXPIRED[/bold red]")
|
||||||
|
await asyncio.sleep(0.8)
|
||||||
|
self._go_to_login()
|
||||||
|
|
||||||
|
def _go_to_login(self) -> None:
|
||||||
|
from tui_client.screens.connect import ConnectScreen
|
||||||
|
|
||||||
|
self.app.switch_screen(ConnectScreen())
|
||||||
|
|
||||||
|
async def _go_to_lobby(self) -> None:
|
||||||
|
client = self.app.client
|
||||||
|
await client.connect()
|
||||||
|
client.save_session()
|
||||||
|
from tui_client.screens.lobby import LobbyScreen
|
||||||
|
|
||||||
|
self.app.switch_screen(LobbyScreen())
|
||||||
@ -3,6 +3,17 @@ Screen {
|
|||||||
background: $surface;
|
background: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Splash screen */
|
||||||
|
SplashScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-status {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Connect screen */
|
/* Connect screen */
|
||||||
ConnectScreen {
|
ConnectScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
@ -65,6 +76,23 @@ ConnectScreen {
|
|||||||
height: 1;
|
height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Screen footer bar (shared by connect + lobby) */
|
||||||
|
.screen-footer {
|
||||||
|
dock: bottom;
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #888888;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
.screen-footer-left {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.screen-footer-right {
|
||||||
|
width: 1fr;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
/* Lobby screen */
|
/* Lobby screen */
|
||||||
LobbyScreen {
|
LobbyScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
@ -231,7 +259,7 @@ GameScreen {
|
|||||||
#opponents-area {
|
#opponents-area {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 50%;
|
max-height: 50%;
|
||||||
padding: 1 2 0 2;
|
padding: 1 2 1 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
@ -270,7 +298,7 @@ GameScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Scoreboard overlay */
|
/* Scoreboard overlay */
|
||||||
#scoreboard-overlay {
|
ScoreboardScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
background: $surface 80%;
|
background: $surface 80%;
|
||||||
}
|
}
|
||||||
@ -284,6 +312,7 @@ GameScreen {
|
|||||||
border: thick $primary;
|
border: thick $primary;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#scoreboard-title {
|
#scoreboard-title {
|
||||||
@ -293,7 +322,7 @@ GameScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#scoreboard-table {
|
#scoreboard-table {
|
||||||
width: 100%;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,9 +393,9 @@ HelpScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#help-dialog {
|
#help-dialog {
|
||||||
width: auto;
|
width: 48;
|
||||||
max-width: 48;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 80%;
|
||||||
border: thick $primary;
|
border: thick $primary;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
@ -374,6 +403,7 @@ HelpScreen {
|
|||||||
|
|
||||||
#help-text {
|
#help-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standings dialog */
|
/* Standings dialog */
|
||||||
@ -383,9 +413,9 @@ StandingsScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#standings-dialog {
|
#standings-dialog {
|
||||||
width: auto;
|
width: 48;
|
||||||
max-width: 48;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 80%;
|
||||||
border: thick $primary;
|
border: thick $primary;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
@ -399,6 +429,13 @@ StandingsScreen {
|
|||||||
|
|
||||||
#standings-body {
|
#standings-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#standings-hint {
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
|
margin-top: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#standings-hint {
|
#standings-hint {
|
||||||
|
|||||||
@ -24,13 +24,14 @@ BACK_COLORS: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Face-up card text colors (matching web UI)
|
# Face-up card text colors (matching web UI)
|
||||||
SUIT_RED = "#c0392b" # hearts, diamonds
|
SUIT_RED = "#ff4444" # hearts, diamonds — bright red
|
||||||
SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg)
|
SUIT_BLACK = "#ffffff" # clubs, spades — white for dark terminal bg
|
||||||
JOKER_COLOR = "#9b59b6" # purple
|
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
|
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention
|
||||||
|
FLASH_COLOR = "#00ffff" # bright cyan — swap/discard flash
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@ -59,38 +60,49 @@ def render_card(
|
|||||||
deck_colors: list[str] | None = None,
|
deck_colors: list[str] | None = None,
|
||||||
dim: bool = False,
|
dim: bool = False,
|
||||||
highlight: bool = False,
|
highlight: bool = False,
|
||||||
|
flash: bool = False,
|
||||||
|
connect_top: bool = False,
|
||||||
|
connect_bottom: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a card as a 4-line Rich-markup string.
|
"""Render a card as a 4-line Rich-markup string.
|
||||||
|
|
||||||
Face-up: Face-down: Empty:
|
Face-up: Face-down: Empty:
|
||||||
┌───┐ 1───┐ ┌───┐
|
┌───┐ 1───┐ ┌───┐
|
||||||
│ A │ │░░░│ │ │
|
│ A │ │▓▓▓│ │ │
|
||||||
│ ♠ │ │░░░│ │ │
|
│ ♠ │ │▓▓▓│ │ │
|
||||||
└───┘ └───┘ └───┘
|
└───┘ └───┘ └───┘
|
||||||
|
|
||||||
|
connect_top/connect_bottom merge borders for matched column pairs.
|
||||||
"""
|
"""
|
||||||
d = "dim " if dim else ""
|
d = "dim " if dim else ""
|
||||||
bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR
|
bc = FLASH_COLOR if flash else HIGHLIGHT_COLOR if highlight else BORDER_COLOR
|
||||||
|
bot = f"[{d}{bc}]├───┤[/{d}{bc}]" if connect_bottom else f"[{d}{bc}]└───┘[/{d}{bc}]"
|
||||||
|
|
||||||
# Empty slot
|
# Empty slot
|
||||||
if card is None:
|
if card is None:
|
||||||
c = EMPTY_COLOR
|
c = EMPTY_COLOR
|
||||||
|
top_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_top else f"[{d}{c}]┌───┐[/{d}{c}]"
|
||||||
|
bot_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_bottom else f"[{d}{c}]└───┘[/{d}{c}]"
|
||||||
return (
|
return (
|
||||||
f"[{d}{c}]┌───┐[/{d}{c}]\n"
|
f"{top_line}\n"
|
||||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
f"[{d}{c}]│ │[/{d}{c}]\n"
|
||||||
f"[{d}{c}]│ │[/{d}{c}]\n"
|
f"[{d}{c}]│ │[/{d}{c}]\n"
|
||||||
f"[{d}{c}]└───┘[/{d}{c}]"
|
f"{bot_line}"
|
||||||
)
|
)
|
||||||
|
|
||||||
top = _top_border(position, d, bc, highlight=highlight)
|
if connect_top:
|
||||||
|
top = f"[{d}{bc}]├───┤[/{d}{bc}]"
|
||||||
|
else:
|
||||||
|
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:
|
||||||
back = _back_color_for_card(card, deck_colors)
|
back = _back_color_for_card(card, deck_colors)
|
||||||
return (
|
return (
|
||||||
f"{top}\n"
|
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}][{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}]"
|
f"{bot}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Joker
|
# Joker
|
||||||
@ -101,11 +113,12 @@ def render_card(
|
|||||||
f"{top}\n"
|
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}] {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}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||||
f"[{d}{bc}]└───┘[/{d}{bc}]"
|
f"{bot}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Face-up normal card
|
# Face-up normal card
|
||||||
fc = SUIT_RED if card.is_red else SUIT_BLACK
|
fc = SUIT_RED if card.is_red else SUIT_BLACK
|
||||||
|
b = "bold " if dim else ""
|
||||||
rank = card.display_rank
|
rank = card.display_rank
|
||||||
suit = card.display_suit
|
suit = card.display_suit
|
||||||
rank_line = f"{rank:^3}"
|
rank_line = f"{rank:^3}"
|
||||||
@ -113,9 +126,9 @@ def render_card(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
f"{top}\n"
|
f"{top}\n"
|
||||||
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{rank_line}[/{b}{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}][{b}{d}{fc}]{suit_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
|
||||||
f"[{d}{bc}]└───┘[/{d}{bc}]"
|
f"{bot}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,6 @@ from textual.widgets import Static
|
|||||||
from tui_client.models import CardData, PlayerData
|
from tui_client.models import CardData, PlayerData
|
||||||
from tui_client.widgets.card import render_card
|
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]:
|
def _check_column_match(cards: list[CardData]) -> list[bool]:
|
||||||
@ -38,30 +36,6 @@ def _check_column_match(cards: list[CardData]) -> list[bool]:
|
|||||||
return matched
|
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(
|
def _render_card_lines(
|
||||||
cards: list[CardData],
|
cards: list[CardData],
|
||||||
@ -70,32 +44,35 @@ def _render_card_lines(
|
|||||||
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,
|
highlight: bool = False,
|
||||||
|
flash_position: int | None = None,
|
||||||
) -> 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).
|
||||||
|
|
||||||
Inserts a connector line with ║ ║ between rows for matched columns.
|
Matched columns use connected borders (├───┤) instead of separate
|
||||||
|
└───┘/┌───┐ to avoid an extra connector row.
|
||||||
"""
|
"""
|
||||||
if matched is None:
|
if matched is None:
|
||||||
matched = _check_column_match(cards)
|
matched = _check_column_match(cards)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for row_idx, row_start in enumerate((0, 3)):
|
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]] = []
|
row_line_parts: list[list[str]] = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
idx = row_start + i
|
idx = row_start + i
|
||||||
card = cards[idx] if idx < len(cards) else None
|
card = cards[idx] if idx < len(cards) else None
|
||||||
pos = idx + 1 if is_local else None
|
pos = idx + 1 if is_local else None
|
||||||
|
# Top row cards: connect_bottom if matched
|
||||||
|
# Bottom row cards: connect_top if matched
|
||||||
|
cb = matched[idx] if row_idx == 0 else False
|
||||||
|
ct = matched[idx] if row_idx == 1 else False
|
||||||
text = render_card(
|
text = render_card(
|
||||||
card,
|
card,
|
||||||
position=pos,
|
position=pos,
|
||||||
deck_colors=deck_colors,
|
deck_colors=deck_colors,
|
||||||
dim=matched[idx],
|
dim=matched[idx],
|
||||||
highlight=highlight,
|
highlight=highlight,
|
||||||
|
flash=(flash_position == idx),
|
||||||
|
connect_bottom=cb,
|
||||||
|
connect_top=ct,
|
||||||
)
|
)
|
||||||
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):
|
||||||
@ -132,8 +109,8 @@ class HandWidget(Static):
|
|||||||
self._is_current_turn: bool = False
|
self._is_current_turn: bool = False
|
||||||
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._highlight: bool = False
|
self._highlight: bool = False
|
||||||
|
self._flash_position: int | None = None
|
||||||
self._box_width: int = 0
|
self._box_width: int = 0
|
||||||
|
|
||||||
def update_player(
|
def update_player(
|
||||||
@ -145,6 +122,7 @@ class HandWidget(Static):
|
|||||||
is_knocker: bool = False,
|
is_knocker: bool = False,
|
||||||
is_dealer: bool = False,
|
is_dealer: bool = False,
|
||||||
highlight: bool = False,
|
highlight: bool = False,
|
||||||
|
flash_position: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._player = player
|
self._player = player
|
||||||
if deck_colors is not None:
|
if deck_colors is not None:
|
||||||
@ -153,6 +131,7 @@ class HandWidget(Static):
|
|||||||
self._is_knocker = is_knocker
|
self._is_knocker = is_knocker
|
||||||
self._is_dealer = is_dealer
|
self._is_dealer = is_dealer
|
||||||
self._highlight = highlight
|
self._highlight = highlight
|
||||||
|
self._flash_position = flash_position
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@ -171,9 +150,8 @@ class HandWidget(Static):
|
|||||||
# 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)
|
||||||
# Line 5 (optional): match connector
|
# Lines 5-8: row 1 cards
|
||||||
# Lines 5-8 or 6-9: row 1 cards
|
# Line 9: bottom border
|
||||||
# Last line: bottom border
|
|
||||||
#
|
#
|
||||||
# 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)
|
||||||
|
|
||||||
@ -191,14 +169,12 @@ class HandWidget(Static):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Determine row from y
|
# Determine row from y
|
||||||
# y=0: top border, y=1..4: row 0 cards, then optional connector, then row 1
|
# y=0: top border, y=1..4: row 0, y=5..8: row 1, y=9: bottom border
|
||||||
row = -1
|
row = -1
|
||||||
if 1 <= y <= 4:
|
if 1 <= y <= 4:
|
||||||
row = 0
|
row = 0
|
||||||
else:
|
elif 5 <= y <= 8:
|
||||||
row1_start = 6 if self._has_connector else 5
|
row = 1
|
||||||
if row1_start <= y <= row1_start + 3:
|
|
||||||
row = 1
|
|
||||||
|
|
||||||
if row < 0:
|
if row < 0:
|
||||||
return
|
return
|
||||||
@ -215,7 +191,6 @@ class HandWidget(Static):
|
|||||||
|
|
||||||
cards = self._player.cards
|
cards = self._player.cards
|
||||||
matched = _check_column_match(cards)
|
matched = _check_column_match(cards)
|
||||||
self._has_connector = any(matched[:3])
|
|
||||||
|
|
||||||
card_lines = _render_card_lines(
|
card_lines = _render_card_lines(
|
||||||
cards,
|
cards,
|
||||||
@ -223,18 +198,21 @@ class HandWidget(Static):
|
|||||||
deck_colors=self._deck_colors,
|
deck_colors=self._deck_colors,
|
||||||
matched=matched,
|
matched=matched,
|
||||||
highlight=self._highlight,
|
highlight=self._highlight,
|
||||||
|
flash_position=self._flash_position,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Use visible_score (computed from face-up cards) during play,
|
||||||
|
# server-provided score at round/game over
|
||||||
|
display_score = self._player.score if self._player.score is not None else self._player.visible_score
|
||||||
box_lines = render_player_box(
|
box_lines = render_player_box(
|
||||||
self._player.name,
|
self._player.name,
|
||||||
score=self._player.score,
|
score=display_score,
|
||||||
total_score=self._player.total_score,
|
total_score=self._player.total_score,
|
||||||
content_lines=card_lines,
|
content_lines=card_lines,
|
||||||
is_current_turn=self._is_current_turn,
|
is_current_turn=self._is_current_turn,
|
||||||
is_knocker=self._is_knocker,
|
is_knocker=self._is_knocker,
|
||||||
is_dealer=self._is_dealer,
|
is_dealer=self._is_dealer,
|
||||||
is_local=self._is_local,
|
is_local=self._is_local,
|
||||||
all_face_up=self._player.all_face_up,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store box width for click coordinate mapping
|
# Store box width for click coordinate mapping
|
||||||
|
|||||||
@ -46,9 +46,11 @@ class PlayAreaWidget(Static):
|
|||||||
self._state: GameState | None = None
|
self._state: GameState | None = None
|
||||||
self._local_player_id: str = ""
|
self._local_player_id: str = ""
|
||||||
self._has_holding: bool = False
|
self._has_holding: bool = False
|
||||||
|
self._discard_flash: bool = False
|
||||||
|
|
||||||
def update_state(self, state: GameState, local_player_id: str = "") -> None:
|
def update_state(self, state: GameState, local_player_id: str = "", discard_flash: bool = False) -> None:
|
||||||
self._state = state
|
self._state = state
|
||||||
|
self._discard_flash = discard_flash
|
||||||
if local_player_id:
|
if local_player_id:
|
||||||
self._local_player_id = local_player_id
|
self._local_player_id = local_player_id
|
||||||
self._refresh()
|
self._refresh()
|
||||||
@ -82,21 +84,17 @@ class PlayAreaWidget(Static):
|
|||||||
deck_lines = deck_text.split("\n")
|
deck_lines = deck_text.split("\n")
|
||||||
|
|
||||||
# Discard card
|
# Discard card
|
||||||
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors)
|
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors, flash=self._discard_flash)
|
||||||
discard_lines = discard_text.split("\n")
|
discard_lines = discard_text.split("\n")
|
||||||
|
|
||||||
# Held card — force face_up so render_card shows the face
|
# Held card — show for any player holding
|
||||||
# Only show when it's the local player holding
|
|
||||||
held_lines = None
|
held_lines = None
|
||||||
local_holding = (
|
is_local_holding = False
|
||||||
state.has_drawn_card
|
if state.has_drawn_card and state.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)
|
revealed = replace(state.drawn_card, face_up=True)
|
||||||
held_text = render_card(revealed, deck_colors=state.deck_colors)
|
held_text = render_card(revealed, deck_colors=state.deck_colors)
|
||||||
held_lines = held_text.split("\n")
|
held_lines = held_text.split("\n")
|
||||||
|
is_local_holding = state.drawn_player_id == self._local_player_id
|
||||||
|
|
||||||
self._has_holding = held_lines is not None
|
self._has_holding = held_lines is not None
|
||||||
|
|
||||||
@ -120,7 +118,10 @@ class PlayAreaWidget(Static):
|
|||||||
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:
|
||||||
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
|
if is_local_holding:
|
||||||
|
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
|
||||||
|
else:
|
||||||
|
holding_label = "[dim]HOLDING[/dim]"
|
||||||
label += _pad_center(holding_label, _COL_WIDTH)
|
label += _pad_center(holding_label, _COL_WIDTH)
|
||||||
else:
|
else:
|
||||||
label += " " * _COL_WIDTH
|
label += " " * _COL_WIDTH
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import re
|
|||||||
|
|
||||||
# Border colors matching web UI palette
|
# Border colors matching web UI palette
|
||||||
_BORDER_NORMAL = "#555555"
|
_BORDER_NORMAL = "#555555"
|
||||||
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn
|
_BORDER_TURN_LOCAL = "#f4a460" # sandy orange — your turn (matches opponent 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"
|
||||||
@ -27,7 +27,6 @@ def render_player_box(
|
|||||||
is_knocker: bool = False,
|
is_knocker: bool = False,
|
||||||
is_dealer: bool = False,
|
is_dealer: bool = False,
|
||||||
is_local: bool = False,
|
is_local: bool = False,
|
||||||
all_face_up: bool = False,
|
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Render a bordered player container with name/score header.
|
"""Render a bordered player container with name/score header.
|
||||||
|
|
||||||
@ -60,13 +59,9 @@ def render_player_box(
|
|||||||
display_name = name
|
display_name = name
|
||||||
if is_dealer:
|
if is_dealer:
|
||||||
display_name = f"Ⓓ {display_name}"
|
display_name = f"Ⓓ {display_name}"
|
||||||
if all_face_up:
|
|
||||||
display_name += " ✓"
|
|
||||||
if is_knocker:
|
|
||||||
display_name += " OUT"
|
|
||||||
|
|
||||||
# Score text
|
# Score text
|
||||||
score_text = f"{score}" if score is not None else f"{total_score}"
|
score_val = f"{score}" if score is not None else f"{total_score}"
|
||||||
|
score_text = f"{score_val}"
|
||||||
|
|
||||||
# Compute box width. Every line is exactly box_width visible chars.
|
# Compute box width. Every line is exactly box_width visible chars.
|
||||||
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
|
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
|
||||||
@ -109,6 +104,16 @@ def render_player_box(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Bottom border
|
# Bottom border
|
||||||
result.append(f"[{bc}]╰{'─' * inner}╯[/]")
|
if is_knocker:
|
||||||
|
out_label = " OUT "
|
||||||
|
left_fill = 1
|
||||||
|
right_fill = inner - left_fill - len(out_label)
|
||||||
|
result.append(
|
||||||
|
f"[{bc}]╰{'─' * left_fill}[/]"
|
||||||
|
f"[bold {bc}]{out_label}[/]"
|
||||||
|
f"[{bc}]{'─' * max(1, right_fill)}╯[/]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result.append(f"[{bc}]╰{'─' * inner}╯[/]")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user