Chess on pygame: take two

I recently posted my first article, in which I talked about the experience of creating a chess game for two on pygame. There I encountered a lot of objective criticism: there were really a lot of mistakes. And so today I will try to fix all my mistakes by writing a clean and structured code taking into account all the criticism. Let's get started

Let's create a matrix corresponding to the board, where the dot is an empty cell

Board=[['.']*8 for y in range(8)]

Now let's create a shape class

class ChessPiece():
    def __init__(self, name, color):
        self.color=color
        self.already_moved=False
        self.name=name
    def __str__(self):
        return self.name+self.color

To create an instance of the class, we will enter the name and color. The name is the Latin designation of the figure in the standard chess notation (exception: P-pawn). To denote color, let's take 0 for white, 1 for black.

We can already add instances of the ChessPiece class to the board matrix, but we will do this later.

It would be good to create a method in the class that returns a list of moves available to the figure. To do this, we will create a dictionary attack_dict. Now I will explain why it is needed

attack_dict={'R':[[0,1],[1,0],[0,-1],[-1,0],1],
            'B':[[1,1],[-1,-1],[1,-1],[-1,1],1],
            'Q':[[1,1],[-1,-1],[1,-1],[-1,1],[0,1],[1,0],[0,-1],[-1,0],1],
            'N':[[1,2],[2,1],[-1,-2],[-2,-1],[-1,2],[-2,1],[1,-2],[2,-1],0],
            'K':[[1,1],[-1,-1],[1,-1],[-1,1],[0,1],[1,0],[0,-1],[-1,0],0],}

Let me explain: by entering the name of the figure as a key for the dictionary, we get a list. All values ​​except the last one are the direction of the attack, they show the shift in X and Y that must be made to get the cell that the figure attacks. The last value shows the length of the attack. If 1, then the figure attacks the entire field, if 0, then once.

It's time to add the get_moves method to the class, it will return a list of moves available to the figure.
We will use the same method later to check for a check. So, if we find that we are attacking the enemy king, we will return True instead of the list of moves, because the opportunity to cut down the king in the game does not present itself anyway.

def get_moves(self, x, y):
    moves=[]
    piece=Board[y][x]
    attack=attack_dict[piece.name][0:-1]
    for shift in attack: #shift - смещение о котором говорил ранее
        pos=[x,y]
        for i in range(attack_dict[piece.name][-1]*6+1): #если атака на всё поле, то цикл повторится 7 раз, иначе - 1
            pos[0]+=shift[0]
            pos[1]+=shift[1]
            if pos[0]>7 or pos[0]<0 or pos[1]>7 or pos[1]<0: break #вышли за поле-стоп
            under_attack=Board[pos[1]][pos[0]]
            if under_attack!='.':
                if under_attack.name=='K' and under_attack.color!=piece.color:return True #если бьём короля, вернём True
                elif under_attack.color!=piece.color: moves.append(pos[:])
                break
            moves.append(pos[:])
    return moves

Note that pawns are strange creatures. Their moves and unusual properties are too out of place compared to other pieces. For this reason, they are not in the attack_dict. So, for pawns we will create our own subclass Pawn and our own method get_moves

class Pawn(ChessPiece):
    def __init__(self, name, color):
        self.color=color
        self.already_moved=False
        self.name=name

    def get_moves(self, x, y):
        moves=[]
        pos=[x,y]
        if self.color=='1': y+=1
        else: y-=1
        x-=1 #сместимся по диагонали
        for i in range(2):
            if 7>=y>=0 and 7>=x>=0:
                if Board[y][x]!='.' and Board[y][x].color!=self.color:
                    moves.append([x,y][:]) #если в клетке стоит враг-добавить, как вариант хода
                    if Board[y][x].name=='K': return True #проверка на шах
            x+=2 #проверим другую диагональ
        x,y=pos[0], pos[1] #вернём x и y старые значения
        
        for i in range(2-self.already_moved): #добавим ходы без взятия (если пешку ещё не трогали, то 2 клетки сразу)
            if self.color=='1': y+=1
            else: y-=1
            if y>7 or y<0: break
            if Board[y][x]!='.': break
            moves.append([x,y][:])
        return moves

Let's make a function – check for check

