Come implementare correttamente la gestione dei messaggi in un sistema di entità basato su componenti?


30

Sto implementando una variante del sistema di entità che ha:

  • Una classe Entity che è poco più di un ID che lega i componenti insieme

  • Un gruppo di classi di componenti che non hanno "logica dei componenti", solo dati

  • Un gruppo di classi di sistema (aka "sottosistemi", "gestori"). Questi eseguono tutta l'elaborazione logica dell'entità. Nella maggior parte dei casi di base, i sistemi ripetono semplicemente attraverso un elenco di entità a cui sono interessati e fanno un'azione su ciascuna di esse

  • Un oggetto classe MessageChannel condiviso da tutti i sistemi di gioco. Ogni sistema può iscriversi a un tipo specifico di messaggi da ascoltare e può anche utilizzare il canale per trasmettere messaggi ad altri sistemi

La variante iniziale della gestione dei messaggi di sistema era qualcosa del genere:

  1. Esegui un aggiornamento su ciascun sistema di gioco in sequenza
  2. Se un sistema fa qualcosa a un componente e quell'azione potrebbe essere di interesse per altri sistemi, il sistema invia un messaggio appropriato (ad esempio, un sistema chiama

    messageChannel.Broadcast(new EntityMovedMessage(entity, oldPosition, newPosition))

    ogni volta che un'entità viene spostata)

  3. Ogni sistema che si è abbonato al messaggio specifico ottiene il suo metodo di gestione dei messaggi chiamato

  4. Se un sistema gestisce un evento e la logica di elaborazione degli eventi richiede la trasmissione di un altro messaggio, il messaggio viene trasmesso immediatamente e viene chiamata un'altra catena di metodi di elaborazione dei messaggi

