algorembrant's picture
Upload 18 files
57f1366 verified
import customtkinter as ctk
import threading
import chess
import time
from src.game_state import GameState
from src.engine import EngineHandler
from src.board_ui import BoardUI
from src.overlay import SelectionOverlay, ProjectionOverlay
from src.vision import VisionHandler
from src.mirror import MirrorHandler
class ChessApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("CheckerChesser")
self.geometry("900x700")
self.minsize(600, 500) # Minimum window size
# Initialize Logic
self.game_state = GameState()
self.engine = EngineHandler()
self.vision = VisionHandler()
self.mirror = MirrorHandler()
# Screen Mirroring State
self.mirroring = False
self.mirror_region = None
self.projection_overlay = None
self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(0, weight=0) # Sidebar
self.grid_columnconfigure(1, weight=1) # Main Content
# Sidebar
self.sidebar = ctk.CTkFrame(self, width=200, corner_radius=0)
self.sidebar.grid(row=0, column=0, sticky="nsew")
self.logo_label = ctk.CTkLabel(self.sidebar, text="CheckerChesser", font=ctk.CTkFont(size=20, weight="bold"))
self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10))
self.new_game_btn = ctk.CTkButton(self.sidebar, text="New Local Game", command=self.start_local_game)
self.new_game_btn.grid(row=1, column=0, padx=20, pady=10)
self.mirror_btn = ctk.CTkButton(self.sidebar, text="Screen Mirroring", command=self.start_screen_mirroring)
self.mirror_btn.grid(row=2, column=0, padx=20, pady=10)
self.stop_btn = ctk.CTkButton(self.sidebar, text="Stop Mirroring", command=self.stop_mirroring, fg_color="red", hover_color="darkred")
self.stop_btn.grid(row=3, column=0, padx=20, pady=10)
self.stop_btn.grid_remove() # Hidden by default
self.status_label = ctk.CTkLabel(self.sidebar, text="Status: Idle", anchor="w")
self.status_label.grid(row=4, column=0, padx=20, pady=(20, 0), sticky="ew")
# Content Area
self.content_frame = ctk.CTkFrame(self, corner_radius=0, fg_color="transparent")
self.content_frame.grid(row=0, column=1, sticky="nsew")
self.content_frame.grid_rowconfigure(0, weight=1)
self.content_frame.grid_columnconfigure(0, weight=1)
# Init Engine
# Defer execution to ensure mainloop is running before thread tries to callback
self.after(200, self.init_engine_thread)
# Sidebar Toggle State
self.sidebar_visible = True
# Sidebar Toggle Button (Floating)
self.sidebar_toggle_btn = ctk.CTkButton(self, text="☰", width=30, height=30,
command=self.toggle_sidebar,
fg_color="gray20", hover_color="gray30")
self.sidebar_toggle_btn.place(x=10, y=10)
# Current Board View
self.board_ui = None
self.start_local_game()
def toggle_sidebar(self):
if self.sidebar_visible:
self.sidebar.grid_remove()
self.sidebar_visible = False
self.sidebar_toggle_btn.configure(fg_color="transparent") # Blend in more when closed? Or keep visible?
# When closed, content takes full width.
# self.grid_columnconfigure(0, weight=0) is already set for sidebar column.
# But the sidebar frame is gone, so column 0 collapses.
else:
self.sidebar.grid()
self.sidebar_visible = True
self.sidebar_toggle_btn.configure(fg_color="gray20")
def init_engine_thread(self):
def _init():
success, msg = self.engine.initialize_engine()
self.after(0, lambda: self.status_label.configure(text="Engine: Ready" if success else "Engine: Not Found"))
if not success:
print(msg)
threading.Thread(target=_init, daemon=True).start()
def start_local_game(self):
self.stop_mirroring() # Stop any active mirroring
# Clear content
for widget in self.content_frame.winfo_children():
widget.destroy()
self.game_state.reset()
# Controls Frame (horizontal layout for controls)
self.controls_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
self.controls_frame.grid(row=1, column=0, pady=10, sticky="ew")
# Left controls
left_frame = ctk.CTkFrame(self.controls_frame, fg_color="transparent")
left_frame.pack(side="left", padx=20)
# Play As selector
self.play_as_var = ctk.StringVar(value="White")
play_as_label = ctk.CTkLabel(left_frame, text="Play as:")
play_as_label.pack(side="left", padx=(0, 5))
self.play_as_menu = ctk.CTkOptionMenu(left_frame, variable=self.play_as_var,
values=["White", "Black"],
command=self.on_play_as_change,
width=80)
self.play_as_menu.pack(side="left", padx=5)
# First Move selector
self.first_move_var = ctk.StringVar(value="White")
first_move_label = ctk.CTkLabel(left_frame, text="First Move:")
first_move_label.pack(side="left", padx=(10, 5))
self.first_move_menu = ctk.CTkOptionMenu(left_frame, variable=self.first_move_var,
values=["White", "Black"],
command=self.on_first_move_change,
width=80)
self.first_move_menu.pack(side="left", padx=5)
# Flip Board button
self.flip_btn = ctk.CTkButton(left_frame, text="⟳ Flip Board", command=self.flip_board, width=100)
self.flip_btn.pack(side="left", padx=10)
# Right controls
right_frame = ctk.CTkFrame(self.controls_frame, fg_color="transparent")
right_frame.pack(side="right", padx=20)
# Best Moves count
moves_label = ctk.CTkLabel(right_frame, text="Show Best Moves:")
moves_label.pack(side="left", padx=(0, 5))
self.best_moves_var = ctk.StringVar(value="3")
self.best_moves_menu = ctk.CTkOptionMenu(right_frame, variable=self.best_moves_var,
values=["1", "2", "3"],
command=self.on_best_moves_change,
width=60)
self.best_moves_menu.pack(side="left", padx=5)
# Board UI
self.board_ui = BoardUI(self.content_frame, self.game_state)
self.board_ui.grid(row=0, column=0, padx=20, pady=20, sticky="nsew")
# Toggles Frame
toggles_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
toggles_frame.grid(row=2, column=0, pady=5)
# Analysis Toggle
self.analysis_var = ctk.BooleanVar(value=False)
self.analysis_switch = ctk.CTkSwitch(toggles_frame, text="Analysis Mode",
variable=self.analysis_var, command=self.toggle_analysis)
self.analysis_switch.pack(side="left", padx=15)
# Two Player Mode Toggle
self.two_player_var = ctk.BooleanVar(value=False)
self.two_player_switch = ctk.CTkSwitch(toggles_frame, text="Two Player Mode",
variable=self.two_player_var, command=self.toggle_two_player)
self.two_player_switch.pack(side="left", padx=15)
# Force Move Button (Initially hidden or shown based on mode? Best to just have it handy)
self.force_move_btn = ctk.CTkButton(left_frame, text="⚡ Force Move", command=self.force_ai_move, width=100, fg_color="orange", hover_color="darkorange")
self.force_move_btn.pack(side="left", padx=10)
# Edit Mode Toggle
self.edit_mode_var = ctk.BooleanVar(value=False)
self.edit_mode_switch = ctk.CTkSwitch(toggles_frame, text="Edit Mode",
variable=self.edit_mode_var, command=self.toggle_edit_mode)
self.edit_mode_switch.pack(side="left", padx=15)
# Piece Palette (Hidden by default)
self.palette_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
self.palette_frame.grid(row=3, column=0, pady=5)
self.palette_frame.grid_remove()
self.init_palette()
# Score display label
self.score_label = ctk.CTkLabel(self.content_frame, text="", font=("Arial", 12))
self.score_label.grid(row=4, column=0, pady=5)
# Bind move event
self.bind("<<MoveMade>>", self.on_move_made)
self.status_label.configure(text="Mode: vs AI (White)")
def start_screen_mirroring(self):
self.status_label.configure(text="Select Region to Mirror to...")
self.withdraw() # Hide main win
# Function called when region is selected
def on_selection(region):
self.deiconify() # Show main win
self.begin_mirroring(region)
SelectionOverlay(self, on_selection)
def begin_mirroring(self, region):
"""
Start mirroring moves to the selected region.
"""
self.mirror_region = region
self.mirroring = True
self.stop_btn.grid() # Show stop button
# Clear content and show mirroring UI
# We can actually KEEP the board UI for mirroring, so the user can play!
# The original analysis mode cleared everything. Mirroring implies playing locally.
# Just update status
self.status_label.configure(text="Mode: Screen Mirroring Active")
self.stop_btn.grid()
# Optional: Show overlay on the target region to confirm?
self.projection_overlay = ProjectionOverlay(region)
# Maybe draw a box or something? For now just keep it simple.
def stop_mirroring(self):
self.mirroring = False
self.mirror_region = None
if self.projection_overlay:
self.projection_overlay.destroy()
self.projection_overlay = None
self.stop_btn.grid_remove()
self.status_label.configure(text="Mirroring Stopped")
# Restore normal status text if game is ongoing
if not self.game_state.is_game_over():
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
self.status_label.configure(text=f"Your Turn ({turn_str})")
def toggle_analysis(self):
if self.analysis_var.get():
self.update_analysis()
else:
self.board_ui.canvas.delete("arrow")
def toggle_two_player(self):
if self.two_player_var.get():
self.status_label.configure(text="Mode: Two Player")
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
self.status_label.configure(text=f"{turn}'s Turn")
# Clear AI thinking text if any
if "Thinking" in self.status_label.cget("text"):
self.status_label.configure(text=f"{turn}'s Turn")
else:
self.status_label.configure(text="Mode: vs AI (White)")
if self.game_state.board.turn == chess.WHITE:
self.status_label.configure(text="Your Turn (White)")
def flip_board(self):
"""Flip the board orientation."""
if hasattr(self, 'board_ui') and self.board_ui:
self.board_ui.flipped = not getattr(self.board_ui, 'flipped', False)
self.board_ui.draw_board()
def on_play_as_change(self, value):
"""Handle play as color change."""
if self.edit_mode_var.get():
return # Do not reset if editing
self.reset_game()
def init_palette(self):
"""Initialize the piece palette for editing."""
pieces = [
(chess.PAWN, chess.WHITE, "♙"), (chess.KNIGHT, chess.WHITE, "♘"), (chess.BISHOP, chess.WHITE, "♗"),
(chess.ROOK, chess.WHITE, "♖"), (chess.QUEEN, chess.WHITE, "♕"), (chess.KING, chess.WHITE, "♔"),
(chess.PAWN, chess.BLACK, "♟"), (chess.KNIGHT, chess.BLACK, "♞"), (chess.BISHOP, chess.BLACK, "♝"),
(chess.ROOK, chess.BLACK, "♜"), (chess.QUEEN, chess.BLACK, "♛"), (chess.KING, chess.BLACK, "♚")
]
# White pieces row
for i, (pt, color, symbol) in enumerate(pieces[:6]):
btn = ctk.CTkButton(self.palette_frame, text=symbol, width=40, font=("Segoe UI Symbol", 24),
command=lambda p=chess.Piece(pt, color): self.select_palette_piece(p))
btn.grid(row=0, column=i, padx=2, pady=2)
# Black pieces row
for i, (pt, color, symbol) in enumerate(pieces[6:]):
btn = ctk.CTkButton(self.palette_frame, text=symbol, width=40, font=("Segoe UI Symbol", 24),
command=lambda p=chess.Piece(pt, color): self.select_palette_piece(p))
btn.grid(row=1, column=i, padx=2, pady=2)
# Trash / Delete
trash_btn = ctk.CTkButton(self.palette_frame, text="🗑", width=40, font=("Segoe UI Symbol", 20), fg_color="red", hover_color="darkred",
command=lambda: self.select_palette_piece(None))
trash_btn.grid(row=0, column=6, rowspan=2, padx=5, sticky="ns")
def select_palette_piece(self, piece):
self.board_ui.selected_edit_piece = piece
def toggle_edit_mode(self):
is_edit = self.edit_mode_var.get()
self.board_ui.edit_mode = is_edit
self.board_ui.selected_square = None # Clear selection
self.board_ui.draw_board()
if is_edit:
self.palette_frame.grid()
self.status_label.configure(text="Edit Mode: Select piece to place")
else:
self.palette_frame.grid_remove()
self.status_label.configure(text="Edit Mode Disabled")
# Resume game logic state
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
self.status_label.configure(text=f"Your Turn ({turn_str})")
def on_first_move_change(self, value):
"""Handle first move color change."""
if self.edit_mode_var.get():
return # Do not reset if editing
self.reset_game()
def reset_game(self):
"""Reset the game state based on current controls."""
self.game_state.reset()
# Set turn based on First Move selection
first_move_color = chess.BLACK if self.first_move_var.get() == "Black" else chess.WHITE
self.game_state.board.turn = first_move_color
play_as = self.play_as_var.get()
if play_as == "Black":
# Flip board so black is at bottom
self.board_ui.flipped = True
else:
self.board_ui.flipped = False
# Check if AI should move
ai_color = chess.WHITE if play_as == "Black" else chess.BLACK
if self.game_state.board.turn == ai_color and not self.two_player_var.get():
self.status_label.configure(text="AI Thinking...")
threading.Thread(target=self.make_ai_move, daemon=True).start()
else:
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
self.status_label.configure(text=f"Your Turn ({turn_str})")
self.board_ui.draw_board()
def on_best_moves_change(self, value):
"""Handle best moves count change."""
if self.analysis_var.get():
self.update_analysis()
def update_analysis(self):
if not self.analysis_var.get():
return
def _analyze():
fen = self.game_state.get_fen()
limit = int(self.best_moves_var.get()) if hasattr(self, 'best_moves_var') else 3
top_moves = self.engine.get_top_moves(fen, limit=limit)
self.after(0, lambda: self.display_analysis_results(top_moves))
threading.Thread(target=_analyze, daemon=True).start()
def display_analysis_results(self, top_moves):
"""Display analysis results with scores."""
if hasattr(self, 'board_ui') and self.board_ui:
self.board_ui.display_analysis(top_moves)
# Update score label
if hasattr(self, 'score_label') and top_moves:
score_texts = []
found_mate = False
for i, move_data in enumerate(top_moves):
move = move_data["move"]
score = move_data["score"]
# Convert score to more readable format
if abs(score) >= 9000:
# Mate score
found_mate = True
mate_in = (10000 - abs(score))
score_str = f"M{mate_in}" if score > 0 else f"-M{mate_in}"
if i == 0: # If top move is mate
if score > 0:
self.status_label.configure(text=f"White wins in {mate_in}!")
else:
self.status_label.configure(text=f"Black wins in {mate_in}!")
else:
score_str = f"{score/100:+.2f}"
colors = ["🟢", "🔵", "🟡"]
score_texts.append(f"{colors[i]} {move}: {score_str}")
self.score_label.configure(text=" | ".join(score_texts))
# If no mate found in top moves, ensure status doesn't stick (unless editing/thinking)
if not found_mate and not self.mirroring and not self.edit_mode_var.get() and "Thinking" not in self.status_label.cget("text"):
# Restore turn status
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
if self.two_player_var.get():
self.status_label.configure(text=f"{turn}'s Turn")
# Else AI turn text is handled by 'AI Thinking' or 'Your Turn'
elif hasattr(self, 'score_label'):
self.score_label.configure(text="")
def on_move_made(self, event=None):
# Check for game over
if self.game_state.is_game_over():
result = self.game_state.board.result()
self.status_label.configure(text=f"Game Over: {result}")
# Do NOT return here, we might still want to mirror the last move that caused game over?
# Actually if game is over, we still want to update UI.
# But let's act normal.
# MIRROR LOGIC
if self.mirroring and event:
# Just made a move.
# The event might not carry the move info directly if it's a generic binding,
# but we can get the last move from the board stack.
try:
if self.game_state.board.move_stack:
last_move = self.game_state.board.peek()
# If it's a move made by the player (or AI if we want to mirror AI too)
# For now, let's mirror ALL moves (Player and AI) so the external board stays in sync?
# User said "whatever i move ... it will mirroe".
# If AI moves on local board, user probably wants that on external board too if playing against external opponent?
# OR if playing vs AI locally, and mirroring to analysis board.
# Let's mirror everything.
# Need to know if we are 'flipped' on the external board?
# User selects one region. We assume it matches our orientation?
# User request: "make the mirror doenst matter if i am black of white... it will mirror i move on the right"
# This implies they want the Target Orientation to MATCH the Local Orientation.
# If I am flipped (Black), Target is flipped (Black).
is_flipped = getattr(self.board_ui, 'flipped', False)
threading.Thread(target=self.mirror.execute_move, args=(last_move, self.mirror_region, is_flipped), daemon=True).start()
except Exception as e:
print(f"Mirror error: {e}")
if self.analysis_var.get():
self.update_analysis()
# Check if two player mode
if self.two_player_var.get():
# Two player mode - just update turn indicator
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
self.status_label.configure(text=f"{turn}'s Turn")
else:
# vs AI mode
if self.game_state.board.turn == chess.BLACK:
# Trigger AI move
if not self.edit_mode_var.get() and not self.two_player_var.get():
self.status_label.configure(text="AI Thinking...")
threading.Thread(target=self.make_ai_move, daemon=True).start()
else:
self.status_label.configure(text="Your Turn (White)")
def make_ai_move(self):
if self.edit_mode_var.get():
return
# AI plays best move
best_move = self.engine.get_best_move(self.game_state.get_fen())
if best_move:
self.game_state.board.push(best_move)
self.after(0, self.update_board_after_ai)
else:
self.after(0, lambda: self.status_label.configure(text="Engine Error"))
def update_board_after_ai(self):
self.board_ui.draw_board()
self.status_label.configure(text="Your Turn")
if self.analysis_var.get():
self.update_analysis()
if self.game_state.is_game_over():
self.status_label.configure(text=f"Game Over: {self.game_state.board.result()}")
def force_ai_move(self):
"""Force the AI to make a move for the current side."""
if self.game_state.is_game_over():
return
self.status_label.configure(text="Forcing AI Move...")
threading.Thread(target=self.make_ai_move, daemon=True).start()