Adds undo feature
This commit is contained in:
parent
b6e0405538
commit
3b3cfa9d8a
2 changed files with 125 additions and 2 deletions
|
|
@ -26,6 +26,7 @@ The project started as a novelty FreeCell prototype and grew into a playable, ke
|
|||
- `y` - pick up selected card or stack
|
||||
- `p` - drop held card or stack
|
||||
- `Enter` / `Space` - alternate pick up / drop
|
||||
- `u` - undo the last completed move
|
||||
- `?` - show a suggested move
|
||||
- `f` - quick move selected card to a free cell
|
||||
- `d` - quick move selected card to foundation
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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
|
||||
u : Undo the last completed move
|
||||
? : Show a suggested move
|
||||
Space or Enter : Pick up / drop a card or stack
|
||||
f : Quick move selected card to a free cell
|
||||
|
|
@ -109,6 +110,17 @@ class HintMove:
|
|||
score: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoveRecord:
|
||||
"""Describes one completed move that can be undone."""
|
||||
|
||||
source_type: str
|
||||
source_index: int
|
||||
dest_type: str
|
||||
dest_index: int
|
||||
cards: Tuple[Card, ...]
|
||||
|
||||
|
||||
class FreeCellGame:
|
||||
"""Holds the state and rules for a small FreeCell prototype."""
|
||||
|
||||
|
|
@ -118,6 +130,7 @@ class FreeCellGame:
|
|||
self.foundations: List[List[Card]] = [[] for _ in range(4)]
|
||||
self.held: Optional[HeldCards] = None
|
||||
self.hint_move: Optional[HintMove] = None
|
||||
self.move_history: List[MoveRecord] = []
|
||||
self.visual_mode = False
|
||||
self.visual_row = 0
|
||||
|
||||
|
|
@ -134,6 +147,86 @@ class FreeCellGame:
|
|||
"""Drop the currently remembered hint highlight."""
|
||||
self.hint_move = None
|
||||
|
||||
def record_move(
|
||||
self,
|
||||
source_type: str,
|
||||
source_index: int,
|
||||
dest_type: str,
|
||||
dest_index: int,
|
||||
cards: List[Card],
|
||||
) -> None:
|
||||
"""Remember one completed move so it can be undone later."""
|
||||
self.move_history.append(
|
||||
MoveRecord(
|
||||
source_type=source_type,
|
||||
source_index=source_index,
|
||||
dest_type=dest_type,
|
||||
dest_index=dest_index,
|
||||
cards=tuple(cards),
|
||||
)
|
||||
)
|
||||
|
||||
def _remove_cards_from_destination(self, move: MoveRecord) -> bool:
|
||||
"""Remove the recorded cards from a move destination during undo."""
|
||||
cards = list(move.cards)
|
||||
|
||||
if move.dest_type == "col":
|
||||
column = self.columns[move.dest_index]
|
||||
if len(column) < len(cards) or column[-len(cards) :] != cards:
|
||||
return False
|
||||
del column[-len(cards) :]
|
||||
return True
|
||||
|
||||
if move.dest_type == "free":
|
||||
if self.freecells[move.dest_index] != cards[0]:
|
||||
return False
|
||||
self.freecells[move.dest_index] = None
|
||||
return True
|
||||
|
||||
foundation = self.foundations[move.dest_index]
|
||||
if not foundation or foundation[-1] != cards[0]:
|
||||
return False
|
||||
foundation.pop()
|
||||
return True
|
||||
|
||||
def _restore_cards_to_source(self, move: MoveRecord) -> None:
|
||||
"""Put cards back at the recorded move source during undo."""
|
||||
cards = list(move.cards)
|
||||
|
||||
if move.source_type == "col":
|
||||
self.columns[move.source_index].extend(cards)
|
||||
return
|
||||
|
||||
self.freecells[move.source_index] = cards[0]
|
||||
|
||||
def undo_last_move(self) -> None:
|
||||
"""Undo the most recent completed move."""
|
||||
if self.held is not None:
|
||||
self.status = "Drop or cancel the held cards before undoing a move."
|
||||
return
|
||||
|
||||
if not self.move_history:
|
||||
self.status = "Nothing to undo."
|
||||
return
|
||||
|
||||
move = self.move_history[-1]
|
||||
if not self._remove_cards_from_destination(move):
|
||||
self.status = "Undo failed because the board no longer matches the last move."
|
||||
return
|
||||
|
||||
self.move_history.pop()
|
||||
|
||||
self._restore_cards_to_source(move)
|
||||
self.clear_hint()
|
||||
if self.visual_mode:
|
||||
self.exit_visual_mode()
|
||||
|
||||
move_count = len(move.cards)
|
||||
if move_count == 1:
|
||||
self.status = f"Undid move of {move.cards[0].short_name()}."
|
||||
else:
|
||||
self.status = f"Undid move of {move_count} cards."
|
||||
|
||||
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]
|
||||
|
|
@ -684,6 +777,13 @@ class FreeCellGame:
|
|||
if self.can_place_stack_on_column(cards, self.cursor_index):
|
||||
self.clear_hint()
|
||||
self.columns[self.cursor_index].extend(cards)
|
||||
self.record_move(
|
||||
self.held.source_type,
|
||||
self.held.source_index,
|
||||
"col",
|
||||
self.cursor_index,
|
||||
cards,
|
||||
)
|
||||
if moving_count == 1:
|
||||
self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}."
|
||||
else:
|
||||
|
|
@ -702,6 +802,13 @@ class FreeCellGame:
|
|||
if self.freecells[self.cursor_index] is None:
|
||||
self.clear_hint()
|
||||
self.freecells[self.cursor_index] = moving_top
|
||||
self.record_move(
|
||||
self.held.source_type,
|
||||
self.held.source_index,
|
||||
"free",
|
||||
self.cursor_index,
|
||||
cards,
|
||||
)
|
||||
self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}."
|
||||
self.held = None
|
||||
else:
|
||||
|
|
@ -717,6 +824,13 @@ class FreeCellGame:
|
|||
self.clear_hint()
|
||||
foundation = self.foundation_for_suit(moving_top.suit.name)
|
||||
foundation.append(moving_top)
|
||||
self.record_move(
|
||||
self.held.source_type,
|
||||
self.held.source_index,
|
||||
"foundation",
|
||||
SUITS.index(moving_top.suit),
|
||||
cards,
|
||||
)
|
||||
self.status = f"Moved {moving_top.short_name()} to foundation."
|
||||
self.held = None
|
||||
else:
|
||||
|
|
@ -745,7 +859,9 @@ class FreeCellGame:
|
|||
self.status = "Quick freecell move uses only the bottom card."
|
||||
return
|
||||
self.clear_hint()
|
||||
self.freecells[free_idx] = col.pop()
|
||||
moved_card = col.pop()
|
||||
self.freecells[free_idx] = moved_card
|
||||
self.record_move("col", self.cursor_index, "free", free_idx, [moved_card])
|
||||
self.status = f"Moved card to free cell {free_idx + 1}."
|
||||
return
|
||||
|
||||
|
|
@ -801,6 +917,8 @@ class FreeCellGame:
|
|||
else:
|
||||
self.freecells[source[1]] = None
|
||||
|
||||
self.record_move(source[0], source[1], "foundation", SUITS.index(card.suit), [card])
|
||||
|
||||
self.status = f"Moved {card.short_name()} to foundation."
|
||||
|
||||
def is_won(self) -> bool:
|
||||
|
|
@ -1107,7 +1225,7 @@ def draw_help(stdscr, max_y: int) -> None:
|
|||
y = max_y - 1
|
||||
stdscr.move(y, 0)
|
||||
stdscr.clrtoeol()
|
||||
help_text = "Move arrows/hjkl v select y/p/ENT/SPC move ? hint f freecell d foundation Esc cancel q quit"
|
||||
help_text = "Move arrows/hjkl v select y/p/ENT/SPC move u undo ? hint f freecell d foundation Esc cancel q quit"
|
||||
stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1))
|
||||
|
||||
|
||||
|
|
@ -1239,6 +1357,10 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
|
|||
game.drop()
|
||||
return True
|
||||
|
||||
if ch in (ord("u"), ord("U")):
|
||||
game.undo_last_move()
|
||||
return True
|
||||
|
||||
if ch == ord("?"):
|
||||
game.show_hint()
|
||||
return True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue