✚ Sviluppo videogiochi

Macchine a stati finiti: dare un comportamento ai nemici

Come usare le macchine a stati finiti per l'intelligenza dei nemici: stati, transizioni, azioni di ingresso e uscita, percezione e stati gerarchici.

Molti nemici dei videogiochi sembrano intelligenti pur seguendo regole molto semplici: pattugliano, notano il giocatore, lo inseguono, attaccano, fuggono quando sono feriti. Lo strumento più usato per organizzare questi comportamenti è la macchina a stati finiti (finite state machine, FSM): un modello in cui l'entità si trova sempre in uno solo di un numero limitato di stati, e passa dall'uno all'altro in base a condizioni precise.

Gli elementi di una macchina a stati

  • Stati: le modalità di comportamento, come «pattuglia», «inseguimento», «attacco», «fuga».
  • Transizioni: le regole che portano da uno stato all'altro, per esempio «se vede il giocatore, passa a inseguimento».
  • Azioni: ciò che il nemico fa mentre si trova in uno stato, a ogni aggiornamento.

Un esempio per una guardia:

Stato Comportamento Transizioni
Pattuglia Cammina tra punti prefissati Vede il giocatore → Inseguimento
Inseguimento Si muove verso il giocatore A distanza di attacco → Attacco; perde il giocatore → Ricerca
Attacco Colpisce a intervalli Giocatore lontano → Inseguimento; salute bassa → Fuga
Ricerca Va all'ultima posizione nota Vede il giocatore → Inseguimento; tempo scaduto → Pattuglia
Fuga Si allontana dal giocatore Salute recuperata → Pattuglia

Disegnare questa tabella, o un diagramma con cerchi e frecce, prima di scrivere codice aiuta a scoprire casi dimenticati: che cosa fa la guardia se viene colpita alle spalle mentre pattuglia?

La versione più semplice

Per pochi stati basta una variabile e un costrutto di selezione:

switch (stato) {
  case PATTUGLIA:
    muovi_verso(prossimo_punto)
    if (vede_giocatore()) stato = INSEGUIMENTO
    break
  case INSEGUIMENTO:
    muovi_verso(giocatore)
    if (distanza(giocatore) < portata) stato = ATTACCO
    else if (!vede_giocatore()) stato = RICERCA
    break
  ...
}

È leggibile e veloce. Il limite emerge quando gli stati crescono: il blocco diventa lungo e le operazioni da fare entrando o uscendo da uno stato si sparpagliano.

Ingresso, aggiornamento, uscita

Una struttura più robusta tratta ogni stato come un oggetto con tre metodi:

  • entra(): eseguito una sola volta all'ingresso, per esempio per avviare l'animazione, emettere un suono di allarme o azzerare un timer;
  • aggiorna(dt): eseguito a ogni passo, contiene il comportamento e verifica le transizioni;
  • esci(): eseguito all'uscita, per ripulire, per esempio fermare un effetto sonoro in ciclo.

La macchina si limita a chiamare esci() sullo stato attuale, sostituirlo e chiamare entra() sul nuovo. In questo modo ogni stato è autonomo, testabile e riutilizzabile tra nemici diversi.

Percezione: vedere e sentire

Le transizioni dipendono da ciò che il nemico «percepisce». Le verifiche più comuni:

  • distanza: il giocatore è entro un certo raggio?
  • cono visivo: l'angolo tra la direzione dello sguardo e la direzione verso il giocatore è minore di metà dell'ampiezza del cono? Si calcola con il prodotto scalare tra vettori normalizzati.
  • linea di vista: un raggio dal nemico al giocatore incontra muri? Si verifica con un raycast sulla mappa.
  • udito: rumori come spari o passi di corsa generano eventi con un raggio entro cui i nemici reagiscono.

Questi controlli possono essere costosi. Non serve eseguirli a ogni fotogramma: farli alcune volte al secondo, distribuendo i nemici su fotogrammi diversi, è quasi sempre sufficiente e rende anche il comportamento più umano, con un piccolo tempo di reazione.

Evitare le oscillazioni

Un problema tipico: il giocatore si trova esattamente al limite della distanza di attacco e il nemico alterna attacco e inseguimento a ogni fotogramma. Due rimedi:

  • isteresi: soglie diverse per entrare e uscire, per esempio entra in attacco sotto i 2 metri ed esce sopra i 2,5;
  • durata minima: uno stato non può essere abbandonato prima di un certo tempo.

Macchine a stati gerarchiche

Quando un nemico ha molti stati, alcuni condividono transizioni. Per esempio, da qualsiasi stato di combattimento, se la salute scende sotto una soglia, deve fuggire. Ripetere la stessa transizione ovunque è scomodo e fragile.

Una macchina a stati gerarchica raggruppa gli stati in superstati. Il superstato «combattimento» contiene «inseguimento», «attacco» e «schivata», e definisce una sola volta la transizione verso «fuga». Gli stati interni ereditano il comportamento comune.

Quando passare ad altro

Le macchine a stati funzionano molto bene per nemici con comportamenti chiari e un numero ragionevole di stati. Quando le combinazioni esplodono, o si vuole dare priorità dinamiche a molti obiettivi, si valutano alternative come i behavior tree o i sistemi basati sull'utilità. Spesso però una FSM ben disegnata è tutto ciò che serve, e la sua prevedibilità è un pregio: il giocatore impara i comportamenti dei nemici e li sfrutta, cosa che fa parte del divertimento.

In sintesi

  • Una macchina a stati finiti tiene il nemico in un solo stato alla volta, con transizioni esplicite.
  • Una tabella o un diagramma degli stati aiuta a scoprire i casi dimenticati.
  • Metodi di ingresso, aggiornamento e uscita rendono ogni stato autonomo.
  • Percezione per distanza, cono visivo e linea di vista guida le transizioni.
  • Isteresi e stati gerarchici evitano oscillazioni e duplicazioni.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica, unisce il rigore dell'analisi tecnica all'attenzione per le persone che usano la tecnologia.

Leggi anche