mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user