Intento

Consentire a una singola entità/oggetto di coprire più domini del gioco senza accoppiarli tra di loro.

Motivazione

Diciamo che stiamo costruendo un platformer (ndr. un gioco 2D). L’idraulico italiano (ndr. Mario) è già troppo conosciuto, quindi il nostro gioco avrà come protagonista un panettiere danese, Bjørn. È logico che avremo una classe che rappresenta il nostro simpatico fornaio e che conterrà tutto ciò che il personaggio fa nel gioco.

Dato che il giocatore lo controlla, può leggere gli input del controller e tradurli in movimento. Una volta fatto questo, deve apparire sullo schermo, quindi bisogna aggiungere l’animazione e il rendering. Probabilmente emetterà anche qualche suono.

Aspettate un attimo, la situazione sta sfuggendo di mano. L’architettura del software ci dice che i diversi domini di un programma devono essere tenuti isolati l’uno dall’altro. Se stiamo realizzando un elaboratore di testo, il codice che gestisce la stampa non dovrebbe essere influenzato da quello che carica e salva i documenti. Un gioco non ha gli stessi domini di un’applicazione commerciale, ma la regola si applica comunque.

Per quanto possibile, non vogliamo che l’intelligenza artificiale, la fisica, il rendering, il suono e altri domini si influenzino tra loro, ma ora abbiamo tutto questo racchiuso in un’unica classe. Abbiamo visto dove porta questa strada: un unico file sorgente di 5.000 righe così grande che solo i coder ninja più coraggiosi del team osano entrarci.

Questa è zona di confidenza per i pochi che riescono a gestirla, ma è un inferno per il resto di noi. Una classe così grande significa che anche le modifiche apparentemente più banali possono avere implicazioni di vasta portata. Molto presto, la classe collezionerà bug più velocemente di quanto non faccia con le funzionalità.

Il nodo Gordiano

Ancora peggiore del semplice problema della scalabilità è quello dell’accoppiamento. Tutti i diversi sistemi del nostro gioco sono stati legati in un gigantesco gomitolo di codice annodato come:

if (collidingWithFloor() && (getRenderState() != INVISIBLE))
{
  playSound(HIT_FLOOR);
}
 

Qualsiasi programmatore che cerchi di apportare una modifica al codice dovrà conoscere qualcosa di fisica, grafica e musica per essere sicuro di non compromettere niente.

Questi due problemi si sommano a vicenda: la classe tocca così tanti ambiti che ogni programmatore dovrà lavorarci, ma è così grande che farlo è un incubo. Se la situazione diventa abbastanza grave, i programmatori inizieranno a inserire hack o workaround in altre parti del codice, solo per rimanere fuori dalla matassa che è diventata questa classe Bjorn.

Tagliare il nodo

Possiamo risolvere questo problema come Alessandro Magno: con una spada. Prendiamo la nostra classe monolitica Bjorn  e la tagliamo in parti separate lungo i confini del dominio. Ad esempio, prenderemo tutto il codice per la gestione degli input dell’utente e lo sposteremo in una classe InputComponent separata. Bjorn possiederà quindi un’istanza di questo componente. Ripeteremo questo processo per ogni dominio collegato a Bjorn .

Al termine, avremo spostato quasi tutto al di fuori diBjorn. Tutto ciò che rimane è un sottile involucro che lega i componenti tra loro. Abbiamo risolto il nostro enorme problema di classe semplicemente dividendolo in più classi più piccole, ma abbiamo ottenuto molto di più.

Fine della storia

Le classi dei componenti sono ora separate. Anche se Bjorn ha un PhysicsComponente un GraphicsComponent, i due non sanno l’uno dell’altro. Ciò significa che la persona che lavora sulla fisica può modificare il suo componente senza dover sapere nulla della grafica e viceversa.

In pratica, i componenti dovranno interagire tra loro. Per esempio, il componente di intelligenza artificiale potrebbe aver bisogno di comunicare al componente di fisica dove sta andando Bjorn . Tuttavia, possiamo limitare questo aspetto ai componenti che hanno bisogno di parlare, invece di metterli tutti insieme nello stesso spazio.

