Come funziona la comunicazione tra entità?


115

Ho due casi utente:

  1. Come entity_Ainvierebbe un take-damagemessaggio a entity_B?
  2. Come entity_Ainterrogare entity_Bgli HP?

Ecco cosa ho incontrato finora:

  • Coda messaggi
    1. entity_Acrea un take-damagemessaggio e lo inserisce nella entity_Bcoda dei messaggi.
    2. entity_Acrea un query-hpmessaggio e lo pubblica su entity_B. entity_Bin cambio crea un response-hpmessaggio e lo pubblica su entity_A.
  • Publish / Subscribe
    1. entity_Bsi iscrive ai take-damagemessaggi (possibilmente con alcuni filtri preventivi in ​​modo da consegnare solo i messaggi pertinenti). entity_Aproduce un take-damagemessaggio che fa riferimento entity_B.
    2. entity_Asi iscrive ai update-hpmessaggi (eventualmente filtrati). Ogni frame entity_Btrasmette update-hpmessaggi.
  • Segnale / Slot
    1. ???
    2. entity_Acollega una update-hpscanalatura a entity_B's update-hpsegnale.

C'è qualcosa di meglio? Ho una corretta comprensione di come questi schemi di comunicazione si legerebbero al sistema di entità di un motore di gioco?

Risposte:


67

Buona domanda! Prima di arrivare alle domande specifiche che mi hai posto, dirò: non sottovalutare il potere della semplicità. Tenpn ha ragione. Tieni presente che tutto ciò che stai cercando di fare con questi approcci è trovare un modo elegante per rinviare una chiamata di funzione o disaccoppiare il chiamante dalla chiamata. Posso raccomandare le coroutine come un modo sorprendentemente intuitivo per alleviare alcuni di questi problemi, ma è un po 'fuori tema. A volte, stai meglio chiamando semplicemente la funzione e vivendo con il fatto che l'entità A è accoppiata direttamente all'entità B. Vedi YAGNI.

Detto questo, ho usato ed ero soddisfatto del modello di segnale / slot combinato con il semplice passaggio di messaggi. L'ho usato in C ++ e Lua per un titolo iPhone abbastanza riuscito che aveva un programma molto stretto.

