Vim style controls added
This commit is contained in:
parent
26dff88a5b
commit
9aa5c5ef06
1 changed files with 147 additions and 45 deletions
|
|
@ -7,8 +7,10 @@ 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
|
||||
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
|
||||
|
|
@ -102,7 +104,8 @@ class FreeCellGame:
|
|||
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
|
||||
self.visual_mode = False
|
||||
self.visual_row = 0
|
||||
|
||||
# Cursor zones:
|
||||
# "top" -> freecells + foundations
|
||||
|
|
@ -110,7 +113,7 @@ class FreeCellGame:
|
|||
self.cursor_zone = "bottom"
|
||||
self.cursor_index = 0
|
||||
|
||||
self.status = "Arrow keys move, Enter picks up/drops, [ ] change stack depth."
|
||||
self.status = "Arrow keys move, v selects a stack, Enter picks up/drops."
|
||||
self._deal_new_game()
|
||||
|
||||
def _deal_new_game(self) -> None:
|
||||
|
|
@ -160,25 +163,55 @@ class FreeCellGame:
|
|||
)
|
||||
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."
|
||||
|
||||
offset = min(self.selection_offsets[col_index], len(column) - 1)
|
||||
moving_count = offset + 1
|
||||
cards = column[-moving_count:]
|
||||
if self.visual_mode and self.cursor_zone == "bottom":
|
||||
return self.current_visual_stack()
|
||||
|
||||
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."
|
||||
cards = [column[-1]]
|
||||
|
||||
return cards, ""
|
||||
|
||||
|
|
@ -236,35 +269,62 @@ class FreeCellGame:
|
|||
return i
|
||||
return None
|
||||
|
||||
def adjust_selection_offset(self, delta: int) -> None:
|
||||
"""Grow or shrink the selected tableau stack depth for the current column."""
|
||||
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 = "Stack depth only applies to tableau columns."
|
||||
self.status = "Visual mode only works on 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)
|
||||
self.visual_mode = True
|
||||
self.visual_row = len(column) - 1
|
||||
cards, reason = self.current_visual_stack()
|
||||
if reason:
|
||||
self.status = f"Selecting {selected_count} cards. {reason}"
|
||||
self.status = f"Visual: {len(cards)} cards selected. {reason}"
|
||||
else:
|
||||
self.status = (
|
||||
f"Selecting {selected_count} cards from column {self.cursor_index + 1}."
|
||||
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."
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
@ -300,6 +360,7 @@ class FreeCellGame:
|
|||
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
|
||||
|
|
@ -406,7 +467,7 @@ class FreeCellGame:
|
|||
if not col:
|
||||
self.status = "That column is empty."
|
||||
return
|
||||
if self.selection_offsets[self.cursor_index] > 0:
|
||||
if self.visual_mode:
|
||||
self.status = "Quick freecell move uses only the bottom card."
|
||||
return
|
||||
self.freecells[free_idx] = col.pop()
|
||||
|
|
@ -436,7 +497,7 @@ class FreeCellGame:
|
|||
if self.cursor_zone == "bottom":
|
||||
col = self.columns[self.cursor_index]
|
||||
if col:
|
||||
if self.selection_offsets[self.cursor_index] > 0:
|
||||
if self.visual_mode:
|
||||
self.status = "Quick foundation move uses only the bottom card."
|
||||
return
|
||||
card = col[-1]
|
||||
|
|
@ -589,7 +650,11 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
|
|||
|
||||
selected_cards: List[Card] = []
|
||||
selection_reason = ""
|
||||
if selected:
|
||||
valid_tail_start = game.valid_tail_start_index(col_idx)
|
||||
if valid_tail_start is None:
|
||||
valid_tail_start = len(column)
|
||||
|
||||
if selected and game.visual_mode:
|
||||
selected_cards, selection_reason = game.selected_stack_from_column(col_idx)
|
||||
|
||||
selected_start = (
|
||||
|
|
@ -599,13 +664,22 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
|
|||
for row_idx, card in enumerate(column):
|
||||
y = COL_Y + 1 + row_idx
|
||||
|
||||
in_selected_stack = selected and row_idx >= selected_start
|
||||
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
|
||||
)
|
||||
if in_selected_stack:
|
||||
attr = curses.color_pair(4)
|
||||
if selection_reason:
|
||||
attr |= curses.A_DIM
|
||||
else:
|
||||
attr |= 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))
|
||||
|
||||
|
|
@ -654,7 +728,7 @@ def draw_help(stdscr, max_y: int) -> None:
|
|||
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"
|
||||
help_text = "Move: arrows/hjkl v: visual y/p: pick/drop 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))
|
||||
|
||||
|
||||
|
|
@ -666,6 +740,9 @@ def render(stdscr, game: FreeCellGame) -> None:
|
|||
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)
|
||||
|
|
@ -688,16 +765,26 @@ 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:
|
||||
|
|
@ -729,12 +816,11 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
|
|||
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)
|
||||
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
|
||||
|
|
@ -745,6 +831,20 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
|
|||
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
|
||||
|
||||
# Quick helpers
|
||||
if ch in (ord("f"), ord("F")):
|
||||
game.quick_to_freecell()
|
||||
|
|
@ -756,7 +856,9 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
|
|||
|
||||
# Escape: cancel held card by restoring it
|
||||
if ch == 27:
|
||||
if game.held is not None:
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue