mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 14:55:08 +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': '♟',
|
'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):
|
for rank in range(7, -1, -1):
|
||||||
# Top padding row
|
# Piece row
|
||||||
pad_row = " ║"
|
piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
|
||||||
for file in range(8):
|
for file in range(8):
|
||||||
is_light = (rank + file) % 2 == 1
|
square = chess.square(file, rank)
|
||||||
fill = " " if is_light else "\033[48;5;236m \033[0m"
|
piece = board.piece_at(square)
|
||||||
pad_row += fill
|
is_light = (rank + file) % 2 == 1
|
||||||
if file < 7:
|
bg, bg_end = get_cell_style(square, piece, is_light)
|
||||||
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:
|
||||||
|
# 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}"
|
||||||
|
|
||||||
if file < 7:
|
if file < 7:
|
||||||
piece_row += "│"
|
piece_row += "│"
|
||||||
piece_row += f"║ \033[1;36m{rank + 1}\033[0m"
|
piece_row += f"║ \033[1;36m{rank + 1}\033[0m"
|
||||||
lines.append(piece_row)
|
lines.append(piece_row)
|
||||||
|
|
||||||
# Bottom padding row
|
if rank > 0:
|
||||||
pad_row = " ║"
|
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
|
||||||
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
|
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
|
||||||
if rank > 0:
|
lines.append(" A B C D E F G H")
|
||||||
lines.append(" ╟───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────╢")
|
board_width = 57
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
|
||||||
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
|
# Escape - cancel selection
|
||||||
if opponent == "ai" and not board.is_game_over():
|
if char == '\x1b':
|
||||||
session.hide_cursor()
|
if selected_square is not None:
|
||||||
session.write("\r\n\033[36m AI thinking...\033[0m")
|
clear_selection()
|
||||||
|
render_board()
|
||||||
|
input_buffer = ""
|
||||||
|
continue
|
||||||
|
|
||||||
if stockfish_engine:
|
# Ctrl+C/D
|
||||||
try:
|
if char in ('\x03', '\x04'):
|
||||||
stockfish_engine.set_fen_position(board.fen())
|
break
|
||||||
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)
|
# Backspace
|
||||||
move_history.append(ai_move.uci())
|
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()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user