Introducció als jocs amb pygame

Conceptes bàsics: jocs i ús de la biblioteca pygame

La biblioteca de subprogrames pygame està pensada per facilitar el desenvolupament de jocs per computador. La biblioteca presenta un conjunt molt extens de funcionalitats i possibilitats repartits entre diversos mòduls, entre ells:

  • display: control de la finestra principal o pantalla.

  • draw: dibuix de figures geomètriques bàsiques sobre imatges.

  • event: administra esdeveniments i la cua d’esdeveniments.

  • font: generació de famílies tipogràfiques TrueType.

  • image: llegeix i grava imatges.

  • time: controlador de temps.

  • transform: per escalar, girar i invertir imatges.

El game-loop o bucle principal d’un joc

La majoria dels jocs tenen una estructura com la mostrada a continuació: en primer lloc es fan les inicialitzacions pertinents (creació de la finestra gràfica, dels personatges, inicialització de la puntuació, etc.) i després hi ha una composició iterativa principal anomenada game-loop de la qual se’n surt quan s’acaba el joc, bé perquè l’usuari ha guanyat la partida, bé perquè l’ha perduda.

def joc:
    <inicialitza el joc>
    while not <final del joc>:
         <sondeja i tracta esdeveniments>
         <actualitza els elements del joc>
         <dibuixa una nova imatge>
         <mostra la nova imatge>
    <tanca el joc>

En aquest esquema, inicialitza el joc és un seguit d’operacions com per exemple crear una finestra per visualitzar el joc (pygame.display.set_mode()) o preparar i inicialitzar els continguts del joc necessaris per a entrar-hi.

La iteració principal del joc ha d’estar atenta periòdicament a la interacció de l’usuari en el joc (el que s’anomenen esdeveniments). Per tant, sondeja i tracta esdeveniments enquesta al sistema per si han passat esdeveniments (prémer tecles, botons de la interfície gràfica, botons o moviments del del ratolí, etc) i en el cas que així hagi estat actuar en conseqüència. La funció pygame.event.get() retorna una llista d’esdeveniments.

La segona part de la iteració principal del joc té a veure amb la representació (gràfica) de l’evolució del joc d’acord al guió, regles del joc i interacció de l’usuari. Típicament actualitza els elements del joc haurà de tenir present:

  • L’escenari o fons (paisatge, castell, etc) on passa el joc. Pot ser fixe o variar d’acord al guió o regles del joc que es vol dissenyar. La classe pygame.Surface i el mòdul pygame.draw seran útils per a representar el fons.

  • Els objectes gràfics mòbils i canviants (jugadors, naus, bombes, etc) que es mouen respecte l’escenari i responen a unes regles determinades pel guió o regles del joc. La seva posició i/o forma gràfica es canvia per tal de representar-la en la següent imatge que es genera. Tradicionalment, a un objecte gràfic mòbil i canviant se l’anomena sprite (literalment, follet). Vegeu el mòdul pygame.sprite.

Els sprites es poden crear a partir d’elements geomètrics que es dibuixen sobre una imatge (pygame.Surface) o a partir d’imatges prèviament creades. Per a dibuixar elements geomètrics en una imatge disposeu de les funcions pygame.draw.line(), pygame.draw.polygon(), pygame.draw.circle(), pygame.draw.ellipse(), pygame.draw.rect()… Per a pintar una àrea, el mètode pygame.Surface.fill(). Si disposem de les imatges dels sprites ja fetes, només cal copiar els seus píxels al lloc adient de la imatge final mitjançant el mètode pygame.Surface.blit(). L’actualització de l’estat dels sprites consisteix tant en canviar la seva mida i posició com en canviar la imatge associada.

El procés de dibuix de la nova imatge (dibuixa una nova imatge) es realitza sobre una imatge encara no visible a pantalla. Quan s’acaba de dibuixar, es mostra la nova imatge a la pantalla (pygame.display.flip()).

