Smooth two-step move interaction with visual feedback

- Type source square (e.g. E2), see piece selected (blue highlight)
- Legal destinations shown in green with dots
- Type destination to complete move
- ESC to cancel selection
- Click same piece to deselect
- Much smoother than typing full move
This commit is contained in:
Greg Hendrickson
2026-01-27 20:37:36 +00:00
parent 38c83fbe51
commit d698caf846

View File

@@ -281,132 +281,78 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '', 'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
} }
# Selection state for two-step moves
selected_square = None # None or chess square int
legal_targets = set() # Set of legal destination squares
def get_cell_style(square, piece, is_light):
"""Get ANSI style for a cell based on selection state."""
is_selected = (square == selected_square)
is_target = (square in legal_targets)
# Background colors
if is_selected:
bg = "\033[48;5;33m" # Blue for selected
elif is_target:
bg = "\033[48;5;28m" # Green for legal moves
elif is_light:
bg = "" # Default terminal
else:
bg = "\033[48;5;236m" # Dark grey
bg_end = "\033[0m" if bg else ""
return bg, bg_end
def render_board(): def render_board():
"""Large, terminal-filling chess board.""" """Large, terminal-filling chess board with selection highlighting."""
nonlocal status_msg nonlocal status_msg
session._update_size() session._update_size()
session.clear() session.clear()
# Determine cell size based on terminal # Use compact board to fit most terminals
# Big board for terminals 80+ wide, 40+ tall
big = session.width >= 80 and session.height >= 40
lines = [] lines = []
# Title # Title
lines.append("") lines.append("")
if big: lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
lines.append("\033[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m")
else:
lines.append("\033[1;32m♔ SHELLMATE ♔\033[0m")
lines.append("") lines.append("")
if big: # Column labels
# BIG BOARD - 7 char wide cells, 3 rows tall lines.append(" A B C D E F G H")
cell_w = 7 lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗")
lines.append(" A B C D E F G H")
lines.append(" ╔═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╗") for rank in range(7, -1, -1):
# Piece row
for rank in range(7, -1, -1): piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
# Top padding row for file in range(8):
pad_row = "" square = chess.square(file, rank)
for file in range(8): piece = board.piece_at(square)
is_light = (rank + file) % 2 == 1 is_light = (rank + file) % 2 == 1
fill = " " if is_light else "\033[48;5;236m \033[0m" bg, bg_end = get_cell_style(square, piece, is_light)
pad_row += fill
if file < 7:
pad_row += ""
pad_row += ""
lines.append(pad_row)
# Piece row if piece:
piece_row = f" \033[1;36m{rank + 1}\033[0m ║" char = PIECES.get(piece.symbol(), '?')
for file in range(8): if piece.color == chess.WHITE:
square = chess.square(file, rank) piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}"
piece = board.piece_at(square)
is_light = (rank + file) % 2 == 1
bg = "" if is_light else "\033[48;5;236m"
bg_end = "" if is_light else "\033[0m"
if piece:
char = PIECES.get(piece.symbol(), '?')
if piece.color == chess.WHITE:
piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}"
else:
piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}"
else: else:
piece_row += f"{bg} {bg_end}" piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}"
elif square in legal_targets:
if file < 7: # Show dot for legal empty target
piece_row += "" piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}"
piece_row += f"\033[1;36m{rank + 1}\033[0m" else:
lines.append(piece_row) piece_row += f"{bg} {bg_end}"
# Bottom padding row if file < 7:
pad_row = "" piece_row += ""
for file in range(8): piece_row += f"\033[1;36m{rank + 1}\033[0m"
is_light = (rank + file) % 2 == 1 lines.append(piece_row)
fill = " " if is_light else "\033[48;5;236m \033[0m"
pad_row += fill
if file < 7:
pad_row += ""
pad_row += ""
lines.append(pad_row)
# Row separator
if rank > 0:
lines.append(" ╟───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────╢")
lines.append(" ╚═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╝") if rank > 0:
lines.append(" A B C D E F G H") lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
board_width = 73
else: lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
# COMPACT BOARD - 5 char cells, 2 rows tall lines.append(" A B C D E F G H")
lines.append(" A B C D E F G H") board_width = 57
lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗")
for rank in range(7, -1, -1):
# Piece row
piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
for file in range(8):
square = chess.square(file, rank)
piece = board.piece_at(square)
is_light = (rank + file) % 2 == 1
bg = "" if is_light else "\033[48;5;236m"
bg_end = "" if is_light else "\033[0m"
if piece:
char = PIECES.get(piece.symbol(), '?')
if piece.color == chess.WHITE:
piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}"
else:
piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}"
else:
piece_row += f"{bg} {bg_end}"
if file < 7:
piece_row += ""
piece_row += f"\033[1;36m{rank + 1}\033[0m"
lines.append(piece_row)
# Padding row
pad_row = ""
for file in range(8):
is_light = (rank + file) % 2 == 1
fill = " " if is_light else "\033[48;5;236m \033[0m"
pad_row += fill
if file < 7:
pad_row += ""
pad_row += ""
lines.append(pad_row)
if rank > 0:
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
lines.append(" A B C D E F G H")
board_width = 57
lines.append("") lines.append("")
@@ -428,33 +374,69 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
status_msg = "" status_msg = ""
lines.append("") lines.append("")
lines.append(" \033[32mType move\033[0m (e.g. e2e4) │ \033[31mq\033[0m = quit │ \033[31mr\033[0m = resign")
# Instructions based on state
if selected_square is not None:
sq_name = chess.square_name(selected_square).upper()
lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)")
else:
lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit")
lines.append("") lines.append("")
# Calculate centering # Calculate centering
total_height = len(lines) total_height = len(lines)
# Horizontal padding
left_pad = max(0, (session.width - board_width) // 2) left_pad = max(0, (session.width - board_width) // 2)
pad = " " * left_pad pad = " " * left_pad
# Vertical centering
top_pad = max(0, (session.height - total_height) // 2) top_pad = max(0, (session.height - total_height) // 2)
# Output
for _ in range(top_pad): for _ in range(top_pad):
session.write("\r\n") session.write("\r\n")
for line in lines: for line in lines:
session.write(pad + line + "\r\n") session.write(pad + line + "\r\n")
# Move prompt # Input prompt
session.write(pad + " \033[36m> \033[0m") if selected_square is not None:
session.write(pad + f" \033[32m→ \033[0m")
else:
session.write(pad + f" \033[36m> \033[0m")
session.show_cursor() session.show_cursor()
def parse_square(text):
"""Parse a square name like 'e2' or 'E2' into a chess square."""
text = text.strip().lower()
if len(text) != 2:
return None
file_char, rank_char = text[0], text[1]
if file_char not in 'abcdefgh' or rank_char not in '12345678':
return None
file_idx = ord(file_char) - ord('a')
rank_idx = int(rank_char) - 1
return chess.square(file_idx, rank_idx)
def select_square(sq):
"""Select a square and compute legal moves from it."""
nonlocal selected_square, legal_targets
selected_square = sq
legal_targets = set()
piece = board.piece_at(sq)
if piece and piece.color == board.turn:
# Find all legal moves from this square
for move in board.legal_moves:
if move.from_square == sq:
legal_targets.add(move.to_square)
def clear_selection():
"""Clear the current selection."""
nonlocal selected_square, legal_targets
selected_square = None
legal_targets = set()
render_board() render_board()
move_buffer = "" input_buffer = ""
while not board.is_game_over(): while not board.is_game_over():
try: try:
@@ -463,63 +445,111 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
break break
char = data.decode() if isinstance(data, bytes) else data char = data.decode() if isinstance(data, bytes) else data
# Update terminal size in case it changed
session._update_size() session._update_size()
if char in ('\x03', '\x04'): # Ctrl+C/D # Quit
break if char.lower() == 'q' and not input_buffer:
elif char in ('q', 'r'): status_msg = "Thanks for playing!"
status_msg = "Game resigned. Thanks for playing!" clear_selection()
render_board() render_board()
await asyncio.sleep(2) await asyncio.sleep(1)
break break
elif char == '\r' or char == '\n':
if move_buffer: # Escape - cancel selection
try: if char == '\x1b':
move = chess.Move.from_uci(move_buffer.strip().lower()) if selected_square is not None:
if move in board.legal_moves: clear_selection()
board.push(move) render_board()
move_history.append(move_buffer.lower()) input_buffer = ""
move_buffer = "" continue
render_board()
# Ctrl+C/D
# AI response if char in ('\x03', '\x04'):
if opponent == "ai" and not board.is_game_over(): break
session.hide_cursor()
session.write("\r\n\033[36m AI thinking...\033[0m") # Backspace
if char == '\x7f' or char == '\b':
if stockfish_engine: if input_buffer:
try: input_buffer = input_buffer[:-1]
stockfish_engine.set_fen_position(board.fen()) session.write('\b \b')
best_move = stockfish_engine.get_best_move() continue
ai_move = chess.Move.from_uci(best_move)
except: # Enter - process input
import random if char in ('\r', '\n'):
ai_move = random.choice(list(board.legal_moves)) if input_buffer:
else: sq = parse_square(input_buffer)
import random input_buffer = ""
await asyncio.sleep(0.5)
ai_move = random.choice(list(board.legal_moves)) if sq is not None:
if selected_square is None:
board.push(ai_move) # First click - select piece
move_history.append(ai_move.uci()) piece = board.piece_at(sq)
if piece and piece.color == board.turn:
select_square(sq)
render_board()
else:
status_msg = "Select your own piece"
render_board() render_board()
else: else:
status_msg = f"Illegal move: {move_buffer}" # Second click - make move
move_buffer = "" if sq in legal_targets:
render_board() move = chess.Move(selected_square, sq)
except Exception as e: # Check for promotion
status_msg = f"Invalid move format: {move_buffer}" piece = board.piece_at(selected_square)
move_buffer = "" if piece and piece.piece_type == chess.PAWN:
if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \
(piece.color == chess.BLACK and chess.square_rank(sq) == 0):
move = chess.Move(selected_square, sq, promotion=chess.QUEEN)
board.push(move)
move_history.append(move.uci())
clear_selection()
render_board()
# AI response
if opponent == "ai" and not board.is_game_over():
session.hide_cursor()
session.write("\r\n \033[36mAI thinking...\033[0m")
if stockfish_engine:
try:
stockfish_engine.set_fen_position(board.fen())
best_move = stockfish_engine.get_best_move()
ai_move = chess.Move.from_uci(best_move)
except:
import random
ai_move = random.choice(list(board.legal_moves))
else:
import random
await asyncio.sleep(0.5)
ai_move = random.choice(list(board.legal_moves))
board.push(ai_move)
move_history.append(ai_move.uci())
render_board()
elif sq == selected_square:
# Clicked same square - deselect
clear_selection()
render_board()
else:
# Try selecting new piece
piece = board.piece_at(sq)
if piece and piece.color == board.turn:
select_square(sq)
render_board()
else:
status_msg = "Invalid move"
clear_selection()
render_board()
else:
status_msg = "Invalid square (use a1-h8)"
render_board() render_board()
elif char == '\x7f' or char == '\b': # Backspace continue
if move_buffer:
move_buffer = move_buffer[:-1] # Regular character input
session.write('\b \b') if char.isprintable() and len(input_buffer) < 2:
elif char.isprintable() and len(move_buffer) < 5: input_buffer += char
move_buffer += char session.write(char.upper())
session.write(char)
except asyncio.CancelledError: except asyncio.CancelledError:
break break