This amazing blog is being migrated to mathspp.com!
Tutorial on programming a memory card game
Get link
Facebook
X
Pinterest
Email
Other Apps
Este post vai ser um tutorial, não muito detalhado, sobre como fazer um jogo de memória com Python e pygame. O jogo que vamos implementar é um jogo comum: viramos uma série de cartas para baixo e temos que as virar duas a duas, tentando encontrar os pares. Claro que quando viramos duas cartas que não são um par, temos de as voltar de novo para baixo.
Quando estou a criar um jogo, gosto de o ir desenvolvendo por etapas funcionais: partir o processo em várias fases que representem pontos nos quais eu tenho algo que posso testar. Deste modo, não só o processo se torna muito mais interessante, como posso ir controlando o aspeto do que estou a produzir. Deixo de seguida uma lista das etapas que eu pensei para este projeto; cada ponto da lista descreve a funcionalidade que o jogo já suporta:
Criar um ecrã onde mostro todas as cartas dispostas, face para baixo;
Clicar em cima de uma carta faz com que ela se vire para cima;
Clicar na segunda carta verifica se encontrei um par ou não e trata as cartas de acordo com isso: se forem um par, retira-as; se forem diferentes volta-as de novo para baixo;
Um temporizador impede que gastemos tempo infinito num só jogo; acertar num par dá mais tempo e falhar num par retira tempo;
Um ecrã final que diz se ganhámos/perdemos e quantos pontos fizémos;
Um pequeno ficheiro de configuração para que se possa alterar o número de cartas em cima da mesa.
Ao longo do tutorial eu vou incluindo o código que estiver a descrever; basta carregar no botão que diga respeito ao código descrito. A razão pela qual escondo tudo atrás de botões é dupla: por um lado, quem quiser pode tentar acrescentar, sozinho, os bocados de código que faltam; por outro lado, impede que o post tenha um comprimento descomunal. Todo o código (e imagens) está disponível aqui, no meu GitHub. No link encontram também o ficheiro com o código do fim de cada etapa.
Etapa 0: parte da frente das cartas
Antes de começar a programar o jogo decidi que as cartas teriam vários polígonos regulares para serem emparelhados. A razão pela qual fiz isso é porque, por um lado gerar essas imagens todas seria fácil, e por outro lado tornaria o jogo difícil: à medida que o número de lados aumenta, torna-se cada vez mais difícil distinguir os diferentes polígonos. Para este efeito, escrevi um pequeno script que pede repetidamente valores inteiros para desenhar polígonos regulares (um por cada cor) com o número de lados especificados. Com esse script criei uma série de imagens para serem usadas nas minhas cartas. O script segue.
from PIL import Image, ImageDraw
import sys
import os
from math import pi, sin, cos
def generate_polygon(n, colours):
theta = (2*pi)/n
vertices = [(20*cos(theta*j), 20*sin(theta*j))
for j in range(0, n)]
for j in range(1, n//2+1):
vertices[j] = (vertices[n-j][0], -vertices[n-j][1])
vertices = [(v[0]+25, v[1]+25) for v in vertices]
im = Image.new("RGB", (50,50), (255,255,255))
for name in colours.keys():
c = colours[name]
draw = ImageDraw.Draw(im)
draw.polygon(vertices, fill=c, outline=c)
im.save(os.path.join("polygonbin",
"sides"+str(n)+name+".png"))
# colours taken from https://www.w3schools.com/tags/ref_colornames.asp
COLOURS = {
"Black": (0,0,0),
"Red": (255,0,0),
"Green": (0,255,0),
"Blue": (0,0,255),
"Aqua": (0,255,255),
"Brown": (165, 42, 42),
"Chocolate": (210, 105, 30),
"Crimson": (220, 20, 60),
"DarkGoldenRod": (184, 134, 11),
"DarkGreen": (0, 100, 0),
"DarkOrange": (255, 140, 0),
"Fuchsia": (255, 0, 255),
"Gold": (255, 190, 0),
"SeaGreen": (46, 139, 87),
"Yellow": (255, 255, 0)
}
if __name__ == "__main__":
n = -1
while True:
try:
n = int(input("# of sides >> "))
if n <= 0:
sys.exit()
except Exception:
continue
generate_polygon(n, COLOURS)
Etapa 1: mostrar as cartas todas voltadas para baixo
Para podermos mostrar uma "mesa" com as cartas todas voltadas para baixo precisamos de decidir quantas cartas estão na mesa e com que configuração (quantas linhas/colunas). Precisamos também de uma imagem para representar a parte de trás de uma carta.
from pygame.locals import *
import pygame
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
Obviamente começamos por importar tudo o que é necessário. Vamos precisar da livraria sys para podermos terminar a aplicação quando o utilizador carregar na cruzinha vermelha da janela. A função load_image é uma função que (quase de certeza absoluta) copiei da internet; o que ela faz é importar a imagem que estiver no argumento que se passa à função. Podemos também indicar se a imagem tem um fundo transparente ou não. De seguida inicializamos algumas variáveis globais que nos vão ser úteis. width e height são para o número de colunas e linhas, respetivamente, em termos de cartas. WIDTH e HEIGHT são para o comprimento e altura da janela do jogo em pixéis. Definimos ainda a cor de fundo, a margem (em pixéis) que vamos ter entre cada carta e o tamanho (em pixéis) de cada carta, que assumimos ser um quadrado.
O ciclo infinito incluído é bastante standard e serve para a aplicação correr sem "congelar"; a única ação que produz algum resultado é carregar na cruz vermelha, que fecha a janela.
Etapa 2: clicar numa carta faz com que ela se volte para cima
Para completar este passo é preciso acrescentar várias coisas que tratam já de alguma lógica do jogo: é preciso distribuir os pares de cartas pelas suas posições e é preciso reconhecer quando um clique do rato foi feito em cima de uma das cartas.
Começamos por acrescentar a escolha das cartas e a distribuição das mesmas pela mesa de jogo. Através das dimensões dadas pelas variáveis width, height conseguimos saber de quantas cartas precisamos. Basta-nos abrir o diretório onde estão guardadas as cartas todas e escolher aleatoriamente o número certo de cartas. Depois de escolhidas, guardamo-las num dicionário. O passo seguinte é criar uma matriz, com as dimensões da mesa de jogo, onde cada posição da matriz indica que carta está nessa posição da mesa: para isso duplicamos o vetor com os nomes das cartas escolhidas, baralhamos esse vetor, e partimos o vetor nas diversas colunas da mesa.
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
Para este código funcionar precisamos de importar tanto a biblioteca random como a biblioteca os.
Agora que o jogo já sabe que cartas ficam em que posições, precisamos de fazer com que as cartas escolhidas sejam voltadas para cima. Depois de carregarmos em cima de uma delas temos ainda de a desenhar para mostrar a barte de baixo. Porque nos vai dar bastante jeito, vamos ainda criar uma função auxiliar, board_to_pixels que recebe um par de inteiros $(x,y)$, que dizem respeito à posição de uma carta na lista card_list, e devolve um outro par de inteiros: a posição, em pixéis, do canto superior esquerdo da mesma carta. Isto vai ser útil para conseguirmos criar os retângulos necessários para redesenhar a carta e para atualizar a porção certa do ecrã.
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
Tendo esta função auxiliar, podemos agora processar os cliques em cima de cartas da mesa para as virar. Para esse feito, temos de criar outra condição no if dentro do ciclo que processa os eventos do pygame. Podemos usar um pouco de matemática e a função divmod para descobrir em que carta tentámos carregar e perceber se acertámos no espaço entre duas cartas ou não.
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
Depois de todas estas alterações, este é o código que já temos:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
Etapa 3: pares encontrados são retirados da mesa
Se queremos que os pares encontrados sejam retirados, é óbvio que temos de saber se há alguma carta virada para cima e que carta é essa (quando há). Tendo isso em conta, quando se carrega em cima de uma carta temos de a virar para cima e ver se é o par de outra carta que esteja virada para cima. Adicionamos então uma série de variáveis antes do ciclo principal e alteramos o processamento dos cliques do rato.
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = None
flipped_coords = None
to_find = len(cards)/2
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = card_list[x][y]
flipped_coords = (x, y)
# there is a card face up
else:
# I just clicked it
if flipped_coords == (x, y):
continue
else:
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
pygame.time.delay(800)
# if we got it right
if flipped_card == card_list[x][y]:
to_find -= 1
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the previously clicked card from the game table
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
is_flipped = False
Este novo ciclo já retira pares de cartas iguais mas há um comportamento muito engraçado que não era suposto: se carregarmos num espaço onde tenha estado uma carta que já tenha sido retirada, essa carta volta a aparecer! Esta situação é ilustrada na imagem que se segue, onde já encontrei um par (triângulos pretos) e depois voltei a carregar na região a vermelho.
Para evitarmos esta situação temos de guardar todas as posições de cartas que já foram encontradas e, quando carregamos num espaço de uma carta, vemos se essa carta já foi encontrada. Podemos fazer isso adicionando uma lista found_cards onde guardo as coordenadas das cartas encontradas; de seguida alteramos o if que decide o que fazer quando se carrega em cima de uma carta. Precisamos ainda de guardas as cartas que encontramos, quando as encontramos. O código final desta etapa fica então:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = None
flipped_coords = None
to_find = len(cards)/2
found_cards = []
while to_find > 0:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = card_list[x][y]
flipped_coords = (x, y)
# there is a card face up
else:
# I just clicked it
if flipped_coords == (x, y):
continue
else:
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
pygame.time.delay(800)
# if we got it right
if flipped_card == card_list[x][y]:
to_find -= 1
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the previously clicked card from the game table
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append(flipped_coords)
found_cards.append((x,y))
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
is_flipped = False
Etapa 4: acrescentar um temporizador
Nesta etapa vamos acrescentar uma funcionalidade que impõe um tempo limite ao jogador. De cada vez que o jogador encontrar um par, vai ser bonificado com tempo extra para jogar; de cada vez que falhar, vai perder algum do tempo disponível. Para o jogador saber quanto tempo ainda tem, vamos apresentar uma barra à direita que vai ficando mais pequena à medida que o tempo passa. Para isso vamos precisar de uma função que desenhe a barra do tempo e vamos precisar de uma maneira de controlar quando é que o jogador perde. Vamos ainda dar tempo extra de cada vez que um par é encontrado e retirar tempo de cada vez que se falha um par. Começamos por criar duas variáveis BONUSTIME e PENALTYTIME que têm, em milissegundos, respetivamente a bonificação e a penalização que acabámos de descrever. Os valores são arbitrários; eu decidi BONUSTIME = 3000 e PENALTYTIME = 600
Se definirmos que desenhamos a barra do tempo à direita da mesa de jogo, podemos tomar como único argumento a percentagem de tempo que ainda nos resta. Essa informação é suficiente para desenhar a barra, desde que acrescentemos uma variável para controlar a largura da barra. É ainda necessário atualizar a largura da janela em pixéis, WIDTH, para ter em consideração o espaço extra necessário para a barra:
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
TIMEBARWIDTH = 25
# ...
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
Antes do jogo entrar no seu ciclo principal, temos também de definir qual é o momento em que supostamente o jogo acaba. É ainda necessário guardar todas as bonificações e penalizações que o jogador recebeu, para podermos atualizar o tempo ainda disponível. Para isso, criamos duas variáveis end e score onde guardamos, respetivamente, o momento do fim do jogo e as bonificações/penalizações acumuladas. Podemos definir o momento do fim do jogo com facilidade e alterar a guarda do ciclo principal para que o jogo acabe quando ficarmos sem tempo:
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
Resta-nos alterar o valor de score quando encontramos um par ou quando nos enganámos, fazendo uso das variáveis BONUSTIME e PENALTYTIME:
Para concluir todas as alterações a que nos propusémos precisamos ainda de chamar a função que desenha a barra de tempo em cada iteração do ciclo. Para tal, calculamos a percentagem de tempo que ainda temos disponível e chamamos a função:
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
Se jogarmos podemos ver que já temos uma barra que se vai atualizando! Os mais atentos, no entanto, deverão reparar que quando duas cartas estão viradas para cima ao mesmo tempo, a barra do tempo congela um pouco e depois dá um salto para a sua nova posição. O problema está na linha pygame.time.delay(800), que faz com que todo o pygame fique parado durante 800ms. Uma maneira de darmos a volta a isto é se criarmos uma variável auxiliar, wait, que nos diz se estamos numa pausa porque duas cartas estão viradas para cima, ou não. Caso não estejamos, então o jogo decorre normalmente. Se estivermos, temos de esperar que a pausa acabe e depois ou mandamos fora as duas cartas ou voltamo-las de novo para baixo.
As alterações que têm de ser feitas são:
Quando viramos uma segunda carta para cima, guardamos a sua posição e definimos um tempo de espera;
Quando entramos no ciclo principal do jogo, temos de perceber se estamos em pausa ou não;
Quando estamos em pausa, temos de desligar a capacidade do jogador de continuar a carregar em cartas;
Quando a pausa acaba, voltar a virar as duas cartas para baixo ou removê-las do jogo
Tendo tudo isto em conta, o ciclo principal e as variáveis auxiliares ficam como se mostra de seguida, onde todas as alterações estão entre ### --> e ### <--
# auxiliary variables to control the state of the game
is_flipped = False
### -->
flipped_card = []
flipped_coords = []
wait = False
wait_until = None
### <--
to_find = len(cards)/2
found_cards = []
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
clock.tick(60)
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
### --> this is VERY similar to what used to be in the end of the loop
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
### <--
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
### -->
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
### <--
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y): ### just a minor change, use index notation
continue
else:
### -->
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
### <--
Agora que temos todas estas alterações implementadas, o nosso programa já cumpre tudo o que era suposto nesta etapa. O código todo, até esta etapa, segue:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 2
height = 3
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
BONUSTIME = 3000
PENALTYTIME = 600
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = []
flipped_coords = []
to_find = len(cards)/2
found_cards = []
wait = False
wait_until = None
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y):
continue
else:
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
Etapa 5: ecrã com pontuação
Acrescentar uma mensagem de vitória com os pontos ou uma mensagem de derrota é relativamente fácil; podemos fazê-lo depois do ciclo principal do jogo, acrescentando o seguinte código:
# add the time left to the score in case we won
score += (end+score)-pygame.time.get_ticks()
# initialize a font to print the results
pygame.font.init()
font = pygame.font.Font(None, 40)
if to_find:
img = font.render("You lost!", True, (0,0,0))
pygame.display.set_caption("You lost!")
else:
img = font.render("You scored {}!".format(score), True, (0,0,0))
pygame.display.set_caption("You won!")
screen = pygame.display.set_mode((img.get_width()+60,
img.get_height()+60))
screen.fill(BACKGROUND_COLOR)
screen.blit(img, (30, 30))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
Etapa 6: ficheiro de configuração
Vamos criar um ficheiro de configuração onde possamos, com facilidade, alterar o número de cartas a usar durante o jogo. Vamos ainda acrescentar a possiblidade de se alterar o valor das variáveis BONUSTIME e PENALTYTIME. Após uma breve pesquisa, vemos que o módulo configparser pode ser de grande ajuda; importamo-lo, e de seguida definimos uma função com o propósito de ler as configurações do ficheiro no início do jogo. Incluímos ainda a possibilidade de o ficheiro não existir; nesse caso a função cria-o com valores por defeito para essas variáveis. A função parse_configurations trata das tarefas descritas. Incluímos a sua implementação, bem como a secção onde as variáveis globais são definidas; note-se que apagámos as linhas que inicializam as variáveis width, height, BONUSTIME e PENALTYTIME.
import configparser
# ...
def parse_configurations():
configfile = "cardconfig.ini"
c = configparser.ConfigParser()
r = c.read(configfile)
if not r:
# create the configfile
global width, height, BONUSTIME, PENALTYTIME
c["DEFAULT"] = {"width": 5,
"height": 4,
"BONUSTIME": 3000,
"PENALTYTIME": 600
}
with open(configfile, "w") as f:
c.write(f)
# create the globals
global width, height, BONUSTIME, PENALTYTIME
width = int(c["DEFAULT"]["width"])
height = int(c["DEFAULT"]["height"])
BONUSTIME = int(c["DEFAULT"]["bonustime"])
PENALTYTIME = int(c["DEFAULT"]["penaltytime"])
parse_configurations()
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
O código completo final, em não mais do que 230 linhas, segue de seguida:
from pygame.locals import *
import pygame
import random
import os
import sys
import configparser
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
def parse_configurations():
configfile = "cardconfig.ini"
c = configparser.ConfigParser()
r = c.read(configfile)
if not r:
# create the configfile
global width, height, BONUSTIME, PENALTYTIME
c["DEFAULT"] = {"width": 5,
"height": 4,
"BONUSTIME": 3000,
"PENALTYTIME": 600
}
with open(configfile, "w") as f:
c.write(f)
# create the globals
global width, height, BONUSTIME, PENALTYTIME
width = int(c["DEFAULT"]["width"])
height = int(c["DEFAULT"]["height"])
BONUSTIME = int(c["DEFAULT"]["bonustime"])
PENALTYTIME = int(c["DEFAULT"]["penaltytime"])
parse_configurations()
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = []
flipped_coords = []
to_find = len(cards)/2
found_cards = []
wait = False
wait_until = None
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y):
continue
else:
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
# add the time left to the score in case we won
score += (end+score)-pygame.time.get_ticks()
# initialize a font to print the results
pygame.font.init()
font = pygame.font.Font(None, 40)
if to_find:
img = font.render("You lost!", True, (0,0,0))
pygame.display.set_caption("You lost!")
else:
img = font.render("You scored {}!".format(score), True, (0,0,0))
pygame.display.set_caption("You won!")
screen = pygame.display.set_mode((img.get_width()+60,
img.get_height()+60))
screen.fill(BACKGROUND_COLOR)
screen.blit(img, (30, 30))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
Concluo assim este post, onde vos apresentei o meu código para um jogo completamente funcional, em pygame e Python! Se fizerem alterações e/ou tiverem perguntas, utilizem a secção dos comentários em baixo para partilhar!
This post will be a not-so-detailed tutorial about creating a memory game with Python and pygame. The game we are about to implement is a fairly common game: we have some cards face down and we turn them face up two by two, trying to pair them up. If we manage to turn a pair face up, we remove those two cards from the table. If the cards don't match, we turn them face down again.
When I am coding a game, I like to set functional milestones: stages at which I can test whatever I have up to that point. When I follow this method, the whole process becomes much more interesting and engaging. It also makes it easier for me to control how the end product will turn out to be. In what follows I laid out a list of said milestones to be achieved. Each item is a new functionality to be added:
Creates the screen and shows it with all the cards face down;
Clicking a card flips it face up;
Clicking a second card flips it up and then: if the two cards match they are removed from the table; if the cards don't match they are turned face down again;
The player now has a time restriction; matching two cards awards the player with extra time and failing to match two cards penalizes the player;
A final screen tells the player whether he lost or won. If the player won, display the score;
A configuration file is used to set how many cards are initially on the table.
As we go along I will include the code I am describing; to show it just click the button that matches the description. The reason I am hiding all the code behind some buttons is twofold: on the one hand, those who want to try and implement by themselves what I describe won't be spoiled; on the other hand, I will be including so many snippets of code that having them all without hiding them would make this post too big. All the code and images is available in my GitHub page. Following that link you will also find snapshots of the game at the end of each major step.
Step 0: the face of the cards
Before starting to code the memory game I decided the front face of the cards would have different regular polygons. This meant it was very easy to create a series of images for the cards and it also made the game mildly difficult. Polygons with more sides start to become hard to distinguish when you have little time to look at them. To create all the polygons I wrote a small script that repeatedly asks for an integer and then draws different coloured polygons with the specified number of sides. With that script I created several images to use as my cards.
from PIL import Image, ImageDraw
import sys
import os
from math import pi, sin, cos
def generate_polygon(n, colours):
theta = (2*pi)/n
vertices = [(20*cos(theta*j), 20*sin(theta*j))
for j in range(0, n)]
for j in range(1, n//2+1):
vertices[j] = (vertices[n-j][0], -vertices[n-j][1])
vertices = [(v[0]+25, v[1]+25) for v in vertices]
im = Image.new("RGB", (50,50), (255,255,255))
for name in colours.keys():
c = colours[name]
draw = ImageDraw.Draw(im)
draw.polygon(vertices, fill=c, outline=c)
im.save(os.path.join("polygonbin",
"sides"+str(n)+name+".png"))
# colours taken from https://www.w3schools.com/tags/ref_colornames.asp
COLOURS = {
"Black": (0,0,0),
"Red": (255,0,0),
"Green": (0,255,0),
"Blue": (0,0,255),
"Aqua": (0,255,255),
"Brown": (165, 42, 42),
"Chocolate": (210, 105, 30),
"Crimson": (220, 20, 60),
"DarkGoldenRod": (184, 134, 11),
"DarkGreen": (0, 100, 0),
"DarkOrange": (255, 140, 0),
"Fuchsia": (255, 0, 255),
"Gold": (255, 190, 0),
"SeaGreen": (46, 139, 87),
"Yellow": (255, 255, 0)
}
if __name__ == "__main__":
n = -1
while True:
try:
n = int(input("# of sides >> "))
if n <= 0:
sys.exit()
except Exception:
continue
generate_polygon(n, COLOURS)
Step 1: show all the cards face down
In order to have a "table" with all the cards face down, we need to decide how many cards we are going to have and in what configuration (how many rows/columns). We also need an image to represent the back of the cards.
from pygame.locals import *
import pygame
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
Obviously we started by importing everything necessary. We need to import the sys module so we can terminate the program when the user clicks the red x. The function load_image is a function that I (almost surely) copied from the internet; what it does is import the given image and then return its surface and rect. We can also specify if the image has a transparent background or not. After that we initialize some global variables that will be needed along the way. width and height are the number of columns and rows of cards we are going to have. WIDTH and HEIGHT are the dimensions of the window, in pixels. We also set the background colour, the padding (in pixels) between each card and the size (in pixels) of each card, which we assume to be a square.
The infinite cycle we also included is fairly standard and it keeps the game from freezing; as of now, the only action allowed is to close the game window.
Step 2: clicking a card turns it face up
To complete this step we will need to add a couple of things that handle some game logic: we need to shuffle the pairs of cards into their positions and we need to recognize when a card is clicked on.
We start by choosing the cards to be used and we distribute them on the table. Using the variables width, height we can know how many cards will be needed. All we have to do is open the directory where the images are and load randomly as many cards as needed. After we choose them, we store them in a dictionary and then create a matrix, with the same dimensions as the table, where we store which card is in which position. For that we start by shuffling all the cards in a vector and then we split the vector to create a matrix.
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
For this code to work we also need to import the modules random and os.
The game already knows what cards to put where, now we need to turn them face up. After we click the screen we need to recognize if we clicked a card and then turn it face up. As it will turn out to be very helpful, let us create a function board_to_pixels that receives a pair of integers $(x,y)$, a card position on the card matrix card_list, and returns another pair of integers: the position, in pixels, of the top-left corner of the given card. This will be useful to create some rectangles that are going to be needed to update the screen and to redraw the cards.
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
With this auxiliary function we can now process the player clicks. For that matter, we create another branch in the if statement inside our main cycle. We can use a bit of maths and the divmod functoin to know which card was clicked. We will also ignore clicks between cards.
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
After all these changes, this is what we have:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
Step 3: matches are removed from the table
If we want the matches to be removed from the table we will need to know if there is a card face up and which one it is. Having that in mind, we will add some auxiliary variables that will help us check if the second card to be turned face up is a match or not. We also need to modify the processing of the mouse clicks to take this into consideration.
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = None
flipped_coords = None
to_find = len(cards)/2
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = card_list[x][y]
flipped_coords = (x, y)
# there is a card face up
else:
# I just clicked it
if flipped_coords == (x, y):
continue
else:
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
pygame.time.delay(800)
# if we got it right
if flipped_card == card_list[x][y]:
to_find -= 1
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the previously clicked card from the game table
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
is_flipped = False
This new game cycle removes the matches, which is what we wanted, but there is a funny behaviour that wasn't supposed to happen: if we click a spot where there used to be a card, that card will show again! I illustrate this behaviour in the following image, where a pair of triangles has already been found and then I clicked the region in red.
To avoid this situation we need to store all positions of all cards that have already been found. When we click a region of a card, we check if that card has already been removed. We do that by adding a new variable found_cards where we store all positions that have been found. We add a small check so we ignore these clicks and then change the code to store the cards found when we find them. The final code for this step is:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 5
height = 4
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = None
flipped_coords = None
to_find = len(cards)/2
found_cards = []
while to_find > 0:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = card_list[x][y]
flipped_coords = (x, y)
# there is a card face up
else:
# I just clicked it
if flipped_coords == (x, y):
continue
else:
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
pygame.time.delay(800)
# if we got it right
if flipped_card == card_list[x][y]:
to_find -= 1
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the previously clicked card from the game table
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append(flipped_coords)
found_cards.append((x,y))
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels(flipped_coords)
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
is_flipped = False
Step 4: adding a timer
At this point we will add a timer for the game. Whenever the player finds a match a time bonus is awarded and whenever the player fails to find a match, a time penalty is applied. For the player to keep track of the time left we will also include a time bar. We will draw it to the right of the table and it will empty itself as the time goes by. We will create a function to draw the bar and we will create two global variables BONUSTIME and PENALTYTIME to store, in milliseconds, the time bonus and the time penalty. We will also change the main loop to stop whenever the time is up. As a default, we set BONUSTIME = 3000 and PENALTYTIME = 600
If we set the time bar to be drawn to the right, the function that draws the bar only needs the percentage of time left. That information is all we need, as long as we also have a variable controlling the width of the time bar. We will also need to update the variable WIDTH to reflect the extra pixels needed to draw the bar:
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
TIMEBARWIDTH = 25
# ...
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
Before going into the main loop we also need to define the moment the game is supposed to end. We also need to score all the bonuses and penalties the player has gotten, so we can update the time left. For that we will create two variables end and score in which we store the time at which the game ends and all the bonuses and penalties the player got. We then change the main loop to stop whenever we run out of time:
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
All that is left is changing the score whenever we match two cards or fail at doing so, making use of the variables BONUSTIME and PENALTYTIME...
and updating the time bar at each time step. To do so we need to calculate the percentage of time left and then call the function that draws the bar.
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
If we play our game now we will see the new time bar! We will also notice, however, that when two cards are face up the time bar freezes for a little. This happens because of the line pygame.time.delay(800), which stops pygame for 800ms. A way to circumvent this problem is by creating an auxiliary variable wait that tells the program to keep running BUT to ignore all user input. After that waiting time, we process the two cards that are facing up and then let the game flow as usual.
These are the changes that are due:
When we flip a second card face up, we store its position and define a waiting time;
When we are inside the main loop check if it is time for us to end the pause;
When we are in the waiting time, do not let the user click any more cards;
When the waiting time is over, turn the two cards face down or remove them from the table.
Taking this into consideration, the code could end up like this: (where the changes are in between ### --> and ### <--)
# auxiliary variables to control the state of the game
is_flipped = False
### -->
flipped_card = []
flipped_coords = []
wait = False
wait_until = None
### <--
to_find = len(cards)/2
found_cards = []
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
clock.tick(60)
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
### --> this is VERY similar to what used to be in the end of the loop
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
### <--
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
### -->
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
### <--
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y): ### just a minor change, use index notation
continue
else:
### -->
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
### <--
If we put together all of these changes we get a game that fulfills all requirements up to step 4. This is the final code for this step:
from pygame.locals import *
import pygame
import random
import os
import sys
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
width = 2
height = 3
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
BONUSTIME = 3000
PENALTYTIME = 600
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = []
flipped_coords = []
to_find = len(cards)/2
found_cards = []
wait = False
wait_until = None
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y):
continue
else:
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
Step 5: scoreboard
To add a victory/defeat screen is relatively easy. We can go about it by adding a bit of code after the main game loop:
# add the time left to the score in case we won
score += (end+score)-pygame.time.get_ticks()
# initialize a font to print the results
pygame.font.init()
font = pygame.font.Font(None, 40)
if to_find:
img = font.render("You lost!", True, (0,0,0))
pygame.display.set_caption("You lost!")
else:
img = font.render("You scored {}!".format(score), True, (0,0,0))
pygame.display.set_caption("You won!")
screen = pygame.display.set_mode((img.get_width()+60,
img.get_height()+60))
screen.fill(BACKGROUND_COLOR)
screen.blit(img, (30, 30))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
Step 6: configuration file
In this step we will create a configuration file so we can easily change the number of cards on the table (through the width and height variables), as well as change the bonus and penalty times, through BONUSTIME and PENALTYTIME. After a bit of googling I found the module configparser would be of great help; we import it and then define a function with the purpose of reading the configuration file and parsing it; if the file doesn't exist, we create it with default values. We called this function parse_configurations. We include its implementation, as well as the section where the global variables for the whole game are initialized; notice we removed the lines regarding width, height, BONUSTIME and PENALTYTIME.
import configparser
# ...
def parse_configurations():
configfile = "cardconfig.ini"
c = configparser.ConfigParser()
r = c.read(configfile)
if not r:
# create the configfile
global width, height, BONUSTIME, PENALTYTIME
c["DEFAULT"] = {"width": 5,
"height": 4,
"BONUSTIME": 3000,
"PENALTYTIME": 600
}
with open(configfile, "w") as f:
c.write(f)
# create the globals
global width, height, BONUSTIME, PENALTYTIME
width = int(c["DEFAULT"]["width"])
height = int(c["DEFAULT"]["height"])
BONUSTIME = int(c["DEFAULT"]["bonustime"])
PENALTYTIME = int(c["DEFAULT"]["penaltytime"])
parse_configurations()
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
The whole game, in no more than 230 lines, is thus:
from pygame.locals import *
import pygame
import random
import os
import sys
import configparser
def load_image(name, transparent=False):
"""Function that handles image loading
Returns the image and its Rect"""
try:
img = pygame.image.load(name)
except pygame.error:
raise SystemExit("Could not load image " + name)
if not transparent:
img = img.convert()
img = img.convert_alpha()
img_rect = img.get_rect()
return img, img_rect
def parse_configurations():
configfile = "cardconfig.ini"
c = configparser.ConfigParser()
r = c.read(configfile)
if not r:
# create the configfile
global width, height, BONUSTIME, PENALTYTIME
c["DEFAULT"] = {"width": 5,
"height": 4,
"BONUSTIME": 3000,
"PENALTYTIME": 600
}
with open(configfile, "w") as f:
c.write(f)
# create the globals
global width, height, BONUSTIME, PENALTYTIME
width = int(c["DEFAULT"]["width"])
height = int(c["DEFAULT"]["height"])
BONUSTIME = int(c["DEFAULT"]["bonustime"])
PENALTYTIME = int(c["DEFAULT"]["penaltytime"])
parse_configurations()
CARD_PIXEL_WIDTH = 50
PIXEL_BORDER = 5
TIMEBARWIDTH = 25
WIDTH = width*(CARD_PIXEL_WIDTH) + (width+2)*PIXEL_BORDER + TIMEBARWIDTH
HEIGHT = height*(CARD_PIXEL_WIDTH) + (height+1)*PIXEL_BORDER
BACKGROUND_COLOR = (20, 200, 20)
IMAGE_BIN = "polygonbin"
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
def board_to_pixels(coords):
# receives a pair (x, y) pertaining a card position on the table
# transforms it into a pair (xc, yc) of pixel coordinates of the
# top left corner of the card
xc = (coords[0]+1)*PIXEL_BORDER + coords[0]*CARD_PIXEL_WIDTH
yc = (coords[1]+1)*PIXEL_BORDER + coords[1]*CARD_PIXEL_WIDTH
return xc, yc
def draw_timebar(percentage):
# draws a black timebar to let the user know how much time is left
# find the total height of the bar
height_used = height*(CARD_PIXEL_WIDTH) + (height-1)*PIXEL_BORDER
# cover the existing timebar with the background color
pygame.draw.rect(screen, BACKGROUND_COLOR, pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# draw the timebar frame
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used), 3)
# draw the time that is still left
pygame.draw.rect(screen, (0,0,0), pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER+(1-percentage)*height_used,
TIMEBARWIDTH, percentage*height_used))
# update the timebar area
pygame.display.update(pygame.Rect(width*(CARD_PIXEL_WIDTH) + (width+1)*PIXEL_BORDER,
PIXEL_BORDER,
TIMEBARWIDTH, height_used))
# does the board have legal dimensions?
if width*height % 2:
print("Either 'width' or 'height' must be an even number")
sys.exit()
# choose the cards to be used
cards = random.sample(os.listdir(IMAGE_BIN), (width*height)//2)
images = dict()
for card in cards:
path = os.path.join(IMAGE_BIN, card)
images[card] = load_image(path)
cards = cards*2
random.shuffle(cards)
# card_list is a 2D array with the same structure as the game table
card_list = [cards[height*i:height*(i+1)] for i in range(width)]
cardback, cardbackrect = load_image("cardback.png")
# initialize the screen
screen.fill(BACKGROUND_COLOR)
for x in range(width):
for y in range(height):
xc = (x+1)*PIXEL_BORDER + x*CARD_PIXEL_WIDTH
yc = (y+1)*PIXEL_BORDER + y*CARD_PIXEL_WIDTH
screen.blit(cardback, (xc, yc))
pygame.display.update()
# auxiliary variables to control the state of the game
is_flipped = False
flipped_card = []
flipped_coords = []
to_find = len(cards)/2
found_cards = []
wait = False
wait_until = None
end = pygame.time.get_ticks() + BONUSTIME*width*height
score = 0
while to_find > 0 and pygame.time.get_ticks() < end + score:
# find the percentage of time left and update the timebar
perc = min(1, (end+score-pygame.time.get_ticks())/(BONUSTIME*width*height))
draw_timebar(perc)
if wait and pygame.time.get_ticks() > wait_until:
# we have waited already, now we take care of the cards
wait = False
pygame.event.set_allowed(MOUSEBUTTONDOWN)
# if we got it right
x1, y1 = flipped_coords[0]
x2, y2 = flipped_coords[1]
if card_list[x1][y1] == card_list[x2][y2]:
to_find -= 1
# this is the old rect pointing to the card
# that was most recently turned up
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# erase the oldest facing up card from the game table
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
pygame.draw.rect(screen, BACKGROUND_COLOR, rect)
pygame.display.update(rect)
# flag these two cards as found
found_cards.append((x1, y1))
found_cards.append((x2, y2))
score += BONUSTIME
# if we got it wrong
else:
# cover both cards again
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
xc, yc = board_to_pixels((x1, y1))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
screen.blit(cardback, (xc, yc))
pygame.display.update(rect)
score -= PENALTYTIME
is_flipped = False
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
elif ev.type == MOUSEBUTTONDOWN:
if wait:
continue
# find the card in which we clicked; ignore clicks in the gaps between cards
x, pad = divmod(ev.pos[0], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if x >= width or pad < PIXEL_BORDER:
continue
y, pad = divmod(ev.pos[1], PIXEL_BORDER+CARD_PIXEL_WIDTH)
if y >= height or pad < PIXEL_BORDER:
continue
# find the top left corner of the clicked card
xc, yc = board_to_pixels((x, y))
rect = pygame.Rect(xc, yc, CARD_PIXEL_WIDTH, CARD_PIXEL_WIDTH)
if (x,y) in found_cards:
continue
elif not is_flipped:
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
is_flipped = True
flipped_card = [card_list[x][y]]
flipped_coords = [(x, y)]
# there is a card face up
else:
# I just clicked it
if flipped_coords[0] == (x, y):
continue
else:
# set a waiting interval where no events are allowed
wait = True
# turn this new card face up; wait
screen.blit(images[card_list[x][y]][0], (xc, yc))
pygame.display.update(rect)
# disable new clicks
pygame.event.set_blocked(MOUSEBUTTONDOWN)
if flipped_card[0] != card_list[x][y]:
flipped_card.append(card_list[x][y])
wait_until = pygame.time.get_ticks() + 800
else:
wait_until = pygame.time.get_ticks() + 300
flipped_coords.append((x,y))
# add the time left to the score in case we won
score += (end+score)-pygame.time.get_ticks()
# initialize a font to print the results
pygame.font.init()
font = pygame.font.Font(None, 40)
if to_find:
img = font.render("You lost!", True, (0,0,0))
pygame.display.set_caption("You lost!")
else:
img = font.render("You scored {}!".format(score), True, (0,0,0))
pygame.display.set_caption("You won!")
screen = pygame.display.set_mode((img.get_width()+60,
img.get_height()+60))
screen.fill(BACKGROUND_COLOR)
screen.blit(img, (30, 30))
pygame.display.update()
while True:
for ev in pygame.event.get():
if ev.type == QUIT:
pygame.quit()
sys.exit()
This concludes the post, where I gave you the code for a working game with Python and pygame! If you make any modifications or have any suggestions and/or questions, please use the comment section below!
Pt En Being able to do basic arithmetic calculations in your head is a great skill. Not because it is sexy but because it is useful in your daily life: it can help you check the change you are given when shopping, it can help you know if you will have enough money to pay for your groceries, it can help you estimate how much things cost after the discounts, etc... This often reduces to being able to sum and subtract decently; sometimes you need to make a couple of small multiplications, but that is it. More likely than not, you don't need to compute averages every day. But sometimes you just want the scoring average of your team for the past few games, or the average price per person of a given meal, or the average time you spent stuck in traffic this past week... And averages may appear nastier than simply adding or subtracting, because averages also require you to perform a division: in fact, you have to add all the numbers you want and then divide the total by ho...
Pt En In this previous post I defined a Markov Decision Process and explained all of its components; now, we will be exploring what the discount factor $\gamma$ really is and how it influences the MDP. Let us start with the complete example of last post: In this MDP the states are Hungry and Thirsty (which we will represent with $H$ and $T$) and the actions are Eat and Drink (which we will represent with $E$ and $D$). The transition probabilities are specified by the numbers on top of the arrows. In the previous post we put forward that the best policy for this MDP was defined as $$\begin{cases} \pi(H) = E\\ \pi(T) = D\end{cases}$$ but I didn't really prove that. I will do that in a second, but first what are all the other possible policies? Well, recall that the policy $\pi$ is the "best strategy" to be followed, and $\pi$ is formally seen as a function from the states to the actions, i.e. $\pi: S \to A$. Because of that, we must know what $\pi(H)...