Exemple d’Animació

Heus ací un exemple, adaptat de la introducció:

import pygame

pygame.init()
mida = ample, alt = 320, 240
velocitat = [2, 2]
colorFons = 0, 128, 0
pantalla = pygame.display.set_mode(mida)
pilota = pygame.image.load("pilota.png")
capsaPilota = pilota.get_rect()
final = False
while not final:
    for event in pygame.event.get():
         if event.type == pygame.QUIT:
             final=True
             break
    capsaPilota = capsaPilota.move(velocitat)
    if capsaPilota.left < 0 or capsaPilota.right > ample:
        velocitat[0] = -velocitat[0]
    if capsaPilota.top < 0 or capsaPilota.bottom > alt:
        velocitat[1] = -velocitat[1]
    pantalla.fill(colorFons)
    pantalla.blit(pilota, capsaPilota)
    pygame.display.flip()
pygame.quit()

Copieu el codi en un fitxer anomenat joc1.py, proveu-lo i estudieu-lo (podeu trobar el codi comentat en la introducció abans referida). Baixeu-vos la següent imatge pilota per a fer les proves.

Si heu fet la prova, haureu notat que la pilota va tant de pressa que els vostres ulls no poden seguir-la. De fet, us sembla veure entre 2 i 4 pilotes. Això es deu a la persistència de la imatge sobre la retina de l’ull.

Per a controlar el refresc de les successives pantalles de l’animació, podeu temporitzar-ho instanciant un objecte de classe pygame.time.Clock (crono=pygame.time.Clock()) i afegint la crida crono.tick(24) al cos de la iteració principal; això farà que el programa no faci més de 24 imatges per segon. Vegeu el mètode pygame.time.Clock.tick().

Exemples de sondeig i tractament d’esdeveniments

Seguint amb l’exemple de la pilota, podríem visualitzar una per una la seqüència d’imatges (quadre a quadre) que donen la sensació de moviment de la pilota. Per això ens proposem generar una imatge, cada cop que premem una tecla del teclat que no sigui Escape. Si premem Escape sortim del programa, a l’igual que ho fem quan premem amb el ratolí el botó de sortida de la finestra del joc.

Consulteu la documentació del mòdul de pygame key, per les etiquetes de cadascuna de les tecles del teclat, i en la documentació del mòdul event trobareu els identificadors de tipus d’esdeveniments que capta pygame.

import pygame

pygame.init()
mida = ampla, alt = 320, 240
velocitat = [2, 2]
colorFons = 0, 128, 0
pantalla = pygame.display.set_mode(mida)
pilota = pygame.image.load("pilota.png")
capsaPilota = pilota.get_rect()
final = False
while not final:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            final = True
            break
        elif event.type == pygame.KEYDOWN:
            tecla = event.key
            if tecla == pygame.K_ESCAPE:
                final = True
                break
            else:
                capsaPilota = capsaPilota.move(velocitat)
                if capsaPilota.left < 0 or capsaPilota.right > ampla:
                    velocitat[0] = -velocitat[0]
                if capsaPilota.top < 0 or capsaPilota.bottom > alt:
                    velocitat[1] = -velocitat[1]
    pantalla.fill(colorFons)
    pantalla.blit(pilota, capsaPilota)
    pygame.display.flip()

Copieu el codi en un fitxer anomenat joc3.py i proveu-ho.

Un altre plantejament seria que que la pilota es mogui d’acord al que indiqui el ratolí dins de la pantalla del joc. Per aconseguir-ho, ens caldrà vigilar a cada pas de la iteració del joc, trobar-nos amb un esdeveniment del tipus pygame.MOUSEMOTION. Si el trobem, obtindrem les noves coordenades del ratolí en pantalla (pygame.mouse.get_pos()).

import pygame

