diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..9d7178a --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,30 @@ +name: Pylint + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') diff --git a/.gitignore b/.gitignore index 61dd7a1..2fd7334 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ __pycache__/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json + +test.py diff --git a/README.md b/README.md index 4588cdb..1d6c8d6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ # YACS - Yet Another Chess Software -> warning : currently in development. +> warning : currently in development + +## Getting Started + +#### For Developers +- clone this repo +- go to the directory where you cloned this repo +- pip install -r requirements. txt + +#### For Users +> build file(s) yet to be created diff --git a/chessboard.py b/chessboard.py index 53b2ba3..d9e4a8a 100644 --- a/chessboard.py +++ b/chessboard.py @@ -14,20 +14,40 @@ def __init__(self): self.board = chess.Board(chess960=self.fischer_random) self.is_board_flipped = False self.move_manager = MoveManager(self) + self.starting_board_position_fen = None def set_chess960_board(self): self.board.set_chess960_pos(random.randint(1, 959)) - def get_square_coordinates(self, square): + def get_pieces_squares(self, piece_name, piece_color): """ - col, row, x, y = self.get_square_coordinates(square) + returns the list of squares coordinates of the given piece name & color + get_pieces_squares(chess.ROOK, chess.WHITE) => [(0, 7), (7, 7)] + """ + squares = [] + for square in self.board.pieces(piece_name, piece_color): + if self.is_board_flipped: + squares.append( + (7 - chess.square_file(square), chess.square_rank(square)) + ) + else: + squares.append( + (chess.square_file(square), 7 - chess.square_rank(square)) + ) + + return squares + + def get_square_coordinates(self, square_number): + """ + returns the coordinates of given square number + col, row, x, y = self.get_square_coordinates(20) """ if self.is_board_flipped: - col = 7 - chess.square_file(square) - row = chess.square_rank(square) + col = 7 - chess.square_file(square_number) + row = chess.square_rank(square_number) else: - col = chess.square_file(square) - row = 7 - chess.square_rank(square) + col = chess.square_file(square_number) + row = 7 - chess.square_rank(square_number) return col, row, col * vars.SQUARE_SIZE, row * vars.SQUARE_SIZE def get_selected_square_number(self, event): @@ -42,27 +62,148 @@ def get_selected_square_number(self, event): return chess.square(7 - col, row) return chess.square(col, 7 - row) + def get_source_square_from_move(self, move): + """ + returns a square coordinates where piece is moved from + source_square = get_source_square_from_move(e2e4) + => (4, 6) + """ + if self.is_board_flipped: + return ( + 7 - chess.square_file(move.from_square), + chess.square_rank(move.from_square), + ) + return chess.square_file(move.from_square), 7 - chess.square_rank( + move.from_square + ) + + def get_destination_square_from_move(self, move): + """ + returns a square coordinates where piece is moved to + destination_square = get_destination_square_from_move(e2e4) + => (4, 4) + """ + if self.is_board_flipped: + return ( + 7 - chess.square_file(move.to_square), + chess.square_rank(move.to_square), + ) + return chess.square_file(move.to_square), 7 - chess.square_rank(move.to_square) + + def get_selected_piece_color_and_name(self, square_number): + """ + returns the color and name of the piece at the selected square + """ + piece = self.board.piece_at(square_number) + if piece: + piece_color = "w" if piece.color == chess.WHITE else "b" + piece_name = piece.symbol().upper() + return piece_color, piece_name + return None, None + + def get_board_turn(self): + """ + returns which player's turn to play + """ + return "w" if self.board.turn == chess.WHITE else "b" + + def highlight_legal_moves(self, scene, square_number): + """ + highlights the legal moves of a selected piece/square + """ + legal_moves = self.move_manager.get_legal_moves(square_number) + + for target_square in set(move.to_square for move in legal_moves): + col, row, x, y = self.get_square_coordinates(target_square) + + # Add a circle in the center of the square + circle = scene.addEllipse( + x + vars.SQUARE_SIZE / 3, + y + vars.SQUARE_SIZE / 3, + vars.SQUARE_SIZE / 3, + vars.SQUARE_SIZE / 3, + ) + circle.setPen(QtCore.Qt.NoPen) + circle.setBrush(QtGui.QColor(vars.THEME_COLORS["highlight_legal_moves"])) + circle.setOpacity(0.45) + + def delete_highlighted_legal_moves(self, scene): + items = scene.items() + for item in items: + if isinstance(item, QtWidgets.QGraphicsEllipseItem): + brush_color = item.brush().color() + if brush_color == QtGui.QColor( + vars.THEME_COLORS["highlight_legal_moves"] + ): + scene.removeItem(item) + class ChessBoardEvents: def __init__(self, chessboard): self.chessboard = chessboard def mousePress(self, event): + square_number = self.chessboard.get_selected_square_number(event) if event.buttons() == QtCore.Qt.LeftButton: - square_number = self.chessboard.get_selected_square_number(event) - if self.chessboard.move_manager.selected_square is None: self.chessboard.move_manager.selected_square = square_number + self.chessboard.highlight_legal_moves( + self.chessboard.scene, self.chessboard.move_manager.selected_square + ) else: if square_number == self.chessboard.move_manager.selected_square: self.chessboard.move_manager.selected_square = None + self.chessboard.delete_highlighted_legal_moves( + self.chessboard.scene + ) return self.chessboard.move_manager.move_piece(square_number) + if self.chessboard.move_manager.is_piece_moved is True: + # this if condition is here because, in chess960 variant, user + # have to click on a rook to do castling and don't know why the + # `get_selected_piece_color_and_name` method returns None if user + # try to click on a rook to do castling. + # (tested on chess960 position number 665, 342 or similar positions) + if self.chessboard.fischer_random and ( + self.chessboard.move_manager.is_queenside_castling + or self.chessboard.move_manager.is_kingside_castling + ): + piece_color = ( + "b" if self.chessboard.board.turn == chess.WHITE else "w" + ) + piece_name = "R" + else: + piece_color, piece_name = ( + self.chessboard.get_selected_piece_color_and_name( + square_number + ) + ) + last_move = self.chessboard.move_manager.get_last_move() + source_square = self.chessboard.get_source_square_from_move( + last_move + ) + destination_square = ( + self.chessboard.get_destination_square_from_move(last_move) + ) + + self.chessboard.chess_pieces.delete_piece(source_square) + + if self.chessboard.move_manager.is_capture: + self.chessboard.chess_pieces.delete_piece(destination_square) + self.chessboard.move_manager.is_capture = False + + self.chessboard.chess_pieces.draw_piece( + piece_name, piece_color, destination_square + ) + + self.chessboard.move_manager.is_piece_moved = False self.chessboard.move_manager.selected_square = None - self.chessboard.chess_pieces.delete_pieces() - self.chessboard.chess_pieces.draw_pieces() + self.chessboard.delete_highlighted_legal_moves( + self.chessboard.scene + ) + square_number = None class DrawChessBoard(QtWidgets.QGraphicsView, ChessBoard): @@ -71,9 +212,10 @@ def __init__(self): super().__init__() self.scene = QtWidgets.QGraphicsScene() self.setScene(self.scene) - self.chess_pieces = ChessPieces( - self, self.is_board_flipped, self.scene, "cardinal" + self.setRenderHints( + QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) + self.chess_pieces = ChessPieces(self, self.scene, "cardinal") self.chess_pieces.load_chess_piece_images() self.show_labels = True self.events = ChessBoardEvents(self) @@ -141,6 +283,9 @@ def draw_chessboard(self): if self.show_labels: self.draw_labels() self.chess_pieces.draw_pieces() + self.starting_board_position_fen = ( + self.board.board_fen() + ) # hack to get the fen of only starting position def mousePressEvent(self, event): self.events.mousePress(event) diff --git a/chesspieces.py b/chesspieces.py index a5ee890..a22c604 100644 --- a/chesspieces.py +++ b/chesspieces.py @@ -1,12 +1,13 @@ -import vars import chess from PySide6 import QtCore, QtGui, QtSvg, QtSvgWidgets, QtWidgets +import vars + + class ChessPieces: - def __init__(self, chessboard, is_chessboard_flipped, scene, piece_set="staunty"): + def __init__(self, chessboard, scene, piece_set="staunty"): self.chessboard = chessboard - self.is_chessboard_flipped = is_chessboard_flipped self.scene = scene self.piece_images = {} self.piece_set = piece_set @@ -37,7 +38,7 @@ def draw_pieces(self): piece_name = char.upper() piece_color = "w" if char.isupper() else "b" - if self.is_chessboard_flipped: + if self.chessboard.is_board_flipped: x = (7 - square % 8) * vars.SQUARE_SIZE + 5 y = (7 - square // 8) * vars.SQUARE_SIZE + 5 else: @@ -57,3 +58,174 @@ def delete_pieces(self): for item in items: if isinstance(item, QtWidgets.QGraphicsPixmapItem): self.scene.removeItem(item) + + def delete_piece(self, square): + items = self.scene.items() + x = square[0] * vars.SQUARE_SIZE + 5 + y = square[1] * vars.SQUARE_SIZE + 5 + for item in items: + if isinstance( + item, QtWidgets.QGraphicsPixmapItem + ) and item.pos() == QtCore.QPointF(x, y): + self.scene.removeItem(item) + + def get_piece_position(self, piece_name, fen): + rows = fen.split("/") + + piece_positions = [] + for y, row in enumerate(rows): + x = 0 + for char in row: + if char in piece_name: + if self.chessboard.is_board_flipped: + piece_positions.append((7 - x, 7 - y)) + else: + piece_positions.append((x, y)) + if char.isdigit(): + x += int(char) + else: + x += 1 + + return piece_positions + + def draw_piece(self, piece_name, piece_color, destination_square): + x = destination_square[0] * vars.SQUARE_SIZE + 5 + y = destination_square[1] * vars.SQUARE_SIZE + 5 + + piece_item = QtWidgets.QGraphicsPixmapItem( + self.piece_images[(piece_color, piece_name)] + ) + piece_item.setPos(x, y) + self.scene.addItem(piece_item) + + self.handle_special_cases(piece_color, destination_square) + + def handle_special_cases(self, piece_color, destination_square): + """ + handle special cases such as, castling & en-passant + for drawing and removing pieces at source & destination square + """ + white_rooks_positions = self.chessboard.get_pieces_squares( + chess.ROOK, chess.WHITE + ) + black_rooks_positions = self.chessboard.get_pieces_squares( + chess.ROOK, chess.BLACK + ) + white_king_position = self.chessboard.get_pieces_squares( + chess.KING, chess.WHITE + ) + black_king_position = self.chessboard.get_pieces_squares( + chess.KING, chess.BLACK + ) + + white_rooks_positions_before_castling = self.get_piece_position( + ["R"], self.chessboard.starting_board_position_fen + ) + black_rooks_positions_before_castling = self.get_piece_position( + ["r"], self.chessboard.starting_board_position_fen + ) + white_king_position_before_castling = self.get_piece_position( + ["K"], self.chessboard.starting_board_position_fen + ) + black_king_position_before_castling = self.get_piece_position( + ["k"], self.chessboard.starting_board_position_fen + ) + + ep_pawn_square = ( + destination_square[0], + ( + destination_square[1] + 1 + if piece_color == "w" + else destination_square[1] - 1 + ), + ) + + # check if the move is kingside castling + if self.chessboard.move_manager.is_kingside_castling == True: + # delete the rook from old square + if piece_color == "w": + self.delete_piece(white_rooks_positions_before_castling[1]) + if piece_color == "b": + self.delete_piece(black_rooks_positions_before_castling[1]) + + # draw the rook to new square + rook = QtWidgets.QGraphicsPixmapItem( + self.piece_images[("w" if piece_color == "w" else "b", "R")] + ) + if piece_color == "w": + x = white_rooks_positions[1][0] * vars.SQUARE_SIZE + 5 + y = white_rooks_positions[1][1] * vars.SQUARE_SIZE + 5 + if piece_color == "b": + x = black_rooks_positions[1][0] * vars.SQUARE_SIZE + 5 + y = black_rooks_positions[1][1] * vars.SQUARE_SIZE + 5 + rook.setPos(x, y) + self.scene.addItem(rook) + + # draw the king to new square + king = QtWidgets.QGraphicsPixmapItem( + self.piece_images[("w" if piece_color == "w" else "b", "K")] + ) + if piece_color == "w": + x = white_king_position[0][0] * vars.SQUARE_SIZE + 5 + y = white_king_position[0][1] * vars.SQUARE_SIZE + 5 + if piece_color == "b": + x = black_king_position[0][0] * vars.SQUARE_SIZE + 5 + y = black_king_position[0][1] * vars.SQUARE_SIZE + 5 + + if white_king_position == white_king_position_before_castling: + pass + elif black_king_position == black_king_position_before_castling: + pass + else: + king.setPos(x, y) + self.scene.addItem(king) + + self.chessboard.move_manager.is_kingside_castling = False + + # check if the move is queenside castling + if self.chessboard.move_manager.is_queenside_castling == True: + # delete the rook from old square + if piece_color == "w": + self.delete_piece(white_rooks_positions_before_castling[0]) + if piece_color == "b": + self.delete_piece(black_rooks_positions_before_castling[0]) + + # draw the rook to new square + rook = QtWidgets.QGraphicsPixmapItem( + self.piece_images[("w" if piece_color == "w" else "b", "R")] + ) + if piece_color == "w": + x = white_rooks_positions[0][0] * vars.SQUARE_SIZE + 5 + y = white_rooks_positions[0][1] * vars.SQUARE_SIZE + 5 + if piece_color == "b": + x = black_rooks_positions[0][0] * vars.SQUARE_SIZE + 5 + y = black_rooks_positions[0][1] * vars.SQUARE_SIZE + 5 + rook.setPos(x, y) + self.scene.addItem(rook) + + # draw the king to new square + king = QtWidgets.QGraphicsPixmapItem( + self.piece_images[("w" if piece_color == "w" else "b", "K")] + ) + if piece_color == "w": + x = white_king_position[0][0] * vars.SQUARE_SIZE + 5 + y = white_king_position[0][1] * vars.SQUARE_SIZE + 5 + if piece_color == "b": + x = black_king_position[0][0] * vars.SQUARE_SIZE + 5 + y = black_king_position[0][1] * vars.SQUARE_SIZE + 5 + + if white_king_position == white_king_position_before_castling: + pass + elif black_king_position == black_king_position_before_castling: + pass + else: + king.setPos(x, y) + self.scene.addItem(king) + + self.chessboard.move_manager.is_queenside_castling = False + + # check if the move is an en-passant capture + if self.chessboard.move_manager.is_ep: + # delete opponent's pawn from the scene at the square + self.delete_piece(ep_pawn_square) + self.chessboard.move_manager.is_ep = False diff --git a/movemanager.py b/movemanager.py index da318e6..1b1da37 100644 --- a/movemanager.py +++ b/movemanager.py @@ -1,5 +1,6 @@ import chess -from PySide6 import QtWidgets +from PySide6 import QtWidgets, QtCore +import vars class MoveManager: @@ -8,6 +9,10 @@ def __init__(self, chessboard): self.chessboard = chessboard self.selected_square = None self.is_piece_moved = False + self.is_capture = False + self.is_ep = False + self.is_kingside_castling = False + self.is_queenside_castling = False def move_piece(self, target_square): if self.selected_square is not None: @@ -18,7 +23,14 @@ def move_piece(self, target_square): ): if self._is_pawn_promotion(target_square): self._show_pawn_promotion_dialog(move) - + if self.chessboard.board.is_capture(move): + self.is_capture = True + if self.chessboard.board.is_en_passant(move): + self.is_ep = True + if self.chessboard.board.is_kingside_castling(move): + self.is_kingside_castling = True + if self.chessboard.board.is_queenside_castling(move): + self.is_queenside_castling = True self.chessboard.board.push(move) self.is_piece_moved = True break @@ -44,6 +56,16 @@ def _show_pawn_promotion_dialog(self, move): pawn_promotion = PawnPromotion(self.chessboard) pawn_promotion.pawn_promotion_dialog(move) + def get_last_move(self): + return self.chessboard.board.peek() + + def get_legal_moves(self, square): + moves = [] + for move in self.chessboard.board.legal_moves: + if move.from_square == square: + moves.append(move) + return moves + class PawnPromotion: def __init__(self, chessboard): diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..780eb5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +chess==1.10.0 +PySide6==6.6.2 +PySide6_Addons==6.6.2 +PySide6_Essentials==6.6.2 +shiboken6==6.6.2