def check_shah(B_W): #если B_W равен 0, то интересует шах белым, 1-чёрным
    for y in range(8):
        for x in range(8):
            if Board[y][x]!='.' and Board[y][x].color!=B_W:
                if Board[y][x].get_moves(x,y)==True: return True
    return False

But not all moves received from the get_moves function are acceptable. We need to discard those after which the king is in check. Let's write the corresponding function

def filter_moves(x,y):
    piece=Board[y][x]
    moves=piece.get_moves(x,y)
    Board[y][x]='.' #уберем фигуру с поля
    for_deletion=[]
    for move in moves:
        remember=Board[move[1]][move[0]] #запомним клетку, куда сейчас поставим фигуру
        Board[move[1]][move[0]]=piece #ставим
        if check_shah(piece.color): for_deletion.append(move) #если король под шахом-записать этот ход на удаление
        Board[move[1]][move[0]]=remember #возвращаем всё как было
    Board[y][x]=piece
    for delet in for_deletion: #удалим лишние ходы
        moves.remove(delet)
    return moves

Next in line is the function of checking for mat or patent

def checkmate_stalemate(B_W):
    for y in range(8):
        for x in range(8):
            if Board[y][x]!='.' and Board[y][x].color==B_W:
                if filter_moves(x,y)!=[]: return None #ходы есть, значит мата/пата нет
    if check_shah(B_W): return 1 #мат
    return 0 #пат

The core functionality is almost ready. Let's get to work importing libraries, creating a window and a game loop.

import pygame
from pygame import *
import pygame as pg
import math

wind=display.set_mode((640,640))
display.set_caption('Chess')
clock=time.Clock()
font.init()
game=True
while game:
    for e in event.get():
        if e.type==QUIT:
            game=False
    clock.tick(60)

The board is missing. Let's add pictures to the game folder, named according to the name and color of the piece. Example: N0.png – white knight, Q1.png – black queen, P1.png – black pawn, etc.

RectList=[] #список белых клеточек
for i in range(8):
    for n in range(4):
        RectList.append(pygame.Rect((n*160+(i%2)*80,i*80, 80, 80)))

def draw_board():
    pygame.draw.rect(wind, (181, 136, 99), (0, 0, 640, 640)) #одна большая черная клетка
    for R in RectList:
        pygame.draw.rect(wind, ((240, 217, 181)), R) #много маленьких белых клеток
    for y in range(8):
        for x in range(8):
            if Board[y][x]!='.':
                wind.blit(transform.scale(pygame.image.load(Board[y][x].__str__()+'.png'),(70,70)),(5+x*80,5+y*80))
                #рисуем фигуры
    display.update()

A little more decoration: let's add a function that draws circles as hints for the move

def draw_circles(moves):
    for circle in moves:
        pygame.draw.circle(wind, (200,200,200), (circle[0]*80+40, circle[1]*80+40), 10)
    display.update()

Now the function that displays the winner or draw

def try_print_winner(turn):
    check=checkmate_stalemate(str(turn))
    if check!=None: #если мат или пат, выведем это на экран
        draw_board()
        if check==1 and turn==0: 
            wind.blit(pygame.font.SysFont(None,30).render('BLACK WON', False,(30, 30, 30)),(260,310))
        elif check==1 and turn==1: 
            wind.blit(pygame.font.SysFont(None,30).render('WHITE WON', False,(30, 30, 30)),(260,310))
        else: 
            wind.blit(pygame.font.SysFont(None,30).render('DRAW', False,(30, 30, 30)),(290,310))
        display.update()

Let's move the pieces by the player. For simplicity, we'll grab the piece by clicking the mouse and move it by releasing it. This is what the game loop looks like now:

turn=0 #turn показывает, чья очередь
draw_board()
game=True
while game:
    for e in event.get():
        if e.type==QUIT:
            game=False
        if e.type==pg.MOUSEBUTTONDOWN and e.button==1: #Если нажата ЛКМ
            x_from,y_from=(e.pos)
            x_from,y_from=math.floor(x_from/80),math.floor(y_from/80)
            if Board[y_from][x_from]!='.' and Board[y_from][x_from].color==str(turn):
                    moves=filter_moves(x_from,y_from) #получаем ходы для фигуры
                    draw_circles(moves)
                else: x_from=-1
            else: x_from=-1
        if e.type==pg.MOUSEBUTTONUP and e.button==1 and x_from!=-1: #если отжата ЛКМ
            x_to,y_to=(e.pos)
            x_to,y_to=math.floor(x_to/80),math.floor(y_to/80)
            if moves.count([x_to,y_to]): #если ход приемлем, переставляем фигуру
                Board[y_to][x_to]=Board[y_from][x_from]
                Board[y_to][x_to].already_moved=True
                Board[y_from][x_from]='.'
                turn=1-turn #меняем ход
            draw_board()
            try_print_winner(turn)
    clock.tick(60)