pygame.init()
mida = ampla, alt = 320, 240
velocitat = [2, 2]
colorFons = 0, 128, 0
pantalla = pygame.display.set_mode(mida)
pilota = pygame.image.load("pilota.png")
capsaPilota = pilota.get_rect()
crono=pygame.time.Clock()
x, y = 0, 0
final = False
while not final:
    for event in pygame.event.get():
         if event.type == pygame.QUIT:
            final = True
            break
         elif event.type == pygame.MOUSEMOTION:
                 x, y = pygame.mouse.get_pos()
                 capsaPilota.left = x
                 capsaPilota.top = y
    pantalla.fill(colorFons)
    pantalla.blit(pilota, capsaPilota)
    pygame.display.flip()
    crono.tick(24)

Copieu el codi en un fitxer anomenat joc4.py. El provar-ho notareu que el ratolí apunta a l’extrem superior-esquerra del rectangle que encapsula la pilota. Podeu modificar-lo per que quedi centrat en la pilota. Vegeu pygame.Rect on podeu llegir que a més dels atributs pygame.Rect.top i pygame.Rect.left, n’hi han d’altres com pygame.Rect.center.

Exemple de Sprites

El mòdul pygame.sprite conté les classes pygame.sprite.Sprite i pygame.sprite.Group, i algunes funcions per detectar col·lisions entre sprites. Cal que cada follet que participi en el joc es defineixi com un objecte d’una classe derivada de la classe pygame.sprite.Sprite. En el següent exemple de codi ampliem el joc amb dues pilotes. Observeu que és indispensable que la classe Pilota tingui els atributs image i rect, així com el mètode update(). L’atribut image ha de ser de classe pygame.Surface i rect de classe pygame.Rect. A més a més, cal afegir tots els sprites en un grup (en aquest cas, un objecte de la classe pygame.sprite.Group). Per actualitzar els sprites n’hi ha prou amb cridar al mètode pygame.sprite.Group.update() del grup; i per visualitzar-los, al mètode pygame.sprite.Group.draw() del grup, el qual utilitza els atributs image i rect de cada sprite.

import  pygame

class Pilota(pygame.sprite.Sprite):

    def __init__(self, posicio, velocitat):
        super().__init__() # constructor de classe Sprite
        self.image = pygame.image.load("pilota.png")
        self.rect = self.image.get_rect()  # rectangle de la imatge
        self.rect.left = posicio[0]
        self.rect.top = posicio[1]
        self.velocitat = list(velocitat)
        self.mon = pygame.display.get_surface().get_rect() # Àrea on es mourà

    def update(self): # Actualitza posició i la velocitat de la pilota 
        self.rect.move_ip(self.velocitat)
        if self.rect.left < 0 or self.rect.right > self.mon.width:
            self.velocitat[0] = -self.velocitat[0]
        if self.rect.top < 0 or self.rect.bottom > self.mon.height:
            self.velocitat[1] = -self.velocitat[1]


pygame.init()
colorFons = (0, 200, 255)
pantalla = pygame.display.set_mode( (320,240) )
pilota1 = Pilota( (0,0), (2,2) )
pilota2 = Pilota( (100,100) , (1,1) )
pilotes = pygame.sprite.Group() # grup de Sprites
pilotes.add(pilota1)
pilotes.add(pilota2)
crono = pygame.time.Clock()
final = False
while not final:
    for event in pygame.event.get():
        if event.type == pygame.QUIT: 
            final = True
    pilotes.update()         # Actualitza la llista de sprites
    pantalla.fill(colorFons) # pinta el fons de la pantalla
    pilotes.draw(pantalla)   # pinta sprites
    pygame.display.flip()    # visualitza la nova pantalla
    crono.tick(24)           # limita a 24 imatges per segon (24 fps)
pygame.quit()

Copieu el codi en un fitxer anomenat joc2.py, proveu-lo i estudieu-lo.

Un exemple més treballat

