24 Commits

Author SHA1 Message Date
c14a8c83b3 WE CAN FINALLY PLAY THE GAME. Made controller
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
working
2025-01-31 11:39:23 +01:00
ac85f3e6d3 added controller to model view controller
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
2025-01-31 10:52:25 +01:00
ddfb95176b fixed import issue with the queen 2025-01-31 10:52:02 +01:00
bb0a3266c7 implemented queen legal moves
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
2025-01-30 17:12:53 +01:00
aabbaa83a8 again extracted some logic, implemented rook legal
moves
2025-01-30 17:11:36 +01:00
96b9b3db86 extracted some logic to the piece class and
implemented the knights legal moves
2025-01-30 17:07:50 +01:00
6b0a134230 fixed more colour issues 2025-01-30 16:56:44 +01:00
e95caa0015 implemented bishop's possible moves
Some checks are pending
pre-release / Pre Release (push) Waiting to run
2025-01-30 11:48:45 +01:00
bb0b8cdd27 imported colour also in gui 2025-01-30 11:48:27 +01:00
55ba824b13 fixed circular dependency 2025-01-30 11:48:03 +01:00
16d107e5ea fixed the colour that was extracted 2025-01-30 11:45:47 +01:00
baa09135ee made is_capturing a kwarg 2025-01-30 10:46:06 +01:00
eae87f353b now the legal moves of the pawn actually returns
moves
2025-01-30 10:45:43 +01:00
362b0e157d annotated the legal moves function better 2025-01-30 10:45:24 +01:00
c900ebcfa0 extracted the colour to an enum 2025-01-30 10:45:12 +01:00
c3e46017eb now the legal moves function actually returns a
list of moves
2025-01-30 10:28:41 +01:00
324484aa31 created the move class 2025-01-30 09:41:46 +01:00
eca7a6ae0c now black pawns can also go forward 2025-01-29 16:59:13 +01:00
ffe76b161a minor fixes 2025-01-29 16:55:54 +01:00
06f78487d9 the FEN notation can be read to create a position
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
2025-01-29 16:50:08 +01:00
51648a5960 fixed some issues, now showing legal moves of
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
selected piece
2025-01-29 15:02:52 +01:00
331c475c2a made members of enum better 2025-01-29 15:02:31 +01:00
28ef132944 created basic gui 2025-01-29 14:46:04 +01:00
f7c0dcbd4b final update (spoiler: no) of the github workflow
Some checks are pending
pre-release / Pre Release (push) Waiting to run
2025-01-29 12:11:36 +01:00
14 changed files with 449 additions and 51 deletions

View File

@ -17,3 +17,4 @@ jobs:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: true
automatic_release_tag: "latest"
title: "Development Build"

View File

@ -0,0 +1,57 @@
from logic.board import Board
from logic.move import Move
from logic.pieces.piece import Piece
from logic.position import Position
from view.view import View
class Controller:
def __init__(self, board: Board, view: View) -> None:
self._board = board
self._view = view
self._view.set_controller(self)
self._reset_selection()
self._selected_piece: Piece = None
self._legal_moves: list[Move] = []
def _reset_selection(self):
self._selected_piece = None
self._legal_moves = []
self._view.update_board(self._board, self._selected_piece, self._legal_moves)
def _show_legal_moves(self, pos: Position):
piece = self._board.piece_at(pos.x, pos.y)
if piece:
if piece.colour != self._board._turn:
return
self._selected_piece = piece
self._legal_moves = piece.legal_moves(self._board)
self._view.update_board(self._board, self._selected_piece, self._legal_moves)
else:
self._reset_selection()
def _make_move(self, move: Move) -> None:
self._board = self._board.make_move(move)
self._reset_selection()
def on_tile_selected(self, x: int, y: int) -> None:
pos = Position(x, y)
print(f"Clicked on {pos.to_algebraic()}")
piece = self._board.piece_at(x, y)
if self._selected_piece is None or (piece is not None and piece != self._selected_piece):
self._show_legal_moves(pos)
else:
legal_moves_positions = [move for move in self._legal_moves if move.pos == pos]
assert len(legal_moves_positions) <= 1, f"Apparently we can make multiple moves towards {pos.to_algebraic()} with {type(self._selected_piece)}, which doesn't make sense..."
if len(legal_moves_positions) == 0: # click on a square outside of the possible moves
self._reset_selection()
else:
move = legal_moves_positions[0]
self._make_move(move)

