Tutorial on programming a memory card game


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:
  1. Criar um ecrã onde mostro todas as cartas dispostas, face para baixo;
  2. Clicar em cima de uma carta faz com que ela se vire para cima;
  3. 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;
  4. Um temporizador impede que gastemos tempo infinito num só jogo; acertar num par dá mais tempo e falhar num par retira tempo;
  5. Um ecrã final que diz se ganhámos/perdemos e quantos pontos fizémos;
  6. 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.

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.
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.
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ã.
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.
Depois de todas estas alterações, este é o código que já temos:

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.
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:

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:
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:
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:
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 ### <--

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:

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:

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.

O código completo final, em não mais do que 230 linhas, segue de seguida:

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:
  1. Creates the screen and shows it with all the cards face down;
  2. Clicking a card flips it face up;
  3. 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;
  4. 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;
  5. A final screen tells the player whether he lost or won. If the player won, display the score;
  6. 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.

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.
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.
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.
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.
After all these changes, this is what we have:

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.
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:

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:
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:
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.
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 ### <--)
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:

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:

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.
The whole game, in no more than 230 lines, is thus:
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!

  - RGS

Popular posts from this blog

Markov Decision Processes 01: the basics

The hairy ball theorem and why there is no wind (somewhere) on Earth