Acabarem la introducció amb una proposta de disseny d’una funció anomenada joc que amplia les prestacions l’exemple anterior. El nou programa farà que les pilotes tinguin un comportament físic més real, i serà configurable. Configurar com han de ser les pilotes, (radi, imatge, etc) i quin comportament tindran (gravetat, acceleració, rebots entre elles, etc).

A mode d’exemple d’ús, l’anterior programa s’escriuria de la següent manera:

import pygame
from joc import joc
import math

midesP = (320, 240)
sons = ['rock.mid', 'puf.wav']
ipilota = pygame.image.load("pilota.png")
pilotes = []
radi = 55
pilotes.append((ipilota, (0, 0), radi, 200, (math.pi / 4, 2)))
pilotes.append((ipilota, (100, 100), radi, 200, (math.pi / 4, 1)))
regles = ['velocitat', 'límits']
regles2 = []
joc(midesP, pilotes, sons, regles, regles2)

És a dir, la funció joc rep com a paràmetres la mida de la pantalla, una llista de tuples especificant les pilotes, una llista de fitxers de sons, quin comportament han de mostrar les pilotes individualment en el seu món, i quines funcions hem d’aplicar en el cas que les pilotes col·lisionin entre sí.

Un altre objectiu de l’exposició que segueix, és veure com estructurem el disseny per tal d’aconseguir expressivitat, genericitat, i separació de conceptes en el programa final. Els conceptes, seran separats i agrupats en mòduls o classes.

Afegint una mica de física

Començarem estudiant com implementar conceptes físics. Aquests, els centralitzarem en un mòdul que anomenarem fisica.py. Com que treballarem amb vectors, necessitarem també el mòdul vector2D.py on trobarem funcions i operadors per l’aritmètica de vectors de dos dimensions. Els vectors s’expressaran de forma polar: angle i mòdul.

En fisica.py introduirem els conceptes de velocitat, acceleració (força), pèrdua d’energia que pot tenir un sprite. Cada concepte l’encapsularem com una classe d’objecte que proporciona al sprite els atributs que calen per fer-ho funcionar, i contindrà un mètode que actualitza els atributs corresponents pel següent quadre o imatge (frame) a representar el fenomen.

Si el sprite es mou, com a mínim ha de tenir una velocitat (píxels/quadre):

class Velocitat:
    def __init__(self, follet, vel):
        self.follet = follet
        self.follet.posicio = list(self.follet.rect.topleft)
        self.follet.velocitat = Vector2D(vel[0], vel[1])

    def update(self):
        self.follet.posicio[0] += self.follet.velocitat.x()
        self.follet.posicio[1] += self.follet.velocitat.y()
        self.follet.rect.left = self.follet.posicio[0]
        self.follet.rect.top = self.follet.posicio[1]

Noteu que guardem la posició en l’atribut posicio fora del rectangle rect. Això ho fem per poder tenir càlculs de tipus i precisió float. Els objectes pygame.Rect de _pygame guarden la seva posicio i dimensió com a int (coordenades pixel). Encara que intentem assignar valors de tipus float, aquests es truncaran a int. En conseqüència, els següents càlculs perdràn precisió i les pèrdues s’aniran acumulant molt en els següents quadres.

Guardant els resultats a posicio, gaudim de la precisió dels float (que també es limitada), i trunquem el resultat float un cop per quadre.

Si s’aplica una força, hi haurà acceleració. L’acceleració es un canvi de velocitat en el temps que en l’animació de quadre a quadre serà el canvi discret de la velocitat del sprite:

class Acceleracio:
    def __init__(self, follet, a):
        self.follet = follet
        self.follet.acceleracio = Vector2D(a[0], a[1])

    def update(self):
        self.follet.velocitat = self.follet.velocitat + self.follet.acceleracio

Una força podria ser la gravetat. La constant G la hem decidit empíricament fent proves.

class Gravetat(Acceleracio):
    G = (math.pi / 2.0, 1)
    
    def __init__(self, follet):
        super().__init__(follet, self.G)