Legare insieme

Un’altra caratteristica di questo progetto è che i componenti sono ora pacchetti riutilizzabili. Finora ci siamo concentrati sul nostro fornaio, ma consideriamo un paio di altri tipi di oggetti nel nostro gioco. Le decorazioni sono oggetti del mondo che il giocatore vede ma con cui non interagisce: cespugli, detriti e altri dettagli visivi. Gli oggetti di scena sono come le decorazioni, ma possono essere toccati: scatole, massi e alberi. Le zone sono l’opposto delle decorazioni: invisibili ma interattive. Sono utili per attivare una scena quando Bjørn entra in un’area.

Consideriamo ora come impostare una gerarchia di ereditarietà per queste classi se non usassimo i componenti. Un primo passo potrebbe assomigliare a:

A class diagram. Zone has collision code and inherits from GameObject. Decoration also inherits from GameObject and has rendering code. Prop inherits from Zone but then has redundant rendering code.

Abbiamo una classe GameObject di base che ha elementi comuni come la posizione e l’orientamento. Zone eredita da questa e aggiunge il rilevamento delle collisioni. Allo stesso modo, Decoration eredita da GameObject e aggiunge il rendering. Prop eredita da Zone , quindi può riutilizzare il codice delle collisioni. Tuttavia, Prop non può ereditare anche da Decoration per riutilizzare il codice di rendering senza incorrere nel Diamante Mortale.

Potremmo invertire le cose in modo che Properediti da Decoration , ma in questo caso finiremmo per dover duplicare il codice delle collisioni. In ogni caso, non c’è un modo per riutilizzare il codice delle collisioni e del rendering tra le classi che ne hanno bisogno senza ricorrere all’ereditarietà multipla. L’unica altra opzione è quella di far confluire tutto in GameObject , ma in questo modo Zone spreca memoria per il rendering di dati che non gli servono e Decoration fa lo stesso con la fisica.

Ora proviamo con i componenti. Le nostre sottoclassi scompaiono completamente. Abbiamo invece una singola classe GameObject e due classi di componenti: PhysicsComponente GraphicsComponent. Una decorazione è semplicemente un GameObject con un GraphicsComponentma senza PhysicsComponente . Una zona è l’opposto e un oggetto di scena ha entrambi i componenti. Nessuna duplicazione di codice, nessuna ereditarietà multipla e solo tre classi invece di quattro.

I componenti sono fondamentalmente dei plug-and-play per gli oggetti. Ci permettono di costruire entità complesse con un comportamento dettagliato, collegando diversi component object riutilizzabili in socket. Pensate al software come a Voltron.

Il modello

Una singola entità si estende su più domini. Per mantenere i domini isolati, il codice di ciascuno di essi viene inserito nella propria classe di componenti. L’entità è ridotta a un semplice contenitore di componenti.

Quando usarlo

I componenti si trovano più comunemente all’interno della classe centrale che definisce le entità di un gioco, ma possono essere utili anche in altri luoghi. Questo pattern può essere utilizzato quando una di queste situazioni è vera:

  • Avete una classe che tocca più domini che volete mantenere separati l’uno dall’altro.
  • Una classe sta diventando enorme e difficile da gestire.
  • Si vuole definire una varietà di oggetti che condividono diverse capacità, ma l’uso dell’ereditarietà non consente di scegliere con sufficiente precisione le parti da riutilizzare.

Da tenere presente

Il pattern Component aggiunge un bel po’ di complessità rispetto alla semplice creazione di una classe e all’inserimento di codice in essa. Ogni “oggetto” concettuale diventa un gruppo di oggetti che devono essere istanziati, inizializzati e collegati correttamente tra loro. La comunicazione tra i diversi componenti diventa più impegnativa e il controllo dell’occupazione della memoria è più complesso.

