mcfreecell/linux-freecell.py

794 lines
25 KiB
Python
Raw Normal View History

2026-03-18 17:25:39 -04:00
#!/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()