Sviluppare un gioco in Python: isometria, Seconda parte.

Introduzione

In questa parte vedremo le migliorie che si possono apportare all’engine isometrico, per non perdere prestazioni e per rendere più malleabile la creazione del gioco.

Per il momento ho eliminato il giocatore, ma ho lasciato la possibilità di selezionare e deselezionare le piastrelle (tasto sinistro del mouse) ed ora è anche possibile creare un oggetto (in questo caso un albero) tramite il tasto destro del mouse, su una data piastrella. Il terreno viene caricato seguendo una stringa che indica il tipo di tile che dovrà essere visualizzata. Sarà anche possibile scorrere la mappa con le frecce direzionali, visto che ora è più grande del campo visivo messo a disposizione dalla finestra.

Tutte queste piccole cose, oltre a fornirci una solida base per le prossime puntate, sono di vitale importanza per capire l’isometria e i suoi principi. Resta il fatto che le soluzioni proposte sono molto generali e che quindi possono essere applicate a qualsiasi progetto abbiate in mente. Non è stato facile trovare delle applicazioni utili per i più disparati casi, ma penso di esserci riuscito, se non altro concettualmente; c’è da sottolineare anche il fatto che si possono prendere delle scelte inerenti solamente al gioco da voi sviluppato: per esempio scegliere delle piastrelle quadrate od esagonali può portare a delle semplificazioni notevoli, ma dipende sempre se il nostro gioco si sviluppa con un assonometria di 45° o semplicemente in verticale.

Per ricoprire una casistica più vasta ho quindi utilizzato tecniche più generali e pochi trucchi (soltanto quelli necessari a far alzare il frame rate).

Codice

#!/usr/bin/python
# -*- coding: utf-8 -*-

import pygame
from sys import exit
from pygame.locals import *

s_terreno = """ 2 2 2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2 2 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 1 1 1 1 1 1 1 1 2
2 2 2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2 2 2"""

class oggetto():
    """
    Classe che rappresenta un oggetto sulla piastrella
    """
    def __init__(self,imm,xy,screen):
        """
        Inizializzazione dell'oggetto
        """
        self.aggiorna = True
        self.imm = imm
        self.xy = xy
        self.rect = pygame.Rect(self.xy,imm.get_size())
        self.screen = screen

    def render(self):
        """
        Rendering dell'oggetto
        """
        self.screen.fill((0,0,0),self.rect, BLEND_MAX)
        self.screen.blit(self.imm,self.xy)
        self.aggiorna = False

class pointocollide():
    """
    Classe che rappresenta il punto di collisione del puntatore del mouse
    """
    def __init__(self):
        """
        Inizializza la mashera
        """
        self.mask = pygame.mask.from_surface(pygame.Surface((1,1)))
        self.mask.fill()
        self.rect = pygame.Rect((0,0),(150,105))

class tile():
    """
    Classe che rappresenta la piastrella sullo schermo
    """
    def __init__(self,img,cor,xy,screen):
        """
        Inizializzazione della piastrella.
        """
        self.aggiorna = True
        self.selected = False
        self.collide = False
        self.walkable = True
        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.cor = cor
        self.img = img
        self.screen = screen
        self.xy = xy
        self.center = (self.xy[0]+50,self.xy[1]+35)
        self.rect = pygame.Rect(xy,(100,70))
        self.mask = pygame.mask.from_surface(self.img)

    def collide_mask(self,punto):
        """
        Ritorna true se il punto collide con la piastrella
        """
        x,y = self.xy
        z,t = punto
        if self.mask.overlap( mousepoint.mask , (z-x,t-y)):
            self.collide = True
            self.aggiorna = True
            return self.collide
        else:
            self.collide = False
            self.aggiorna = True
            return self.collide

    def render(self):
        """
        Rendering della piastrella
        """
        self.screen.fill((0,0,0),self.rect, BLEND_MAX)
        self.screen.blit(self.img,self.xy)
        if self.selected or self.collide:
            self.screen.blit(self.cor,self.xy)
        pygame.draw.rect(self.screen,(255,255,0),(self.center,(2,2)),5)
        self.aggiorna = False