Per una codebase di grandi dimensioni, questa complessità può valere la pena per la separazione e il riutilizzo del codice che essa consente, ma prima di applicare questo modello bisogna assicurarsi che non si stia creando una “soluzione” eccessiva a un problema inesistente.

Un’altra conseguenza dell’uso dei componenti è che spesso si deve passare attraverso un livello di codifica per ottenere qualcosa. Dato il component object, prima si deve ottenere il componente desiderato e solo poi si può fare ciò che serve. Nei cicli interni critici dal punto di vista delle prestazioni, questo pointer following può portare a scarse prestazioni.

Esempio di codice

Una delle sfide più grandi è capire come isolare ogni schema. Molti schemi di progettazione esistono per contenere codice che di per sé non fanno parte dello schema. Per ridurre il pattern alla sua essenza, cerca di eliminare il più possibile questo codice, anche se a un certo punto diventa un po’ come spiegare come organizzare un armadio senza mostrare alcun vestito.

Il modello Component è particolarmente difficile. Non è possibile farsi un’idea precisa senza vedere del codice per ciascuno dei domini che separa, quindi abbozzeremo un po’ più di codice di Bjørn di quanto vorremmo. Il modello è costituito solo dalle classi dei componenti, ma il codice in esse contenuto dovrebbe aiutare a chiarire a cosa servono le classi. Si tratta di codice falso, che fa riferimento ad altre classi che non sono presentate qui, ma dovrebbe dare un’idea di ciò che stiamo cercando di fare.

Una classe monolitica

Per avere un’immagine più chiara di come viene applicato questo schema, inizieremo mostrando una classe monolitica di Bjorn che fa tutto ciò di cui abbiamo bisogno, ma non utilizza questo schema:

class Bjorn
{
public:
  Bjorn()
  : velocity_(0),
    x_(0), y_(0)
  {}

  void update(World& world, Graphics& graphics);

private:
  static const int WALK_ACCELERATION = 1;

  int velocity_;
  int x_, y_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
}; Bjorn ha un metodo update() che viene chiamato una volta per ogni frame dal gioco:
void Bjorn::update(World& world, Graphics& graphics)
{
  // Apply user input to hero's velocity.
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;

    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }

  // Modify position by velocity.
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);

  // Draw the appropriate sprite.
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0)
  {
    sprite = &spriteWalkLeft_;
  }
  else if (velocity_ > 0)
  {
    sprite = &spriteWalkRight_;
  }

  graphics.draw(*sprite, x_, y_);
}
 

Legge il joystick per determinare come accelerare il fornaio. Poi risolve la sua nuova posizione con il motore fisico. Infine, disegna Bjørn sullo schermo.

L’implementazione dell’esempio è banalmente semplice. Non ci sono gravità, animazioni o altre decine di dettagli che rendono un personaggio divertente per il giocatore. Tuttavia, possiamo notare che abbiamo una singola funzione a cui probabilmente dovranno dedicare tempo diversi codificatori del nostro team e che inizia a diventare un po’ complicata. Immaginiamo che questa funzione sia scalata a un migliaio di righe e possiamo avere un’idea di quanto possa diventare complicata.

Suddividere un dominio

Partendo da un dominio, estraiamo un pezzo di Bjorn e inseriamolo in una component class separata. Inizieremo con il primo dominio che viene elaborato: l’input. La prima cosa che Bjorn fa è leggere gli input dell’utente e regolare la sua velocità in base ad essi. Spostiamo questa logica in una classe separata:

class InputComponent
{
public:
  void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;

      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};

Piuttosto semplice. Abbiamo preso la prima sezione del metodo update() di Bjorne l’abbiamo inserita in questa classe. Anche le modifiche a Bjorn sono semplici:

class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);

    // Modify position by velocity.
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);

    // Draw the appropriate sprite.
    Sprite* sprite = &spriteStand_;
    if (velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }

    graphics.draw(*sprite, x, y);
  }

private:
  InputComponent input_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};
 

Bjorn ora possiede un oggetto InputComponent . Mentre prima gestiva gli input dell’utente direttamente nel metodo update(), ora delega al componente:

