325 lines
8.5 KiB
Python
325 lines
8.5 KiB
Python
"""
|
|
Card Game Template for Textual
|
|
|
|
A starter template for building turn-based card games with Textual.
|
|
Includes:
|
|
- Card widget with customizable suit, rank, and face-up/down state
|
|
- Hand container for displaying player hands
|
|
- Play area for cards in play
|
|
- Turn management system
|
|
- Action system with key bindings
|
|
|
|
Customize this template for your specific card game rules.
|
|
"""
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.containers import Container, Horizontal, Vertical
|
|
from textual.widgets import Header, Footer, Label, Static
|
|
from textual.widget import Widget
|
|
from textual.reactive import reactive
|
|
from textual.message import Message
|
|
from textual.binding import Binding
|
|
|
|
|
|
class Card(Widget):
|
|
"""A playing card widget."""
|
|
|
|
DEFAULT_CSS = """
|
|
Card {
|
|
width: 12;
|
|
height: 10;
|
|
border: round white;
|
|
background: $panel;
|
|
content-align: center middle;
|
|
}
|
|
|
|
Card:hover {
|
|
background: $boost;
|
|
border: heavy $primary;
|
|
}
|
|
|
|
Card.selected {
|
|
border: double cyan;
|
|
background: $accent;
|
|
}
|
|
|
|
Card.face-down {
|
|
background: $surface;
|
|
color: $text-muted;
|
|
}
|
|
|
|
Card.disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.card-rank {
|
|
text-style: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
.card-suit {
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
class Selected(Message):
|
|
"""Posted when card is selected."""
|
|
|
|
def __init__(self, card: "Card") -> None:
|
|
super().__init__()
|
|
self.card = card
|
|
|
|
class Played(Message):
|
|
"""Posted when card is played."""
|
|
|
|
def __init__(self, card: "Card") -> None:
|
|
super().__init__()
|
|
self.card = card
|
|
|
|
face_up = reactive(True)
|
|
selectable = reactive(True)
|
|
|
|
def __init__(
|
|
self,
|
|
rank: str,
|
|
suit: str,
|
|
value: int = 0,
|
|
face_up: bool = True,
|
|
card_id: str | None = None,
|
|
) -> None:
|
|
super().__init__(id=card_id)
|
|
self.rank = rank
|
|
self.suit = suit
|
|
self.value = value
|
|
self.face_up = face_up
|
|
|
|
def compose(self) -> ComposeResult:
|
|
if self.face_up:
|
|
yield Label(self.rank, classes="card-rank")
|
|
yield Label(self.suit, classes="card-suit")
|
|
else:
|
|
yield Label("🂠", classes="card-back")
|
|
|
|
def watch_face_up(self, face_up: bool) -> None:
|
|
"""Update display when card is flipped."""
|
|
if face_up:
|
|
self.remove_class("face-down")
|
|
else:
|
|
self.add_class("face-down")
|
|
# Refresh the card content
|
|
self.recompose()
|
|
|
|
def on_click(self) -> None:
|
|
"""Handle card click."""
|
|
if self.selectable:
|
|
self.post_message(self.Selected(self))
|
|
|
|
def flip(self) -> None:
|
|
"""Flip the card."""
|
|
self.face_up = not self.face_up
|
|
|
|
def play(self) -> None:
|
|
"""Play this card."""
|
|
if self.selectable:
|
|
self.post_message(self.Played(self))
|
|
|
|
|
|
class Hand(Container):
|
|
"""Container for a player's hand of cards."""
|
|
|
|
DEFAULT_CSS = """
|
|
Hand {
|
|
layout: horizontal;
|
|
height: auto;
|
|
width: 100%;
|
|
align: center middle;
|
|
}
|
|
|
|
Hand > Card {
|
|
margin: 0 1;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, player_name: str, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.player_name = player_name
|
|
|
|
def add_card(self, card: Card) -> None:
|
|
"""Add a card to this hand."""
|
|
self.mount(card)
|
|
|
|
def remove_card(self, card: Card) -> None:
|
|
"""Remove a card from this hand."""
|
|
card.remove()
|
|
|
|
def get_cards(self) -> list[Card]:
|
|
"""Get all cards in this hand."""
|
|
return list(self.query(Card))
|
|
|
|
|
|
class PlayArea(Container):
|
|
"""Central area where cards are played."""
|
|
|
|
DEFAULT_CSS = """
|
|
PlayArea {
|
|
height: 1fr;
|
|
border: round $primary;
|
|
background: $surface;
|
|
align: center middle;
|
|
}
|
|
|
|
PlayArea > .play-area-label {
|
|
color: $text-muted;
|
|
}
|
|
"""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Play Area", classes="play-area-label")
|
|
|
|
|
|
class GameState(Static):
|
|
"""Display current game state."""
|
|
|
|
DEFAULT_CSS = """
|
|
GameState {
|
|
dock: top;
|
|
height: 3;
|
|
background: $boost;
|
|
content-align: center middle;
|
|
text-style: bold;
|
|
}
|
|
"""
|
|
|
|
current_player = reactive("Player 1")
|
|
turn = reactive(1)
|
|
|
|
def render(self) -> str:
|
|
return f"Turn {self.turn} | Current Player: {self.current_player}"
|
|
|
|
|
|
class CardGameApp(App):
|
|
"""A card game application."""
|
|
|
|
CSS_PATH = "app.tcss"
|
|
|
|
BINDINGS = [
|
|
("q", "quit", "Quit"),
|
|
("n", "next_turn", "Next Turn"),
|
|
("d", "draw_card", "Draw Card"),
|
|
("p", "play_selected", "Play Card"),
|
|
Binding("space", "toggle_select", "Select/Deselect", show=True),
|
|
]
|
|
|
|
selected_card: Card | None = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield GameState(id="game-state")
|
|
|
|
with Vertical(id="game-container"):
|
|
# Opponent's hand (face down)
|
|
with Container(id="opponent-area"):
|
|
yield Label("Opponent", id="opponent-label")
|
|
yield Hand("Opponent", id="opponent-hand")
|
|
|
|
# Play area
|
|
yield PlayArea(id="play-area")
|
|
|
|
# Player's hand (face up)
|
|
with Container(id="player-area"):
|
|
yield Hand("Player", id="player-hand")
|
|
yield Label("Your Hand", id="player-label")
|
|
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Initialize the game when app starts."""
|
|
# Deal initial cards (example)
|
|
player_hand = self.query_one("#player-hand", Hand)
|
|
opponent_hand = self.query_one("#opponent-hand", Hand)
|
|
|
|
# Example: Deal 5 cards to each player
|
|
suits = ["♠", "♥", "♦", "♣"]
|
|
ranks = ["A", "2", "3", "4", "5"]
|
|
|
|
for i, rank in enumerate(ranks):
|
|
# Player cards (face up)
|
|
player_hand.add_card(Card(rank, suits[i % 4], face_up=True, card_id=f"player-{i}"))
|
|
# Opponent cards (face down)
|
|
opponent_hand.add_card(Card(rank, suits[i % 4], face_up=False, card_id=f"opp-{i}"))
|
|
|
|
def on_card_selected(self, event: Card.Selected) -> None:
|
|
"""Handle card selection."""
|
|
# Deselect previous
|
|
if self.selected_card:
|
|
self.selected_card.remove_class("selected")
|
|
|
|
# Select new card
|
|
self.selected_card = event.card
|
|
event.card.add_class("selected")
|
|
self.notify(f"Selected {event.card.rank} of {event.card.suit}")
|
|
|
|
def on_card_played(self, event: Card.Played) -> None:
|
|
"""Handle card being played."""
|
|
play_area = self.query_one("#play-area", PlayArea)
|
|
card = event.card
|
|
|
|
# Remove from hand
|
|
hand = card.parent
|
|
if isinstance(hand, Hand):
|
|
hand.remove_card(card)
|
|
|
|
# Move to play area
|
|
play_area.mount(card)
|
|
self.notify(f"Played {card.rank} of {card.suit}")
|
|
|
|
# Deselect
|
|
if self.selected_card == card:
|
|
self.selected_card = None
|
|
|
|
def action_next_turn(self) -> None:
|
|
"""Advance to next turn."""
|
|
game_state = self.query_one(GameState)
|
|
game_state.turn += 1
|
|
|
|
# Toggle current player
|
|
if game_state.current_player == "Player 1":
|
|
game_state.current_player = "Player 2"
|
|
else:
|
|
game_state.current_player = "Player 1"
|
|
|
|
self.notify(f"Turn {game_state.turn}")
|
|
|
|
def action_draw_card(self) -> None:
|
|
"""Draw a card (example)."""
|
|
player_hand = self.query_one("#player-hand", Hand)
|
|
# Example: Draw a random card
|
|
import random
|
|
|
|
suits = ["♠", "♥", "♦", "♣"]
|
|
ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
|
|
|
card = Card(random.choice(ranks), random.choice(suits), face_up=True)
|
|
player_hand.add_card(card)
|
|
self.notify("Drew a card")
|
|
|
|
def action_play_selected(self) -> None:
|
|
"""Play the currently selected card."""
|
|
if self.selected_card:
|
|
self.selected_card.play()
|
|
else:
|
|
self.notify("No card selected", severity="warning")
|
|
|
|
def action_toggle_select(self) -> None:
|
|
"""Select/deselect hovered card."""
|
|
# This is a simplified version - in practice you'd track the hovered card
|
|
if self.selected_card:
|
|
self.selected_card.remove_class("selected")
|
|
self.selected_card = None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = CardGameApp()
|
|
app.run()
|