class iso_engine():
    """
    Semplice engine per gestire l'isometria
    """
    def __init__(self,screen,terr,acq,cor,tipi):
        """
        Inizializza il motore isometrico
        """
        self.aggiorna = True
        self.tiles_list = []
        self.level = []
        self.tipi = tipi
        self.anim = 25
        self.screen = screen
        self.list_visibili_tiles = []
        self.list_visibili_level = []
        self.rect_visibili = pygame.Rect((0,0),(640,480))
        self.coordinate_rect = (-1,-1)
        self.prev_collide = []

        temp = s_terreno.split('\n')
        for x in range(0,len(temp)):
            temp[x] = temp[x].split()

        x_d = 0
        y_d = 0
        for x in range(0,len(temp)):
            self.tiles_list.append([])
            self.level.append([])
            current = temp[x]
            for y in range(0,len(current)):
                if current[y] is '1':
                    self.tiles_list[x].append(tile(terr,cor,(x_d+100*y,y_d),screen))
                elif current[y] is '2':
                    self.tiles_list[x].append(tile(acq,cor,(x_d+100*y,y_d),screen))
                    self.tiles_list[x][y].walkable = False
                self.level[x].append(0)
            y_d+=35
            x_d+=50

    def aggiorna_visibili(self,cgame):
        """
        Aggiorna la lista delle piastrelle visibili
        """
        if self.coordinate_rect != cgame:
            x,y = self.coordinate_rect
            z,t = cgame
            self.rect_visibili.move_ip(x-z,y-t)
            self.coordinate_rect = cgame

        del self.list_visibili_level[:]
        del self.list_visibili_tiles[:]

        for x in range(0,len(self.tiles_list)):
            for y in range(0,len(self.tiles_list[x])):
                if self.tiles_list[x][y].rect.colliderect(self.rect_visibili):
                        self.list_visibili_tiles.append(self.tiles_list[x][y])
                        self.list_visibili_level.append(self.level[x][y])

    def add_oggetto(self,pun,type):
        """
        Aggiunge un oggetto in base al tipo passato nella
        piastrella selezionata
        """
        for x in range(0,len(self.tiles_list)):
            for y in range(0,len(self.tiles_list[x])):
                if self.tiles_list[x][y].collide_mask(pun):
                    if self.tiles_list[x][y].walkable:
                        self.tiles_list[x][y].aggiorna = True
                        self.tiles_list[x][y].walkable = False
                        z,t = self.tipi[type].get_size()
                        i = self.tiles_list[x][y].center[0] - z/2
                        p = self.tiles_list[x][y].center[1] - t+18
                        self.level[x][y] = oggetto(self.tipi[type],(i,p),self.screen)
                        self.aggiorna = True

    def selection(self,pun):
        """
        Seleziona e deseleziona una piastrella
        """
        for x in self.prev_collide:
            if x.collide_mask(pun):
                x.aggiorna = True
                if x.selected:
                    x.selected = False
                else:
                    x.selected = True

    def collide(self,pun):
        """
        Aggiorna le tiles per visualizzare la selezione del mouse
        """
        if len(self.prev_collide)>0:
            for x in self.prev_collide:
                x.aggiorna = True

        del self.prev_collide[:]

        for x in self.list_visibili_tiles:
            if x.rect.colliderect(mousepoint.rect):
                x.aggiorna = True
                self.prev_collide.append(x)

        for x in self.prev_collide:
            if x.collide_mask(pun):
                x.aggiorna = True

    def aggiorna_blitter(self):
        """
        Aggiorna lo schermo in base alle piastrelle che
        devono essere aggiornate
        """
        var = orologio.get_time()
        #print var # For debug
        if self.aggiorna and self.anim >= 50:
            #print "\n aggiornato \n" # For debug
            for x in self.list_visibili_tiles:
                #print x.aggiorna, # For debug
                if x.aggiorna:
                    x.render()
            for x in self.list_visibili_level:
                if x != 0:
                    x.render()
            self.aggiorna = False
            self.anim = 0
        else:
            self.anim+= var

    def first_render(self):
        """
        Primo rendering
        """
        for x in range(0,len(self.tiles_list)):
            for y in range(0,len(self.tiles_list[x])):
                self.tiles_list[x][y].render()
        for x in range(0,len(self.level)):
            for y in range(0,len(self.level[x])):
                if self.level[x][y] != 0:
                    self.level[x][y].render()