The cycle is hard to read. We'll fix it by dividing the lines by functions.

def grab_piece(x,y): #взять фигуру
    piece=Board[y][x]
    moves=[]
    if piece=='.' or piece.color!=str(turn): return []
    moves=filter_moves(x,y)
    return moves

def put_piece(x_to,y_to,x_from,y_from,moves): #поставить фигуру
    if moves.count([x_to,y_to]): #если ход приемлем, переставляем фигуру
        Board[y_to][x_to]=Board[y_from][x_from]
        Board[y_to][x_to].already_moved=True
        Board[y_from][x_from]='.'
        return True #флаг, который подскажет сменить ход

Here's a simple, straightforward game loop:

while game:
    for e in event.get():
        if e.type==QUIT:
            game=False
        if e.type==pg.MOUSEBUTTONDOWN and e.button==1: #Если нажата ЛКМ
            x_from,y_from=(e.pos)
            x_from,y_from=math.floor(x_from/80),math.floor(y_from/80)
            moves=grab_piece(x_from,y_from)
            draw_circles(moves)
        if e.type==pg.MOUSEBUTTONUP and e.button==1: #если отжата ЛКМ
            x_to,y_to=(e.pos)
            x_to,y_to=math.floor(x_to/80),math.floor(y_to/80)
            if put_piece(x_to,y_to,x_from,y_from,moves):
                turn=1-turn #меняем ход
            draw_board()
            try_print_winner(turn)
    clock.tick(60)

By the way, remember the checkmate_stalemate function? It's a check for checkmate or stalemate. Well, it uses another function – filter_moves to get the list of moves. I'll replace filter_moves with grab_piece (it does almost the same thing now) because soon we'll add more moves to grab_piece and filter_moves will become irrelevant.

This was an important digression, and now we will add all the pieces to the board.

for i in range(8):
    Board[0][i]=ChessPiece('RNBQKBNR'[i],'1')
    Board[1][i]=Pawn('P','1')
    Board[7][i]=ChessPiece('RNBQKBNR'[i],'0')
    Board[6][i]=Pawn('P','0')

The game is ready except for three things. It is missing castling, captures on passage And pawn promotions upon reaching the edge of the board. I recommend reading how these moves are made at the link if you are not aware.

Castling is a king's move, and a very unusual one at that. So let's create a separate subclass for the king, and at the same time write a function that adds castling

class King(ChessPiece):
    def add_castling(self):
        castlings_list=[]
        if self.already_moved==False:
            y=7-int(self.color)*7
            
            rooks_cond=[False,False] #проверка ладей
            for x in range(2):
                check_piece=Board[y][x*7]
                if check_piece!='.':
                    if check_piece.name==('R') and check_piece.color==self.color and check_piece.already_moved==False:
                        rooks_cond[x]=True
            
            reach_cond=[False,False] #проверка места между королём и ладьями
            for x in range(2):
                if x==0 and Board[y][1:4]==['.','.','.']: reach_cond[0]=True
                elif x==1 and Board[y][5:7]==['.','.']: reach_cond[1]=True
            
            shah_cond=[False,False] #проверка шаха
            for x in range(2):
                #будем ставить временных королей, если они под шахом-условие не соблюдено
                if x==0 and reach_cond[x]:
                    Board[y][2],Board[y][3]=King('K',self.color),King('K',self.color) 
                    if check_shah(self.color)==False: shah_cond[x]=True
                    Board[y][2],Board[y][3]='.','.'
                elif x==1 and reach_cond[x]:
                    Board[y][5],Board[y][6]=King('K',self.color),King('K',self.color)
                    if check_shah(self.color)==False: shah_cond[x]=True
                    Board[y][5],Board[y][6]='.','.'
                    
            all_cond=(shah_cond[0] and rooks_cond[0],shah_cond[1] and rooks_cond[1])
            if all_cond[0]: castlings_list.append([2,y])
            if all_cond[1]: castlings_list.append([6,y])
        return castlings_list