View File

@ -1,41 +1,106 @@
from logic.move import Move
from logic.pieces.bishop import Bishop
from logic.pieces.king import King
from logic.pieces.knight import Knight
from logic.pieces.queen import Queen
from logic.pieces.rook import Rook
from logic.pieces.pawn import Pawn
from logic.pieces.piece import Piece
from logic.pieces.piece import Colour, Piece
from logic.position import Position
from typing import Type
class Board:
def __init__(self) -> None:
self._white: dict[Position, Piece] = {}
self._black: dict[Position, Piece] = {}
self._turn = None
self._white_castling_write = set()
self._black_castling_write = set()
self._en_passant_target = None
for x in range(8):
pos_w_pawn = Position(x, 1)
pos_b_pawn = Position(x, 6)
@staticmethod
def _piece_class_from_char(c: str) -> Type[Piece]:
assert len(c) == 1, f"The piece {c} isn't denoted by 1 character"
c = c.lower()
if c == "p":
return Pawn
if c == "r":
return Rook
if c == "n":
return Knight
if c == "b":
return Bishop
if c == "q":
return Queen
if c == "k":
return King
raise ValueError(f"Unknown piece '{c}'")
self._white[pos_w_pawn] = Pawn(pos_w_pawn, Piece.WHITE)
self._black[pos_b_pawn] = Pawn(pos_b_pawn, Piece.BLACK)
@staticmethod
def setup_FEN_position(position: str) -> "Board":
ret = Board()
index = 0
pos_w_piece = Position(x, 0)
pos_b_piece = Position(x, 7)
# -- Pieces
pieces = "prnbqk" # possible pieces
numbers = "12345678" # possible number of empty squares
piece = None
if x == 0 or x == 7:
piece = Rook
elif x == 1 or x == 6:
piece = Knight
elif x == 2 or x == 5:
piece = Bishop
elif x == 3:
piece = Queen
elif x == 4:
piece = King
assert piece != None, f"Didn't know which piece to assign for {x = }"
self._white[pos_w_piece] = piece(pos_w_piece, Piece.WHITE)
self._black[pos_b_piece] = piece(pos_b_piece, Piece.BLACK)
x = 0
y = 7 # FEN starts from the top left, so 8th rank
for c in position:
index += 1
if c == " ":
break
if c in pieces or c in pieces.upper():
pos = Position(x, y)
piece = Board._piece_class_from_char(c)
if c.isupper():
ret._white[pos] = piece(pos, Colour.WHITE)
else:
ret._black[pos] = piece(pos, Colour.BLACK)
x += 1
continue
if c in numbers:
x += int(c)
if c == '/':
x = 0
y -= 1
# -- Active colour
if position[index] == "w":
ret._turn = Colour.WHITE
elif position[index] == "b":
ret._turn = Colour.BLACK
else:
raise ValueError(f"The FEN position is malformed, the active colour should be either 'w' or 'b', but is '{position[index]}'")
index += 1
# -- Castling Rights
for c in position[index:]:
index += 1
if c == "-" or c == " ":
break
sides = "kq"
assert c in sides or c in sides.upper(), f"The FEN position is malformed, the castling rights should be either k or q (both either lower- or upper-case), instead is '{c}'"
if c == "K":
ret._white_castling_write.add(Board.KING_SIDE_CASTLE)
if c == "Q":
ret._white_castling_write.add(Board.QUEEN_SIDE_CASTLE)
if c == "k":
ret._black_castling_write.add(Board.KING_SIDE_CASTLE)
if c == "q":
ret._black_castling_write.add(Board.QUEEN_SIDE_CASTLE)
# -- En passant target
if position[index] != "-":
ret._en_passant_target = position[index:index+2]
return ret
def piece_at(self, x: int, y: int) -> Piece | None:
pos = Position(x, y)
@ -48,5 +113,31 @@ class Board:
return white_piece
return black_piece
def create_board():
return Board()
def make_move(self, move: Move) -> "Board":
dest_piece = self.piece_at(move.pos.x, move.pos.y)
if dest_piece:
assert dest_piece.colour != move.piece.colour, "A piece cannot cannot eat another piece of the same colour"
ret = Board()
ret._white = self._white.copy()
ret._black = self._black.copy()
ret._turn = Colour.WHITE if self._turn == Colour.BLACK else Colour.BLACK
ret._white_castling_write = self._white_castling_write.copy()
ret._black_castling_write = self._black_castling_write.copy()
ret._en_passant_target = self._en_passant_target
piece = move.piece
if piece.colour == Colour.WHITE:
del ret._white[piece.pos]
ret._white[move.pos] = piece.move_to(move.pos)
if move.pos in ret._black:
del ret._black[move.pos]
else:
del ret._black[piece.pos]
ret._black[move.pos] = piece.move_to(move.pos)
if move.pos in ret._white:
del ret._white[move.pos]
return ret