def run():
    pygame.init()
    pygame.key.set_repeat(25,10)
    screen = pygame.display.set_mode((640,480), DOUBLEBUF | HWSURFACE, 32)
    sup_terr = pygame.Surface((1750,600), DOUBLEBUF | HWSURFACE, screen)
    pygame.display.set_caption("Isometria")

    global orologio
    orologio = pygame.time.Clock()

    global mousepoint
    mousepoint = pointocollide()

    t = "terreno.png"
    terreno = pygame.image.load(t).convert_alpha()
    cc = "cornicer.png"
    cornicer = pygame.image.load(cc).convert_alpha()
    a = "albero_plus.png"
    albero = pygame.image.load(a).convert_alpha()
    s = "sfera.png"
    sfera = pygame.image.load(s).convert_alpha()
    ac = "acqua.png"
    acqua = pygame.image.load(ac).convert_alpha()

    game = (0,0)
    mousepoint.rect.center = game
    puntatore = (0,0)
    ENGINE = iso_engine(sup_terr,terreno,acqua,cornicer,[albero,sfera])
    ENGINE.first_render()
    ENGINE.aggiorna_visibili(game)

    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                exit()

            if event.type == MOUSEBUTTONDOWN:

                pulsanti_mouse = pygame.mouse.get_pressed()

                if pulsanti_mouse[0]==1:
                    x,y = pygame.mouse.get_pos()
                    z,t = game
                    puntatore = (x-z,y-t)
                    ENGINE.selection(puntatore)

                elif pulsanti_mouse[2]==1:
                    x,y = pygame.mouse.get_pos()
                    z,t = game
                    puntatore = (x-z,y-t)
                    ENGINE.add_oggetto(puntatore,0)
                    ENGINE.aggiorna_visibili(game)
                    ENGINE.aggiorna = True

            if event.type == KEYDOWN:
                tasti_premuti = pygame.key.get_pressed()

                if tasti_premuti[K_ESCAPE]:
                    exit()
                elif tasti_premuti[K_LEFT]:
                    ENGINE.aggiorna_visibili(game)
                    ENGINE.aggiorna = True
                    x,y = game
                    x+= 10
                    game = (x,y)
                elif tasti_premuti[K_RIGHT]:
                    ENGINE.aggiorna_visibili(game)
                    ENGINE.aggiorna = True
                    x,y = game
                    x-= 10
                    game = (x,y)
                elif tasti_premuti[K_UP]:
                    ENGINE.aggiorna_visibili(game)
                    ENGINE.aggiorna = True
                    x,y = game
                    y+= 10
                    game = (x,y)
                elif tasti_premuti[K_DOWN]:
                    ENGINE.aggiorna_visibili(game)
                    ENGINE.aggiorna = True
                    x,y = game
                    y-= 10
                    game = (x,y)

            if event.type == MOUSEMOTION:
                x,y = pygame.mouse.get_pos()
                z,t = game
                puntatore = (x-z,y-t)
                mousepoint.rect.center = puntatore
                ENGINE.collide(puntatore)
                ENGINE.aggiorna = True

        tempo_p = orologio.tick(60)
        screen.fill((150,150,150))
        ENGINE.aggiorna_blitter()
        screen.blit(sup_terr,game)
        pygame.display.set_caption("Isometria FPS = "+str(orologio.get_fps()))
        pygame.display.flip()

if __name__ == '__main__':
    exit(run())

Analisi

s_terreno = Questa stringa rapprensenta il terreno piastrellato in base al suo contenuto. Per questo esempio avremo che il 2 rappresenza il mare e 1 la terra. Sarà quindi molto semplice creare una qualsiasi mappa secondo le vostre necessità e tipi di celle, naturalmente con qualche piccola modifica.

class oggetto() = L’oggetto sulla piastrella è rappresentato da questa classe. Dell’oggetto memorizziamo poche informazioni, ma può essere ampliato secondo le necessità.

class pointocollide() = Per gestire le collisioni con il puntatore del mouse ci appoggeremo a questa classe. Come potete vedere, il puntatore avrà sia la sua parte mask che quella rect.

class tile() = Abbiamo già visto questa classe nella precedente puntata. Gli unici cambiamenti degni di nota sono la variabile booleana walkable, per determinare se la piastrella è percorribile o meno, ed il rendering, che è stato aggiornato per disegnare sullo schermo solo la parte coperta dalla piastrella. Questo era già possibile da prima, visto che le piastrelle non cambiano di dimensione ed utilizzano il canale alpha, ma l’istruzione fill precedente cancellava tutto il contenuto all’interno del rect dell’immagine, mentre ora si cancellano solo i pixel interessati grazie a blend_max. Questo flag (ce ne sono anche altri) può essere utilizzato anche in blit per dare alcuni effetti visivi, come per esempio lo schiarimento di un’immagine. Infine memorizziamo anche il centro della piastrella.

