Initial Commit
This commit is contained in:
commit
26dff88a5b
2 changed files with 795 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*pycache*
|
||||
794
linux-freecell.py
Normal file
794
linux-freecell.py
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue