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:
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 – 1Let 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