L’energia degut a fregament, etc, es va perdent. Podriem reduir la seva velocitat per un factor menor que 1 en el temps (quadre a quadre):

class Perdues:
    def __init__(self, follet, p):
        self.follet = follet
        follet.perdua = p

    def update(self):
        self.follet.velocitat = self.follet.velocitat * self.follet.perdua

Acabem la part de comportament individual de les pilotes, afegint en fisica.py la classe fisica.RebotaLimits, que calcula el rebots amb les parets de la finestra del joc vistos en els exemples anteriors.

A més, del comportament individual que puguin tenir les pilotes, hi ha les possibles interaccions entre elles. Per exemple, que ensopeguin.

Si dues pilotes ensopeguen, caldrà calcular com afecta a la seva trajectòria i velocitat. A la wikipedia trobareu una explicació detallada de la col·lisió elàstica en que l’energia cinètica total dels dos cossos es conserva. Resumidament, per una pilota amb masa \(m_{1}\) i velocitat \(\vec{u}_{1}\), i una altra pilota amb masa \(m_{2}\) i velocitat \(\vec{u}_{2}\), un cop han impactat, les seves velocitats respectives \(\vec{v}_{1}\) i \(\vec{v}_{2}\) seran:

\[ \begin{align}\begin{aligned}\vec{v}_{1} = \frac{\vec{u}_{1}(m_{1}-m_{2})+2m_{2}\vec{u}_{2}}{m_{1}+m_{2}}\\\vec{v}_{2} = \frac{\vec{u}_{2}(m_{2}-m_{1})+2m_{1}\vec{u}_{1}}{m_{1}+m_{2}}\end{aligned}\end{align} \]

En el mòdul de fisica.py disposeu de la funcio rebota(f1, f2) que donat dos sprites que col·lisionen, aplica les fòrmules anteriors per calcular les noves velocitats dels sprites.

Un detall a tenir en compte és que les actualitzacions geomètriques o físiques es fan de forma discreta. Els càlculs els efectuem en un espai discret de píxels, i en un temps marcat pels instants de cada quadre de l’animació (frame to frame).

En el mòn analògic l’ensopegada pot haver estat en un instant comprès entre quadres ja que el temps és continu, mentre que en el programa ho calculem en l’instant que correspon a un quadre sense tenir en compte que ha passat entre el quadre anterior i l’actual.

En particular, si dues pilotes ensopeguen, serà una casualitat que ho facin just en l’instant que representi un quadre. El normal serà que les dues s’encavalquin com si la matèria de les dues pilotes fos traspassable. Caldrà doncs recuperar les posicions que tenien en l’impacte un temps abans i prosseguir. En la funció trobareu la correcció de posicions que atribuirem a un quadre (Oh! Trampa!).

Adoneu-vos que si les velocitats de les dues pilotes són molt grans, podrien creuar-se en el temps que hi ha entre un quadre i l’altre quadre, i el programa no assabentar-se per que en l’altre quadre ja no interseca.

So

En so.py es troba una classe per tractar el so del nostre exemple. Per això usarem els mòduls mixer i music. Bàsicament, la classe compta en que hi ha una música de fons que vindrà donat per un fitxer del tipus midi, ogg, o wav. En alguns sistemes també s’accepta el mp3. Estudieu el mòdul i veureu que és força senzill posar alguns sons al nostre joc exemple.

En el nostre exemple farem servir els fitxers rock.mid i puf.wav que us podeu baixar.

Redefinint la classe Pilota

En el fitxer pilotes.py redefinirem la classe pilotes.Pilota en que a l’inici caldrà fixar la posició del seu centre, el seu radi, la seva masa (pels impactes), l’objecte so per els efectes de so, i el seu comportament en forma de llista de classes del mòdul de física. Amb aquest últim paràmetre podrem fixar si volem que reboti a les parets de la finestra, si té velocitat, gravetat, alguna força, i/o pèrdues d’energia.