input_.update(*this);
 

Abbiamo solo iniziato, ma abbiamo già eliminato alcuni accoppiamenti: la classe principale Bjorn non ha più alcun riferimento a Controller. Questo ci tornerà utile in seguito.

Dividere il resto

Ora, procediamo con lo stesso lavoro di copia-e-incolla sul codice della fisica e della grafica. Ecco il nostro nuovo PhysicsComponent:

class PhysicsComponent
{
public:
  void update(Bjorn& bjorn, World& world)
  {
    bjorn.x += bjorn.velocity;
    world.resolveCollision(volume_,
        bjorn.x, bjorn.y, bjorn.velocity);
  }

private:
  Volume volume_;
};

Oltre a spostare il comportamento della fisica dalla classe principale Bjorn , si può notare che abbiamo spostato anche i dati: L’oggetto Volume è ora di proprietà del componente.

Infine, ecco dove risiede il codice di rendering:

class GraphicsComponent
{
public:
  void update(Bjorn& bjorn, Graphics& graphics)
  {
    Sprite* sprite = &spriteStand_;
    if (bjorn.velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (bjorn.velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }

    graphics.draw(*sprite, bjorn.x, bjorn.y);
  }

private:
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};
 

Abbiamo eliminato quasi tutto, quindi cosa è rimasto del nostro umile pasticcere? Non molto:

class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};
 

La classe Bjorn ora fa fondamentalmente due cose: contiene l’insieme dei componenti che la definiscono e contiene lo stato che è condiviso tra più domini. Posizione e velocità sono ancora nel nucleo della classe Bjorn per due motivi. In primo luogo, si tratta di uno stato “pan-dominio”: quasi tutti i componenti ne faranno uso, quindi non è chiaro quale componente dovrebbe possederli se volessimo spingerli verso il basso.

In secondo luogo, cosa più importante, ci dà un modo semplice per far comunicare i componenti senza essere accoppiati tra loro. Vediamo se riusciamo a metterlo in pratica.

Robo-Bjørn

Finora abbiamo distribuito il nostro comportamento in classi di componenti separate, ma non abbiamo astratto il comportamento. Bjorn conosce ancora le classi esatte in cui è definito il suo comportamento. Cambiamo questa situazione.

Prendiamo il nostro componente per gestire l’input dell’utente e lo nascondiamo dietro un’interfaccia. Trasformeremo InputComponent in una classe base astratta:

class InputComponent
{
public:
  virtual ~InputComponent() {}
  virtual void update(Bjorn& bjorn) = 0;
};

Quindi, prenderemo il codice esistente per la gestione degli input dell’utente e lo inseriremo in una classe che implementa questa interfaccia:

class PlayerInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;

      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};

Cambieremo Bjorn in modo che mantenga un pointer al componente di input, invece di avere un’istanza in linea:

class Bjorn
{
public:
  int velocity;
  int x, y;

  Bjorn(InputComponent* input)
  : input_(input)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

Ora, quando creiamoBjorn, possiamo passargli un componente di input da utilizzare, in questo modo:

Bjorn* bjorn = new Bjorn(new PlayerInputComponent()); 
 

Questa istanza può essere un qualsiasi concrete type che implementa la nostra interfaccia astratta InputComponent . Paghiamo un prezzo per questo: update() è ora una chiamata a un metodo virtuale che è un po’ più lento. Cosa otteniamo in cambio di questo prezzo?

La maggior parte delle console richiede che il gioco supporti la “modalità demo”. Se il giocatore si ferma al menu principale senza fare nulla, il gioco si avvia automaticamente, con il computer al posto del giocatore. In questo modo si evita che il gioco cancelli il menu principale sul televisore e renda più gradevole l’aspetto del gioco quando viene eseguito su un kiosk in un negozio.

Nascondere la classe del componente di input dietro un’interfaccia ci permette di farlo funzionare. Abbiamo già il nostro PlayerInputComponent concreto, che viene normalmente utilizzato durante il gioco. Ora, creiamone un altro:

class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // AI to automatically control Bjorn...
  }
};
 

