14: L'OGGETTO Sprite

UNA Surface + UN Rect

Nelle lezioni precedenti abbiamo visto come usare una Surface assieme ad un Rect con le sue stesse dimensioni per posizionare le immagini sullo schermo. Ho molto insistito su questa tecnica perchè è fondamentale per capire il movimento in pygame. Ora che (spero!) avete una buona confidenza con le Surface ed i Rect posso rivelarvi un segreto: esiste in pygame un oggetto più evoluto, che è proprio l'unione di una Surface ed un Rect: questo oggetto si chiama Sprite (il nome deriva da uno dei primissimi computer da casa, il famoso Commodore 64, che aveva la possibilità di programmare degli oggetti grafici così chiamati).

Uno Sprite è un oggetto che ha due attributi fondamentali:

image Una Surface che rappresenta l'immagine dello Sprite
rect Un Rect che rappresenta la posizione dello Sprite

Potreste chiedervi quale vantaggio ci sia ad usare questo nuovo oggetto, quando ormai già sappiamo usare Surface e Rect separatamente. L'uso degli Sprite è più coerente con la programmazione ad oggetti, che prima o poi sarà necessario approfondire: ad una sola immagine sullo schermo corrisponde una sola variabile nel programma (che contiene nei suoi attributi tutto ciò che è necessario per disegnarla). Vedremo più avanti i vantaggi che ci offre questa situazione.

Un altro importante motivo è che gli Sprite sono ottimizzati per quelle situazioni in cui nello schermo sono presenti molti oggetti simili: finora abbiamo avuto al massimo due oggetti in movimento, ma molti giochi sono molto più "affollati". Nelle prossime lezioni realizzeremo uno space shooter, e sullo schermo dovremo disegnare contemporaneamente molte astronavi nemiche e molti proiettili laser. Questa situazione comporta l'uso di liste di oggetti e (nonostante le liste di Python siano piuttosto facili da usare) porterebbe a complicazioni notevoli. Nel sottomodulo sprite di pygame sono definiti anche dei particolari tipi di liste, i Group, ottimizzati per lavorare con gli Sprite.

CREARE UNO Sprite

Vediamo ora come si usano concretamente gli Sprite. Per creare uno Sprite sono necessarie di solito quattro linee di codice. Infatti dobbiamo:

Riapriamo ancora una volta il nostro programma preferito, panda.py (qui), e modifichiamolo usando uno Sprite per disegnare l'immagine del panda.

NUOVO PROGRAMMA: panda_sprite.py

Come al solito aprite il programma originale, salvatelo con il nuovo nome e cominciate a modificarlo. Ecco l'inizio del programma modificato:


import pygame
from pygame.locals import *
from os.path import join

pygame.init()
screen = pygame.display.set_mode((800, 600))
clk = pygame.time.Clock()

# crea lo Sprite del panda
spr_panda = pygame.sprite.Sprite()
spr_panda.image = pygame.image.load(join("Sprites", "panda.png")).convert_alpha()
spr_panda.rect = spr_panda.image.get_rect()
.   .   .

Nella #10 creiamo lo Sprite spr_panda, nella #11 assegniamo al suo atributo image l'immagine del nostro file e nella #12 assegniamo all'attributo rect il Rect corrispondente. Notate la sintassi a destra del segno = nella #12: stiamo applicando il metodo get_rect() all'attributo image dell'oggetto spr_panda, e quindi dobbiamo concatenare due volte l'operatore punto. Dovremo abituarci ad usare molto spesso istruzioni simili. Non abbiamo aggiunto una quarta riga di codice perchè ci va bene la posizione predefinita in (0, 0) del Rect.

Muoviamo adesso il nostro panda, modificando la parte centrale del ciclo principale in questo modo:


.   .   .
    # movimento
    if spr_panda.rect.left < 0 or spr_panda.rect.right > screen.get_width():
        vel_panda[0] *= -1
    if spr_panda.rect.top < 0 or spr_panda.rect.bottom > screen.get_height():
        vel_panda[1] *= -1

    spr_panda.rect.x += vel_panda[0]
    spr_panda.rect.y += vel_panda[1]
.   .   .

