Quando un gioco cresce, la domanda più difficile non riguarda la grafica o la fisica, ma l'organizzazione del codice. Come rappresentare un nemico che vola, uno che spara, uno che vola e spara, una cassa che si può distruggere e una che si può anche spingere? Il modello Entity Component System (ECS) nasce proprio per rispondere a questa domanda in modo flessibile.
Il problema dell'ereditarietà profonda
L'approccio intuitivo, soprattutto per chi viene dalla programmazione a oggetti classica, è costruire una gerarchia di classi:
Oggetto
└─ Personaggio
├─ Giocatore
└─ Nemico
├─ NemicoVolante
└─ NemicoCheSpara
Il guaio arriva quando serve un nemico che vola e spara. Bisogna duplicare codice, ricorrere all'ereditarietà multipla o spostare tutto nella classe base, che diventa enorme e piena di comportamenti che la maggior parte delle sottoclassi non usa. Dopo qualche mese di sviluppo la gerarchia diventa rigida: ogni nuova idea del game designer richiede di ristrutturarla.
Composizione al posto di ereditarietà
ECS ribalta la prospettiva. Invece di chiedersi «che cosa è» un oggetto, ci si chiede «che cosa ha». Il modello si basa su tre concetti.
Entità
Un'entità è soltanto un identificatore, spesso un numero intero. Non contiene dati né logica: è un'etichetta a cui si agganciano componenti.
Componenti
Un componente è un piccolo contenitore di dati puri, senza comportamento:
Posizione { x, y }Velocita { vx, vy }Sprite { immagine, fotogramma }Salute { punti }Volante { quota }Arma { cadenza, danno }
Un nemico che vola e spara è semplicemente un'entità con Posizione, Velocita, Sprite, Salute, Volante e Arma. Una cassa distruttibile ha Posizione, Sprite e Salute. Nessuna gerarchia, nessuna duplicazione.
Sistemi
Un sistema contiene la logica e opera su tutte le entità che possiedono una certa combinazione di componenti. Il sistema di movimento, per esempio, elabora tutte le entità con Posizione e Velocita:
per ogni entita con (Posizione p, Velocita v):
p.x += v.vx * dt
p.y += v.vy * dt
Il sistema di rendering considera le entità con Posizione e Sprite; quello dei danni le entità con Salute. Ogni sistema fa una cosa sola e la fa su tutti gli oggetti interessati.
I vantaggi pratici
Flessibilità
Aggiungere un comportamento significa aggiungere un componente. Se il game designer vuole che le casse possano essere spinte, basta dare loro Velocita e un componente Spingibile, poi scrivere il sistema corrispondente. Si possono anche aggiungere o togliere componenti durante il gioco: un nemico congelato perde temporaneamente Velocita, e il sistema di movimento smette automaticamente di spostarlo.
Prestazioni
Nelle implementazioni orientate ai dati, i componenti dello stesso tipo sono memorizzati in array contigui. Quando il sistema di movimento scorre tutte le posizioni e le velocità, il processore legge memoria sequenziale e sfrutta al meglio la cache. Con migliaia di entità la differenza rispetto a oggetti sparsi in memoria e raggiunti tramite puntatori può essere notevole.
Separazione delle responsabilità
Dati e logica sono separati in modo netto. Il salvataggio del gioco, per esempio, diventa più semplice: basta serializzare i componenti, che sono dati puri. Anche il test è più facile, perché un sistema si può verificare fornendogli poche entità costruite a mano.
Dove ECS è meno comodo
Non è una soluzione universale. Alcuni aspetti richiedono attenzione:
- Relazioni tra entità: un'arma impugnata da un personaggio, un vagone agganciato a un treno. Si gestiscono con componenti che contengono l'identificatore di un'altra entità, ma bisogna verificare che l'entità collegata esista ancora.
- Ordine dei sistemi: il sistema di input deve girare prima di quello di movimento, il movimento prima delle collisioni. L'ordine va definito esplicitamente.
- Logica molto specifica: il boss finale con un comportamento unico può essere più leggibile come script dedicato che come insieme di componenti generici.
- Curva di apprendimento: per un gioco piccolo, con poche decine di oggetti, ECS può sembrare una complicazione inutile.
ECS nei motori più diffusi
I motori adottano idee simili in forme diverse. Unity offre il modello classico a GameObject e MonoBehaviour, basato su componenti con logica al loro interno, e un'architettura orientata ai dati separata per i progetti che ne hanno bisogno. Godot si basa su un albero di nodi, che è una forma di composizione diversa ma con obiettivi simili. Esistono poi librerie ECS indipendenti per molti linguaggi, utili se si lavora con un framework leggero.
Un esempio di progettazione
Supponiamo di dover realizzare un piccolo sparatutto a scorrimento. Una possibile suddivisione:
| Sistema | Componenti richiesti |
|---|---|
| Input | ControllatoDalGiocatore, Velocita |
| Movimento | Posizione, Velocita |
| Sparo | Arma, Posizione |
| Collisioni | Posizione, Collisore |
| Danni | Salute, eventi di collisione |
| Rendering | Posizione, Sprite |
| Pulizia | DaDistruggere |
Il sistema di pulizia, eseguito per ultimo, rimuove le entità marcate durante il fotogramma. Distruggere entità mentre altri sistemi le stanno ancora scorrendo è una fonte classica di errori, ed è meglio rimandare l'operazione alla fine del ciclo.
In sintesi
- ECS sostituisce le gerarchie di classi con la composizione: un oggetto è definito da ciò che ha.
- Le entità sono identificatori, i componenti dati puri, i sistemi la logica.
- Aggiungere comportamenti significa aggiungere componenti, anche durante il gioco.
- I componenti in array contigui migliorano le prestazioni con molte entità.
- Ordine dei sistemi, relazioni e distruzione delle entità vanno progettati con cura.