From 29b70f2d49df7142bfcb06eb1e9227f777bae328 Mon Sep 17 00:00:00 2001 From: HoangMinhDuc Date: Fri, 17 Apr 2026 14:35:11 +0700 Subject: [PATCH 1/8] add .gitignore to exclude Python cache files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ab343b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_pycache_/ +*.py[cod] \ No newline at end of file From 9ddd288fd60b9fa7a01753b693b5f90b3b3a8ce5 Mon Sep 17 00:00:00 2001 From: phamnam-data-ai <24021579@vnu.edu.vn> Date: Fri, 17 Apr 2026 15:47:00 +0700 Subject: [PATCH 2/8] =?UTF-8?q?=C4=91=E1=BB=95i=20k=C3=ADch=20c=E1=BB=A1?= =?UTF-8?q?=20m=C3=A0n=20h=C3=ACnh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 5 +++++ .idea/inspectionProfiles/profiles_settings.xml | 6 ++++++ .idea/misc.xml | 7 +++++++ .idea/modules.xml | 8 ++++++++ .idea/python-chess.iml | 10 ++++++++++ .idea/vcs.xml | 6 ++++++ main.py | 2 +- 7 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/python-chess.iml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8f8921a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a86a030 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/python-chess.iml b/.idea/python-chess.iml new file mode 100644 index 0000000..db9fab9 --- /dev/null +++ b/.idea/python-chess.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/main.py b/main.py index 4e97ac4..bce38df 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ pygame.init() -WINDOW_SIZE = (1000, 1000) +WINDOW_SIZE = (600, 600) screen = pygame.display.set_mode(WINDOW_SIZE) board = Board(WINDOW_SIZE[0], WINDOW_SIZE[1]) From b6da5eb6fbfd4c9504ab278960d055ac3b612fb5 Mon Sep 17 00:00:00 2001 From: HoangMinhDuc Date: Wed, 22 Apr 2026 14:33:03 +0700 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20th=C3=AAm=20bitboard,=20l=E1=BB=9Bp?= =?UTF-8?q?=20Bot=20v=C3=A0=20m=E1=BB=99t=20s=E1=BB=91=20h=C3=A0m=20h?= =?UTF-8?q?=E1=BB=97=20tr=E1=BB=A3=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/classes/Board.py | 42 ++++++++++++++++++++++++++++ data/classes/chess_bot/Bot.py | 7 +++++ data/classes/chess_bot/constants.py | 21 ++++++++++++++ data/classes/chess_bot/evaluate.py | 2 ++ data/classes/chess_bot/move_gens.py | 27 ++++++++++++++++++ data/classes/chess_bot/precompute.py | 27 ++++++++++++++++++ data/classes/chess_bot/search.py | 2 ++ data/classes/pieces/Bishop.py | 1 + main.py | 37 ++++++++++++++++++++++-- tempCodeRunnerFile.py | 1 + 10 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 data/classes/chess_bot/Bot.py create mode 100644 data/classes/chess_bot/constants.py create mode 100644 data/classes/chess_bot/evaluate.py create mode 100644 data/classes/chess_bot/move_gens.py create mode 100644 data/classes/chess_bot/precompute.py create mode 100644 data/classes/chess_bot/search.py create mode 100644 tempCodeRunnerFile.py diff --git a/data/classes/Board.py b/data/classes/Board.py index 5653d4e..1e8c37d 100644 --- a/data/classes/Board.py +++ b/data/classes/Board.py @@ -186,6 +186,48 @@ def get_piece_from_pos(self, pos): return self.get_square_from_pos(pos).occupying_piece + def get_bitboards(self): + bitboards = { + 'P': 0, 'N': 0, 'B': 0, 'R': 0, 'Q': 0, 'K': 0, # White pieces + 'p': 0, 'n': 0, 'b': 0, 'r': 0, 'q': 0, 'k': 0 # Black pieces + } + + for square in self.squares: + piece = square.occupying_piece + if piece is not None: + # Pygame y=0 is Rank 8, y=7 is Rank 1. + # Invert y so index 0 is A1 and index 63 is H8 (Little Edian Rank File mapping). + lerf_index = (7 - square.y) * 8 + square.x + + # Safely get the piece type by its class name + piece_type = piece.__class__.__name__ + + # Map the class name to standard FIDE notation + symbol_map = { + 'Pawn': 'P', 'Knight': 'N', 'Bishop': 'B', + 'Rook': 'R', 'Queen': 'Q', 'King': 'K' + } + symbol = symbol_map.get(piece_type) + + # Convert to lowercase if the piece is black + if piece.color == 'black': + symbol = symbol.lower() + + # Stamp this piece onto its specific bitboard using Bitwise OR + # Example: 0001, shift by the lerf index 3 to 1000 and OR 0100 to get 1100 + bitboards[symbol] |= (1 << lerf_index) + + # Generate the summary bitboards for quick AI lookups + bitboards['white_pieces'] = (bitboards['P'] | bitboards['N'] | bitboards['B'] | + bitboards['R'] | bitboards['Q'] | bitboards['K']) + + bitboards['black_pieces'] = (bitboards['p'] | bitboards['n'] | bitboards['b'] | + bitboards['r'] | bitboards['q'] | bitboards['k']) + + bitboards['occupied_squares'] = bitboards['white_pieces'] | bitboards['black_pieces'] + + return bitboards + def draw(self, display): if self.selected_piece is not None: self.get_square_from_pos(self.selected_piece.pos).highlight = True diff --git a/data/classes/chess_bot/Bot.py b/data/classes/chess_bot/Bot.py new file mode 100644 index 0000000..259dd44 --- /dev/null +++ b/data/classes/chess_bot/Bot.py @@ -0,0 +1,7 @@ +class Bot: + def __init__(self, depth=1, color='white'): + pass + + def get_best_move(): + pass + \ No newline at end of file diff --git a/data/classes/chess_bot/constants.py b/data/classes/chess_bot/constants.py new file mode 100644 index 0000000..33d90e9 --- /dev/null +++ b/data/classes/chess_bot/constants.py @@ -0,0 +1,21 @@ +# Colors +WHITE = 'white' +BLACK = 'black' + +W_PAWN = 'P' +W_KNIGHT = 'N' +W_BISHOP = 'B' +W_ROOK = 'R' +W_QUEEN = 'Q' +W_KING = 'K' + +B_PAWN = 'p' +B_KNIGHT = 'n' +B_BISHOP = 'b' +B_ROOK = 'r' +B_QUEEN = 'q' +B_KING = 'k' + +W_PIECES = 'white_pieces' +B_PIECES = 'black_pieces' +OCCUPIED = 'occupied_squares' \ No newline at end of file diff --git a/data/classes/chess_bot/evaluate.py b/data/classes/chess_bot/evaluate.py new file mode 100644 index 0000000..550d4a2 --- /dev/null +++ b/data/classes/chess_bot/evaluate.py @@ -0,0 +1,2 @@ +def evaluate(): + pass \ No newline at end of file diff --git a/data/classes/chess_bot/move_gens.py b/data/classes/chess_bot/move_gens.py new file mode 100644 index 0000000..543164f --- /dev/null +++ b/data/classes/chess_bot/move_gens.py @@ -0,0 +1,27 @@ +from .constants import * +from .precompute import KNIGHT_MOVES, KING_MOVES + +# Returns the indices of the set bits in a bitboard +# Example: 0b1010 -> yields 1 and 3 (0-indexed from the right) +def get_set_bits(bitboard): + while bitboard: + lsb = bitboard & -bitboard + yield lsb.bit_length() - 1 + bitboard &= bitboard - 1 + +def get_knights_moves(bitboards, color): + moves = [] + if color == WHITE: + knights = bitboards[W_KNIGHT] + friendly_pieces = bitboards[W_PIECES] + else: + knights = bitboards[B_KNIGHT] + friendly_pieces = bitboards[B_PIECES] + + for knight in get_set_bits(knights): + raw_moves = KNIGHT_MOVES[knight] + legal_moves = raw_moves & ~friendly_pieces + + for target in get_set_bits(legal_moves): + moves.append((knight, target)) + return moves \ No newline at end of file diff --git a/data/classes/chess_bot/precompute.py b/data/classes/chess_bot/precompute.py new file mode 100644 index 0000000..feac52a --- /dev/null +++ b/data/classes/chess_bot/precompute.py @@ -0,0 +1,27 @@ +NOT_A_FILE = 0xFEFEFEFEFEFEFEFE # A-file bits are 0, everywhere else is 1 +NOT_AB_FILE = 0xFCFCFCFCFCFCFCFC # A and B files are 0 +NOT_H_FILE = 0x7F7F7F7F7F7F7F7F # H-file bits are 0 +NOT_GH_FILE = 0x3F3F3F3F3F3F3F3F # G and H files are 0 + +def precomputed_knight_moves(): + moves = [] + for square in range(64): + knight = 1 << square + board = 0 + board |= (knight & NOT_A_FILE) << 15 # Move 2 up, 1 left + board |= (knight & NOT_H_FILE) << 17 # Move 2 up, 1 right + board |= (knight & NOT_AB_FILE) << 6 # Move 1 up, 2 left + board |= (knight & NOT_GH_FILE) << 10 # Move 1 up, 2 right + board |= (knight & NOT_A_FILE) >> 17 # Move 2 down, 1 left + board |= (knight & NOT_H_FILE) >> 15 # Move 2 down, 1 right + board |= (knight & NOT_AB_FILE) >> 10 # Move 1 down, 2 left + board |= (knight & NOT_GH_FILE) >> 6 # Move 1 down, 2 right + moves.append(board) + return moves + +def precomputed_king_moves(): + moves = [] + return moves + +KNIGHT_MOVES = precomputed_knight_moves() +KING_MOVES = precomputed_king_moves() \ No newline at end of file diff --git a/data/classes/chess_bot/search.py b/data/classes/chess_bot/search.py new file mode 100644 index 0000000..34b5f72 --- /dev/null +++ b/data/classes/chess_bot/search.py @@ -0,0 +1,2 @@ +def negamax(): + pass diff --git a/data/classes/pieces/Bishop.py b/data/classes/pieces/Bishop.py index 18ef4d2..b095229 100644 --- a/data/classes/pieces/Bishop.py +++ b/data/classes/pieces/Bishop.py @@ -13,6 +13,7 @@ def __init__(self, pos, color, board): self.notation = 'B' + def get_possible_moves(self, board): output = [] diff --git a/main.py b/main.py index bce38df..8362427 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ import pygame +from data.classes.chess_bot.constants import * +from data.classes.chess_bot.move_gens import get_knights_moves from data.classes.Board import Board @@ -9,6 +11,29 @@ board = Board(WINDOW_SIZE[0], WINDOW_SIZE[1]) +# debug function to visualize bitboards in the console +def print_bitboards(bitboard): + print("\n A B C D E F G H") + print(" ---------------") + + for rank in range(7, -1, -1): + row_string = f"{rank + 1}|" + + for file in range(8): + square_index = (rank * 8) + file + + # check if the bit at this index is 1 + # do this by creating a temporary board with a 1 at the target square, + # using Bitwise AND to see if they overlap. + if bitboard & (1 << square_index): + row_string += "1 " # Piece exists + else: + row_string += ". " # Empty square + + print(row_string) + + print(" ---------------\n") + def draw(display): display.fill('white') @@ -16,7 +41,6 @@ def draw(display): pygame.display.update() - running = True while running: mx, my = pygame.mouse.get_pos() @@ -27,7 +51,16 @@ def draw(display): elif event.type == pygame.MOUSEBUTTONDOWN: if event.button == 1: board.handle_click(mx, my) - + + # debug + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_d: + bitboards = board.get_bitboards() + knight_test_moves = bitboards[W_KNIGHT] + for move in get_knights_moves(bitboards, WHITE): + knight_test_moves |= (1 << move[1]) + print_bitboards(knight_test_moves) + if board.is_in_checkmate('black'): print('White wins!') running = False diff --git a/tempCodeRunnerFile.py b/tempCodeRunnerFile.py new file mode 100644 index 0000000..8096b59 --- /dev/null +++ b/tempCodeRunnerFile.py @@ -0,0 +1 @@ +from data.classes.chess_bot.constants import * \ No newline at end of file From 51d1fbfa563f29b16e07cd46712b5e14b8b072cc Mon Sep 17 00:00:00 2001 From: minhcaoj <87977362+minhcaoj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:16:08 +0700 Subject: [PATCH 4/8] fix: fix window size --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index bce38df..4e97ac4 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ pygame.init() -WINDOW_SIZE = (600, 600) +WINDOW_SIZE = (1000, 1000) screen = pygame.display.set_mode(WINDOW_SIZE) board = Board(WINDOW_SIZE[0], WINDOW_SIZE[1]) From 0e1123ea8594590f632ef5034dd11f0808206e1c Mon Sep 17 00:00:00 2001 From: minhcaoj <87977362+minhcaoj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:16:54 +0700 Subject: [PATCH 5/8] fix: window size --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 4e97ac4..bce38df 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ pygame.init() -WINDOW_SIZE = (1000, 1000) +WINDOW_SIZE = (600, 600) screen = pygame.display.set_mode(WINDOW_SIZE) board = Board(WINDOW_SIZE[0], WINDOW_SIZE[1]) From 61aa33fb84a3834f578d09a2cb15f6d69a353f4d Mon Sep 17 00:00:00 2001 From: minhcaoj <87977362+minhcaoj@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:48:49 +0700 Subject: [PATCH 6/8] feat: add UI --- data/classes/Board.py | 31 +++++++++++++- data/classes/Button.py | 28 ++++++++++++ data/classes/Square.py | 14 ++++++ data/classes/StateManager.py | 25 +++++++++++ data/states/MenuState.py | 21 +++++++++ data/states/PvEState.py | 81 +++++++++++++++++++++++++++++++++++ data/states/PvPState.py | 30 +++++++++++++ data/states/State.py | 15 +++++++ main.py | 82 ++++++++++++------------------------ 9 files changed, 270 insertions(+), 57 deletions(-) create mode 100644 data/classes/Button.py create mode 100644 data/classes/StateManager.py create mode 100644 data/states/MenuState.py create mode 100644 data/states/PvEState.py create mode 100644 data/states/PvPState.py create mode 100644 data/states/State.py diff --git a/data/classes/Board.py b/data/classes/Board.py index 1e8c37d..4b47051 100644 --- a/data/classes/Board.py +++ b/data/classes/Board.py @@ -9,13 +9,14 @@ from data.classes.pieces.Pawn import Pawn class Board: - def __init__(self, width, height): + def __init__(self, width, height, is_flipped=False): self.width = width self.height = height self.square_width = width // 8 self.square_height = height // 8 self.selected_piece = None self.turn = 'white' + self.is_flipped = is_flipped self.config = [ ['bR', 'bN', 'bB', 'bQ', 'bK', 'bB', 'bN', 'bR'], @@ -31,6 +32,8 @@ def __init__(self, width, height): self.squares = self.generate_squares() self.setup_board() + if self.is_flipped: + self.apply_view(True) def generate_squares(self): output = [] @@ -100,6 +103,8 @@ def setup_board(self): def handle_click(self, mx, my): x = mx // self.square_width y = my // self.square_height + if x < 0 or x > 7 or y < 0 or y > 7: + return clicked_square = self.get_square_from_pos((x, y)) if self.selected_piece is None: @@ -114,6 +119,30 @@ def handle_click(self, mx, my): if clicked_square.occupying_piece.color == self.turn: self.selected_piece = clicked_square.occupying_piece + def handle_click_flipped(self, mx, my): + x = 7 - (mx // self.square_width) + y = 7 - (my // self.square_height) + if x < 0 or x > 7 or y < 0 or y > 7: + return + clicked_square = self.get_square_from_pos((x, y)) + + if self.selected_piece is None: + if clicked_square.occupying_piece is not None: + if clicked_square.occupying_piece.color == self.turn: + self.selected_piece = clicked_square.occupying_piece + + elif self.selected_piece.move(self, clicked_square): + self.turn = 'white' if self.turn == 'black' else 'black' + + elif clicked_square.occupying_piece is not None: + if clicked_square.occupying_piece.color == self.turn: + self.selected_piece = clicked_square.occupying_piece + + def apply_view(self, is_flipped): + self.is_flipped = is_flipped + for square in self.squares: + square.set_view(self.is_flipped) + def is_in_check(self, color, board_change=None): # board_change = [(x1, y1), (x2, y2)] output = False diff --git a/data/classes/Button.py b/data/classes/Button.py new file mode 100644 index 0000000..a466aaf --- /dev/null +++ b/data/classes/Button.py @@ -0,0 +1,28 @@ +import pygame + +class Button: + def __init__(self, x, y, width, height, text, color=(100, 100, 100), hover_color=(150, 150, 150), text_color=(255, 255, 255)): + self.rect = pygame.Rect(x, y, width, height) + self.text = text + self.color = color + self.hover_color = hover_color + self.text_color = text_color + self.font = pygame.font.SysFont(None, 36) + self.is_hovered = False + + def draw(self, surface): + mouse_pos = pygame.mouse.get_pos() + self.is_hovered = self.rect.collidepoint(mouse_pos) + + current_color = self.hover_color if self.is_hovered else self.color + pygame.draw.rect(surface, current_color, self.rect) + + text_surf = self.font.render(self.text, True, self.text_color) + text_rect = text_surf.get_rect(center=self.rect.center) + surface.blit(text_surf, text_rect) + + def is_clicked(self, event): + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + if self.rect.collidepoint(event.pos): + return True + return False diff --git a/data/classes/Square.py b/data/classes/Square.py index 4ccb6aa..23a67fb 100644 --- a/data/classes/Square.py +++ b/data/classes/Square.py @@ -25,6 +25,20 @@ def __init__(self, x, y, width, height): self.height ) + def set_view(self, is_flipped): + draw_x = 7 - self.x if is_flipped else self.x + draw_y = 7 - self.y if is_flipped else self.y + + self.abs_x = draw_x * self.width + self.abs_y = draw_y * self.height + self.abs_pos = (self.abs_x, self.abs_y) + self.rect = pygame.Rect( + self.abs_x, + self.abs_y, + self.width, + self.height + ) + def get_coord(self): columns = 'abcdefgh' diff --git a/data/classes/StateManager.py b/data/classes/StateManager.py new file mode 100644 index 0000000..4765b0d --- /dev/null +++ b/data/classes/StateManager.py @@ -0,0 +1,25 @@ +class StateManager: + def __init__(self): + self.states = {} + self.current_state = None + + def setup(self, states, initial_state): + self.states = states + self.change_state(initial_state) + + def change_state(self, state_name): + if state_name in self.states: + self.current_state = self.states[state_name] + self.current_state.on_enter() + + def handle_events(self, events): + if self.current_state: + self.current_state.handle_events(events) + + def update(self): + if self.current_state: + self.current_state.update() + + def draw(self, surface): + if self.current_state: + self.current_state.draw(surface) diff --git a/data/states/MenuState.py b/data/states/MenuState.py new file mode 100644 index 0000000..78ff9b6 --- /dev/null +++ b/data/states/MenuState.py @@ -0,0 +1,21 @@ +from data.states.State import State +from data.classes.Button import Button + +class MenuState(State): + def __init__(self, manager): + super().__init__(manager) + # Centered horizontally for a 600x600 window + self.btn_pvp = Button(200, 200, 200, 60, "PvP") + self.btn_pve = Button(200, 300, 200, 60, "PvE") + + def handle_events(self, events): + for event in events: + if self.btn_pvp.is_clicked(event): + self.manager.change_state('pvp') + elif self.btn_pve.is_clicked(event): + self.manager.change_state('pve') + + def draw(self, surface): + surface.fill((230, 230, 230)) # Light gray background + self.btn_pvp.draw(surface) + self.btn_pve.draw(surface) diff --git a/data/states/PvEState.py b/data/states/PvEState.py new file mode 100644 index 0000000..80dcfea --- /dev/null +++ b/data/states/PvEState.py @@ -0,0 +1,81 @@ +import pygame +import random +from data.states.State import State +from data.classes.Board import Board + +class PvEState(State): + def __init__(self, manager): + super().__init__(manager) + self.board = None + self.player_color = None + self.game_over = False + + def on_enter(self): + # Fresh board and random color assignment on load + self.player_color = random.choice(['white', 'black']) + self.board = Board(600, 600, is_flipped=(self.player_color == 'black')) + self.game_over = False + print(f"PvE Started! You are playing as {self.player_color}.") + + def handle_events(self, events): + if self.game_over: + return + + for event in events: + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + # Only let player click if it is currently their turn + if self.board.turn == self.player_color: + mx, my = event.pos + if self.board.is_flipped: + self.board.handle_click_flipped(mx, my) + else: + self.board.handle_click(mx, my) + + def update(self): + if self.game_over: + return + + if self.board.is_in_checkmate('black'): + print('White wins!') + self.game_over = True + self.manager.change_state('menu') + return + elif self.board.is_in_checkmate('white'): + print('Black wins!') + self.game_over = True + self.manager.change_state('menu') + return + + # If it is not the player's turn, instantly trigger the bot + if self.board.turn != self.player_color: + self.run_bot_move() + + def run_bot_move(self): + # TODO: Add Model prediction here. + # Integrate the AI model to calculate and return the best move on self.board. + + bot_color = 'white' if self.player_color == 'black' else 'black' + + # --- Temporarily use a random move to prevent soft locks during testing --- + # NOTE: Remove this stubbed logic once the model provides a move! + valid_moves = [] + for square in self.board.squares: + piece = square.occupying_piece + if piece and piece.color == bot_color: + moves = piece.get_valid_moves(self.board) + for move_square in moves: + valid_moves.append((piece, move_square)) + + if valid_moves: + piece, move_square = random.choice(valid_moves) + # The .move() function returns True if valid execution + if piece.move(self.board, move_square): + # Switch turn back to the player manually after bot hits move + self.board.turn = self.player_color + else: + # No valid moves edge case (Should be covered by checkmate checks normally) + pass + + def draw(self, surface): + surface.fill('white') + self.board.draw(surface) diff --git a/data/states/PvPState.py b/data/states/PvPState.py new file mode 100644 index 0000000..ca9cdab --- /dev/null +++ b/data/states/PvPState.py @@ -0,0 +1,30 @@ +import pygame +from data.states.State import State +from data.classes.Board import Board + +class PvPState(State): + def __init__(self, manager): + super().__init__(manager) + self.board = None + + def on_enter(self): + # We start a fresh board whenever we enter PvP mode + self.board = Board(600, 600) + + def handle_events(self, events): + for event in events: + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + mx, my = event.pos + self.board.handle_click(mx, my) + + def update(self): + if self.board.is_in_checkmate('black'): + print('White wins!') + self.manager.change_state('menu') + elif self.board.is_in_checkmate('white'): + print('Black wins!') + self.manager.change_state('menu') + + def draw(self, surface): + surface.fill('white') + self.board.draw(surface) diff --git a/data/states/State.py b/data/states/State.py new file mode 100644 index 0000000..3c33174 --- /dev/null +++ b/data/states/State.py @@ -0,0 +1,15 @@ +class State: + def __init__(self, manager): + self.manager = manager + + def on_enter(self): + pass + + def handle_events(self, events): + pass + + def update(self): + pass + + def draw(self, surface): + pass diff --git a/main.py b/main.py index 8362427..1b739b8 100644 --- a/main.py +++ b/main.py @@ -1,71 +1,41 @@ import pygame -from data.classes.chess_bot.constants import * -from data.classes.chess_bot.move_gens import get_knights_moves +import sys -from data.classes.Board import Board +from data.classes.StateManager import StateManager +from data.states.MenuState import MenuState +from data.states.PvPState import PvPState +from data.states.PvEState import PvEState pygame.init() WINDOW_SIZE = (600, 600) screen = pygame.display.set_mode(WINDOW_SIZE) +pygame.display.set_caption("Chess AI") -board = Board(WINDOW_SIZE[0], WINDOW_SIZE[1]) - -# debug function to visualize bitboards in the console -def print_bitboards(bitboard): - print("\n A B C D E F G H") - print(" ---------------") - - for rank in range(7, -1, -1): - row_string = f"{rank + 1}|" - - for file in range(8): - square_index = (rank * 8) + file - - # check if the bit at this index is 1 - # do this by creating a temporary board with a 1 at the target square, - # using Bitwise AND to see if they overlap. - if bitboard & (1 << square_index): - row_string += "1 " # Piece exists - else: - row_string += ". " # Empty square - - print(row_string) - - print(" ---------------\n") - -def draw(display): - display.fill('white') - - board.draw(display) - - pygame.display.update() +manager = StateManager() +states = { + 'menu': MenuState(manager), + 'pvp': PvPState(manager), + 'pve': PvEState(manager) +} +manager.setup(states, 'menu') +clock = pygame.time.Clock() running = True + while running: - mx, my = pygame.mouse.get_pos() - for event in pygame.event.get(): + events = pygame.event.get() + for event in events: if event.type == pygame.QUIT: running = False - - elif event.type == pygame.MOUSEBUTTONDOWN: - if event.button == 1: - board.handle_click(mx, my) - - # debug - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_d: - bitboards = board.get_bitboards() - knight_test_moves = bitboards[W_KNIGHT] - for move in get_knights_moves(bitboards, WHITE): - knight_test_moves |= (1 << move[1]) - print_bitboards(knight_test_moves) + + manager.handle_events(events) + manager.update() + + manager.draw(screen) + pygame.display.update() - if board.is_in_checkmate('black'): - print('White wins!') - running = False - elif board.is_in_checkmate('white'): - print('Black wins!') - running = False + clock.tick(60) - draw(screen) \ No newline at end of file +pygame.quit() +sys.exit() \ No newline at end of file From 2ba4147698b40d6eb138259672f30714ced0fba1 Mon Sep 17 00:00:00 2001 From: minhcaoj <87977362+minhcaoj@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:37:51 +0700 Subject: [PATCH 7/8] feat: add en passant Co-authored-by: Copilot --- data/classes/Board.py | 14 ++++++++++++++ data/classes/Piece.py | 15 +++++++++++++++ data/classes/pieces/Pawn.py | 10 ++++++++++ 3 files changed, 39 insertions(+) diff --git a/data/classes/Board.py b/data/classes/Board.py index 4b47051..fd5ca70 100644 --- a/data/classes/Board.py +++ b/data/classes/Board.py @@ -17,6 +17,7 @@ def __init__(self, width, height, is_flipped=False): self.selected_piece = None self.turn = 'white' self.is_flipped = is_flipped + self.en_passant_target = None self.config = [ ['bR', 'bN', 'bB', 'bQ', 'bK', 'bB', 'bN', 'bR'], @@ -152,6 +153,8 @@ def is_in_check(self, color, board_change=None): # board_change = [(x1, y1), (x2 old_square = None new_square = None new_square_old_piece = None + en_passant_captured_square = None + en_passant_captured_piece = None if board_change is not None: for square in self.squares: @@ -165,6 +168,15 @@ def is_in_check(self, color, board_change=None): # board_change = [(x1, y1), (x2 new_square_old_piece = new_square.occupying_piece new_square.occupying_piece = changing_piece + # Simulate en passant capture by temporarily removing the captured pawn. + if changing_piece is not None and changing_piece.notation == ' ' and self.en_passant_target is not None: + if board_change[1] == self.en_passant_target and new_square_old_piece is None and old_square.x != new_square.x: + capture_y = new_square.y + (1 if changing_piece.color == 'white' else -1) + if 0 <= capture_y < 8: + en_passant_captured_square = self.get_square_from_pos((new_square.x, capture_y)) + en_passant_captured_piece = en_passant_captured_square.occupying_piece + en_passant_captured_square.occupying_piece = None + pieces = [ i.occupying_piece for i in self.squares if i.occupying_piece is not None ] @@ -186,6 +198,8 @@ def is_in_check(self, color, board_change=None): # board_change = [(x1, y1), (x2 if board_change is not None: old_square.occupying_piece = changing_piece new_square.occupying_piece = new_square_old_piece + if en_passant_captured_square is not None: + en_passant_captured_square.occupying_piece = en_passant_captured_piece return output diff --git a/data/classes/Piece.py b/data/classes/Piece.py index 7e0ed05..b22b544 100644 --- a/data/classes/Piece.py +++ b/data/classes/Piece.py @@ -15,6 +15,16 @@ def move(self, board, square, force=False): if square in self.get_valid_moves(board) or force: prev_square = board.get_square_from_pos(self.pos) + + if self.notation == ' ' and board.en_passant_target is not None: + if square.pos == board.en_passant_target and square.occupying_piece is None and prev_square.x != square.x: + capture_y = square.y + (1 if self.color == 'white' else -1) + if 0 <= capture_y < 8: + captured_square = board.get_square_from_pos((square.x, capture_y)) + captured_piece = captured_square.occupying_piece + if captured_piece is not None and captured_piece.notation == ' ' and captured_piece.color != self.color: + captured_square.occupying_piece = None + self.pos, self.x, self.y = square.pos, square.x, square.y prev_square.occupying_piece = None @@ -22,6 +32,11 @@ def move(self, board, square, force=False): board.selected_piece = None self.has_moved = True + board.en_passant_target = None + if self.notation == ' ' and abs(prev_square.y - self.y) == 2: + middle_y = (prev_square.y + self.y) // 2 + board.en_passant_target = (self.x, middle_y) + # Pawn promotion if self.notation == ' ': if self.y == 0 or self.y == 7: diff --git a/data/classes/pieces/Pawn.py b/data/classes/pieces/Pawn.py index d37b4f7..1716195 100644 --- a/data/classes/pieces/Pawn.py +++ b/data/classes/pieces/Pawn.py @@ -46,6 +46,8 @@ def get_moves(self, board): else: output.append(square) + en_passant_target = board.en_passant_target + if self.color == 'white': if self.x + 1 < 8 and self.y - 1 >= 0: square = board.get_square_from_pos( @@ -54,6 +56,8 @@ def get_moves(self, board): if square.occupying_piece != None: if square.occupying_piece.color != self.color: output.append(square) + elif en_passant_target is not None and square.pos == en_passant_target: + output.append(square) if self.x - 1 >= 0 and self.y - 1 >= 0: square = board.get_square_from_pos( (self.x - 1, self.y - 1) @@ -61,6 +65,8 @@ def get_moves(self, board): if square.occupying_piece != None: if square.occupying_piece.color != self.color: output.append(square) + elif en_passant_target is not None and square.pos == en_passant_target: + output.append(square) elif self.color == 'black': if self.x + 1 < 8 and self.y + 1 < 8: @@ -70,6 +76,8 @@ def get_moves(self, board): if square.occupying_piece != None: if square.occupying_piece.color != self.color: output.append(square) + elif en_passant_target is not None and square.pos == en_passant_target: + output.append(square) if self.x - 1 >= 0 and self.y + 1 < 8: square = board.get_square_from_pos( (self.x - 1, self.y + 1) @@ -77,6 +85,8 @@ def get_moves(self, board): if square.occupying_piece != None: if square.occupying_piece.color != self.color: output.append(square) + elif en_passant_target is not None and square.pos == en_passant_target: + output.append(square) return output From aee64ed00e817f5936b972e6e14426831a3e25f9 Mon Sep 17 00:00:00 2001 From: minhcaoj <87977362+minhcaoj@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:12:16 +0700 Subject: [PATCH 8/8] feat: adding negamax-alpha-beta with order_moves for the bot. Adding priority placing area for each pieces. Fix castling bug Co-authored-by: Copilot --- data/classes/Board.py | 124 +++++++++++++++++++- data/classes/Piece.py | 58 +++++++++ data/classes/chess_bot/evaluate.py | 167 +++++++++++++++++++++++++- data/classes/chess_bot/precompute.py | 3 +- data/classes/chess_bot/search.py | 169 ++++++++++++++++++++++++++- data/classes/pieces/King.py | 18 +-- data/states/PvEState.py | 37 +++--- 7 files changed, 540 insertions(+), 36 deletions(-) diff --git a/data/classes/Board.py b/data/classes/Board.py index fd5ca70..c9c7634 100644 --- a/data/classes/Board.py +++ b/data/classes/Board.py @@ -145,7 +145,10 @@ def apply_view(self, is_flipped): square.set_view(self.is_flipped) - def is_in_check(self, color, board_change=None): # board_change = [(x1, y1), (x2, y2)] + def is_in_check(self, color=None, board_change=None): # board_change = [(x1, y1), (x2, y2)] + if color == None: + color = self.turn + output = False king_pos = None @@ -228,6 +231,57 @@ def get_square_from_pos(self, pos): def get_piece_from_pos(self, pos): return self.get_square_from_pos(pos).occupying_piece + def get_all_valid_moves(self, color=None): + if color == None: + color = self.turn + + moves = [] + for square in self.squares: + piece = square.occupying_piece + if piece and piece.color == color: + for move_square in piece.get_valid_moves(self): + moves.append((piece, move_square)) + return moves + + def is_recapturable(self, piece, target_square): + """Check if capturing piece can be immediately recaptured by lower-value opponent piece. + + Used by move ordering to avoid scoring bad trades (e.g., Qxp when pawn recaptures). + + Args: + piece: The attacking piece about to capture. + target_square: The destination square (where capture happens). + + Returns: + Tuple (is_recapturable, min_recapture_value): + - is_recapturable: True if an opponent piece can immediately recapture + - min_recapture_value: Value of lowest-value piece that can recapture (0 if none) + """ + if target_square.occupying_piece is None: + return False, 0 + + # Get all opponent pieces that can attack this square + opponent_color = 'black' if piece.color == 'white' else 'white' + min_recapture_value = float('inf') + can_recapture = False + + # Check all opponent pieces + for square in self.squares: + opponent_piece = square.occupying_piece + if opponent_piece and opponent_piece.color == opponent_color: + # Check if this opponent piece can attack target_square + for attacked_square in opponent_piece.get_moves(self): + if attacked_square.pos == target_square.pos: + # This opponent piece can recapture + can_recapture = True + # Get piece value + piece_type = opponent_piece.notation + value_map = {' ': 1, 'N': 3, 'B': 3, 'R': 5, 'Q': 9, 'K': 100} + value = value_map.get(piece_type, 0) + min_recapture_value = min(min_recapture_value, value) + break + + return can_recapture, min_recapture_value if can_recapture else 0 def get_bitboards(self): bitboards = { @@ -271,6 +325,74 @@ def get_bitboards(self): return bitboards + def capture_move_state(self, piece, target_square): + """Capture all board state before executing a move. + + Used by search algorithms (e.g., alpha-beta minimax) to enable move/unmake cycles. + Must be called BEFORE piece.move() executes. + + Args: + piece: The piece about to move. + target_square: The destination square. + + Returns: + dict with keys: from_pos, to_pos, piece_had_moved_before, captured_piece, + captured_pos, en_passant_target_before, is_promotion, is_castling, + rook_state (for castling only) + """ + from_square = self.get_square_from_pos(piece.pos) + captured_piece = target_square.occupying_piece + captured_pos = target_square.pos + + # Handle en passant: captured piece is on different square + if (piece.notation == ' ' and self.en_passant_target is not None and + target_square.pos == self.en_passant_target and + captured_piece is None and from_square.x != target_square.x): + capture_y = target_square.y + (1 if piece.color == 'white' else -1) + if 0 <= capture_y < 8: + en_passant_square = self.get_square_from_pos((target_square.x, capture_y)) + captured_piece = en_passant_square.occupying_piece + captured_pos = en_passant_square.pos + + # Detect if this is a promotion + is_promotion = (piece.notation == ' ' and (target_square.y == 0 or target_square.y == 7)) + + # Detect if this is castling and snapshot rook state + is_castling = (piece.notation == 'K' and abs(from_square.x - target_square.x) == 2) + rook_state = None + if is_castling: + if from_square.x - target_square.x == 2: + # Queenside castling + rook = self.get_piece_from_pos((0, piece.y)) + rook_from = (0, piece.y) + rook_to = (3, piece.y) + elif from_square.x - target_square.x == -2: + # Kingside castling + rook = self.get_piece_from_pos((7, piece.y)) + rook_from = (7, piece.y) + rook_to = (5, piece.y) + + if rook is not None: + rook_state = { + 'rook': rook, + 'from_pos': rook_from, + 'to_pos': rook_to, + 'rook_had_moved_before': rook.has_moved + } + + return { + 'piece': piece, + 'from_pos': piece.pos, + 'to_pos': target_square.pos, + 'piece_had_moved_before': piece.has_moved, + 'captured_piece': captured_piece, + 'captured_pos': captured_pos, + 'en_passant_target_before': self.en_passant_target, + 'is_promotion': is_promotion, + 'is_castling': is_castling, + 'rook_state': rook_state + } + def draw(self, display): if self.selected_piece is not None: self.get_square_from_pos(self.selected_piece.pos).highlight = True diff --git a/data/classes/Piece.py b/data/classes/Piece.py index b22b544..ffde67f 100644 --- a/data/classes/Piece.py +++ b/data/classes/Piece.py @@ -62,6 +62,64 @@ def move(self, board, square, force=False): return False + def unmake_move(self, board, move_state): + """Reverse all state changes from a move. + + Used by search algorithms to explore move trees via make/unmake cycles. + Restores: piece position, has_moved flag, captures, en passant, promotion, castling. + + Args: + board: The board object. + move_state: Dict returned by board.capture_move_state() before the move. + """ + # Restore piece position + self.pos = move_state['from_pos'] + self.x = move_state['from_pos'][0] + self.y = move_state['from_pos'][1] + + # Restore has_moved flag (critical for castling/pawn legality) + self.has_moved = move_state['piece_had_moved_before'] + + # Restore source and destination squares + from_square = board.get_square_from_pos(move_state['from_pos']) + to_square = board.get_square_from_pos(move_state['to_pos']) + + # Remove piece from destination + to_square.occupying_piece = None + + # Place piece back on source square + from_square.occupying_piece = self + + # Restore captured piece (if any) + if move_state['captured_piece'] is not None: + captured_square = board.get_square_from_pos(move_state['captured_pos']) + captured_square.occupying_piece = move_state['captured_piece'] + + # Restore en passant target state + board.en_passant_target = move_state['en_passant_target_before'] + + # Handle promotion: replace promoted piece back with original pawn + if move_state['is_promotion']: + from_square.occupying_piece = self + + # Handle castling: recursively unmake rook's move + if move_state['is_castling'] and move_state['rook_state'] is not None: + rook_state = move_state['rook_state'] + rook = rook_state['rook'] + rook.unmake_move(board, { + 'piece': rook, + 'from_pos': rook_state['from_pos'], + 'to_pos': rook_state['to_pos'], + 'piece_had_moved_before': rook_state['rook_had_moved_before'], + 'captured_piece': None, + 'captured_pos': None, + 'en_passant_target_before': board.en_passant_target, + 'is_promotion': False, + 'is_castling': False, + 'rook_state': None + }) + + def get_moves(self, board): output = [] for direction in self.get_possible_moves(board): diff --git a/data/classes/chess_bot/evaluate.py b/data/classes/chess_bot/evaluate.py index 550d4a2..f93fea7 100644 --- a/data/classes/chess_bot/evaluate.py +++ b/data/classes/chess_bot/evaluate.py @@ -1,2 +1,165 @@ -def evaluate(): - pass \ No newline at end of file +from data.classes.Board import Board + +class Evaluate: + def __init__(self, board): + self.board = board + self.pawn_value = 1 + self.knight_value = 3 + self.bishop_value = 3 + self.rook_value = 5 + self.queen_value = 9 + + # Positional heatmaps for opening play (8x8 arrays, indexed [y][x]) + # y=0 is rank 8 (black's back), y=7 is rank 1 (white's back) + # Higher value = more preferred square for piece in opening + + PAWN_HEATMAP = [ + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 8: No pawns here + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 7: Promotion imminent (not starting) + [2, 2, 3, 3, 3, 3, 2, 2], # Rank 6: Advanced pawns good + [2, 2, 3, 4, 4, 3, 2, 2], # Rank 5: Pushing forward + [2, 2, 3, 4, 4, 3, 2, 2], # Rank 4: Center good + [1, 1, 2, 3, 3, 2, 1, 1], # Rank 3: Starting to push + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 2: Starting position + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 1: No pawns here + ] + + KNIGHT_HEATMAP = [ + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 8: Starting position + [1, 2, 2, 3, 3, 2, 2, 1], # Rank 7: Beginning to centralize + [1, 2, 4, 5, 5, 4, 2, 1], # Rank 6: Good outposts + [1, 3, 5, 6, 6, 5, 3, 1], # Rank 5: Excellent central squares + [1, 3, 5, 6, 6, 5, 3, 1], # Rank 4: Excellent central squares + [1, 2, 4, 5, 5, 4, 2, 1], # Rank 3: Development squares (Nf3, Nc3) + [1, 2, 2, 3, 3, 2, 2, 1], # Rank 2: Development squares + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 1: Starting position + ] + + BISHOP_HEATMAP = [ + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 8: Starting position + [1, 2, 2, 2, 2, 2, 2, 1], # Rank 7: Developing + [1, 2, 3, 3, 3, 3, 2, 1], # Rank 6: Active squares + [1, 2, 3, 4, 4, 3, 2, 1], # Rank 5: Long diagonal activation + [1, 2, 3, 4, 4, 3, 2, 1], # Rank 4: Long diagonal activation + [1, 2, 3, 4, 4, 3, 2, 1], # Rank 3: Development (Bc4, Bf4) + [1, 2, 3, 3, 3, 3, 2, 1], # Rank 2: Starting squares + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 1: Starting position + ] + + ROOK_HEATMAP = [ + [2, 2, 2, 3, 3, 2, 2, 2], # Rank 8: Back rank, central files better + [2, 2, 2, 3, 3, 2, 2, 2], # Rank 7 + [1, 2, 3, 3, 3, 3, 2, 1], # Rank 6: Semi-open files good + [1, 2, 3, 3, 3, 3, 2, 1], # Rank 5: Active play + [1, 2, 3, 3, 3, 3, 2, 1], # Rank 4: Active play + [1, 1, 2, 2, 2, 2, 1, 1], # Rank 3: Still developing + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 2: Back rank + [2, 2, 2, 3, 3, 2, 2, 2], # Rank 1: Starting position + ] + + QUEEN_HEATMAP = [ + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 8: Starting position + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 7 + [1, 1, 2, 2, 2, 2, 1, 1], # Rank 6: Moderate activation + [1, 1, 2, 3, 3, 2, 1, 1], # Rank 5: Center-biased + [1, 1, 2, 3, 3, 2, 1, 1], # Rank 4: Center-biased + [1, 1, 2, 2, 2, 2, 1, 1], # Rank 3: Development (Qd3, Qe3 in some lines) + [1, 1, 1, 2, 2, 1, 1, 1], # Rank 2: Flexible + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 1: Starting position + ] + + KING_HEATMAP = [ + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 8: Starting position + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 7 + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 6 + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 5: Avoid center (exposed) + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 4: Avoid center (exposed) + [0, 0, 0, 0, 0, 0, 0, 0], # Rank 3: Avoid center (exposed) + [1, 1, 1, 1, 1, 1, 1, 1], # Rank 2: Back rank is safe + [1, 1, 1, 2, 2, 1, 2, 2], # Rank 1: Kingside castled (g1) better than center + ] + + def countMaterial(self, color): + material = 0 + if(color == 'w'): + material = material + bin(self.board.get_bitboards()['P']).count('1') + material = material + bin(self.board.get_bitboards()['N']).count('1') + material = material + bin(self.board.get_bitboards()['B']).count('1') + material = material + bin(self.board.get_bitboards()['R']).count('1') + material = material + bin(self.board.get_bitboards()['Q']).count('1') + + else: + material = material + bin(self.board.get_bitboards()['p']).count('1') + material = material + bin(self.board.get_bitboards()['n']).count('1') + material = material + bin(self.board.get_bitboards()['b']).count('1') + material = material + bin(self.board.get_bitboards()['r']).count('1') + material = material + bin(self.board.get_bitboards()['q']).count('1') + return material + + + def get_positional_score(self): + """Calculate positional bonuses based on piece placement on preferred squares. + + Returns: + Positional score (positive for white advantage, negative for black). + Magnitude: 0-500 (doesn't overwhelm material evaluation). + """ + positional_score = 0 + + for square in self.board.squares: + piece = square.occupying_piece + if piece is None: + continue + + # Get the heatmap for this piece type + piece_notation = piece.notation + y, x = square.y, square.x + + if piece_notation == ' ': # Pawn + heatmap = self.PAWN_HEATMAP + base_value = 1 + elif piece_notation == 'N': # Knight + heatmap = self.KNIGHT_HEATMAP + base_value = 1 + elif piece_notation == 'B': # Bishop + heatmap = self.BISHOP_HEATMAP + base_value = 1 + elif piece_notation == 'R': # Rook + heatmap = self.ROOK_HEATMAP + base_value = 1 + elif piece_notation == 'Q': # Queen + heatmap = self.QUEEN_HEATMAP + base_value = 1 + elif piece_notation == 'K': # King + heatmap = self.KING_HEATMAP + base_value = 1 + else: + continue + + # Get the positional value for this square + square_value = heatmap[y][x] * base_value + + # Add to score (positive for white, negative for black) + if piece.color == 'white': + positional_score += square_value + else: + positional_score -= square_value + + return positional_score + + def evaluate(self): + black_eval = self.countMaterial('b') + white_eval = self.countMaterial('w') + + material_eval = white_eval - black_eval + positional_eval = self.get_positional_score() + + # Combine material (weighted heavily) and positional evaluation + # Material: ~30-39 points total, Positional: 0-500 but usually 20-100 in opening + total_eval = material_eval * 10 + positional_eval + + perspective = 1 if self.board.turn == 'white' else -1 + + return total_eval * perspective + + \ No newline at end of file diff --git a/data/classes/chess_bot/precompute.py b/data/classes/chess_bot/precompute.py index feac52a..85e6417 100644 --- a/data/classes/chess_bot/precompute.py +++ b/data/classes/chess_bot/precompute.py @@ -24,4 +24,5 @@ def precomputed_king_moves(): return moves KNIGHT_MOVES = precomputed_knight_moves() -KING_MOVES = precomputed_king_moves() \ No newline at end of file +KING_MOVES = precomputed_king_moves() + diff --git a/data/classes/chess_bot/search.py b/data/classes/chess_bot/search.py index 34b5f72..01ac90d 100644 --- a/data/classes/chess_bot/search.py +++ b/data/classes/chess_bot/search.py @@ -1,2 +1,167 @@ -def negamax(): - pass +from data.classes.chess_bot.evaluate import Evaluate + +def get_piece_value(piece): + """Return material value of a piece for move ordering.""" + if piece is None: + return 0 + + piece_type = piece.notation + if piece_type == ' ': + return 1 + elif piece_type == 'N': + return 3 + elif piece_type == 'B': + return 3 + elif piece_type == 'R': + return 5 + elif piece_type == 'Q': + return 9 + return 0 + + +def order_moves(board, legal_moves, evaluator=None): + """Score and sort moves for alpha-beta pruning efficiency. + + Scoring heuristic: + - Captures: value_of_captured_piece * 10 - recapture_penalty + - Promotions: +8 (pawn promotion is excellent) + - Checks: +1 (forcing moves) + - Quiet moves: 0 + + Args: + board: Board object. + legal_moves: List of (piece, move_square) tuples. + evaluator: Evaluate object (optional). + + Returns: + List of (piece, move_square) tuples sorted by score (descending). + """ + if evaluator is None: + evaluator = Evaluate(board) + + scored_moves = [] + + for piece, move_square in legal_moves: + score = 0 + captured_piece = move_square.occupying_piece + + # 1. Capture scoring + if captured_piece is not None: + victim_value = get_piece_value(captured_piece) + attacker_value = get_piece_value(piece) + + score += victim_value * 10 - attacker_value + + # 2. Promotion scoring + if piece.notation == ' ' and (move_square.y == 0 or move_square.y == 7): + score += 8 + + # 3. Avoid moving to square that opponent team control + is_capturing, min_recapture_value = board.is_recapturable(piece, move_square) + if is_capturing and attacker_value > min_recapture_value: + score -= get_piece_value(piece) + + scored_moves.append((piece, move_square, score)) + + + # Sort by score descending + scored_moves.sort(key=lambda x: x[2], reverse=True) + + # Return without scores + return [(piece, move_square) for piece, move_square, score in scored_moves] + + +def alpha_beta_negamax(board, alpha, beta, depth, evaluator=None): + """Alpha-beta minimax search with make/unmake move cycle. + + Args: + board: Board object with get_all_valid_moves() and capture_move_state(). + alpha: Alpha cutoff value. + beta: Beta cutoff value. + depth: Search depth (0 = leaf node, evaluate position). + evaluator: Evaluate object. Created if None. + + Returns: + Evaluation score from current position. + """ + if evaluator is None: + evaluator = Evaluate(board) + + # Leaf node: evaluate position + if depth == 0: + return evaluator.evaluate() + + # Get all legal moves for current side + legal_moves = board.get_all_valid_moves() + + # Order moves for better alpha-beta pruning + legal_moves = order_moves(board, legal_moves, evaluator) + + # Terminal node: checkmate or stalemate + if len(legal_moves) == 0: + if board.is_in_check(): + # Checkmate: losing side to move + return -10000 - depth # Penalize distant mates + else: + # Stalemate: draw + return 0 + + # Alpha-beta minimax loop: make, recurse, unmake for each move + for piece, move_square in legal_moves: + # 1. Capture board state before move + move_state = board.capture_move_state(piece, move_square) + + # 2. Execute move + piece.move(board, move_square, force=True) + + # 3. Recurse (negate score for opposite side) + value = -alpha_beta_negamax(board, -beta, -alpha, depth - 1, evaluator) + + # 4. Unmake move (restore board to state before move) + piece.unmake_move(board, move_state) + + # 5. Alpha-beta pruning + alpha = max(alpha, value) + if alpha >= beta: + break # Beta cutoff: prune remaining moves + + return alpha + + +def find_best_move(board, depth, evaluator=None): + """Find the best move for the current position using alpha-beta negamax search. + + Args: + board: Board object. + depth: Search depth. + evaluator: Evaluate object (optional). + + Returns: + Tuple (piece, move_square) of the best move, or None if no legal moves. + """ + if evaluator is None: + evaluator = Evaluate(board) + + legal_moves = board.get_all_valid_moves() + if not legal_moves: + return None + + legal_moves = order_moves(board, legal_moves, evaluator) + + best_move = None + best_score = -float('inf') + + for piece, move_square in legal_moves: + move_state = board.capture_move_state(piece, move_square) + piece.move(board, move_square, force=True) + + score = -alpha_beta_negamax(board, -1000000, 1000000, depth - 1, evaluator) + + piece.unmake_move(board, move_state) + + if score > best_score: + best_score = score + best_move = (piece, move_square) + + return best_move + \ No newline at end of file diff --git a/data/classes/pieces/King.py b/data/classes/pieces/King.py index 6a73c87..0eb9a5b 100644 --- a/data/classes/pieces/King.py +++ b/data/classes/pieces/King.py @@ -83,14 +83,16 @@ def get_valid_moves(self, board): for square in self.get_moves(board): if not board.is_in_check(self.color, board_change=[self.pos, square.pos]): output.append(square) - + + # Castling moves with safety validation if self.can_castle(board) == 'queenside': - output.append( - board.get_square_from_pos((self.x - 2, self.y)) - ) + queenside_dest = board.get_square_from_pos((self.x - 2, self.y)) + if not board.is_in_check(self.color, board_change=[self.pos, queenside_dest.pos]): + output.append(queenside_dest) + if self.can_castle(board) == 'kingside': - output.append( - board.get_square_from_pos((self.x + 2, self.y)) - ) - + kingside_dest = board.get_square_from_pos((self.x + 2, self.y)) + if not board.is_in_check(self.color, board_change=[self.pos, kingside_dest.pos]): + output.append(kingside_dest) + return output diff --git a/data/states/PvEState.py b/data/states/PvEState.py index 80dcfea..9056870 100644 --- a/data/states/PvEState.py +++ b/data/states/PvEState.py @@ -2,6 +2,8 @@ import random from data.states.State import State from data.classes.Board import Board +from data.classes.chess_bot.search import find_best_move +from data.classes.chess_bot.evaluate import Evaluate class PvEState(State): def __init__(self, manager): @@ -51,30 +53,21 @@ def update(self): self.run_bot_move() def run_bot_move(self): - # TODO: Add Model prediction here. - # Integrate the AI model to calculate and return the best move on self.board. - + """Execute bot move using alpha-beta negamax search.""" bot_color = 'white' if self.player_color == 'black' else 'black' - # --- Temporarily use a random move to prevent soft locks during testing --- - # NOTE: Remove this stubbed logic once the model provides a move! - valid_moves = [] - for square in self.board.squares: - piece = square.occupying_piece - if piece and piece.color == bot_color: - moves = piece.get_valid_moves(self.board) - for move_square in moves: - valid_moves.append((piece, move_square)) - - if valid_moves: - piece, move_square = random.choice(valid_moves) - # The .move() function returns True if valid execution - if piece.move(self.board, move_square): - # Switch turn back to the player manually after bot hits move - self.board.turn = self.player_color - else: - # No valid moves edge case (Should be covered by checkmate checks normally) - pass + # Temporarily set turn to bot so search works correctly + self.board.turn = bot_color + + # Find best move using alpha-beta search with depth 2 + evaluator = Evaluate(self.board) + best_move = find_best_move(self.board, depth=2, evaluator=evaluator) + + if best_move: + piece, move_square = best_move + piece.move(self.board, move_square, force=True) + # Switch turn back to player + self.board.turn = self.player_color def draw(self, surface): surface.fill('white')