25
src/logic/move.py Normal file
View File

@ -0,0 +1,25 @@
# from logic.pieces.piece import Piece
from logic.position import Position
from enum import Enum
class Move:
def __init__(self, is_capturing: bool) -> None:
self.is_capturing = is_capturing
def to_algebraic(self) -> str:
raise NotImplementedError("The move can't be translated to algbraic notation, as it was not implemented")
@staticmethod
def from_algebraic(move: str) -> "Move":
raise NotImplementedError("The move can't be translated from algbraic notation, as it was not implemented")
class PieceMove(Move):
def __init__(self, piece: "Piece", pos: Position,/, is_capturing: bool = False) -> None:
super().__init__(is_capturing)
self.piece = piece
self.pos = pos
class Castle(Move, Enum):
KING_SIDE_CASTLE = False
QUEEN_SIDE_CASTLE = False

View File

@ -1,4 +1,21 @@
from logic.move import Move
from .piece import Piece
class Bishop(Piece):
pass
def legal_moves(self, board: "Board") -> list[Move]:
ret = []
# looking north east
ret.extend(self._look_direction(board, 1, 1))
# looking south east
ret.extend(self._look_direction(board, 1, -1))
# looking south west
ret.extend(self._look_direction(board, -1, -1))
# looking north west
ret.extend(self._look_direction(board, -1, 1))
return ret

View File

@ -1,5 +1,16 @@
from .piece import Piece
class Knight(Piece):
pass
def legal_moves(self, board: "Board") -> list["Move"]:
ret = []
for dx, dy in [
(+2, +1), (+1, +2), # north east
(+2, -1), (+1, -2), # south east
(-2, -1), (-1, -2), # south west
(-2, +1), (-1, +2), # north west
]:
move = self._move_for_position(board, self.pos.x + dx, self.pos.y + dy)
if move is not None:
ret.append(move)
return ret

View File

@ -1,31 +1,40 @@
from logic.move import Move, PieceMove
from logic.pieces.piece import Colour, Piece
from logic.position import Position
from logic.pieces.piece import Piece
class Pawn(Piece):
def legal_moves(self, board) -> list[Position]:
def legal_moves(self, board) -> list[Move]:
ret = []
# can we capture to the left?
if self.pos.x > 0 and (
(self.colour == self.WHITE and (capturable_piece := board.piece_at(self.pos.x - 1, self.pos.y + 1)))
(self.colour == Colour.WHITE and (capturable_piece := board.piece_at(self.pos.x - 1, self.pos.y + 1)))
or
(self.colour == self.BLACK and (capturable_piece := board.piece_at(self.pos.x - 1, self.pos.y - 1)))
(self.colour == Colour.BLACK and (capturable_piece := board.piece_at(self.pos.x - 1, self.pos.y - 1)))
):
if capturable_piece.colour != self.colour:
ret.append(capturable_piece.pos)
ret.append(PieceMove(self, capturable_piece.pos, is_capturing = True))
# can we capture to the right?
if self.pos.x < 7 and (
(self.colour == self.WHITE and (capturable_piece := board.piece_at(self.pos.x + 1, self.pos.y + 1)))
(self.colour == Colour.WHITE and (capturable_piece := board.piece_at(self.pos.x + 1, self.pos.y + 1)))
or
(self.colour == self.BLACK and (capturable_piece := board.piece_at(self.pos.x + 1, self.pos.y - 1)))
(self.colour == Colour.BLACK and (capturable_piece := board.piece_at(self.pos.x + 1, self.pos.y - 1)))
):
if capturable_piece.colour != self.colour:
ret.append(capturable_piece.pos)
ret.append(PieceMove(self, capturable_piece.pos, is_capturing = True))
for dy in range(1, 3 if self.pos.y == 1 else 2):
if self.pos.y + dy > 7 or board.piece_at(self.pos.x, self.pos.y + dy):
break
ret.append(Position(self.pos.x, self.pos.y + dy))
if self.colour == Colour.WHITE:
for dy in range(1, 3 if self.pos.y == 1 else 2):
if self.pos.y + dy > 7 or board.piece_at(self.pos.x, self.pos.y + dy):
break
pos = Position(self.pos.x, self.pos.y + dy)
ret.append(PieceMove(self, pos))
else:
for dy in range(1, 3 if self.pos.y == 6 else 2):
if self.pos.y - dy < 0 or board.piece_at(self.pos.x, self.pos.y - dy):
break
pos = Position(self.pos.x, self.pos.y - dy)
ret.append(PieceMove(self, pos))
return ret