class iso_engine() = La gestione del rendering isometrico è gestita completamente da questa classe che illustrerò in dettaglio.

  • self.aggiorna indica se lo schermo deve essere aggiornato  o meno.
  • self.tiles_list memorizza tutte le piastrelle che interessano il videogioco.
  • self.level memorizza gli oggetti che sono presenti sulle piastrelle.
  • self.tipi indica i vari tipi di oggetti che si possono inserire.
  • self.anim indica il tempo di refresh delle animazioni.
  • self.screen è lo schermo dove si andrà a renderizzare.
  • self.list_visibili_tiles contiene tutte le piastrelle visibili in quel momento.
  • self.list_visibili_level contiene tutti gli oggetti visibili in quel momento.
  • self.rect_visibili è un oggetto rect che rappresenta la visuale del giocatore. Tramite esso sarà stabilito quali pistrelle ed oggetti sono visibili.
  • self.coordinate_rect sono le coordinate della “telecamera” creata con rect_visibili.
  • self.prev_collide contiene la lista degli elementi da aggiornare al contatto con il puntatore.

Fatto il parsing della stringa, avremo creato il nostro campo con celle attraversabili e non e tutte avranno un certo indice che corrisponderà alla loro posizione allinterno della lista delle piastrelle. Per gli oggetti vale lo stesso principio, con la differenza che all’inizio non mettiamo nulla (quindi 0), ma si potrebbe facilmente creare una seconda stringa che indica la posizione degli oggetti ed il loro tipo per aggiornarla sin dall’inizio del gioco, ma per ora abbiamo previsto solo la possibilità di aggiungere un oggetto ad una piastrella che non ne ha.

  • def aggiorna_visibili() = questa funzione aggiorna la lista degli oggetti e delle celle visibili in base alla “telecamera del giocatore”. Se la telecamera viene spostata si devono prima calcolare le nuove coordinate e poi aggiornare le liste. Questa operazione viene effettuata solo se c’è uno spostamento o si aggiunge un oggetto sul terreno ed utilizzando solo i rect, non spreca molte risorse e ci semplifica molto il lavoro successivo. Da notare che gli oggetti e le piastrelle sono legati sempre dallo stesso indice, altrimenti sarebbe impossibile riferirsi all’oggetto giusto su una data casella.
  • def add_oggetto() = questa funzione aggiunge un oggetto sulla cella puntata dal mouse. L’oggetto viene aggiunto nella lista level, quindi quella che contiene tutti gli oggetti.
  • def selection() = utilizzata per selezionare o deselezionare una piastrella. Utilizza la lista delle piastrelle di collide, visto che sono le uniche in prossimità del mouse.
  • def collide() = gestisce le collisioni tra il puntatore e le piastrelle. Da notare che prima di calcolare le collisioni punto per punto, si cerca di ridurre la zona di lavoro vedendo in che parte dello schermo si trova il puntatore; il tutto grazie a mousepoint.rect. In questo modo abbiamo solo le celle che dobbiamo aggiornare e non sprecheremo ulteriori controlli più pesanti. Infatti controllare le collisioni con mask è molto più dispendioso del farlo con i rect, per questo si preferisce “cadere” prima vicino la zona interessata per poi scegliere tra pochi quello giusto.
  • def aggiorna_blitter() = la funzione di aggiornamento dello schermo è stata già prevista di una sincronia per le animazioni, anche se non ne gestiamo nessuna. In tal caso, l’importante sarà determinare quali celle ed oggetti sono da aggiornare e spuntare i relativi flag di aggiornamento con una funzione apposita.
  • def first_render() = utilizzata per il primo rendering del gioco, poi basterà soltanto aggiornare i cambiamenti.

def run() = nella funzione di main ci sono alcune cose da puntualizzare: per esempio il gioco vero e proprio ora si trova sulla superfice sup_terr, mentre lo schermo del gioco è screen. Quindi se spostiamo la mappa di gioco aggiorniamo solo la posizione di sup_terr rispetto a screen, senza aver bisogno di spostare fisicamente le piastrelle. Naturalmente si dovrà tenere conto di questo memorizzando l’attuale posizione di sup_terr tramite la variabile game.

Conclusioni

Per non perdere prestazioni, in sostanza, si è cercato di ridurre il quantitativo di oggetti su cui si deve lavorare. Utilizzando prima dei controlli più veloci per scremare i risultati si guadagna moltissimo ed il tutto è anche semplice da maneggiare. Spero che il meccanismo di funzionamento sia abbastanza chiaro poiché, come ho già detto, queste soluzioni possono essere apportate in un qualsiasi progetto perché molto generali e non si utilizzano funzioni specifiche per la renderizzazionde del gioco.

Dovete porre particolare attenzione anche all’ordine di renderizzazione degli oggetti e al loro spazio occupato sullo schermo sia come immagini che come oggetti rect, altrimenti non riuscirete a discricarvi in questo scenario. Prossimamente vedremo come far muovere il giocatore in questo ambiente in “falso 3D”, di seguito lascio il link per l’esempio di questa parte:

Isometria 1 : http://tinyurl.com/3kmwmey

Press ESC to close