Questa variante è andata bene fino a quando non ho iniziato a ottimizzare il sistema di rilevamento delle collisioni (stava diventando molto lento all'aumentare del numero di entità). Inizialmente avrebbe semplicemente iterato ogni coppia di entità usando un semplice algoritmo di forza bruta. Quindi ho aggiunto un "indice spaziale" che ha una griglia di celle che memorizza entità che si trovano all'interno dell'area di una cella specifica, permettendo così di fare controlli solo su entità nelle celle vicine.

Ogni volta che un'entità si muove, il sistema di collisione verifica se l'entità si scontra con qualcosa nella nuova posizione. In tal caso, viene rilevata una collisione. E se entrambe le entità in collisione sono "oggetti fisici" (entrambi hanno il componente RigidBody e sono pensati per allontanarsi a vicenda in modo da non occupare lo stesso spazio), un sistema di separazione del corpo rigido dedicato chiede al sistema di movimento di spostare le entità in alcuni posizioni specifiche che li separerebbero. Questo a sua volta fa sì che il sistema di movimento invii messaggi di notifica sulle posizioni delle entità modificate. Il sistema di rilevamento delle collisioni deve reagire perché deve aggiornare il suo indice spaziale.

In alcuni casi causa un problema perché i contenuti della cella (un generico Elenco di oggetti in C #) vengono modificati mentre vengono ripetuti, causando così un'eccezione da parte dell'iteratore.

Quindi ... come posso evitare che il sistema di collisione venga interrotto mentre controlla le collisioni?

Ovviamente potrei aggiungere una logica "intelligente" / "difficile" che assicuri che i contenuti della cella vengano ripetuti correttamente, ma penso che il problema non risieda nel sistema di collisione stesso (ho avuto anche problemi simili in altri sistemi), ma il modo in cui i messaggi vengono gestiti mentre viaggiano da un sistema all'altro. Ciò di cui ho bisogno è un modo per assicurarmi che uno specifico metodo di gestione degli eventi riesca a funzionare senza interruzioni.

Cosa ho provato:

  • Code di messaggi in arrivo . Ogni volta che un sistema trasmette un messaggio, il messaggio viene aggiunto alle code dei sistemi interessati. Questi messaggi vengono elaborati quando viene chiamato un aggiornamento di sistema per ogni frame. Il problema : se un sistema A aggiunge un messaggio alla coda B del sistema, funziona bene se il sistema B deve essere aggiornato successivamente al sistema A (nello stesso frame di gioco); in caso contrario, il messaggio elabora il frame di gioco successivo (non desiderabile per alcuni sistemi)
  • Code di messaggi in uscita . Mentre un sistema gestisce un evento, tutti i messaggi che trasmette vengono aggiunti alla coda dei messaggi in uscita. I messaggi non devono attendere l'elaborazione di un aggiornamento del sistema: vengono gestiti "subito" dopo che il gestore dei messaggi iniziale ha terminato il suo funzionamento. Se la gestione dei messaggi provoca la trasmissione di altri messaggi, anche questi vengono aggiunti a una coda in uscita, quindi tutti i messaggi vengono gestiti nello stesso frame. Il problema: se il sistema di durata dell'entità (ho implementato la gestione della durata dell'entità con un sistema) crea un'entità, ne informa alcuni sistemi A e B. Mentre il sistema A elabora il messaggio, provoca una catena di messaggi che alla fine causano la distruzione dell'entità creata (ad esempio, un'entità proiettile è stata creata proprio dove si scontra con qualche ostacolo, che provoca l'autodistruzione del proiettile). Durante la risoluzione della catena di messaggi, il sistema B non riceve il messaggio di creazione dell'entità. Quindi, se anche il sistema B è interessato al messaggio di distruzione dell'entità, lo ottiene e solo dopo che la "catena" ha terminato di risolversi, ottiene il messaggio iniziale di creazione dell'entità. Questo fa sì che il messaggio di distruzione venga ignorato, il messaggio di creazione sia "accettato",

MODIFICA - RISPOSTE ALLE DOMANDE, COMMENTI:

  • Chi modifica il contenuto della cella mentre il sistema di collisione scorre su di essi?

Mentre il sistema di collisione sta eseguendo controlli di collisione su alcune entità e sui vicini, una collisione potrebbe essere rilevata e il sistema di entità invierà un messaggio che verrà immediatamente reagito da altri sistemi. La reazione al messaggio potrebbe causare la creazione e la gestione immediata di altri messaggi. Quindi un altro sistema potrebbe creare un messaggio che il sistema di collisione dovrebbe quindi elaborare immediatamente (ad esempio, un'entità spostata in modo che il sistema di collisione debba aggiornare il suo indice spaziale), anche se i precedenti controlli di collisione non erano ancora terminati.

  • Non riesci a lavorare con una coda di messaggi in uscita globale?

Di recente ho provato una singola coda globale. Causa nuovi problemi. Problema: sposto un'entità serbatoio in un'entità muro (il serbatoio è controllato con la tastiera). Quindi decido di cambiare direzione del serbatoio. Per separare il serbatoio e il muro da ciascun telaio, il sistema CollidingRigidBodySeparationSystem sposta il serbatoio lontano dal muro con la minima quantità possibile. La direzione di separazione dovrebbe essere opposta alla direzione di movimento del serbatoio (quando inizia il disegno del gioco, il serbatoio dovrebbe apparire come se non si fosse mai mosso nel muro). Ma la direzione diventa opposta alla NUOVA direzione, spostando così il serbatoio su un lato diverso del muro rispetto a com'era inizialmente. Perché si verifica il problema: ecco come vengono gestiti i messaggi ora (codice semplificato):

public void Update(int deltaTime)
{   
    m_messageQueue.Enqueue(new TimePassedMessage(deltaTime));
    while (m_messageQueue.Count > 0)
    {
        Message message = m_messageQueue.Dequeue();
        this.Broadcast(message);
    }
}

private void Broadcast(Message message)
{       
    if (m_messageListenersByMessageType.ContainsKey(message.GetType()))
    {
        // NOTE: all IMessageListener objects here are systems.
        List<IMessageListener> messageListeners = m_messageListenersByMessageType[message.GetType()];
        foreach (IMessageListener listener in messageListeners)
        {
            listener.ReceiveMessage(message);
        }
    }
}

Il codice scorre in questo modo (supponiamo che non sia il primo frame di gioco):

  1. I sistemi iniziano l'elaborazione di TimePassedMessage
  2. InputHandingSystem converte i tasti premuti in azioni entità (in questo caso, una freccia sinistra si trasforma in azione MoveWest). L'azione dell'entità è memorizzata nel componente ActionExecutor
  3. ActionExecutionSystem , in risposta all'azione dell'entità, aggiunge un MovementDirectionChangeRequestedMessage alla fine della coda dei messaggi
  4. MovementSystem sposta la posizione dell'entità in base ai dati del componente Velocity e aggiunge il messaggio PositionChangedMessage alla fine della coda. Il movimento viene eseguito utilizzando la direzione / velocità del movimento del fotogramma precedente (diciamo a nord)
  5. I sistemi interrompono l'elaborazione di TimePassedMessage
  6. I sistemi iniziano l'elaborazione di MovementDirectionChangeRequestedMessage
  7. MovementSystem modifica la velocità dell'entità / direzione del movimento come richiesto
  8. I sistemi interrompono l'elaborazione di MovementDirectionChangeRequestedMessage
  9. I sistemi iniziano l'elaborazione di PositionChangedMessage
  10. CollisionDetectionSystem rileva che, poiché un'entità si spostava, si imbatteva in un'altra entità (il carro armato andava all'interno di un muro). Aggiunge un CollisionOccuredMessage alla coda
  11. I sistemi interrompono l'elaborazione di PositionChangedMessage
  12. I sistemi iniziano l'elaborazione di CollisionOccuredMessage
  13. CollidingRigidBodySeparationSystem reagisce alla collisione separando serbatoio e parete. Poiché il muro è statico, viene spostato solo il serbatoio. La direzione di movimento dei serbatoi viene utilizzata come indicatore della provenienza del serbatoio. È spostato in una direzione opposta

ERRORE: Quando il serbatoio spostava questo frame, si spostava usando la direzione del movimento dal frame precedente, ma quando veniva separato, veniva usata la direzione del movimento da QUESTO frame, anche se era già diverso. Non è così che dovrebbe funzionare!

Per prevenire questo errore, la vecchia direzione del movimento deve essere salvata da qualche parte. Potrei aggiungerlo a qualche componente solo per correggere questo specifico bug, ma questo caso non indica un modo fondamentalmente sbagliato di gestire i messaggi? Perché il sistema di separazione dovrebbe preoccuparsi di quale direzione di movimento utilizza? Come posso risolvere questo problema con eleganza?

  • Potresti voler leggere gamadu.com/artemis per vedere cosa hanno fatto con Aspects, da cui parte alcuni dei problemi che stai riscontrando.

In realtà, conosco Artemis da un po 'di tempo ormai. Ho studiato il suo codice sorgente, letto i forum, ecc. Ma ho visto "Aspetti" menzionati solo in alcuni punti e, per quanto ne capisco, in pratica significano "Sistemi". Ma non riesco a vedere come Artemis side risolva alcuni dei miei problemi. Non usa nemmeno i messaggi.

  • Vedi anche: "Comunicazione entità: coda messaggi vs Pubblica / Iscriviti vs Segnale / Slot"

Ho già letto tutte le domande su gamedev.stackexchange relative ai sistemi di entità. Questo non sembra discutere dei problemi che sto affrontando. Mi sto perdendo qualcosa?

  • Gestire i due casi in modo diverso, l'aggiornamento della griglia non deve fare affidamento sui messaggi di movimento poiché fa parte del sistema di collisione

Non sono sicuro di cosa intendi. Le implementazioni precedenti di CollisionDetectionSystem avrebbero semplicemente verificato la presenza di collisioni su un aggiornamento (quando veniva gestito un TimePassedMessage), ma dovevo minimizzare i controlli quanto potevo a causa delle prestazioni. Quindi sono passato al controllo delle collisioni quando un'entità si muove (la maggior parte delle entità nel mio gioco sono statiche).


C'è qualcosa che non mi è chiaro. Chi modifica il contenuto della cella mentre il sistema di collisione scorre su di essi?
Paul Manta,

Non riesci a lavorare con una coda di messaggi in uscita globale? Quindi tutti i messaggi in essa contenuti vengono inviati ogni volta che viene eseguito un sistema, questo include l'autodistruzione del sistema.
Roy T.,

Se vuoi mantenere questo design contorto, devi seguire @RoyT. il consiglio è l'unico modo (senza messaggi complessi e basati sul tempo) di gestire il problema del sequenziamento. Potresti voler leggere gamadu.com/artemis per vedere cosa hanno fatto con Aspects, da cui parte alcuni dei problemi che stai riscontrando.
Patrick Hughes,


2
Potresti voler imparare come ha fatto Axum scaricando il CTP e compilando un po 'di codice - e quindi decodificando il risultato su C # usando ILSpy. Il passaggio dei messaggi è una caratteristica importante dei linguaggi dei modelli di attori e sono sicuro che Microsoft sappia cosa stanno facendo, quindi potresti scoprire che avevano l'implementazione "migliore".
Jonathan Dickinson,

Risposte:


12

Probabilmente hai sentito parlare dell'anti-pattern dell'oggetto God / Blob. Bene, il tuo problema è un loop God / Blob. Armeggiare con il sistema di trasmissione dei messaggi fornirà nel migliore dei casi una soluzione di Band-Aid e nel peggiore dei casi sarà una completa perdita di tempo. In effetti, il tuo problema non ha assolutamente nulla a che fare con lo sviluppo del gioco. Mi sono sorpreso mentre provavo a modificare una raccolta ripetendo più volte su di essa e la soluzione è sempre la stessa: suddivisione, suddivisione, suddivisione.

Dal momento che capisco la formulazione della tua domanda, il tuo metodo di aggiornamento del tuo sistema di collisione attualmente appare sostanzialmente simile al seguente.

for each possible collision
    check for collision
    handle collision
    modify collision world to reflect change // exception happens here

Scritto chiaramente in questo modo, puoi vedere che il tuo ciclo ha tre responsabilità, quando dovrebbe averne solo una. Per risolvere il problema, dividere il loop corrente in tre loop separati che rappresentano tre diversi passaggi algoritmici .

for each possible collision
    check for collision, record it if a collision occurs

for each found collision
    handle collision, record the collision response (delete object, ignore, etc.)

for each collision response
    modify collision world according to response

Suddividendo il loop originale in tre sottoprocessi, non si tenta più di modificare la raccolta su cui si sta ripetendo. Nota anche che non stai facendo più lavoro che nel tuo ciclo originale, e in effetti potresti ottenere delle vincite nella cache eseguendo le stesse operazioni molte volte in sequenza.

C'è anche un ulteriore vantaggio, che è che ora puoi introdurre il parallelismo nel tuo codice. Il tuo approccio a ciclo combinato è intrinsecamente seriale (che è fondamentalmente ciò che ti dice l'eccezione di modifica concorrente!), Perché ogni iterazione di loop potenzialmente legge e scrive nel tuo mondo di collisione. I tre subloops che presento sopra, tuttavia, sono tutti in lettura o scrittura, ma non in entrambi. Per lo meno il primo passaggio, controllando tutte le possibili collisioni, è diventato imbarazzantemente parallelo e, a seconda di come si scrive il codice, potrebbero essere anche il secondo e il terzo passaggio.


Sono totalmente d'accordo con questo. Sto usando questo approccio molto simile nel mio gioco e credo che questo ripagherà nel lungo periodo. Ecco come dovrebbe funzionare il sistema di collisione (o gestore) (credo davvero che non sia possibile avere un sistema di messaggistica).
Emiliano,

11

Come implementare correttamente la gestione dei messaggi in un sistema di entità basato su componenti?

Direi che vuoi due tipi di messaggi: sincrono e asincrono. I messaggi sincroni vengono gestiti immediatamente mentre quelli asincroni non vengono gestiti nello stesso frame di stack (ma possono essere gestiti nello stesso frame di gioco). La decisione che di solito viene presa in base alla "classe di messaggi", ad esempio "tutti i messaggi EnemyDied sono asincroni".

Alcuni eventi sono gestiti in modo molto più semplice con uno di questi modi. Ad esempio, nella mia esperienza un ObjectGetsDeletedNow - l'evento è molto meno sexy e i callback sono molto più difficili da implementare rispetto a ObjectWillBeDeletedAtEndOfFrame. Inoltre, qualsiasi gestore di messaggi simile a "veto" (codice che può annullare o modificare determinate azioni mentre vengono eseguite, come un effetto Shield modifica DamageEvent ) non sarà facile in ambienti asincroni, ma è un gioco da ragazzi in chiamate sincrone.

In alcuni casi, l'asincrono potrebbe essere più efficiente (ad esempio, è possibile saltare alcuni gestori di eventi quando l'oggetto viene eliminato in un secondo momento). A volte il sincrono è più efficiente, specialmente quando il calcolo del parametro per un evento è costoso e preferisci passare le funzioni di callback per recuperare determinati parametri invece di valori già calcolati (nel caso in cui nessuno sia interessato a questo particolare parametro comunque).

Hai già menzionato un altro problema generale con i sistemi di messaggi solo sincroni: secondo la mia esperienza con i sistemi di messaggi sincroni, uno dei casi più frequenti di errori e dolore in generale è il cambio di elenchi mentre si scorre su questi elenchi.

Pensaci: è nella natura sincrona ( gestisci immediatamente tutti gli effetti collaterali di alcune azioni) e nel sistema di messaggi (disaccoppiando il destinatario dal mittente in modo che il mittente non sappia chi sta reagendo alle azioni) che non potrai facilmente individuare tali anelli. Quello che sto dicendo è: preparatevi a gestire molto questo tipo di iterazione automodificante. È una specie di "design". ;-)

come posso evitare che il sistema di collisione venga interrotto mentre controlla le collisioni?

Per il tuo particolare problema con il rilevamento delle collisioni, potrebbe essere abbastanza buono rendere gli eventi di collisione asincroni, quindi vengono messi in coda fino a quando il gestore delle collisioni non viene terminato ed eseguito come un batch successivo (o in un punto successivo del frame). Questa è la soluzione "coda in entrata".

Il problema: se un sistema A aggiunge un messaggio alla coda B del sistema, funziona bene se si intende aggiornare il sistema B più tardi rispetto al sistema A (nello stesso frame di gioco); in caso contrario, il messaggio elabora il frame di gioco successivo (non desiderabile per alcuni sistemi)

Facile:

while (! queue.empty ()) {queue.pop (). handle (); }

Esegui la coda più volte fino a quando non rimane alcun messaggio. (Se urli "loop infinito" ora, ricorda che molto probabilmente avresti questo problema come "spamming di messaggi" se fosse ritardato al frame successivo. Puoi affermare () un numero ragionevole di iterazioni per rilevare loop infiniti, se ne hai voglia;))


Si noti che non ho parlato esattamente di "quando" vengono gestiti i messaggi asincroni. A mio avviso, è perfettamente corretto consentire al modulo di rilevamento delle collisioni di svuotare i suoi messaggi al termine. Potresti anche pensarlo come "messaggi sincroni, ritardati fino alla fine del ciclo" o un modo ingegnoso di "implementare semplicemente l'iterazione in modo che possa essere modificata durante l'iterazione"
Imi,

5

Se stai effettivamente cercando di sfruttare la natura di ECS orientata ai dati, potresti voler pensare al modo più DOD per farlo.

Dai un'occhiata al blog BitSquid , in particolare la parte relativa agli eventi. Viene presentato un sistema che si adatta bene all'ECS. Buffer tutti gli eventi in una coda pulita per tipo messaggio, allo stesso modo in cui i sistemi in un ECS sono per componente. I sistemi aggiornati successivamente possono iterare in modo efficiente sulla coda per un particolare tipo di messaggio per elaborarli. O semplicemente ignorali. Qualunque sia.

Ad esempio, CollisionSystem genererebbe un buffer pieno di eventi di collisione. Qualsiasi altro sistema eseguito dopo la collisione può quindi scorrere l'elenco ed elaborare quelli necessari.

Mantiene la natura parallela orientata ai dati del progetto ECS senza tutta la complessità della registrazione dei messaggi o simili. Solo i sistemi che si occupano effettivamente di un particolare tipo di evento eseguono l'iterazione sulla coda per quel tipo e che eseguono un'iterazione semplice a passaggio singolo sulla coda dei messaggi è il più efficiente possibile.

Se mantieni i componenti ordinati in modo coerente in ciascun sistema (ad esempio ordina tutti i componenti in base all'ID entità o qualcosa del genere), ottieni anche il piacevole vantaggio che i messaggi verranno generati nell'ordine più efficiente per iterare su di essi e cercare i componenti corrispondenti nel sistema di elaborazione. Cioè, se hai entità 1, 2 e 3, i messaggi vengono generati in quell'ordine e le ricerche dei componenti eseguite durante l'elaborazione del messaggio saranno in ordine di indirizzi strettamente crescente (che è il più veloce).


1
+1, ma non posso credere che questo approccio non abbia svantaggi. Questo non ci costringe a codificare le interdipendenze tra i sistemi? O forse queste interdipendenze sono pensate per essere codificate, in un modo o nell'altro?
Patryk Czachurski,

2
@Daedalus: se la logica di gioco necessita di aggiornamenti di fisica per fare la logica corretta, come hai intenzione di non avere quella dipendenza? Anche con un modello pubsub, devi iscriverti esplicitamente a questo tipo di messaggio che viene generato solo da qualche altro sistema. Evitare le dipendenze è difficile e consiste principalmente nel capire i giusti livelli. Grafica e fisica sono indipendenti, per esempio, ma ci sarà un livello di colla di livello superiore che assicura che gli aggiornamenti di simulazione di fisica interpolata si riflettano nella grafica, ecc.
Sean Middleditch,

Questa dovrebbe essere la risposta accettata. Un modo semplice per farlo è semplicemente creare un nuovo tipo di componente, ad esempio CollisionResolvable che verrà elaborato da ogni sistema interessato a fare le cose dopo che si è verificata una collisione. Che si adatterebbe perfettamente con la proposta di Drake, tuttavia esiste un sistema per ogni ciclo di suddivisione.
user8363
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.