View File

@ -1,17 +1,51 @@
from logic.move import Move, PieceMove
from logic.position import Position
from enum import Enum
class Colour(Enum):
WHITE = "white"
BLACK = "black"
class Piece:
WHITE = 0
BLACK = 1
def __init__(self, pos, colour) -> None:
def __init__(self, pos: Position, colour: Colour) -> None:
self.pos = pos
assert colour == self.WHITE or colour == self.BLACK, "The colour of the piece must be either Piece.WHITE or Piece.BLACK"
assert colour == Colour.WHITE or colour == Colour.BLACK, "The colour of the piece must be either Piece.WHITE or Piece.BLACK"
self.colour = colour
def _look_direction(self, board: "Board", mult_dx: int, mult_dy: int):
ret = []
for d in range(1, 8):
dx = mult_dx * d
dy = mult_dy * d
move = self._move_for_position(board, self.pos.x + dx, self.pos.y + dy)
if move is None:
break
ret.append(move)
if move.is_capturing:
break
return ret
def _move_for_position(self, board: "Board", x: int, y: int) -> Move | None:
if not Position.is_within_bounds(x, y):
return None
piece = board.piece_at(x, y)
if piece is None:
return PieceMove(self, Position(x, y))
if piece.colour != self.colour:
return PieceMove(self, Position(x, y), is_capturing=True)
return None
def position(self) -> Position:
return self.pos
def legal_moves(self, board) -> list[Position]:
def move_to(self, pos: Position) -> "Piece":
ret = type(self)(pos, self.colour)
return ret
def legal_moves(self, board: "Board") -> list["Move"]:
raise NotImplementedError(f"Can't say what the legal moves are for {type(self).__name__}, the method hasn't been implemented yet")

View File

@ -1,5 +1,32 @@
from logic.move import Move
from .piece import Piece
class Queen(Piece):
pass
def legal_moves(self, board: "Board") -> list[Move]:
ret = []
# looking north east
ret.extend(self._look_direction(board, 1, 1))
# looking south east
ret.extend(self._look_direction(board, 1, -1))
# looking south west
ret.extend(self._look_direction(board, -1, -1))
# looking north west
ret.extend(self._look_direction(board, -1, 1))
# looking east
ret.extend(self._look_direction(board, 1, 0))
# looking south
ret.extend(self._look_direction(board, 0, -1))
# looking west
ret.extend(self._look_direction(board, -1, 0))
# looking north
ret.extend(self._look_direction(board, 0, 1))
return ret

View File

@ -1,5 +1,20 @@
from logic.move import Move
from .piece import Piece
class Rook(Piece):
pass
def legal_moves(self, board: "Board") -> list[Move]:
ret = []
# looking east
ret.extend(self._look_direction(board, 1, 0))
# looking south
ret.extend(self._look_direction(board, 0, -1))
# looking west
ret.extend(self._look_direction(board, -1, 0))
# looking north
ret.extend(self._look_direction(board, 0, 1))
return ret

View File