Anche qui dovete notare, nelle istruzioni che abbiamo cambiato, la stessa sintassi: il lato sinistro del Rect che contiene il panda è spr_panda.rect.left (cioè l'attributo left dell'attributo rect dell'oggetto spr_panda) e così via.

I Group - DISEGNARE UNO Sprite

Dobbiamo infine disegnare il panda sullo schermo, ma a questo punto abbiamo una sorpresa: l'oggetto Sprite non ha un metodo equivalente al blit() della Surface e quindi non possiamo disegnare un singolo Sprite sullo schermo. Per fare questo è necessario introdurre un altro oggetto, il Group.

Un Group è in pratica una lista di Sprites: possiamo aggiungerne uno con il metodo add(), eliminarlo con il metodo remove(), ma soprattutto possiamo disegnare contemporaneamente tutti gli Sprite contenuti nel Group semplicemente chiamando il metodo draw(). Per disegnare uno Sprite esso deve necessariamente appartenere ad un Group (uno Sprite può appartenere anche a più Group contemporaneamente). Quando creiamo uno Sprite possiamo assegnarlo ad uno o più Group direttamente nel costruttore, scrivendo i loro nomi tra i parametri della funzione. Modificate ancora il programma così:


import pygame
from pygame.locals import *
from os.path import join

pygame.init()
screen = pygame.display.set_mode((800, 600))
clk = pygame.time.Clock()

# crea un Group con il costruttore
all_sprites = pygame.sprite.Group()

# crea lo Sprite del panda
spr_panda = pygame.sprite.Sprite(all_sprites)
spr_panda.image = pygame.image.load(join("Sprites", "panda.png")).convert_alpha()
spr_panda.rect = spr_panda.image.get_rect()

    .   .   .

    # aggiornamento dello schermo
    screen.fill("darkgreen")
    all_sprites.draw(screen)
    pygame.display.flip()

    clk.tick(30)

pygame.quit()

Nella #10 abbiamo creato il Group all_sprites con la funzione costruttore, che restituisce un Group vuoto. Nella #13 abiamo modificato la chiamata al costruttore Sprite(), inserendo il nuovo Sprite spr_panda nel Group all_sprites. Infine nella #21 usiamo il metodo draw() dell'oggetto Group, che disegna tutti gli Sprite contenuti nel Group (in questo caso il solo spr_panda) nelle loro posizioni (il metodo prende come parametro la Surface sulla quale deve disegnare le immagini). A questo punto abbiamo finito e possiamo vedere nuovamente il nostro panda muoversi e rimbalzare.

Probabilmente le vostre perplessità saranno aumentate: usare gli Sprite richiede in genere più linee di codice, sintassi più complicate e disegnarli sullo schermo sembra un'operazione piuttosto macchinosa. Come vi ho detto l'uso degli Sprite è conveniente in quelle situazioni in cui le immagini da disegnare sullo schermo sono molte.

UN ALTRO ESEMPIO

Ecco un esempio più significativo. Questo programma crea dei fantasmini (quelli di Pac Man) di quattro colori diversi ad intervalli e posizioni casuali, facendoli muovere da sinistra a destra sullo schermo. Per creare i fantasmi il programma usa un timer di pygame, caricandolo con intervalli di tempo generati casualmente (abbiamo già imparato a farlo qui).

NUOVO PROGRAMMA: fantasmi.py

import pygame, random
from pygame.locals import *
from os.path import join

# inizializzione
pygame.init()
screen = pygame.display.set_mode((800, 600))
clk = pygame.time.Clock()

# risorse e variabili
ghost_images = []
fname = join("Sprites", "pac-classic", "ghost-red-right.png")
ghost_images.append(pygame.image.load(fname).convert_alpha())
fname = join("Sprites", "pac-classic", "ghost-lblue-right.png")
ghost_images.append(pygame.image.load(fname).convert_alpha())
fname = join("Sprites", "pac-classic", "ghost-orange-right.png")
ghost_images.append(pygame.image.load(fname).convert_alpha())
fname = join("Sprites", "pac-classic", "ghost-pink-right.png")
ghost_images.append(pygame.image.load(fname).convert_alpha())

all_ghosts = pygame.sprite.Group()
TIMERSHOT = pygame.event.custom_type()
pygame.time.set_timer(TIMERSHOT, random.randrange(200, 2501))

# ciclo principale
done = False
while not done:
    for ev in pygame.event.get():
        if ev.type == QUIT:
            done = True
        # se l'evento e' il timer ...
        elif ev.type == TIMERSHOT:
            pygame.time.set_timer(TIMERSHOT, random.randrange(200, 2501))
            ghost = pygame.sprite.Sprite(all_ghosts)
            ghost.image = ghost_images[random.randrange(4)]
            ghost.rect = ghost.image.get_rect()
            maxy = screen.get_height() - ghost.rect.height - 9
            ghost.rect.topright = (-1, random.randrange(10, maxy))

    for ghost in all_ghosts:
        ghost.rect.x += 4

    screen.fill((160, 160, 255))
    all_ghosts.draw(screen)
    pygame.display.flip()
    clk.tick(30)

pygame.quit()

Ci sono parecchie novità e dobbiamo analizzarlo attentamente (cercate soprattutto di capire l'uso delle varie funzioni del modulo random, che troviamo in più punti).

Fate partire il programma e finalmente vedrete una scena con oggetti che compaiono, si muovono e spariscono. Manca ancora una cosa: dobbiamo eliminare dal Group all_ghosts i fantasmi man mano che escono dallo schermo. Infatti ogni volta che creiamo un fantasma aggiungiamo uno Sprite al Group, ed il programma (nella #40) continua ad aggiornare la posizione di tutti i rispettivi Rect, sprecando potenza di calcolo per muovere anche i fantasmi che ormai non si vedono più. In un animato livello di un videogioco potreste magari creare migliaia di Sprite (pensate a giochi tipo shooter dove si sparano proiettili continuamente): se non si eliminassero quelli ormai inutili ad un certo punto il vostro ciclo principale comincierebbe a rallentare impegnando inutilimente il processore del computer.

Oltre al costruttore Sprite(), che permette di inserire immediatamente lo Sprite in uno o più Group, ci sono altri metodi dell'oggetto Sprite che controllano la sua appartenenza ai Group:

Metodo Esempio Significato
add() sp.add(all_sprites) Aggiunge lo Sprite (già esistente) ad uno o più Group, che vanno indicati nei parametri
remove() sp.remove(all_sprites) Rimuove lo Sprite (già esistente) da uno o più Group, indicati nei parametri.
kill() sp.kill() Rimuove lo Sprite da tutti i Group al quale appartiene. E' il metodo che si chiama di solito quando uno Sprite "non serve più" e può essere eliminato.
alive() if sp.alive(): Restituisce True se lo Sprite appartiene a qualche Group.
ESERCIZIO 14.1: Modificate il programma precedente eliminando gli Sprite che non si vedono più. Per accorgersi che un fantasma è uscito dallo schermo basta verificare se il lato sinistro del suo Rect ha una coordinata x maggiore alla larghezza dello schermo. Ecco il ciclo principale modificato:

    .   .   .
    for ghost in all_ghosts:
        ghost.rect.x += 4
        if ghost.rect.left > screen.get_width():
            ghost.kill()
	.  .  .
  
ESERCIZIO 14.2: Aumentate o diminuite la frequenza dei fantasmi agendo sui parametri dei timer. Cercate anche di capire il significato dei parametri della randrange() nella riga #38 osservando cosa succede se li cambiate.

Facciamo infine qualche considerazione: a differenza dei nostri primi programmi, nei quali avevamo uno o due oggetti che rimanevano sullo schermo dall'inizio alla fine, fantasmi.py crea e distrugge dinamicamente un numero imprecisato di Sprite durante la sua esecuzione (una situazione piuttosto comune nei videogiochi): da questo nasce spontaneamente il bisogno di un "contenitore" per i nostri Sprite. Se avessimo utilizzato il nostro vecchio metodo con Surface e Rect separati avremmo avuto bisogno di due liste di Python separate: una per le Surface (i fantasmi hanno immagini diverse l'uno dall'altro) ed una per i Rect (ogni fantasma è in una posizione diversa). Inoltre avremmo dovuto tenerle sincronizzate, aggiungendo o togliendo oggetti sempre a tutte e due le liste contemporaneamente. In questi casi comincia ad essere chiaro il vantaggio di avere tutte le nostre informazioni racchiuse in una sola variabile; altri vantaggi, legati alla programmazione ad oggetti, li vedremo più avanti.

COLLISIONI TRA Sprite

Anche per le collisioni ci sono nel sottomodulo sprite delle funzioni apposite, ottimizzate per gli Sprite e per i Group; qui possiamo vedere solo le principali.

La funzione

pygame.sprite.collide_rect(left, right) -> bool

verifica la collisione tra i due Sprite left e right controllando se i loro Rect si intersecano (come abbiamo fatto anche noi fino ad adesso). Nel modulo ci sono altre funzioni che usano metodi più precisi (ad esempio un cerchio o una "maschera") per controllare se gli Sprite collidono; vi invito a leggere la documentazione qui: https://www.pygame.org/docs/ref/sprite.html per saperne di più.

La funzione

pygame.sprite.spritecollide(sprite, group, dokill, collided = None) -> Sprite_list

verifica se uno Sprite collide con qualche altro Sprite in un Group. Il significato dei suoi parametri è il seguente:

Parametro Tipo di dato Significato
sprite oggetto Sprite Lo Sprite da testare
group oggetto Group Il Group degli Sprite da testare
dokill booleano Obbligatorio: se è True gli eventuali Sprite che collidono saranno rimossi automaticamente da ogni Group eseguendo il metodo kill()
collided funzione Opzionale: se non è presente la funzione esegue il test standard usando i Rect degli Sprite. Un utente esperto potrebbe usare una sua funzione personalizzata

La funzione restituisce una lista in cui sono contenuti tutti gli Sprite del Group che collidono con lo Sprite di partenza. Notate che la possibilità, offerta dal parametro dokill, di eliminare automaticamente gli Sprite che collidono senza dover scrivere altre istruzioni, è una notevole comodità.

Esiste anche una funzione simile per le collisioni tra due Group:

pygame.sprite.groupcollide(group1, group2, dokill1, dokill2, collided = None) -> Sprite_dict

verifica le collisioni tra due Group. Di nuovo vediamo il significato dei suoi parametri:

Parametro Tipo di dato Significato
group1 oggetto Group Il primo Group da testare
group2 oggetto Group Il secondo Group da testare
dokill1 booleano Se è True gli eventuali Sprite che collidono in group1 saranno cancellati eseguendo il metodo kill()
dokill2 booleano Se è True gli eventuali Sprite che collidono in group2 saranno cancellati eseguendo il metodo kill()
collided funzione Come nella funzione precedente

Questa funzione restituisce un dizionario di Python, cioè un tipo di dato nel quale ad ogni Sprite del Group group1 è associata una lista di tutti gli Sprite del Group group2 che collidono con esso. Se avete bisogno di aiuto sui dizionari potete consultare qui il mio tutorial intermedio. In una delle prossime lezioni vi mostrerò un esempio dell'uso di questa funzione.

Infine si può usare anche una funzione più semplice per le collisioni tra uno Sprite e un Group:

pygame.sprite.spritecollideany(sprite, group, collided = None) -> Sprite

verifica le collisioni tra lo Sprite sprite ed il Group group: se non ce ne sono ritorna None, altrimenti il primo Sprite del Group che collide (l'ordine è casuale); non consente di cancellare gli Sprite.

ESERCIZIO 14.3: Se avete lanciato il programma fantasmi.py vi sarete accorti che a volte genera fantasmi parzialmente sovrapposti: vogliamo evitare che questo accada. Potreste facilmente evitare la cosa aumentando il tempo minimo del timer (provate), ma per esercizio usiamo le collisioni tra sprite. Dobbiamo creare lo Sprite senza aggiungerlo immediatamente al Group: lo aggiungeremo solo se esso non collide con qualche altro fantasma già esistente. Modificate il programma così: OSSERVAZIONE 1: Se lasciate invariata la #34 non vedrete nessun fantasma: infatti se aggiungiamo subito il fantasma al Group esso colliderà con sè stesso e la spritecollideany() restituirà sempre True.
OSSERVAZIONE 2: Volendo essere più precisi in questo caso non c'è bisogno di chiamare la kill(), perchè lo Sprite non è ancora stato assegnato ad alcun Group. Potete modificare il programma usando solo l'if senza l'else.
SOLUZIONI

E' ora il momento di mettere in pratica quanto abbiamo imparato sugli Sprite, realizzando un altro gioco nelle prossime due lezioni.

Fine della lezione