#!/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 Space or Enter : Pick up / drop a card or stack [ / ] : Expand / shrink tableau stack selection 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 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.selection_offsets: List[int] = [0] * 8 # Cursor zones: # "top" -> freecells + foundations # "bottom" -> tableau columns self.cursor_zone = "bottom" self.cursor_index = 0 self.status = "Arrow keys move, Enter picks up/drops, [ ] change stack depth." self._deal_new_game() 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 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." offset = min(self.selection_offsets[col_index], len(column) - 1) moving_count = offset + 1 cards = column[-moving_count:] if not self.is_valid_tableau_sequence(cards): return ( cards, "Selected cards are not a valid alternating descending sequence.", ) max_cards = self.max_movable_cards(col_index, moving_count) if moving_count > max_cards: return cards, f"Move limit is {max_cards} cards with current free space." 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 adjust_selection_offset(self, delta: int) -> None: """Grow or shrink the selected tableau stack depth for the current column.""" if self.cursor_zone != "bottom": self.status = "Stack depth only applies to tableau columns." return column = self.columns[self.cursor_index] if not column: self.selection_offsets[self.cursor_index] = 0 self.status = "That column is empty." return max_offset = len(column) - 1 old_offset = self.selection_offsets[self.cursor_index] new_offset = max(0, min(max_offset, old_offset + delta)) self.selection_offsets[self.cursor_index] = new_offset if new_offset == old_offset: selected_count = new_offset + 1 self.status = f"Selection stays at {selected_count} card{'s' if selected_count != 1 else ''}." return selected_count = new_offset + 1 _, reason = self.selected_stack_from_column(self.cursor_index) if reason: self.status = f"Selecting {selected_count} cards. {reason}" else: self.status = ( f"Selecting {selected_count} cards from column {self.cursor_index + 1}." ) # ----------------------------------------------------------------------- # 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) 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}." 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.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 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.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.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): 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.selection_offsets[self.cursor_index] > 0: self.status = "Quick freecell move uses only the bottom card." return 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.selection_offsets[self.cursor_index] > 0: 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) 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) 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 attr = curses.color_pair(4) if selected 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 attr = curses.color_pair(4) if selected 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 header_attr = curses.color_pair(4) if selected 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 = "" if selected: 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_selected_stack = selected and row_idx >= selected_start if in_selected_stack: attr = curses.color_pair(4) if selection_reason: attr |= curses.A_DIM else: attr |= curses.A_BOLD 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 Pick/Drop: Enter/Space [ / ]: stack size 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) 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: game.cursor_zone = "top" game.cursor_index = min(game.cursor_index, 7) return if dy > 0: game.cursor_zone = "bottom" game.cursor_index = min(game.cursor_index, 7) return # Horizontal motion 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 == ord("["): game.adjust_selection_offset(1) return True if ch == ord("]"): game.adjust_selection_offset(-1) 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 # 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.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()