#!/usr/bin/env python3 """ linux_freecell.py A curses-based FreeCell prototype that uses Linux distro themed suits. Controls -------- Arrow keys / h j k l : Move cursor v : Enter / exit tableau visual selection y : Pick up selected card / stack p : Drop held card / stack ? : Show a suggested move Space or Enter : Pick up / drop a card or stack f : Quick move selected card to a free cell d : Quick move selected card to foundation Esc : Cancel held move q : Quit Notes ----- - This is a prototype, not a complete polished game. - It assumes your terminal can display the suit glyphs you choose. - If a Nerd Font glyph does not render correctly, replace the glyph strings in SUITS below with safer ASCII or Unicode symbols. """ import curses import random from dataclasses import dataclass from typing import List, Optional, Tuple # --------------------------------------------------------------------------- # Card model # --------------------------------------------------------------------------- RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] RANK_VALUES = {rank: i + 1 for i, rank in enumerate(RANKS)} @dataclass(frozen=True) class SuitInfo: """Describes one suit in our Linux-themed deck.""" name: str glyph: str color_group: str # "red" or "black" @dataclass class Card: """Represents one playing card.""" rank: str suit: SuitInfo @property def value(self) -> int: return RANK_VALUES[self.rank] @property def color_group(self) -> str: return self.suit.color_group def short_name(self) -> str: return f"{self.rank}{self.suit.glyph}" # --------------------------------------------------------------------------- # Suit set # # Replace glyphs below with Nerd Font icons you prefer. # These defaults are deliberately conservative so they are more likely to # render in ordinary terminals. # --------------------------------------------------------------------------- SUITS = [ SuitInfo(name="Debian", glyph="", color_group="red"), SuitInfo(name="Gentoo", glyph="", color_group="red"), SuitInfo(name="Arch", glyph="󰣇", color_group="black"), SuitInfo(name="Slackware", glyph="", color_group="black"), ] # --------------------------------------------------------------------------- # Board / game state # --------------------------------------------------------------------------- @dataclass class HeldCards: """Represents one or more cards currently picked up by the player.""" cards: List[Card] source_type: str # "col" or "free" source_index: int @dataclass(frozen=True) class HintMove: """Describes one suggested legal move for the current board.""" source_type: str source_index: int dest_type: str dest_index: int cards: Tuple[Card, ...] score: int class FreeCellGame: """Holds the state and rules for a small FreeCell prototype.""" def __init__(self) -> None: self.columns: List[List[Card]] = [[] for _ in range(8)] self.freecells: List[Optional[Card]] = [None] * 4 self.foundations: List[List[Card]] = [[] for _ in range(4)] self.held: Optional[HeldCards] = None self.hint_move: Optional[HintMove] = None self.visual_mode = False self.visual_row = 0 # Cursor zones: # "top" -> freecells + foundations # "bottom" -> tableau columns self.cursor_zone = "bottom" self.cursor_index = 0 self.status = "Arrow keys move, v selects a stack, ? shows a hint." self._deal_new_game() def clear_hint(self) -> None: """Drop the currently remembered hint highlight.""" self.hint_move = None def _deal_new_game(self) -> None: """Build and shuffle a standard 52-card deck, then deal to 8 columns.""" deck: List[Card] = [Card(rank, suit) for suit in SUITS for rank in RANKS] random.shuffle(deck) for i, card in enumerate(deck): self.columns[i % 8].append(card) # ----------------------------------------------------------------------- # Rules helpers # ----------------------------------------------------------------------- def is_valid_tableau_sequence(self, cards: List[Card]) -> bool: """Return True if cards form a descending alternating-color stack.""" if not cards: return False for upper, lower in zip(cards, cards[1:]): if upper.value != lower.value + 1: return False if upper.color_group == lower.color_group: return False return True def count_empty_freecells(self) -> int: """Count currently open freecells.""" return sum(1 for cell in self.freecells if cell is None) def count_empty_columns(self, exclude_index: Optional[int] = None) -> int: """Count empty tableau columns, optionally excluding one column.""" empty_count = 0 for col_index, column in enumerate(self.columns): if col_index == exclude_index: continue if not column: empty_count += 1 return empty_count def max_movable_cards(self, source_col: int, moving_count: int) -> int: """Return the maximum legal stack size movable from a tableau column.""" source_becomes_empty = moving_count == len(self.columns[source_col]) empty_columns = self.count_empty_columns( exclude_index=source_col if source_becomes_empty else None ) return (self.count_empty_freecells() + 1) * (2**empty_columns) def valid_tail_start_index(self, col_index: int) -> Optional[int]: """Return the first row of the bottom valid descending alternating tail.""" column = self.columns[col_index] if not column: return None start = len(column) - 1 while start > 0: upper = column[start - 1] lower = column[start] if upper.value != lower.value + 1: break if upper.color_group == lower.color_group: break start -= 1 return start def current_visual_stack(self) -> Tuple[List[Card], str]: """Return the currently highlighted tableau stack while in visual mode.""" if self.cursor_zone != "bottom": return [], "Visual mode only works on tableau columns." column = self.columns[self.cursor_index] if not column: return [], "That column is empty." tail_start = self.valid_tail_start_index(self.cursor_index) if tail_start is None: return [], "That column is empty." start_index = max(tail_start, min(self.visual_row, len(column) - 1)) cards = column[start_index:] max_cards = self.max_movable_cards(self.cursor_index, len(cards)) if len(cards) > max_cards: return cards, f"Move limit is {max_cards} cards with current free space." return cards, "" def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]: """Return the currently selected stack slice for a tableau column.""" column = self.columns[col_index] if not column: return [], "That column is empty." if self.visual_mode and self.cursor_zone == "bottom": return self.current_visual_stack() cards = [column[-1]] return cards, "" def can_place_stack_on_column(self, cards: List[Card], col_index: int) -> bool: """Return True if a valid held stack can be dropped on a tableau column.""" if not self.is_valid_tableau_sequence(cards): return False column = self.columns[col_index] if not column: return True moving_top = cards[0] destination_top = column[-1] return ( destination_top.value == moving_top.value + 1 and destination_top.color_group != moving_top.color_group ) def can_place_on_column(self, card: Card, col_index: int) -> bool: """ FreeCell tableau rule: - Empty column accepts any card. - Otherwise, placed card must be one rank lower than destination top, and opposite color group. """ column = self.columns[col_index] if not column: return True top = column[-1] return top.value == card.value + 1 and top.color_group != card.color_group def can_move_to_foundation(self, card: Card) -> bool: """ Foundation rule: - Each suit builds upward from Ace to King. """ foundation = self.foundation_for_suit(card.suit.name) if not foundation: return card.rank == "A" top = foundation[-1] return top.suit.name == card.suit.name and card.value == top.value + 1 def foundation_for_suit(self, suit_name: str) -> List[Card]: """Return the foundation pile corresponding to a given suit.""" suit_index = next(i for i, s in enumerate(SUITS) if s.name == suit_name) return self.foundations[suit_index] def first_empty_freecell(self) -> Optional[int]: """Return the first free freecell slot, or None if full.""" for i, cell in enumerate(self.freecells): if cell is None: return i return None def count_hidden_low_cards(self) -> int: """Count aces and twos that are not currently exposed.""" count = 0 for column in self.columns: for card in column[:-1]: if card.value <= 2: count += 1 return count def card_can_help_foundation(self, card: Card) -> bool: """Return True for low cards that usually unlock early progress.""" return card.value <= 3 def describe_hint_move(self, move: HintMove) -> str: """Return a compact human-readable hint description.""" if move.source_type == "col": source_name = f"column {move.source_index + 1}" else: source_name = f"free cell {move.source_index + 1}" if move.dest_type == "col": dest_name = f"column {move.dest_index + 1}" elif move.dest_type == "free": dest_name = f"free cell {move.dest_index + 1}" else: dest_name = f"{SUITS[move.dest_index].name} foundation" if len(move.cards) == 1: return ( f"move {move.cards[0].short_name()} from {source_name} to {dest_name}" ) return ( f"move {len(move.cards)} cards {move.cards[0].short_name()}..{move.cards[-1].short_name()} " f"from {source_name} to {dest_name}" ) def score_hint_move( self, source_type: str, source_index: int, dest_type: str, dest_index: int, cards: List[Card], ) -> int: """Assign a cheap deterministic score to a legal move.""" score = 0 moving_top = cards[0] source_column = self.columns[source_index] if source_type == "col" else None source_exposes = None hidden_low_before = self.count_hidden_low_cards() if source_type == "col": assert source_column is not None remaining = source_column[: -len(cards)] source_exposes = remaining[-1] if remaining else None if not remaining: score += 250 if source_exposes and self.card_can_help_foundation(source_exposes): score += 320 if dest_type == "foundation": score += 1000 + moving_top.value * 5 elif dest_type == "col": destination = self.columns[dest_index] if destination: score += 220 + len(cards) * 25 if len(cards) > 1: score += 120 else: score += 60 if len(cards) > 1 else -120 elif dest_type == "free": score -= 80 if moving_top.value <= 3: score += 140 if source_exposes and self.can_move_to_foundation(source_exposes): score += 180 if moving_top.value <= 3: score += 80 hidden_low_after = hidden_low_before if source_type == "col" and source_exposes and source_exposes.value <= 2: hidden_low_after -= 1 score += (hidden_low_before - hidden_low_after) * 120 if source_type == "free" and dest_type == "col": score += 200 return score def enumerate_hint_moves(self) -> List[HintMove]: """Enumerate cheap one-ply legal moves for hinting.""" moves: List[HintMove] = [] for col_index, column in enumerate(self.columns): if not column: continue top_card = column[-1] if self.can_move_to_foundation(top_card): cards = (top_card,) moves.append( HintMove( source_type="col", source_index=col_index, dest_type="foundation", dest_index=SUITS.index(top_card.suit), cards=cards, score=self.score_hint_move( "col", col_index, "foundation", SUITS.index(top_card.suit), [top_card], ), ) ) free_idx = self.first_empty_freecell() if free_idx is not None: cards = (top_card,) moves.append( HintMove( source_type="col", source_index=col_index, dest_type="free", dest_index=free_idx, cards=cards, score=self.score_hint_move( "col", col_index, "free", free_idx, [top_card] ), ) ) tail_start = self.valid_tail_start_index(col_index) if tail_start is None: continue for start_index in range(len(column) - 1, tail_start - 1, -1): cards_list = column[start_index:] if len(cards_list) > self.max_movable_cards(col_index, len(cards_list)): continue for dest_index in range(8): if dest_index == col_index: continue if self.can_place_stack_on_column(cards_list, dest_index): moves.append( HintMove( source_type="col", source_index=col_index, dest_type="col", dest_index=dest_index, cards=tuple(cards_list), score=self.score_hint_move( "col", col_index, "col", dest_index, cards_list ), ) ) for free_index, card in enumerate(self.freecells): if card is None: continue if self.can_move_to_foundation(card): moves.append( HintMove( source_type="free", source_index=free_index, dest_type="foundation", dest_index=SUITS.index(card.suit), cards=(card,), score=self.score_hint_move( "free", free_index, "foundation", SUITS.index(card.suit), [card], ), ) ) for dest_index in range(8): if self.can_place_stack_on_column([card], dest_index): moves.append( HintMove( source_type="free", source_index=free_index, dest_type="col", dest_index=dest_index, cards=(card,), score=self.score_hint_move( "free", free_index, "col", dest_index, [card] ), ) ) moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move))) return moves def show_hint(self) -> None: """Pick the best cheap legal move and remember it for highlighting.""" if self.held is not None: self.status = "Drop or cancel the held cards before asking for a hint." return moves = self.enumerate_hint_moves() if not moves: self.hint_move = None self.status = "Hint: no obvious legal move found." return self.hint_move = moves[0] self.status = f"Hint: {self.describe_hint_move(self.hint_move)}." def hinted_freecell(self, index: int) -> bool: """Return True when a freecell is part of the current hint.""" if self.hint_move is None: return False return ( self.hint_move.source_type == "free" and self.hint_move.source_index == index ) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index) def hinted_foundation(self, index: int) -> bool: """Return True when a foundation is the current hint destination.""" return ( self.hint_move is not None and self.hint_move.dest_type == "foundation" and self.hint_move.dest_index == index ) def hinted_column_source_start(self, index: int) -> Optional[int]: """Return the start row for a hinted tableau source stack.""" if self.hint_move is None: return None if self.hint_move.source_type != "col" or self.hint_move.source_index != index: return None return len(self.columns[index]) - len(self.hint_move.cards) def hinted_column_destination(self, index: int) -> bool: """Return True when a tableau column is the hinted destination.""" return ( self.hint_move is not None and self.hint_move.dest_type == "col" and self.hint_move.dest_index == index ) def enter_visual_mode(self) -> None: """Enter tableau visual selection mode on the current column.""" if self.held is not None: self.status = "Drop the held cards before selecting a new stack." return if self.cursor_zone != "bottom": self.status = "Visual mode only works on tableau columns." return column = self.columns[self.cursor_index] if not column: self.status = "That column is empty." return self.visual_mode = True self.visual_row = len(column) - 1 cards, reason = self.current_visual_stack() if reason: self.status = f"Visual: {len(cards)} cards selected. {reason}" else: self.status = ( f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." ) def exit_visual_mode(self, message: Optional[str] = None) -> None: """Leave tableau visual selection mode.""" self.visual_mode = False self.visual_row = 0 if message is not None: self.status = message def move_visual_selection(self, delta: int) -> None: """Adjust the visual selection start within the valid tail.""" if not self.visual_mode: self.status = "Press v to start selecting a stack." return column = self.columns[self.cursor_index] tail_start = self.valid_tail_start_index(self.cursor_index) if not column or tail_start is None: self.exit_visual_mode("That column is empty.") return old_row = self.visual_row self.visual_row = max(tail_start, min(len(column) - 1, self.visual_row + delta)) cards, reason = self.current_visual_stack() if self.visual_row == old_row: self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}." return if reason: self.status = f"Visual: {len(cards)} cards selected. {reason}" else: self.status = ( f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." ) # ----------------------------------------------------------------------- # Movement / interactions # ----------------------------------------------------------------------- def pick_up(self) -> None: """ Pick up the bottom visible card from either: - a tableau column - a freecell slot """ if self.held is not None: self.status = "You are already holding cards." return if self.cursor_zone == "bottom": cards, reason = self.selected_stack_from_column(self.cursor_index) if not cards: self.status = reason return if reason: self.status = reason return moving_count = len(cards) self.clear_hint() del self.columns[self.cursor_index][-moving_count:] self.held = HeldCards( cards=cards, source_type="col", source_index=self.cursor_index ) if moving_count == 1: self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}." else: self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." self.exit_visual_mode() return # top zone: indexes 0..3 freecells, 4..7 foundations if self.cursor_index < 4: card = self.freecells[self.cursor_index] if card is None: self.status = "That free cell is empty." return self.clear_hint() self.freecells[self.cursor_index] = None self.held = HeldCards( cards=[card], source_type="free", source_index=self.cursor_index ) self.status = ( f"Picked up {card.short_name()} from free cell {self.cursor_index + 1}." ) return self.status = "Cannot pick up directly from foundations." def restore_held(self) -> None: """Put the held card back where it came from if a drop fails.""" if self.held is None: return cards = self.held.cards if self.held.source_type == "col": self.columns[self.held.source_index].extend(cards) elif self.held.source_type == "free": self.freecells[self.held.source_index] = cards[0] self.held = None self.clear_hint() def drop(self) -> None: """ Drop the held card onto: - a column - a freecell - a foundation depending on the current cursor position and legal rules. """ if self.held is None: self.status = "You are not holding a card." return cards = self.held.cards moving_count = len(cards) moving_top = cards[0] if self.cursor_zone == "bottom": if self.can_place_stack_on_column(cards, self.cursor_index): self.clear_hint() self.columns[self.cursor_index].extend(cards) if moving_count == 1: self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." else: self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}." self.held = None else: self.status = "Illegal move for tableau column." return # top zone if self.cursor_index < 4: # drop to freecell if moving_count != 1: self.status = "Free cells accept only one card." return if self.freecells[self.cursor_index] is None: self.clear_hint() self.freecells[self.cursor_index] = moving_top self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." self.held = None else: self.status = "That free cell is occupied." return # drop to foundation if moving_count != 1: self.status = "Foundations accept only one card at a time." return if self.can_move_to_foundation(moving_top): self.clear_hint() foundation = self.foundation_for_suit(moving_top.suit.name) foundation.append(moving_top) self.status = f"Moved {moving_top.short_name()} to foundation." self.held = None else: self.status = "Illegal move for foundation." def quick_to_freecell(self) -> None: """ Move the currently selected bottom/freecell card into the first open freecell, if possible. """ if self.held is not None: self.status = "Drop the held card first." return free_idx = self.first_empty_freecell() if free_idx is None: self.status = "No free freecells available." return if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if not col: self.status = "That column is empty." return if self.visual_mode: self.status = "Quick freecell move uses only the bottom card." return self.clear_hint() self.freecells[free_idx] = col.pop() self.status = f"Moved card to free cell {free_idx + 1}." return if self.cursor_zone == "top" and self.cursor_index < 4: self.status = "Already on a free cell." return self.status = "Select a tableau card first." def quick_to_foundation(self) -> None: """ Move the selected card directly to foundation if legal. Works from: - column bottoms - freecells """ if self.held is not None: self.status = "Drop the held card first." return card: Optional[Card] = None source: Optional[Tuple[str, int]] = None if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if col: if self.visual_mode: self.status = "Quick foundation move uses only the bottom card." return card = col[-1] source = ("col", self.cursor_index) elif self.cursor_zone == "top" and self.cursor_index < 4: fc = self.freecells[self.cursor_index] if fc is not None: card = fc source = ("free", self.cursor_index) if card is None or source is None: self.status = "No movable card selected." return if not self.can_move_to_foundation(card): self.status = "That card cannot go to foundation yet." return foundation = self.foundation_for_suit(card.suit.name) self.clear_hint() foundation.append(card) if source[0] == "col": self.columns[source[1]].pop() else: self.freecells[source[1]] = None self.status = f"Moved {card.short_name()} to foundation." def is_won(self) -> bool: """Return True when all cards are in foundations.""" return sum(len(f) for f in self.foundations) == 52 # --------------------------------------------------------------------------- # UI rendering # --------------------------------------------------------------------------- CARD_W = 7 TOP_Y = 1 COL_Y = 6 def init_colors() -> None: """Initialize curses color pairs.""" curses.start_color() curses.use_default_colors() # Pair 1: regular text curses.init_pair(1, curses.COLOR_WHITE, -1) # Pair 2: red cards / symbols curses.init_pair(2, curses.COLOR_RED, -1) # Pair 3: black-group cards rendered as cyan for visibility on dark terms curses.init_pair(3, curses.COLOR_CYAN, -1) # Pair 4: highlighted cursor curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Pair 5: held card curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN) # Pair 6: hint highlight curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) def card_color_pair(card: Card) -> int: """Choose a color pair for a card.""" return 2 if card.color_group == "red" else 3 def draw_box(stdscr, y: int, x: int, w: int, label: str, attr: int) -> None: """Draw a simple one-line boxed area.""" text = f"[{label:^{w - 2}}]" stdscr.addnstr(y, x, text.ljust(w), w, attr) def draw_card_label(card: Optional[Card]) -> str: """Return a compact label used to display a card or empty slot.""" if card is None: return " " return card.short_name() def draw_top_row(stdscr, game: FreeCellGame) -> None: """Draw freecells and foundations.""" stdscr.addstr(TOP_Y - 1, 2, "Free Cells", curses.color_pair(1) | curses.A_BOLD) stdscr.addstr(TOP_Y - 1, 40, "Foundations", curses.color_pair(1) | curses.A_BOLD) # Freecells at indexes 0..3 for i in range(4): x = 2 + i * (CARD_W + 1) selected = game.cursor_zone == "top" and game.cursor_index == i hinted = game.hinted_freecell(i) attr = ( curses.color_pair(4) if selected else curses.color_pair(6) if hinted else curses.color_pair(1) ) card = game.freecells[i] draw_box(stdscr, TOP_Y, x, CARD_W, draw_card_label(card), attr) if card: stdscr.addnstr( TOP_Y, x + 1, card.short_name(), CARD_W - 2, curses.color_pair(card_color_pair(card)), ) # Foundations at indexes 4..7 in cursor space for i in range(4): x = 40 + i * (CARD_W + 1) selected = game.cursor_zone == "top" and game.cursor_index == i + 4 hinted = game.hinted_foundation(i) attr = ( curses.color_pair(4) if selected else curses.color_pair(6) if hinted else curses.color_pair(1) ) foundation = game.foundations[i] top_card = foundation[-1] if foundation else None label = draw_card_label(top_card) if top_card else SUITS[i].glyph draw_box(stdscr, TOP_Y, x, CARD_W, label, attr) if top_card: stdscr.addnstr( TOP_Y, x + 1, top_card.short_name(), CARD_W - 2, curses.color_pair(card_color_pair(top_card)), ) else: # Show suit glyph placeholder in foundation slot color dummy_color = 2 if SUITS[i].color_group == "red" else 3 stdscr.addnstr( TOP_Y, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color) ) def draw_columns(stdscr, game: FreeCellGame) -> None: """Draw the tableau columns.""" stdscr.addstr(COL_Y - 1, 2, "Tableau", curses.color_pair(1) | curses.A_BOLD) max_height = max((len(col) for col in game.columns), default=0) for col_idx in range(8): x = 2 + col_idx * 9 selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx hinted_dest = game.hinted_column_destination(col_idx) header_attr = ( curses.color_pair(4) if selected else curses.color_pair(6) if hinted_dest else curses.color_pair(1) ) draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) column = game.columns[col_idx] if not column: stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1)) continue selected_cards: List[Card] = [] selection_reason = "" valid_tail_start = game.valid_tail_start_index(col_idx) if valid_tail_start is None: valid_tail_start = len(column) hint_source_start = game.hinted_column_source_start(col_idx) if selected and game.visual_mode: selected_cards, selection_reason = game.selected_stack_from_column(col_idx) selected_start = ( len(column) - len(selected_cards) if selected_cards else len(column) ) for row_idx, card in enumerate(column): y = COL_Y + 1 + row_idx in_valid_tail = ( selected and game.visual_mode and row_idx >= valid_tail_start ) in_selected_stack = ( selected and game.visual_mode and row_idx >= selected_start ) in_hint_source = ( hint_source_start is not None and row_idx >= hint_source_start ) if in_selected_stack: attr = curses.color_pair(4) if selection_reason: attr |= curses.A_DIM else: attr |= curses.A_BOLD elif in_hint_source: attr = curses.color_pair(6) | curses.A_BOLD elif in_valid_tail: attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM elif selected and row_idx == len(column) - 1: attr = curses.color_pair(4) else: attr = curses.color_pair(card_color_pair(card)) stdscr.addnstr( y, x + 1, card.short_name().ljust(CARD_W - 2), CARD_W - 2, attr ) # Clear a few lines under the tallest used area to avoid visual leftovers. for extra_y in range(COL_Y + 2 + max_height, COL_Y + 8 + max_height): stdscr.move(extra_y, 0) stdscr.clrtoeol() def draw_held(stdscr, game: FreeCellGame, max_y: int) -> None: """Draw the currently held card stack near the bottom of the screen.""" y = max_y - 3 stdscr.move(y, 0) stdscr.clrtoeol() if game.held is None: stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1)) return stdscr.addstr(y, 2, "Held: ", curses.color_pair(1)) cards = game.held.cards if len(cards) == 1: stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD) return summary = f"{len(cards)} cards ({cards[0].short_name()}..{cards[-1].short_name()})" stdscr.addnstr( y, 8, summary, max(0, curses.COLS - 10), curses.color_pair(5) | curses.A_BOLD ) def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None: """Draw a one-line status message.""" y = max_y - 2 stdscr.move(y, 0) stdscr.clrtoeol() stdscr.addnstr(y, 2, game.status, max(0, curses.COLS - 4), curses.color_pair(1)) def draw_help(stdscr, max_y: int) -> None: """Draw a compact help line.""" y = max_y - 1 stdscr.move(y, 0) stdscr.clrtoeol() help_text = "Move: arrows/hjkl v: visual y/p: pick/drop ?: hint Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit" stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) def render(stdscr, game: FreeCellGame) -> None: """Redraw the full screen.""" stdscr.erase() max_y, _ = stdscr.getmaxyx() title = "Linux FreeCell Prototype" stdscr.addstr(0, 2, title, curses.color_pair(1) | curses.A_BOLD) if game.visual_mode: stdscr.addstr(0, 30, "-- VISUAL --", curses.color_pair(5) | curses.A_BOLD) draw_top_row(stdscr, game) draw_columns(stdscr, game) draw_held(stdscr, game, max_y) draw_status(stdscr, game, max_y) draw_help(stdscr, max_y) if game.is_won(): stdscr.addstr(3, 2, "You won.", curses.color_pair(5) | curses.A_BOLD) stdscr.refresh() # --------------------------------------------------------------------------- # Input handling # --------------------------------------------------------------------------- def move_cursor(game: FreeCellGame, dy: int, dx: int) -> None: """ Move between the top zone and bottom zone, and across valid slot ranges. """ if dy < 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(-1) return game.cursor_zone = "top" game.cursor_index = min(game.cursor_index, 7) return if dy > 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(1) return game.cursor_zone = "bottom" game.cursor_index = min(game.cursor_index, 7) return # Horizontal motion if game.visual_mode and dx != 0: game.exit_visual_mode("Visual selection cancelled.") return if game.cursor_zone == "top": game.cursor_index = max(0, min(7, game.cursor_index + dx)) else: game.cursor_index = max(0, min(7, game.cursor_index + dx)) def handle_key(game: FreeCellGame, ch: int) -> bool: """ Handle one input event. Returns False if the game should exit. """ if ch in (ord("q"), ord("Q")): return False # Movement if ch in (curses.KEY_LEFT, ord("h")): move_cursor(game, 0, -1) return True if ch in (curses.KEY_RIGHT, ord("l")): move_cursor(game, 0, 1) return True if ch in (curses.KEY_UP, ord("k")): move_cursor(game, -1, 0) return True if ch in (curses.KEY_DOWN, ord("j")): move_cursor(game, 1, 0) return True if ch in (ord("v"), ord("V")): if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") else: game.enter_visual_mode() return True # Pick up / drop if ch in (curses.KEY_ENTER, 10, 13, ord(" ")): if game.held is None: game.pick_up() else: game.drop() return True if ch in (ord("y"), ord("Y")): if game.held is not None: game.status = "You are already holding cards." else: game.pick_up() return True if ch in (ord("p"), ord("P")): if game.held is None: game.status = "You are not holding any cards." else: game.drop() return True if ch == ord("?"): game.show_hint() return True # Quick helpers if ch in (ord("f"), ord("F")): game.quick_to_freecell() return True if ch in (ord("d"), ord("D")): game.quick_to_foundation() return True # Escape: cancel held card by restoring it if ch == 27: if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") elif game.held is not None: game.restore_held() game.status = "Cancelled move." return True return True # --------------------------------------------------------------------------- # Main curses loop # --------------------------------------------------------------------------- def curses_main(stdscr) -> None: """Main UI entry point.""" curses.curs_set(0) stdscr.keypad(True) init_colors() game = FreeCellGame() while True: render(stdscr, game) ch = stdscr.getch() if not handle_key(game, ch): break def main() -> None: """Program entry point.""" curses.wrapper(curses_main) if __name__ == "__main__": main()