So far, the add_castling method checks all the conditions for castling, then returns the squares where the king needs to move to castling. Let's give the king the ability to move to these squares. To do this, add the following lines to the end of the grab_piece function:

if piece.name=='K':
    global castlings
    #castlings ещё понадобится, поэтому создадим его перед игр. циклом, а тут глобализуем
    castlings=piece.add_castling()
    for c in castlings:
        moves.append(c)

Now we need to make the rook move. To do this, we will add the move_rook method to the king. We will call it before moving the king.

def move_rook(self,x,y): #x,y-координаты точки, куда ходит король
    if x==2: #если рокировка влево
        Board[y][0]='.'
        Board[y][3]=ChessPiece('R',self.color)
    elif x==6: #если рокировка вправо
        Board[y][7]='.'
        Board[y][5]=ChessPiece('R',self.color)

All that remains is to connect everything in the put_piece function, which rearranges the figures.

def put_piece(x_to,y_to,x_from,y_from,moves):
    if moves.count([x_to,y_to]):
        
        #новые строчки
        if Board[y_from][x_from].name=='K':
            global castlings
            if castlings.count([x_to, y_to]): #если ход является рокировкой
                Board[y_from][x_from].move_rook(x_to,y_to) #переставим ладью
                
        Board[y_to][x_to]=Board[y_from][x_from]
        Board[y_to][x_to].already_moved=True
        Board[y_from][x_from]='.'
        return True

By the way, since we put the king in a separate class, we will not forget to do this when creating this figure:

for i in range(8): #создание всех фигур кроме королей
    Board[0][i]=ChessPiece('RNBQ.BNR'[i],'1')
    Board[1][i]=Pawn('P','1')
    Board[7][i]=ChessPiece('RNBQ.BNR'[i],'0')
    Board[6][i]=Pawn('P','0')
Board[0][4]=King('K','1') #создание двух королей
Board[7][4]=King('K','0')

Castling is ready! Next up is en passant capture.

How can I implement it? Now I will try to explain my idea. To do this, let's consider the following situation:

After the white pawn moves, we call a certain check_en_passant method of the Pawn class, which, if the pawn moved 2 cells, will write the cell to the right and left in the en_passant_pos list. On the next move, enemy pawns on these cells (if there are any) will be able to take en passant.

In en_passant_pos we need to add something else: the cell marked with a red dot (we will go there when taking) and the cell where the white pawn is currently standing (we will cut it)

en_passant_pos was created before the game loop, and the check_en_passant method is created now:

def check_en_passant(x_from,y_from,x_to,y_to):
    if y_from-y_to==2 or y_from-y_to==-2:#если ходим на 2 клетки
        global en_passant_pos
        en_passant_pos=[[x_to-1,y_to],[x_to+1,y_to]] #клетки слева и справа от пешки
        en_passant_pos.append(x_to, (y_from+y_to)//2) #куда ходим при взятии?
        #(найдя среднее между Y до и после хода пешки, получим Y нужной клетки)
        en_passant_pos.append(x_to,y_to) #клетка где стоит наша пешка

In my opinion, if we get values ​​from en_passant_pos by index, it will be difficult to understand, so let's replace it with a dictionary. We will get the value by “code word”.
This is what check_en_passant looks like now:

def check_en_passant(self, x_from,y_from,x_to,y_to):
    if y_from-y_to==2 or y_from-y_to==-2:#если ходим на 2 клетки
        global en_passant_pos
        en_passant_pos['left']=[x_to-1,y_to] #клетка слева от пешки
        en_passant_pos['right']=[x_to+1,y_to] #справа
        en_passant_pos['move']=[x_to, (y_from+y_to)//2] #куда ходим при взятии?
        en_passant_pos['fellt']=[x_to,y_to] #клетка где стоит наша пешка

Now let's think about how to check the moves-captures for check after they are made. Let's create the method en_passant_filt. It, thanks to all the data from en_passant_pos, will simulate a capture en passant, and will check the king for check. But before that, a couple of important things to understand:

  1. To take en passant, three actions are required: 1 – remove the cutting pawn, 2 – place the cutting pawn in another point, 3 – remove the cut pawn.
    The order of execution is not important. Understanding this will come in handy now, because we will first complete points 2 and 3, and only after that – 1

  2. Let me remind you that en_passant_pos stores the coordinates of the cells in which pawns can take. So, if we need to prohibit this action, we will replace the cell coordinates with [-1, -1] (non-existent)

And here is the en_passant_filt function:

def en_passant_filt(self):
    pos=en_passant_pos['fellt']
    Board[pos[1]][pos[0]]='.' #уберём нашу пешку (пункт 3)
    pos=en_passant_pos['move']
    enemy_color=str(1-int(self.color))
    Board[pos[1]][pos[0]]=Pawn('P',enemy_color) #поставим вражескую пешку (пункт 2)
    piece=Board[pos[1]][pos[0]]

    for cell in ['left','right']: #здесь выполним пункт 1
        pos=en_passant_pos[cell]
        piece=Board[pos[1]][pos[0]]
        if piece=='.': en_passant_pos[cell]=[-1,-1]; continue
        if piece.name!='P' or piece.color==self.color: en_passant_pos[cell]=[-1,-1]; continue
        #если в проверяемой клетке не вражеская пешка-запрещаем взятие
        Board[pos[1]][pos[0]]='.' #срубим пешку (пункт 1)
        if check_shah(enemy_color): en_passant_pos[cell]=[-1,-1] #если после пункта 1 шах-запрещаем взятие
        
        #если все этапы проверки пройдены, то не трогаем проверяемую клетку (она может совершить взятие)
        Board[pos[1]][pos[0]]=Pawn('P', enemy_color) #вернём фигуру
        Board[pos[1]][pos[0]].already_moved=True
    pos=en_passant_pos['fellt'] #вернём всё как было
    Board[pos[1]][pos[0]]=Pawn('P',self.color)
    Board[pos[1]][pos[0]].already_moved=True
    pos=en_passant_pos['move']
    Board[pos[1]][pos[0]]='.'

Now let's start calling en_passant_filt at the end of check_en_passant:

self.en_passant_filt()

Congratulations, the hardest part is behind us. Now we need to start calling check_en_passant after each pawn move. To do this, we'll add the following to the end of the put_piece function:

global en_passant_pos 
en_passant_pos={} #будем очищать en_passant_pos каждый ход
if Board[y_to][x_to].name=='P':
    Board[y_to][x_to].check_en_passant(x_from,y_from,x_to,y_to)

There is very little left for en passant capture. We need to add the ability for the pawn that receives permission to capture to do so. Let's add the following to grab_piece:

if piece.name=='P':
    global en_passant_pos
    if len(en_passant_pos)>0:
        if en_passant_pos['left']==[x,y] or en_passant_pos['right']==[x,y]:
            moves.append(en_passant_pos['move'])

Now pawns have simply learned to move diagonally to empty squares, but it is not for nothing that a capture en passant is called a capture, let's start chopping the pawn. To do this, we need to add a little bit of code to put_piece. Let's do this:

def put_piece(x_to,y_to,x_from,y_from,moves):
    if moves.count([x_to,y_to]):
        if Board[y_from][x_from].name=='K':
            global castlings
            if castlings.count([x_to, y_to]):
                Board[y_from][x_from].move_rook(x_to,y_to)
        Board[y_to][x_to]=Board[y_from][x_from]
        Board[y_to][x_to].already_moved=True
        Board[y_from][x_from]='.'
        global en_passant_pos
        
        #новые строчки
        if Board[y_to][x_to].name=='P':
            if len(en_passant_pos)>0:
                if en_passant_pos['move']==[x_to,y_to]:
                    pos=en_passant_pos['fellt']
                    Board[pos[1]][pos[0]]='.'

        en_passant_pos={}
        if Board[y_to][x_to].name=='P':
            Board[y_to][x_to].check_en_passant(x_from,y_from,x_to,y_to)
        return True

Finally, we've figured out how to capture. Let's think about promoting a pawn. When a pawn reaches the edge of the board, we need to somehow stop the cycle of changing moves and start choosing a piece to promote.

To do this, let's remember the variable turn. It takes 0 or 1 depending on whose move it is. So, I suggest that for the event of a pawn's promotion, we set turn to -1. Then no one will be able to make a move, and we will realize our insidious plans.

We will find a pawn on the edge of the board and set turn to -1 using the find_border_pawn function:

def find_border_pawn():
    y=-1
    for x in range(8): #ищем пешку на краю доски
        if Board[0][x]!='.' and Board[0][x].name=='P':
            y=0; break
        if Board[7][x]!='.' and Board[7][x].name=='P':
            y=7; break
    if y!=-1:
        global turn
        turn=-1
        #по углам клетки, на которой стоит пешка, нарисуем ферзя, ладью, слона и коня
        if Board[y][x].color=='0':
            wind.blit(transform.scale(pygame.image.load('Q0.png'),(40,40)),(x*80,y*80))
            wind.blit(transform.scale(pygame.image.load('R0.png'),(40,40)),(x*80+40,y*80))
            wind.blit(transform.scale(pygame.image.load('B0.png'),(40,40)),(x*80,y*80+40))
            wind.blit(transform.scale(pygame.image.load('N0.png'),(40,40)),(x*80+40,y*80+40))
        if Board[y][x].color=='1':
            wind.blit(transform.scale(pygame.image.load('Q1.png'),(40,40)),(x*80,y*80))
            wind.blit(transform.scale(pygame.image.load('R1.png'),(40,40)),(x*80+40,y*80))
            wind.blit(transform.scale(pygame.image.load('B1.png'),(40,40)),(x*80,y*80+40))
            wind.blit(transform.scale(pygame.image.load('N1.png'),(40,40)),(x*80+40,y*80+40))
        display.update()
    return x,y

By the way, while the pawn is on the edge of the board, we don't need the mouse release event. That's why I added a condition to it that turn is not equal to -1. There's nothing to look at here.

So now we have a function that finds a pawn on the edge of the board, draws the necessary pieces around it, sets turn to -1, and returns the coordinates of the pawn. I did some fiddling with those coordinates, and here's what the game loop looks like now:

while game:
    for e in event.get():
        if e.type==QUIT:
            game=False
        if e.type==pg.MOUSEBUTTONDOWN and e.button==1:
            x_from,y_from=(e.pos)
            x_from,y_from=math.floor(x_from/80),math.floor(y_from/80)
            
            if turn==-1 and (x_pawn,y_pawn)==(x_from,y_from): #если нажали на пешку на краю доски
                replace_pawn(x_pawn,y_pawn) #эту функцию ещё не показал

            moves=grab_piece(x_from,y_from)
            draw_circles(moves)
        if e.type==pg.MOUSEBUTTONUP and e.button==1 and turn!=-1:
            x_to,y_to=(e.pos)
            x_to,y_to=math.floor(x_to/80),math.floor(y_to/80)
            if put_piece(x_to,y_to,x_from,y_from,moves):
                turn=1-turn
            draw_board()
            try_print_winner(turn)

            #новая строчка-запоминаем координаты пешки
            x_pawn,y_pawn=find_border_pawn()
            
    clock.tick(60)

The replace_pawn function is the final piece of code I'll show you:

def replace_pawn(x_pawn,y_pawn):
    x,y=(e.pos)
    x,y=math.floor((x%80)/40), math.floor((y%80)/40)
    #теперь x и y отображают угол в который нажали, по ним поймём во что превратить пешку
    piece_dict={
    (0,0): 'Q',
    (1,0): 'R',
    (0,1): 'B',
    (1,1): 'N'}
    piece_name=piece_dict[(x,y)]
    piece_color=Board[y_pawn][x_pawn].color
    Board[y_pawn][x_pawn]=ChessPiece(piece_name,piece_color)
    global turn
    turn=int(piece_color)
    turn=1-turn
    draw_board()

That's it! We have a fully functioning chess game for two. I hope that the positive changes compared to the previous article are noticeable. If you have any ideas on how to improve the code or information about errors, be sure to write about it in the comments. I will try to edit the article if there are useful comments.

Link on Yandex Disk with the game

Follow the link >>> click on the download all button >>> save HabrChess.zip >>> open it >>> drag the Chess folder to your desktop or anywhere in the explorer >>> open this folder >>> open Chess.py >>> you can play

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *