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': '',
}
# 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():
"""Large, terminal-filling chess board."""
"""Large, terminal-filling chess board with selection highlighting."""
nonlocal status_msg
session._update_size()
session.clear()
# Determine cell size based on terminal
# Big board for terminals 80+ wide, 40+ tall
big = session.width >= 80 and session.height >= 40
# Use compact board to fit most terminals
lines = []
# Title
lines.append("")
if big:
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("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
lines.append("")
if big:
# BIG BOARD - 7 char wide cells, 3 rows tall
cell_w = 7
lines.append(" A B C D E F G H")
lines.append(" ╔═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╗")
for rank in range(7, -1, -1):
# Top 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)
# Column labels
lines.append(" A B C D E F G H")
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, bg_end = get_cell_style(square, piece, is_light)
# 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}"
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} {bg_end}"
if file < 7:
piece_row += ""
piece_row += f"\033[1;36m{rank + 1}\033[0m"
lines.append(piece_row)
piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}"
elif square in legal_targets:
# Show dot for legal empty target
piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}"
else:
piece_row += f"{bg} {bg_end}"
# Bottom 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)
# Row separator
if rank > 0:
lines.append(" ╟───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────╢")
if file < 7:
piece_row += ""
piece_row += f"\033[1;36m{rank + 1}\033[0m"
lines.append(piece_row)
lines.append(" ╚═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╝")
lines.append(" A B C D E F G H")
board_width = 73
else:
# COMPACT BOARD - 5 char cells, 2 rows tall
lines.append(" A B C D E F G H")
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
if rank > 0:
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
lines.append(" A B C D E F G H")
board_width = 57
lines.append("")
@@ -428,33 +374,69 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
status_msg = ""
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("")
# Calculate centering
total_height = len(lines)
# Horizontal padding
left_pad = max(0, (session.width - board_width) // 2)
pad = " " * left_pad
# Vertical centering
top_pad = max(0, (session.height - total_height) // 2)
# Output
for _ in range(top_pad):
session.write("\r\n")
for line in lines:
session.write(pad + line + "\r\n")
# Move prompt
session.write(pad + " \033[36m> \033[0m")
# Input prompt
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()
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()
move_buffer = ""
input_buffer = ""
while not board.is_game_over():
try:
@@ -463,63 +445,111 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
break
char = data.decode() if isinstance(data, bytes) else data
# Update terminal size in case it changed
session._update_size()
if char in ('\x03', '\x04'): # Ctrl+C/D
break
elif char in ('q', 'r'):
status_msg = "Game resigned. Thanks for playing!"
# Quit
if char.lower() == 'q' and not input_buffer:
status_msg = "Thanks for playing!"
clear_selection()
render_board()
await asyncio.sleep(2)
await asyncio.sleep(1)
break
elif char == '\r' or char == '\n':
if move_buffer:
try:
move = chess.Move.from_uci(move_buffer.strip().lower())
if move in board.legal_moves:
board.push(move)
move_history.append(move_buffer.lower())
move_buffer = ""
render_board()
# AI response
if opponent == "ai" and not board.is_game_over():
session.hide_cursor()
session.write("\r\n\033[36m AI 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())
# Escape - cancel selection
if char == '\x1b':
if selected_square is not None:
clear_selection()
render_board()
input_buffer = ""
continue
# Ctrl+C/D
if char in ('\x03', '\x04'):
break
# Backspace
if char == '\x7f' or char == '\b':
if input_buffer:
input_buffer = input_buffer[:-1]
session.write('\b \b')
continue
# Enter - process input
if char in ('\r', '\n'):
if input_buffer:
sq = parse_square(input_buffer)
input_buffer = ""
if sq is not None:
if selected_square is None:
# First click - select piece
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()
else:
status_msg = f"Illegal move: {move_buffer}"
move_buffer = ""
render_board()
except Exception as e:
status_msg = f"Invalid move format: {move_buffer}"
move_buffer = ""
# Second click - make move
if sq in legal_targets:
move = chess.Move(selected_square, sq)
# Check for promotion
piece = board.piece_at(selected_square)
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()
elif char == '\x7f' or char == '\b': # Backspace
if move_buffer:
move_buffer = move_buffer[:-1]
session.write('\b \b')
elif char.isprintable() and len(move_buffer) < 5:
move_buffer += char
session.write(char)
continue
# Regular character input
if char.isprintable() and len(input_buffer) < 2:
input_buffer += char
session.write(char.upper())
except asyncio.CancelledError:
break