Quando il gioco entrerà in modalità demo, invece di costruire Bjørn come abbiamo fatto prima, lo collegheremo con il nostro nuovo componente:

Bjorn* bjorn = new Bjorn(new DemoInputComponent());

E ora, semplicemente sostituendo un componente, abbiamo un giocatore controllato dal computer perfettamente funzionante per la modalità demo. Siamo in grado di riutilizzare tutto il codice per Bjørn: la fisica e la grafica non si accorgono nemmeno della differenza. 

Niente Bjørn?

Se guardiamo ora la nostra classe Bjorn , noteremo che non ha nulla di veramente “Bjørn”: è solo un contenitore di componenti. In realtà, sembra un ottimo candidato per una classe “game object” di base, che possiamo usare per ogni oggetto del gioco. Tutto ciò che dobbiamo fare è inserire tutti i componenti e possiamo costruire qualsiasi tipo di oggetto scegliendo e selezionando le parti come il dottor Frankenstein.

Prendiamo i due componenti concreti rimanenti – fisica e grafica – e nascondiamoli dietro le interfacce, come abbiamo fatto con gli input:

class PhysicsComponent
{
public:
  virtual ~PhysicsComponent() {}
  virtual void update(GameObject& obj, World& world) = 0;
};

class GraphicsComponent
{
public:
  virtual ~GraphicsComponent() {}
  virtual void update(GameObject& obj, Graphics& graphics) = 0;
};

Poi ribattezziamo Bjorn in una classe GameObject generica che utilizza queste interfacce:

class GameObject
{
public:
  int velocity;
  int x, y;

  GameObject(InputComponent* input,
             PhysicsComponent* physics,
             GraphicsComponent* graphics)
  : input_(input),
    physics_(physics),
    graphics_(graphics)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent* physics_;
  GraphicsComponent* graphics_;
};
 

Le classi esistenti verranno rinominate e implementeranno queste interfacce:

class BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // Physics code...
  }
};

class BjornGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphics& graphics)
  {
    // Graphics code...
  }
};
 

Ora possiamo costruire un oggetto che abbia tutti i comportamenti originali di Bjørn, senza dover creare una classe per lui, proprio come in questo caso:

GameObject* createBjorn()
{
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}
 

Definendo altre funzioni che creano GameObjects con diversi componenti, possiamo creare tutti i diversi tipi di oggetti di cui il gioco ha bisogno.

Opzioni di Design 

La domanda di progettazione più importante a cui dovrete rispondere con questo schema è: “Di quali componenti ho bisogno?”. La risposta dipende dalle esigenze e dal genere del vostro gioco. Quanto più grande e complesso è il vostro motore, tanto più finemente vorrete tagliare i vostri componenti.

Oltre a questo, ci sono un paio di opzioni più specifiche da considerare:

Come l’oggetto ottiene i suoi componenti?

Una volta suddiviso il nostro oggetto monolitico in alcuni componenti separati, dobbiamo decidere chi rimette insieme i pezzi.

  • Se l’oggetto crea i propri componenti:
    • Assicura che l’oggetto abbia sempre i componenti di cui ha bisogno. Non ci si deve mai preoccupare che qualcuno si dimentichi di collegare i componenti giusti all’oggetto e che il gioco venga interrotto. Se ne occupa il container object stesso.
    • È più difficile riconfigurare l’oggetto. Una delle caratteristiche più potenti di questo schema è che permette di costruire nuovi tipi di oggetti semplicemente ricombinando i componenti. Se il nostro oggetto si lega sempre con lo stesso insieme di componenti codificati, non stiamo sfruttando questa flessibilità.
  • Se il codice esterno fornisce i componenti:
    • L’oggetto diventa più flessibile. Possiamo cambiare completamente il comportamento dell’oggetto dandogli diversi componenti con cui lavorare. Nella sua massima estensione, il nostro oggetto diventa un contenitore generico di componenti che possiamo riutilizzare più volte per scopi diversi.
    • L’oggetto può essere separato dai tipi di componenti concreti. Se permettiamo al codice esterno di aggiungere i componenti, è molto probabile che gli permettiamo anche di aggiungere i tipi di componenti derivati. A quel punto, l’oggetto conosce solo le interfacce dei componenti e non i tipi concreti. Questo può creare un’architettura ben incapsulata.

 