Noteu que el radi ho fem escalant la imatge de la pilota original usant la funció scale() del mòdul transform de pygame.

    def __init__(self, imatge, posicio, radi, masa, so, conductes):
        super().__init__()  # constructor de classe Sprite
        # escalem la imatge de la pilota d'acord al seu radi
        self.image = pygame.transform.scale(imatge, (2 * radi, 2 * radi))
        self.radius = radi
        self.rect = self.image.get_rect()  # rectangle de la imatge
        self.rect.center = posicio
        self.so = so
        self.masa = masa
        self.conductes = []
        for f in conductes:
            self.conductes.append(f(self))

    def update(self):  # Actualitza posició pilota
        for f in self.conductes:
            f.update()


class Pilotes(pygame.sprite.Group):
    def __init__(self, so, entrePilotes):
        super().__init__()  # constructor de classe Sprite

Farem una classe derivada de Group per tractar les colissions entre pilotes:


    def update(self):
        pilotes = self.sprites()
        for pilota1, pilota2 in itertools.combinations(pilotes, 2):
            if pygame.sprite.collide_circle(pilota1, pilota2):
                    for f in self.entrePilotes:
                        f(pilota1, pilota2)
        super().update()

El paràmetre entrepilotes és una llista de funcions a fer per cada parell de pilotes que trobem. Aquí posarem només la funció rebota() del mòdul fisica.

Funció principal

En el fitxer joc.py trobareu la funció principal de l’ampliació joc(midaP, pPilotes, sons, regles, regles2)(). En una sessió de python, podem importar el mòdul i iniciar el joc amb els paràmetres oportuns. Per exemple, l’exemple de les dues pilotes introduit més amunt. Un altre exemple amb 9 pilotes disposades amb cercle, és aquest:

import pygame
from joc import joc
import math
midesP = (800,600)
sons = ['rock.mid', 'puf.wav']
ipilota = pygame.image.load("pilota.png")
pilotes = []
refx, refy = midesP[0] // 2, midesP[1] // 2
radi = 10
r = 4 * radi
for a in map(math.radians, range(0, 360, 30)):
    x = round(r * math.cos(a) + refx)
    y = round(r * math.sin(a) + refy)
    pilotes.append((ipilota, (x, y), radi, 200, ((math.pi / 4, 4.0))))
regles = ['velocitat', 'límits']
regles2 = []
joc(midesP, pilotes, sons, regles, regles2)

Es mouran totes en la mateixa direcció amb velocitat fixa. Podeu obervar que en el temps, per errors de precisió, el cercle de pilotes no es manté.

Un altre exemple usant l’anterior disposició però cada pilota té una velocitat fixe centrífuga com si quelcom esclatés des del centre de la disposició i les empenya cap a fora:

import pygame
from joc import joc
import math

midesP = (800, 600)
sons = ['rock.mid', 'puf.wav']
ipilota = pygame.image.load("pilota.png")
pilotes = []
refx, refy = midesP[0] // 2, midesP[1] // 2
radi = 10
r = 4 * radi
for a in map(math.radians, range(0, 360, 30)):
    x = round(r * math.cos(a) + refx)
    y = round(r * math.sin(a) + refy)
    pilotes.append((ipilota, (x, y), radi, 200, (a, 2.0)))
regles = ['velocitat', 'límits']
regles2 = []
joc(midesP, pilotes, sons, regles, regles2)

Podeu ampliar el comportament de les pilotes fent (cal importar el mòdul fisica):

regles = ['velocitat', 'límits', 'perdues']
regles2 = [fisica.rebota]

o afegint la gravetat:

regles = ['gravetat', 'velocitat', 'límits', 'perdues']

A partir d’aquí, si heu entès el programa, podeu fer els canvis que s’us acudeixi. Per exemple, podrieu tractar les pilotes com si fossin planetes i implementar en el mòdul de física, la gravetat entre cossos. Enjoy it.