Per il caso segnale / slot, se voglio che l'entità A faccia qualcosa in risposta a qualcosa che l'entità B ha fatto (es. Sbloccare una porta quando qualcosa muore) potrei avere l'entità A iscriversi direttamente all'evento di morte dell'entità B. O forse l'entità A si abbonerebbe a ciascuno di un gruppo di entità, incrementerebbe un segnalino su ogni evento sparato e sbloccherebbe la porta dopo la morte di N di esse. Inoltre, "gruppo di entità" e "N di esse" sarebbero in genere definiti dal progettista nei dati di livello. (A parte questo, questa è un'area in cui le coroutine possono davvero brillare, ad esempio WaitForMultiple ("Morire", entA, entB, entC); door.Unlock ();)

Ma ciò può diventare ingombrante quando si tratta di reazioni strettamente legate al codice C ++ o di eventi di gioco intrinsecamente effimeri: infliggere danni, ricaricare armi, debug, feedback AI basato sulla posizione guidato dal giocatore. È qui che il passaggio dei messaggi può colmare le lacune. In sostanza si riduce a qualcosa del tipo "dì a tutte le entità in quest'area di subire danni in 3 secondi" o "ogni volta che completi la fisica per capire chi ho sparato, dì loro di eseguire questa funzione di script". È difficile capire come farlo bene usando pubblica / abbonati o segnale / slot.

Questo può essere facilmente eccessivo (rispetto all'esempio di tenpn). Può anche essere gonfio inefficiente se hai molta azione. Ma nonostante i suoi inconvenienti, questo approccio "messaggi ed eventi" si combina molto bene con il codice di gioco con script (ad esempio in Lua). Il codice dello script può definire e reagire ai propri messaggi ed eventi senza preoccuparsi del codice C ++. E il codice dello script può facilmente inviare messaggi che attivano il codice C ++, come cambiare livello, riprodurre suoni o anche solo lasciare che un'arma stabilisca quanti danni fornisce il messaggio di TakeDamage. Mi ha fatto risparmiare un sacco di tempo perché non dovevo scherzare costantemente con Luabind. E mi ha permesso di conservare tutto il mio codice luabind in un posto, perché non ce n'era molto. Se correttamente accoppiato,

Inoltre, la mia esperienza con il caso d'uso n. 2 è che è meglio gestirlo come evento nell'altra direzione. Invece di chiedere quale sia lo stato dell'entità, attiva un evento / invia un messaggio ogni volta che lo stato fa un cambiamento significativo.

In termini di interfacce, tra l'altro, ho finito con tre classi per implementare tutto questo: EventHost, EventClient e MessageClient. EventHosts crea slot, EventClients si abbona / si connette ad essi e MessageClients associa un delegato a un messaggio. Si noti che il target delegato di MessageClient non deve necessariamente essere lo stesso oggetto proprietario dell'associazione. In altre parole, MessageClients può esistere esclusivamente per inoltrare messaggi ad altri oggetti. FWIW, la metafora host / client è in qualche modo inappropriata. Source / Sink potrebbe essere concetti migliori.

Mi dispiace, ho vagato un po 'lì. È la mia prima risposta :) Spero che abbia senso.


Grazie per la risposta. Ottime intuizioni. Il motivo per cui sto finendo di progettare il passaggio dei messaggi è a causa di Lua. Mi piacerebbe essere in grado di creare nuove armi senza nuovo codice C ++. Quindi i tuoi pensieri hanno risposto ad alcune delle mie domande non poste.
deft_code

Per quanto riguarda le coroutine, anch'io sono un grande sostenitore delle coroutine, ma non riesco mai a giocarci in C ++. Avevo una vaga speranza di usare le coroutine nel codice lua per gestire le chiamate bloccanti (ad esempio, wait-for-death). Ne è valsa la pena? Temo di essere accecato dal mio intenso desiderio di coroutine in c ++.
deft_code

Infine, qual era il gioco per iPhone? Posso ottenere maggiori informazioni sul sistema di entità che hai usato?
deft_code

2
Il sistema di entità era principalmente in C ++. Quindi, ad esempio, c'era una classe Imp che gestiva il comportamento dell'Imp. Lua potrebbe cambiare i parametri di Imp allo spawn o tramite messaggio. L'obiettivo con Lua era quello di adattarsi a un programma rigoroso e il debug del codice Lua richiede molto tempo. Abbiamo usato Lua per i livelli di script (quali entità vanno dove, eventi che accadono quando si premono i trigger). Quindi a Lua, diremmo cose come SpawnEnt ("Imp") in cui Imp è un'associazione di fabbrica registrata manualmente. Si genererebbe sempre in un pool globale di entità. Bello e semplice. Abbiamo usato un sacco di smart_ptr e weak_ptr.
BRaffle,

1
Quindi BananaRaffle: Diresti che questo è un riassunto accurato della tua risposta: "Tutte e 3 le soluzioni che hai pubblicato hanno i loro usi, così come le altre. Non cercare l'unica soluzione perfetta, usa solo ciò di cui hai bisogno dove ha senso ".
Ipsquiggle

76
// in entity_a's code:
entity_b->takeDamage();

Hai chiesto come lo fanno i giochi commerciali. ;)


8
Un voto negativo? Scherzi a parte, è così che si fa normalmente! I sistemi di entità sono fantastici, ma non aiutano a raggiungere i primi traguardi.
tenpn

Realizzo giochi Flash in modo professionale, ed è così che lo faccio. Chiama nemici.damage (10) e poi cerchi tutte le informazioni di cui hai bisogno dai getter pubblici.
Iain,

7
Questo è davvero il modo in cui i motori di gioco commerciali lo fanno. Non sta scherzando. Target.NotifyTakeDamage (DamageType, DamageAmount, DamageDealer, ecc.) Di solito è come va.
AA Grapsas,

3
Anche i giochi commerciali sbagliano "danno"? :-P
Ricket

15
Sì, tra l'altro danneggiano il misspel. :)
LearnCocos2D

17

Una risposta più seria:

Ho visto usare le lavagne molto. Le versioni semplici non sono altro che puntoni che vengono aggiornati con elementi come i HP di un'entità, che le entità possono quindi interrogare.

Le tue lavagne possono essere sia la visione del mondo di questa entità (chiedi alla lavagna di B quale sia il suo HP), sia la visione di un'entità del mondo (A interroga la sua lavagna per vedere quale sia l'HP dell'obiettivo di A).

Se aggiorni solo le lavagne in un punto di sincronizzazione nel frame, puoi quindi leggerle in un secondo momento da qualsiasi thread, rendendo il multithreading piuttosto semplice da implementare.

Le lavagne più avanzate possono essere più come hashtabili, mappare stringhe su valori. Questo è più gestibile ma ovviamente ha un costo di runtime.

Una lavagna è tradizionalmente solo una comunicazione a senso unico: non gestirà l'eruzione del danno.


Non avevo mai sentito parlare del modello di lavagna prima d'ora.
deft_code

Sono anche utili per ridurre le dipendenze, come fa una coda di eventi o un modello di pubblicazione / sottoscrizione.
tenpn

2
Questa è anche la "definizione" canonica di come dovrebbe funzionare un sistema E / C / S "ideale". I componenti formano la lavagna; i sistemi sono il codice che agisce su di esso. (Le entità, ovviamente, sono proprio long long intsimili o simili, in un sistema ECS puro.)
BRPocock

6

Ho studiato un po 'questo problema e ho visto una buona soluzione.

Fondamentalmente si tratta di sottosistemi. È simile all'idea della lavagna menzionata da tenpn.

Le entità sono costituite da componenti, ma sono solo sacchi di proprietà. Nessun comportamento è implementato nelle entità stesse.

Diciamo, le entità hanno un componente di salute e un componente di danno.

Quindi hai alcuni MessageManager e tre sottosistemi: ActionSystem, DamageSystem, HealthSystem. Ad un certo punto ActionSystem esegue i suoi calcoli sul mondo di gioco e genera un evento:

HIT, source=entity_A target=entity_B power=5

Questo evento è pubblicato nel MessageManager. Ora a un certo punto il MessageManager passa attraverso i messaggi in sospeso e scopre che DamageSystem si è abbonato ai messaggi HIT. Ora MessageManager consegna il messaggio HIT a DamageSystem. Il DamageSystem passa attraverso il suo elenco di entità che hanno componente Damage, calcola i punti danno in base alla potenza di colpo o qualche altro stato di entrambe le entità ecc. E pubblica l'evento

DAMAGE, source=entity_A target=entity_B amount=7

HealthSystem ha sottoscritto i messaggi DAMAGE e ora quando MessageManager pubblica il messaggio DAMAGE su HealthSystem, HealthSystem ha accesso a entrambe le entità entity_A e entity_B con i loro componenti Health, quindi anche HealthSystem può fare i suoi calcoli (e forse pubblicare l'evento corrispondente al MessageManager).

In un tale motore di gioco, il formato dei messaggi è l'unico accoppiamento tra tutti i componenti e sottosistemi. I sottosistemi e le entità sono completamente indipendenti e ignari l'uno dell'altro.

Non so se qualche vero motore di gioco abbia implementato questa idea o meno, ma sembra piuttosto solido e pulito e spero un giorno di implementarlo da solo per il mio motore di gioco di livello hobbistico.


Questa è una risposta molto migliore della risposta accettata IMO. Disaccoppiato, mantenibile ed estendibile (e anche non un disastro di accoppiamento come la risposta scherzosa di entity_b->takeDamage();)
Danny Yaroslavski,

4

Perché non avere una coda di messaggi globale, qualcosa del tipo:

messageQueue.push_back(shared_ptr<Event>(new DamageEvent(entityB, 10, entityA)));

Con:

DamageEvent(Entity* toDamage, uint amount, Entity* damageDealer);

E alla fine del loop di gioco / gestione degli eventi:

while(!messageQueue.empty())
{
    Event e = messageQueue.front();
    messageQueue.pop_front();
    e.Execute();
}

Penso che questo sia il modello di comando. Ed Execute()è un puro virtuale in Eventcui i derivati ​​definiscono e fanno cose. Ecco:

DamageEvent::Execute() 
{
    toDamage->takeDamage(amount); // Or of course, you could now have entityA get points, or a recognition of damage, or anything.
}

3

Se il tuo gioco è single player, usa semplicemente il metodo degli oggetti target (come suggerito da tenpn).

Se sei (o vuoi supportare) il multiplayer (multiclient per l'esattezza), usa una coda di comandi.

  • Quando A infligge danni a B sul client 1, accoda semplicemente l'evento di danno.
  • Sincronizza le code dei comandi tramite la rete
  • Gestire i comandi in coda su entrambi i lati.

2
Se sei seriamente intenzionato a evitare di barare, A non considera affatto B sul client. Il client che possiede A invia un comando "attacco B" al server, che fa esattamente quello che ha detto tenpn; il server quindi sincronizza tale stato con tutti i client rilevanti.

@Joe: Sì, se c'è un server che è un punto valido da considerare, ma a volte è giusto fidarsi del client (ad esempio su una console) per evitare un carico pesante del server.
Andreas,

2

Vorrei dire: non utilizzare nessuno dei due, a condizione che non sia esplicitamente necessario un riscontro istantaneo del danno.

L'entità / componente che subisce danni dovrebbe spingere gli eventi in una coda di eventi locale o in un sistema su un livello uguale che contenga eventi di danno.

Dovrebbe quindi esserci un sistema di sovrapposizione con accesso a entrambe le entità che richiede gli eventi dall'entità a e lo passa all'entità b. Non creando un sistema di eventi generale che qualsiasi cosa possa utilizzare da qualsiasi luogo per passare un evento a qualsiasi cosa in qualsiasi momento, si crea un flusso di dati esplicito che rende sempre più facile il debug del codice, più facile misurare le prestazioni, più facile da capire e leggere e spesso porta a un sistema più ben progettato in generale.


1

Basta fare la chiamata. Non fare richiesta-hp seguita da query-hp - se segui quel modello ti troverai in un mondo di dolore.

Potresti voler dare un'occhiata anche alle Continuazioni Mono. Penso che sarebbe l'ideale per gli NPC.


1

Quindi cosa succede se abbiamo i giocatori A e B che cercano di colpire l'un l'altro nello stesso ciclo update ()? Supponiamo che l'aggiornamento () per il giocatore A si verifichi prima dell'aggiornamento () per il giocatore B nel ciclo 1 (o tick, o come lo chiami). Ci sono due scenari a cui riesco a pensare:

  1. Elaborazione immediata tramite un messaggio:

    • il giocatore A.Update () vede che il giocatore vuole colpire B, il giocatore B riceve un messaggio che notifica il danno.
    • il giocatore B.HandleMessage () aggiorna i punti ferita per il giocatore B (muore)
    • il giocatore B.Update () vede che il giocatore B è morto .. non può attaccare il giocatore A

Questo è ingiusto, i giocatori A e B dovrebbero colpirsi a vicenda, il giocatore B è morto prima di colpire A solo perché quell'entità / oggetto di gioco è stato aggiornato () in seguito.

  1. Accodamento del messaggio

    • Il giocatore A.Update () vede che il giocatore vuole colpire B, il giocatore B riceve un messaggio che notifica il danno e lo memorizza in una coda
    • Il giocatore A.Update () controlla la sua coda, è vuota
    • il giocatore B.Update () controlla prima le mosse, quindi il giocatore B invia un messaggio al giocatore A con danni anche
    • il giocatore B.Update () gestisce anche i messaggi in coda, elabora i danni del giocatore A
    • Nuovo ciclo (2): il giocatore A vuole bere una pozione di salute, quindi viene chiamato il giocatore A. Aggiornamento () e la mossa viene elaborata
    • Il giocatore A.Update () controlla la coda dei messaggi ed elabora i danni del giocatore B

Ancora una volta questo è ingiusto .. il giocatore A dovrebbe prendere i punti ferita nello stesso turno / ciclo / tick!


4
Non stai davvero rispondendo alla domanda, ma penso che la tua risposta sarebbe di per sé un'ottima domanda. Perché non andare avanti e chiedere come risolvere una tale priorità "ingiusta"?
Bummzack,

Dubito che la maggior parte dei giochi si preoccupi di questa ingiustizia perché si aggiornano così frequentemente che raramente è un problema. Una soluzione semplice è alternare tra iterando avanti e indietro nell'elenco delle entità durante l'aggiornamento.
Kylotan,

Uso 2 chiamate, quindi chiamo Update () a tutte le entità, quindi dopo il ciclo ripeto e chiamo qualcosa del genere pEntity->Flush( pMessages );. Quando entity_A genera un nuovo evento, non viene letto da entity_B in quel frame (ha anche la possibilità di prendere la pozione), quindi entrambi ricevono danni e successivamente elaborano il messaggio di guarigione della pozione che sarebbe l'ultimo in coda . Il giocatore B muore comunque, poiché il messaggio di pozione è l'ultimo della coda: P, ma può essere utile per altri tipi di messaggi come cancellare i puntatori a entità morte.
Pablo Ariel,

Penso a livello di frame, la maggior parte delle implementazioni di gioco sono semplicemente ingiuste. come ha detto Kylotan.
v.oddou,

Questo problema è follemente facile da risolvere. Basta applicare il danno gli uni agli altri nei gestori dei messaggi o altro. Non dovresti assolutamente segnalare il giocatore come morto all'interno del gestore dei messaggi. In "Update ()" fai semplicemente "if (hp <= 0) die ();" (ad esempio all'inizio di "Aggiorna ()"). In questo modo entrambi possono uccidersi a vicenda contemporaneamente. Inoltre: spesso non danneggi il giocatore direttamente, ma attraverso un oggetto intermedio come un proiettile.
Tara,
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.