Come comunicano i componenti tra loro?

Componenti perfettamente separati che funzionano in modo isolato sono solo una teoria interessante, ma non funzionano nella pratica. Il fatto che questi componenti facciano parte dello stesso oggetto implica che siano parte di un insieme più grande e che debbano coordinarsi. Questo significa comunicazione.

Quindi, come possono i componenti parlare tra di loro? Ci sono un paio di opzioni, ma a differenza della maggior parte delle “alternative” di progettazione, queste non sono esclusive: è probabile che nei progetti se ne utilizzino più di una contemporaneamente.

  • Modificando lo stato dell’oggetto contenitore:
    • Mantiene i componenti separati. Quando il nostro InputComponent ha impostato la velocità di Bjørne il PhysicsComponent l’ha utilizzata, i due componenti non avevano idea dell’esistenza dell’altro. Per quanto ne sanno, la velocità di Bjørn potrebbe essere cambiata per magia nera.
    • Richiede che tutte le informazioni che i componenti devono condividere vengano spinte verso l’alto nell’oggetto contenitore. Spesso, c’è uno stato che è necessario solo per un sottoinsieme di componenti. Ad esempio, un componente di animazione e uno di rendering possono avere bisogno di condividere informazioni specifiche per la grafica. Peggio ancora, se si usa la stessa classe di component object con diverse configurazioni di componenti, si può finire per sprecare memoria su uno stato che non è necessario per nessuno dei componenti dell’oggetto. Se si inseriscono alcuni dati specifici per il rendering nell’oggetto contenitore, qualsiasi oggetto invisibile vi consumerà memoria senza alcun beneficio.
    • Rende la comunicazione implicita e dipendente dall’ordine di elaborazione dei componenti. Nel nostro codice di esempio, il metodo monolitico update()originale aveva un ordine di operazioni molto preciso. L’input dell’utente modificava la velocità che veniva poi usata dal codice fisico per modificare la posizione, che a sua volta veniva usata dal codice di rendering per disegnare Bjørn nel punto giusto. Quando abbiamo suddiviso il codice in componenti, siamo stati attenti a preservare l’ordine delle operazioni. Se non l’avessimo fatto, avremmo introdotto piccoli bug e difficili da rintracciare. Ad esempio, se avessimo aggiornato prima il componente grafico, avremmo erroneamente visualizzato Bjørn nella sua posizione nell’ultimo frame, non in questo. Se si immaginano molti altri componenti e molto altro codice, si può avere un’idea di quanto possa essere difficile evitare bug di questo tipo.
  • Facendo riferimento direttamente tra loro: L‘idea è che i componenti che devono comunicare abbiano riferimenti diretti l’uno all’altro, senza dover passare attraverso il container object. Il codice grafico deve sapere se deve essere disegnato con un jump sprite o meno. Può determinarlo chiedendo al motore fisico se al momento si trova a terra. Un modo semplice per farlo è far sì che il componente grafico conosca direttamente il componente fisico:
     
    class BjornGraphicsComponent
    {
    public:
      BjornGraphicsComponent(BjornPhysicsComponent* physics)
      : physics_(physics)
      {}
    
      void Update(GameObject& obj, Graphics& graphics)
      {
        Sprite* sprite;
        if (!physics_->isOnGround())
        {
          sprite = &spriteJump_;
        }
        else
        {
          // Existing graphics code...
        }
    
        graphics.draw(*sprite, obj.x, obj.y);
      }
    
    private:
      BjornPhysicsComponent* physics_;
    
      Sprite spriteStand_;
      Sprite spriteWalkLeft_;
      Sprite spriteWalkRight_;
      Sprite spriteJump_;
    };
     

    Quando costruiamo il GraphicsComponent di Bjørn, gli diamo un riferimento al suo corrispondente PhysicsComponent.

     

    • È semplice e veloce. La comunicazione è una chiamata diretta a un metodo da un oggetto a un altro. Il componente può chiamare qualsiasi metodo supportato dal componente a cui fa riferimento.
    • I due componenti sono strettamente accoppiati. Abbiamo fatto un passo indietro verso la nostra classe monolitica. Tuttavia, non è così grave come la classe singola originale, perché almeno limitiamo l’accoppiamento solo alle coppie di componenti che devono interagire.
  • Inviando messaggi:
    • Questa è l’alternativa più complessa. Possiamo inserire un piccolo sistema di messaggistica nel nostro container object e lasciare che i componenti si trasmettano informazioni l’un l’altro. Inizieremo definendo un’interfaccia Component di base che tutti i nostri componenti implementeranno:
       
      class Component
      {
      public:
        virtual ~Component() {}
        virtual void receive(int message) = 0;
      };
       

      Ha un singolo metodo receive() che le classi di componenti implementano per ascoltare un messaggio in arrivo. In questo caso, usiamo solo un int per identificare il messaggio, ma un’implementazione più completa potrebbe allegare dati aggiuntivi al messaggio.

      Quindi, aggiungeremo un metodo al nostro container object per inviare messaggi:

      class ContainerObject
      {
      public:
        void send(int message)
        {
          for (int i = 0; i < MAX_COMPONENTS; i++)
          {
            if (components_[i] != NULL)
            {
              components_[i]->receive(message);
            }
          }
        }
      
      private:
        static const int MAX_COMPONENTS = 10;
        Component* components_[MAX_COMPONENTS];
      };
       

