Esperimenti con Pygame

Recentemente ho iniziato a fare qualche esperimento con Pygame e ho scoperto che è divertente. Quindi proverò a raccontare che roba è e come iniziare, nel caso qualcuno voglia provare a darci un'occhiata.

Pygame è una libreria open source per python per la realizzazione di semplici videogiochi e simili applicazioni multimediali in 2D. Il che vuol dire che fornisce gli strumenti necessari per disegnare immagini/animazioni a schermo, gestire gli input tastiera/mouse, gestire le collisioni tra sagome 2D, audio eccetera. In aggiunta a qualunque altra cosa si possa fare normalmente con Python e le sue millemila librerie, ovviamente.

Può andare a sostituire, per progetti semplici, un game engine più sofisticato quale Godot o Unity, a patto di accettare il fatto che molte delle funzionalità che si danno per scontate non sono disponibili e vanno costruite (visto che non c'è una IDE, è tutto realizzato a codice).

Ma è proprio lì che sta il divertimento, no? La risposta è no, naturalmente, a meno che l'obiettivo non sia effettivamente imparare come si costruiscono queste funzionalità. In tal caso va benissimo.

Nota: esistono due versioni di Pygame, quella originale e Pygame Community Edition (CE). Quest'ultima è un fork che si è sviluppato qualche tempo fa dal codice originale dopo che lo sviluppo di quest'ultimo è andato in stallo, ed è quella consigliabile per il semplice motivo che è quella meglio mantenuta. La sintassi è identica, il modulo python si chiama sempre "pygame", l'unica differenza sta nel comando pip per installare la libreria:

pip install pygame-ce

Un semplice programma basato su Pygame è diviso in due parti: l'inizializzazione (in cui vengono predisposti gli oggetti) e il Game Loop.

Il Game Loop è la parte più caratteristica di un programma del genere e rappresenta sostanzialmente l'azione di aggiornare la situazione del gioco e ridisegnare il contenuto della finestra, con una frequenza prestabilita (30/60 frame per secondo). Le parti principali del game loop sono:

  • Gestione eventi: vengono gestiti gli eventi asincroni che si sono generati nel frame precedente (per esempio, un movimento del mouse, la pressione di un tasto, lo scadere di un timer...).
  • Pulizia: il contenuto della finestra viene azzerato (tutto sbiancato, o colorato con un colore o immagine fissa di sfondo).
  • Update: gli oggetti del gioco vengono aggiornati secondo le proprie logiche (gli oggetti che stanno cadendo si muovono verso il basso, i proiettili si spostano lungo la loro traiettoria, un conto alla rovescia si riduce, eccetera).
  • Disegno: tutti gli oggetti che devono apparire a schermo vengono disegnati sulla finestra stessa.

E così via, il ciclo si ripete all'infinito fino a quando il gioco non termina, a una frequenza sufficiente a dare l'illusione che queste singole schermate statiche siano in realtà una animazione. Facile, no?

Proviamo a disegnare qualcosa

Per prima cosa apriamo un terminale, ci spostiamo in una cartella vuota creata allo scopo e predisponiamo un virtual environment Python. E poi ci installiamo pygame.

python -m venv .venv
.\.venv\Scripts\activate
pip install pygame-ce

Prima di partire procuriamoci una immagine qualunque, magari un bel png con sfondo trasparente cercato a caso su internet. Magari qualcosa del genere:

A questo punto iniziamo a scrivere il nostro programma in un nuovo file, chiamato per esempio "main.py". Iniziamo a caricare la libreria principale, e importiamo direttamente alcune costanti da pygame.locals (ci faranno comodo quando andremo a catturare gli eventi da tastiera):

import pygame
from pygame.locals import *

Inizializziamo pygame, e creiamo la finestra di dimensioni 800x600. E già che ci siamo scriviamo "Hello World" nella barra del titolo, come vuole la tradizione.

pygame.init()
screen = pygame.display.set_mode((800, 600), 0 , 32)
pygame.display.set_caption('Hello World')

Predisponiamo l'immagine per l'uso successivo nel game loop. L'immagine viene caricata in una variabile mediante il metodo image.load, cui poi segue il metodo convert_alpha che la converte in una immagine utilizzabile da pygame mantenendo intatta la trasparenza. Estraiamo poi il rettangolo di questa immagine, che altro non è che una rappresentazione delle dimensioni e della posizione dell'immagine. Il rettangolo è uno dei concetti fondamentali in pygame perchè è usato per posizionare l'oggetto quando andremo a disegnarlo (come vedremo poi).

mario = pygame.image.load("mario.png").convert_alpha()
mario_rect = mario.get_rect()

A questo punto iniziamo il game loop:

running = True
while running:

Dentro il game loop per prima cosa ci occupiamo della gestione degli eventi. Gli eventi scattati durante il frame corrente sono restituiti dal metodo event.get(), e noi li analizziamo uno a uno alla ricerca di eventi interessanti. In questo caso andiamo a cercare l'evento QUIT (la chiusura della finestra) o l'evento KEYUP, key ESCAPE, che rappresenta la pressione del tasto ESC. In entrambi questi casi vogliamo semplicemente uscire dal programma, e per questo è sufficiente mettere False nella variabile del game loop "running".

	for event in pygame.event.get():
		if event.type == KEYUP and event.key == K_ESCAPE:
			running = False
		if event.type == QUIT:
			running = False

A questo punto c'è la pulizia. In questo caso vogliamo semplicemente riempire lo schermo con un colore uniforme, per esempio un verde (lo definiamo mediante colori RGB 0-255).

	GREEN = (76, 181, 71)
	screen.fill(GREEN)

Ora è il momento di disegnare la nostra immagine. Per disegnare una immagine si usa il metodo blit, e si passa l'immagine stessa e il rettangolo di riferimento di cui abbiamo parlato poco fa.

	screen.blit(mario, mario_rect)

A questo punto è sufficiente forzare il refresh dello schermo. Pygame di default usa il double buffering, quindi quello che abbiamo disegnato in questo frame non è visibile nella finestra ma è solo in memoria. Per concludere il disegno della schermata non ci resta che chiamare l'update.

	pygame.display.update()

Il programma è terminato, e non ci resta che lanciarlo:

Il programma completo fino a questo punto:

import pygame
from pygame.locals import *

GREEN = (76, 181, 71)

pygame.init()

screen = pygame.display.set_mode((800, 600), 0 , 32)
pygame.display.set_caption('Hello World')

mario = pygame.image.load("mario.png").convert_alpha()
mario_rect = mario.get_rect()

running = True
while running:

	for event in pygame.event.get():
		if event.type == KEYUP and event.key == K_ESCAPE:
			running = False
		if event.type == QUIT:
			running = False
			
	screen.fill(GREEN)

	screen.blit(mario, mario_rect)

	pygame.display.update()

Aggiungiamo un pò di movimento

Molto, molto, MOLTO bello ma non particolarmente interessante. Sarebbe divertente quantomeno poter muovere l'immagine usando le frecce sulla tastiera, no? Per fare questo dobbiamo prima fare una digressione sul concetto di fps.

Come abbiamo visto, il game loop, che si occupa di disegnare i frame, è un loop infinito che gira alla massima velocità permessa dal processore. Questo però non è ideale, perchè significa che il gioco gira più velocemente su pc veloci e più lentamente su pc lenti, mentre noi vorremmo che la velocità fosse approssimativamente identica per tutti i giocatori, quindi con lo stesso numero di fps (frame per second).

Per fare questo Pygame ci mette a disposizione un metodo da chiamare all'interno del game loop che rallenta ogni ciclo in modo tale da tentare di rispettare il numero massimo di fps che gli abbiamo indicato. Il codice è questo:

# fuori dal game loop
clock:pygame.time.Clock = pygame.time.Clock()

    # dentro il game loop
    dt = clock.tick(60)

Il metodo tick rallenta il loop per garantire un fps massimo di, in questo caso, 60. Il metodo tick ci restituisce un valore numerico, che qui ho chiamato dt, che rappresenta il numero di millisecondi passati dal frame precedente.

Questo numero è fondamentale perchè ci permette di fare i nostri calcoli indipendentemente dalla frequenza di aggiornamento. Non dimentichiamo che noi abbiamo fissato una frequenza massima, ma la vera frequenza potrebbe essere inferiore. Per esempio, nei calcoli per il movimento di un personaggio, vogliamo che la velocità percepita dal giocatore sia la medesima anche se la frequenza rallenta, quindi possiamo usare questo valore per riproporzionare i movimenti.

E ora passiamo ai movimenti. La gestione dei movimenti in realtà è semplicissima, basta aggiungere questo spezzone di codice prima della chiamata a blit.

	keys=pygame.key.get_pressed()
	speed = 400
	if keys[K_LEFT]:
		mario_rect.move_ip(-1 * speed * dt / 1000, 0)
	if keys[K_RIGHT]:
		mario_rect.move_ip(1 * speed * dt / 1000, 0)
	if keys[K_UP]:
		mario_rect.move_ip(0, -1 * speed * dt / 1000)
	if keys[K_DOWN]:
		mario_rect.move_ip(0, 1 * speed * dt / 1000)	

Utilizziamo il metodo key_get_pressed per ottenere la lista dei pulsanti premuti in quel momento, e poi cerchiamo i pulsanti che ci interessano (le frecce). In caso di match, modifichiamo la posizione del rect di riferimento usanto il metodo move_ip e passando la distanza su assi x e y in pixel. La distanza è calcolata come prodotto di speed (400) per dt/1000, quindi è di 400 pixel al secondo indipendentemente dalla frequenza di refresh.

Il programma completo è questo:

import pygame
from pygame.locals import *

GREEN = (76, 181, 71)
speed = 400

pygame.init()

screen = pygame.display.set_mode((800, 600), 0 , 32)
pygame.display.set_caption('Hello World')

mario = pygame.image.load("mario.png").convert_alpha()
mario_rect = mario.get_rect()

clock:pygame.time.Clock = pygame.time.Clock()

running = True
while running:

	dt = clock.tick(60)

	for event in pygame.event.get():
		if event.type == KEYUP and event.key == K_ESCAPE:
			running = False
		if event.type == QUIT:
			running = False
			
	screen.fill(GREEN)

	keys=pygame.key.get_pressed()
	if keys[K_LEFT]:
		mario_rect.move_ip(-1 * speed * dt / 1000, 0)
	if keys[K_RIGHT]:
		mario_rect.move_ip(1 * speed * dt / 1000, 0)
	if keys[K_UP]:
		mario_rect.move_ip(0, -1 * speed * dt / 1000)
	if keys[K_DOWN]:
		mario_rect.move_ip(0, 1 * speed * dt / 1000)

	screen.blit(mario, mario_rect)

	pygame.display.update()

Alla prossima!