@ -1,4 +1,7 @@
class Position:
_RANKS = range(1, 9)
_FILES = "abcdefgh"
_MIN_POS = 0
_MAX_POS = 7
@ -9,6 +12,14 @@ class Position:
self.x = x
self.y = y
@staticmethod
def is_within_bounds(x: int, y: int) -> bool:
return x >= Position._MIN_POS and x <= Position._MAX_POS \
and y >= Position._MIN_POS and y <= Position._MAX_POS
def to_algebraic(self) -> str:
return f"{Position._FILES[self.x]}{Position._RANKS[self.y]}"
def __eq__(self, value: object, /) -> bool:
if type(value) != type(self):
return False

View File

@ -1,9 +1,14 @@
from logic.board import create_board
from controller.controller import Controller
from logic.board import Board
from view.gui import GUI
from view.tui import TUI
if __name__ == "__main__":
board = create_board()
initial_board_position = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
board = Board.setup_FEN_position(initial_board_position)
view = TUI(board)
view = GUI()
controller = Controller(board, view)
view.show()

87
src/view/gui.py Normal file
View File

@ -0,0 +1,87 @@
import tkinter as tk
from logic.board import Board
from logic.move import Move
from logic.pieces.piece import Colour, Piece
from logic.position import Position
from view.view import View
class GUI(View):
def __init__(self) -> None:
super().__init__()
self.root = tk.Tk()
self.root.title("Chess Board")
self.tile_size = 80
board_size = self.tile_size * 8
self.canvas = tk.Canvas(self.root, width=board_size, height=board_size)
self.canvas.pack()
self.canvas.bind("<Button-1>", self._on_canvas_click)
def _on_canvas_click(self, event):
x, y = event.x // self.tile_size, event.y // self.tile_size
y = 7 - y
self._controller.on_tile_selected(x, y)
def update_board(self, board: Board, selected_piece: Piece, legal_moves: list[Move]) -> None:
self.canvas.delete("all")
self._draw_chess_board(board, selected_piece, legal_moves)
def _draw_chess_board(self, board, selected_piece = None, legal_moves = []):
colours = ["#F0D9B5", "#B58863"] # Light and dark squares
for y in range(8):
for x in range(8):
colour = colours[(x + y) % 2]
if selected_piece is not None:
possible_positions = [move.pos for move in legal_moves]
if Position(x, 7-y) in possible_positions:
colour = "#ADD8E6" # Highlight legal moves
self.canvas.create_rectangle(
x * self.tile_size,
y * self.tile_size,
(x + 1) * self.tile_size,
(y + 1) * self.tile_size,
fill=colour,
outline=colour,
)
piece = board.piece_at(x, 7-y)
if piece:
text_colour = "white" if piece.colour == Colour.WHITE else "black"
self.canvas.create_text(
(x + 0.5) * self.tile_size,
(y + 0.5) * self.tile_size,
text=piece.__class__.__name__[0],
fill=text_colour,
font=("Arial", 32, "bold")
)
# Cell annotations
text_colour = colours[(x + y + 1) % 2] # the other colour
if x == 0: # numbers in the top left of the first column
self.canvas.create_text(
(x + 0.15) * self.tile_size,
(y + 0.15) * self.tile_size,
text=8-y,
fill=text_colour,
font=("Arial", 10, "bold")
)
if y == 7: # numbers in the top left of the first column
self.canvas.create_text(
(x + 0.85) * self.tile_size,
(y + 0.85) * self.tile_size,
text="abcdefgh"[x],
fill=text_colour,
font=("Arial", 10, "bold")
)
def show(self) -> None:
self.root.mainloop()

View File

@ -1,10 +1,18 @@
from logic.board import Board
from logic.move import Move
from logic.pieces.piece import Piece
class View:
def __init__(self, board: Board) -> None:
self.board: Board = board
def __init__(self) -> None:
self._controller: "Controller" = None
def show(self) -> None:
raise NotImplementedError(f"Can't show the board, the show() method of {type(self)} is not implemented")
def update_board(self, board: Board, selected_piece: Piece, legal_moves: list[Move]) -> None:
raise NotImplementedError(f"Can't update the board, the update_board() method of {type(self)} is not implemented")
def set_controller(self, controller: "Controller") -> None:
self._controller = controller