Ora, se un componente ha accesso al suo contenitore, può inviargli messaggi e lui li ritrasmetterà a tutti i componenti contenuti. (Questo include il componente originale che ha inviato il messaggio; fate attenzione a non rimanere bloccati in un ciclo di feedback). Questo ha delle conseguenze:

  • I componenti fratelli sono separati. Passando attraverso il container object genitore, come la nostra alternativa di stato condiviso, ci assicuriamo che i componenti siano ancora separati l’uno dall’altro. Con questo sistema, l’unico accoppiamento che hanno è quello dei valori dei messaggi stessi.
  • Il container object è semplice. A differenza dell’uso dello stato condiviso in cui il container object stesso possiede e conosce i dati usati dai componenti, qui non fa altro che passare i messaggi alla cieca. Questo può essere utile per permettere a due componenti di passarsi informazioni molto specifiche del dominio senza che queste si riversino nell’oggetto contenitore.

 

Non sorprende che non esista una risposta migliore. È probabile che si finisca per usare un po’ di tutto. Lo stato condiviso è utile per le cose veramente basilari che si possono dare per scontate e che ogni oggetto possiede, come la posizione e la dimensione.

Alcuni domini sono distinti, ma comunque strettamente correlati. Pensate all’animazione e al rendering, all’input dell’utente e all’intelligenza artificiale, o alla fisica e alle collisioni. Se si dispone di componenti separati per ciascuna metà di queste coppie, potrebbe essere più semplice far sapere direttamente a ciascuno di essi quali sono le informazioni relative all’altra metà.

La messaggistica è utile per le comunicazioni “meno importanti”. La sua natura di “fire-and-forget” si adatta bene a situazioni come la riproduzione di un suono da parte di un componente audio quando un componente fisico invia un messaggio che indica che l’oggetto è entrato in contatto con qualcosa.

Come sempre, si consiglia di iniziare in modo semplice e di aggiungere altri percorsi di comunicazione, se necessario.

 

Vi aspettiamo al prossimo workshop gratuito per parlarne dal vivo insieme a Marco Secchi!

Clicca qui per registrarti!

 

Non perderti, ogni mese, gli approfondimenti sulle ultime novità in campo digital! Se vuoi sapere di più, visita la sezione “Blog“ sulla nostra pagina!