Introduzione
Nella prossima serie di articoli analizzeremo delle tematiche che completano il nostro tour nella realizzazione di un gioco in 2D (per quanto riguarda il codice vero e proprio, altri aspetti, come l’organizzazione del lavoro, saranno trattati più avanti).
Per prima cosa vorrei parlare dei videogiochi isometrici: in questo articolo vedremo brevemente come impostare questo effetto.
L’isometria infatti è una tecnica che permette di visualizzare un mondo tridimensionale da un angolo di 45 gradi (prendete per esempio Freeciv). L’unica limitazione è che non c’è prospettiva e quindi gli oggetti più lontani non diventano pù piccoli ed è anche per questo che si renderizza solo una parte dell’ambiente alla volta. Limitare la visuale infatti garantisce un realismo (non paragonabile al 3D) di gran lunga più appagante del semplice gioco in due dimensioni, ma sia ben chiaro che rimane sempre 2D (il che comporta qualche facilitazione).
Quello di cui abbiamo bisogno quindi è una gestione ottimale della profondità, che determina la visualizzazione degli oggetti sullo schermo. Inoltre tratteremo anche una tassellazione di base, ovvero renderemo il backgroud di gioco piastrellato e sensibile al puntatore/giocatore (che nel nostro caso sarà una sfera azzura mossa dal mouse, come potete vedere dall’immagine).
Codice
#!/usr/bin/python # -*- coding: utf-8 -*- import pygame,sys from pygame.locals import* pygame.init() screen = pygame.display.set_mode((640,480), DOUBLEBUF | HWSURFACE, 32) pygame.display.set_caption("Isometria") q = "quadrato.png" t = "terreno.png" cc = "cornicer.png" c = "cornice.png" a = "albero.png" aa = "ombraalbero.png" s = "sfera.png" quadrato = pygame.image.load(q).convert_alpha() terreno = pygame.image.load(t).convert_alpha() cornicer = pygame.image.load(cc).convert_alpha() cornice = pygame.image.load(c).convert_alpha() albero = pygame.image.load(a).convert_alpha() ombra = pygame.image.load(aa).convert_alpha() sfera = pygame.image.load(s).convert_alpha() orologio = pygame.time.Clock() pygame.mouse.set_visible(False) class giocatore(): def __init__(self,xy,img,screen): """ Inizializza il giocatore """ self.img = img self.coordinate = (xy[0]-24,xy[1]-24) self.screen = screen self.depht = self.coordinate[1]+48 self.rect = pygame.Rect((xy[0]-10,xy[1]+30),(28,18)) def move(self,xy): """ Aggiorna le coordinate del giocatore """ self.coordinate = (xy[0]-24,xy[1]-24) self.depht = self.coordinate[1]+48 def render(self): """ Renderizza il giocatore """ self.screen.blit(self.img,self.coordinate) class mouse(): def __init__(self,xy): """ Inizializza il puntatore """ self.img = pygame.Surface((1,1)) self.coordinate = xy self.mask = pygame.mask.from_surface(self.img) self.mask.fill() class tile(): def __init__(self,img,cor,ogg,om,xy,screen): """ Inizializza la piastrella """ img = pygame.transform.rotozoom(img,45.,1) img = pygame.transform.scale(img,(100,70)) cor = pygame.transform.rotozoom(cor,45.,1) cor = pygame.transform.scale(cor,(100,70)) self.img = img self.om = om self.cor = cor self.ogg = ogg self.coordinate = xy self.cooalbero = (xy[0]+27,xy[1]-10) self.dephtalbero = self.cooalbero[1]+55 self.screen = screen self.mask = pygame.mask.from_surface(self.img) self.collide = False def _collide(self,to): """ Gestisce le collisioni tra puntatore e piastrella """ if self.mask.overlap(to.mask, (to.coordinate[0]-self.coordinate[0],to.coordinate[1]-self.coordinate[1])): self.collide = True else: self.collide = False def render_ogg(self): """ Renderizza l'oggetto della piastrella """ self.screen.blit(self.ogg, self.cooalbero) def render_om(self): """ Renderizza l'obra dell'oggetto della piastrella """ self.screen.blit(self.om, self.cooalbero) def render_g(self): """ Renderizza lo sfondo della piastrella e la cornice """ self.screen.blit(self.img,self.coordinate) if self.collide == True: self.screen.blit(self.cor, self.coordinate) p = mouse((0,0)) g = giocatore((0,0),sfera,screen) lista = [] deltax = 100 deltay = 35 plusx = 0 plusy = 0 for y in range(0,13): for x in range(0,6): if y < 6: lista.append(tile(quadrato,cornice,albero,ombra,(x*deltax+plusx,deltay*y),screen)) else: lista.append(tile(terreno,cornicer,albero,ombra,(x*deltax+plusx,deltay*y),screen)) if y%2 == 0: plusx = 50 else : plusx = 0 while True: pos = (-1,-1) for event in pygame.event.get(): if event.type == QUIT: sys.exit() if event.type == MOUSEMOTION: pos = pygame.mouse.get_pos() p.coordinate = pos g.move(pos) tempo_p = orologio.tick(60) screen.fill((0,0,0)) for x in lista: x.render_g() for x in lista: x.render_om() g.render() for x in lista: x._collide(p) if x.collide == False: x.render_ogg() continue elif x.dephtalbero < g.depht: x.render_ogg() g.render() else: g.render() x.render_ogg() pygame.display.set_caption("Isometria FPS = "+str(orologio.get_fps())) pygame.display.flip()
Analisi
- Fino alla linea 30 non ci sono novità, importiamo pygame e tutto il necessario per l’esempio
- La classe giocatore definisce la nostra sfera blu che si muoverà nel bosco. In particolare bisogna notare la presenza del parametro depht, che corrisponde alla profondità dell’oggetto sullo schermo calcolando la base dell’immagine utilizzata (infatti sommiamo 48 perché l’immagine della sfera è alta 48 pixel; non dimenticate che l’asse delle ordinate è rivolto verso il basso). La profondità viene aggiornata con lo spostamento del giocatore, sempre in base all’immagine utilizzata. Da notare che teniamo conto solo delle coordinate sull’asse y.
- La classe mouse ci servirà per gestire le collisioni con le piastrelle dello sfondo. Infatti è composta solo da 1 pixel (per semplificare) che viene utilizzato dalla maschera. Quest’ultima è necessaria per le collisioni punto punto tra superfici; in questo caso sarà utilizzata per le collisioni tra il puntatore del mouse e la piastrella. Il puntatore non è visibile, ma corrisponderà al centro della nostra sfera.
- La classe tile corrisponde alla nostra piastrella. Quest’ultima memorizza la sua immagine (che sarà ruotata e schiacciata per dare l’effetto isometrico), l’immagine della cornice (quando viene selezionata), le proprie coordinate, l’oggetto che si trova su di essa e le sue coordinate, lo schermo dove deve renderizzare ed una variabile che memorizza se l’oggetto collide o no. Il metodo _collide() controlla se l’oggetto passato (ovvero il puntatore) collide con la maschera della piastrella. Le maschere non hanno coordinate spaziali ma sono “generate” tutte nell’angolo in alto a sinistra dello schermo, ovvero a partire dalle coordinate (0,0); ecco perché sottraggo alle coordinate del puntatore quelle della posizione della pistrella, così le coordinate saranno adatte a controllare se il pixel del puntatore si trova all’interno della maschera della piastrella. Le varie funzioni di render, renderizzano rispettivamente l’oggetto che si trova sulla piastrella (in questo caso un albero), l’ombra dell’oggetto e lo sfondo (cioè la piastrella stessa).
- Prima di entrare nel loop di gioco, prepariamo la tassellazione del terreno (metà terreno sarà rosso, mentre l’altra metà con una texture simile ad un terreno erboso, già utilizzata in precedenti esempi), il puntatore ed il giocatore. Non fate molto caso alla funzione utilizzata per generare il terreno, perché serve esclusivamente per questo esempio.
- Nel loop dei controlli aggiorniamo la posizione del giocatore e del puntatore.
- Nel loop principale possiamo vedere che si renderizza il gioco in questa sequenza: terreno, ombre degli oggetti, giocatore, albero con giocatore avanti o viceversa.
- Infine aggiorniamo lo schermo e controlliamo i frame per secondo che verrano visualizzati sul titolo della finestra.
Per prima cosa devo precisare che il giocatore viene renderizzato dopo le ombre poiché deve comparire sopra di esse nell’eventualità che il puntatore vada fuori dalla tasselazione.
La cosa più importante da notare è che il puntatore (quindi anche il giocatore, nel nostro caso), se non è presente in una specifica casella, quest’ultima renderizza solamente l’oggetto che ha sopra di essa; se invece il puntatore collide con la maschera della piastrella, allora si visualizza l’albero e la sfera in base alle loro ordinate, per capire chi deve andare prima o dopo.
L’effetto risultante sarà un primo approccio a questa tecnica non banale ma neanche di difficile utilizzo.
Conclusioni
Come avete ben capito, per gestire la “profondità” di gioco, c’è bisogno di “qualcuno” che decida come renderizzare le immagini sullo schermo. Inoltre in questo tipo di videogame, abbiamo bisogno anche di un gestore del backgroud per le eventuali operazioni che si andranno a fare su di esso, come gli spostamenti degli oggetti e/o dei personaggi, selezioni ecc…
Questo problema è di fondamentale importanza e va affrontato prima di qualsiasi altra cosa. Pensate infatti che scelte prese su questo “gestore” (per ora definiamolo così) andranno ad influire tutta la meccanica di gioco.
Con questo esempio ho lasciato aperte molte porte per non limitare la vostra fantasia, poiché non esistono solo piastrelle quadrate, ma il metodo che abbiamo visto in precedenza per le collisioni con il puntatore (per individuare il tassello selezionato), funziona bene lo stesso (se qualcuno ha un’idea più veloce, che funzioni con qualsiasi tipo di piastrella come quella che ho scritto, lo prego di scrivermi).
Detto questo non mi resta che darvi appuntamento al prossimo articolo e mi scuso per il ritardo.
Sorgenti:
- Isometria 0 : http://dl.dropbox.com/u/16546001/AD/Isometria%200.rar
Fammi capire, hai istanziato NxM oggetti di tipo tile, giusto (non conosco il python, abbi pietà :-D ).
Io preferisco un approccio più “memory saving”:
– Creo una singola istanza di ogni tile e le colleziono nella cosiddetta tilemap, che in pratica è un sempice array
– Creo una matrice che rappresenta lo schermo, lo screenmap, che contiene gli indici dei tiles in ogni sua cella
– In fase di rendering, faccio una cosa del genere (in pseudocodice):
for i=1 to N
for j=1 to M
current = screenmap[i][j]
tile = tilemap[current]
render(tile, i*w, j*h)
Per le collisioni uso un metodo analogo, ma con un livello di ottimizzazione notevole, perchè invece di fare NxM test, faccio un lookup rapido solo nel momento in cui mi serve calcolare la collisione, e chiamo la funzione di collisione al più una sola volta. In questo modo
[i,j] = getTile(target.x, target.y)
[x1, y1] = transform(target.x, target.y)
tile = tilemap[ screenmap[i][j] ]
if tile.walkable then
tile.doCollision(x1, y1)
Le funzioni cruciali sono getTile e transform. getTile associa ad ogni coppia di coordinate schermo, una coppia di coordinate mappa. transform invece mi restituisce da coordinate schermo a coordinate locali, cioè interne al tassello. Scegliendo opportunamente la geometria dei tasselli, queste due funzioni diventano molto economiche da calcolare. Ad esempio se faccio il caso “base” di tasselli quadrati da 32×32 pixel, il calcolo diventa:
function [x1, y1] = transform(x, y)
x1 = (offset_mappa.x + x) % 32 ; oppure AND 31 che è più veloce
y1 = (offset_mappa.y + y) % 32 ; idem come sopra
e poi
function [i, j] = getTile(x, y)
i = (offset_mappa.x + x) / 32 ; oppure RSHIFT 5 che è meglio
j = (offset_mappa.y + y) / 32 ; idem come sopra
Chiaramente se ho geometrie di tipo isometrico, con tiles di forma romboidale o esagonale, dovrò fare la distinzione tra le righe pari e le righe dispari, in quanto ad ogni riga i tiles vengono rappresentati con un offset orizzontale pari a metà della loro larghezza, ma anche questo si calcola banalmente con una formula algebrica simile a quelle di base che ho riportato.
Che ne pensi di queste varianti? Ciao! :-)
ah, nella prima parte, nella fase di rendering ho scritto una cosa sbagliata… invece di render(tile, i*w, j*h) si dovrebbe usare una funzione di trasformazione speculare a quella usata nel calcolo delle collisioni, ma inversa, che trasformi cioè da coordinate mappa a coordinate schermo. La sua formulazione è banale ovviamente (tipicamente un prodotto, o uno shift binario, e una somma) :-)
@ Antonio Barba
1) In poche parole io ho fatto solo una tilemap, non ho aggiunto altre specifiche per ora.
2) Per la conversione delle coordinate potrei sempre riutilizzare mask (per come l’ho impostato in questo esempio), che mi sembra molto utile in questo caso.
3) La soluzione/i che hai proposto fanno parte del risultato finale al quale ambivo con questa serie di articoli. Quindi grazie per avermi confermato che ero sulla strada giusta :D.
Credo che per memorizzare i tiles utilizzero i dizionari, mi preoccupano piuttosto le funzioni getTile e transform. Purtroppo questo esempio serviva più a presentare un effetto visivo che ad implementarlo correttamente, anche perché qui si gestisce solo un oggetto giocatore. Spero di raggiungere un risultato soddisfacente nei prossimi articoli, con un vero e proprio gestore per “giochi isometrici”, se così possiamo dire.
Veramente un ottimo interveto! Ti ringrazio! :P
oops non volevo rovinarti la sorpresa XD
il fatto è che avendo sviluppato ai tempi un bell’engine per giochi isometrici in C, mi vengono in mente tutte le varie sessioni di brainstorming in cui cercavo di minimizzare sia la Ram che i cicli di CPU. Questi che ho tirato fuori sono alcuni dei risultati che personalmente avevo ricavato :-)
Contestualmente avevo anche sviluppato degli algoritmi di rendering 2D abbastanza buoni, per gestire layer con alpha blending ed effetto di parallasse, ampiamente ottimizzati in assembly ARM usando le estensioni DSP dell’ARM9 (Nintendo DS), che eguagliavano in frame rate le prestazioni del rendering hardware (consentendomi quindi di raddoppiare il numero di layers, metà in software e metà in hardware)…
Fighissimo il DS per programmarci, dovrei scriverci qualche articolo :-D
Abile e arruolato! Finalmente ti sei deciso. Ottimo. :)
@ Antonio Barba
Purtroppo il codice non sarà così ottimizzato come quello che hai scritto in assembly, dopotutto è sempre python (quindi interpretato), ma cercherò lo stesso di raggiungere tutti i traguardi dei quali abbiamo scritto prima :D.
PS: Non vedo l’ora di leggere qualche tuo articolo sul DS :P
@Cesare: Ma anche si :)
@Mirco: vabbè io ho dovuto usare un po’ di assembly perchè con 66MHz di processore (equivalenti a 16MHz quando accede in main ram, a causa del clock dimezzato della Ram e del bus a 16 bit) c’era veramente poco succo da spremere. Comunque, e questo tu lo sai ma lo dico per gli altri lettori, la prima ottimizzazione è quella algoritmica, poi se non basta si ricorre ad alcuni trick platform-dependent :-)
@ Antonio
“la prima ottimizzazione è quella algoritmica”, parole sante. Spesso è proprio questo il problema, non trovare la strada giusta. :D