In che modo gli oggetti di gioco dovrebbero essere reciprocamente consapevoli?


18

Trovo difficile trovare un modo per organizzare gli oggetti di gioco in modo che siano polimorfici ma allo stesso tempo non polimorfici.

Ecco un esempio: supponendo che vogliamo tutti i nostri oggetti di update()e draw(). Per fare ciò dobbiamo definire una classe base GameObjectche ha quei due metodi puri virtuali e lascia che il polimorfismo entri in azione:

class World {
private:
    std::vector<GameObject*> objects;
public:
    // ...
    update() {
        for (auto& o : objects) o->update();
        for (auto& o : objects) o->draw(window);
    }
};

Il metodo di aggiornamento dovrebbe occuparsi dello stato che deve essere aggiornato dall'oggetto di classe specifico. Il fatto è che ogni oggetto deve conoscere il mondo che li circonda. Per esempio:

  • Una miniera deve sapere se qualcuno si scontra con essa
  • Un soldato dovrebbe sapere se il soldato di un'altra squadra è nelle vicinanze
  • Uno zombi dovrebbe sapere dov'è il cervello più vicino, entro un raggio

Per interazioni passive (come la prima) pensavo che il rilevamento delle collisioni potesse delegare cosa fare in casi specifici di collisioni all'oggetto stesso con a on_collide(GameObject*).

La maggior parte delle altre informazioni (come gli altri due esempi) potrebbero essere interrogate dal mondo di gioco passato al updatemetodo. Ora il mondo non distingue gli oggetti in base al loro tipo (immagazzina tutti gli oggetti in un singolo contenitore polimorfico), quindi ciò che in realtà restituirà con un ideale world.entities_in(center, radius)è un contenitore di GameObject*. Ma ovviamente il soldato non vuole attaccare altri soldati della sua squadra e uno zombi non fa caso ad altri zombi. Quindi dobbiamo distinguere il comportamento. Una soluzione potrebbe essere la seguente:

void TeamASoldier::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<TeamBSoldier*>(e))
            // shoot towards enemy
}

void Zombie::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<Human*>(e))
            // go and eat brain
}

ma ovviamente il numero di dynamic_cast<>per frame potrebbe essere orribilmente alto, e sappiamo tutti quanto dynamic_castpuò essere lento . Lo stesso problema si applica anche al on_collide(GameObject*)delegato di cui abbiamo discusso in precedenza.

Quindi qual è il modo ideale per organizzare il codice in modo che gli oggetti possano essere consapevoli di altri oggetti ed essere in grado di ignorarli o intraprendere azioni in base al loro tipo?


1
Penso che tu stia cercando un'implementazione personalizzata versatile C ++ RTTI. Tuttavia, la tua domanda non sembra riguardare solo meccanismi RTTI giudiziosi. Le cose che chiedi sono richieste da quasi tutti i middleware che il gioco utilizzerà (sistema di animazione, fisica per citarne alcuni). A seconda dell'elenco di query supportate, è possibile imbrogliare RTTI utilizzando ID e indici in array o si finirà per progettare un protocollo completo per supportare alternative più economiche a dynamic_cast e type_info.
teodron,

Sconsiglio di utilizzare il sistema dei tipi per la logica di gioco. Ad esempio, invece di dipendere dal risultato di dynamic_cast<Human*>, implementare qualcosa come a bool GameObject::IsHuman(), che ritorna falseper impostazione predefinita ma viene sostituito per tornare truenella Humanclasse.
congusbongus,

un extra: non invii quasi mai tonnellate di oggetti a un'altra entità che potrebbero essere interessati a loro. Questa è un'ovvia ottimizzazione che dovrai davvero considerare.
teodron,

@congusbongus L'uso di una vtable e delle IsAsostituzioni personalizzate si è rivelato solo leggermente migliore del casting dinamico in pratica per me. La cosa migliore da fare è che l'utente abbia, ove possibile, elenchi di dati ordinati invece di iterare ciecamente sull'intero pool di entità.
teodron,

4
@Jefffrey: idealmente non scrivi un codice specifico per il tipo. Scrivi il codice specifico dell'interfaccia ("interfaccia" in senso generale). La tua logica per un TeamASoldiered TeamBSoldierè davvero identica - sparata a chiunque nell'altra squadra. Tutto ciò di cui ha bisogno da parte di altre entità è un GetTeam()metodo nella sua forma più specifica e, con l'esempio di congusbongus, che può essere ulteriormente estratto in una IsEnemyOf(this)sorta di interfaccia. Il codice non deve preoccuparsi delle classificazioni tassonomiche di soldati, zombi, giocatori, ecc. Concentrati sull'interazione, non sui tipi.
Sean Middleditch,

Risposte:


11

Invece di implementare il processo decisionale di ciascuna entità in sé, puoi in alternativa scegliere il modello di controller. Avresti classi di controller centrali che sono a conoscenza di tutti gli oggetti (che contano per loro) e ne controllano il comportamento.

Un MovementController gestirà il movimento di tutti gli oggetti che possono muoversi (fare la ricerca del percorso, aggiornare le posizioni in base ai vettori di movimento correnti).

Un controllore del comportamento delle mine controlla tutte le mine e tutti i soldati e ordina a una miniera di esplodere quando un soldato si avvicina troppo.

Uno ZombieBehaviorController controlla tutti gli zombi e i soldati nelle loro vicinanze, sceglie il bersaglio migliore per ogni zombi e gli ordina di spostarsi lì e attaccarlo (la mossa stessa è gestita dal MovementController).

Un SoldierBehaviorController analizzerebbe l'intera situazione e poi fornirebbe istruzioni tattiche per tutti i soldati (ci si sposta lì, si spara questo, si guarisce quel ragazzo ...). L'esecuzione effettiva di questi comandi di livello superiore sarebbe gestita anche da controller di livello inferiore. Quando fai uno sforzo, puoi rendere l'IA capace di decisioni cooperative piuttosto intelligenti.


1
Probabilmente questo è anche noto come "sistema" che gestisce la logica per alcuni tipi di componenti in un'architettura Entità-Componente.
teodron,

Sembra una soluzione in stile C. I componenti sono raggruppati instd::map entità sono solo ID e quindi dobbiamo creare una sorta di sistema di tipi (forse con un componente tag, perché il renderer dovrà sapere cosa disegnare); e se non vogliamo farlo avremo bisogno di un componente di disegno: ma ha bisogno del componente di posizione per sapere dove disegnare, quindi creiamo dipendenze tra i componenti che risolviamo con un sistema di messaggistica super complesso. È questo che stai suggerendo?
Scarpa

1
@Jefffrey "Sembra una soluzione in stile C" - anche quando sarebbe vero, perché sarebbe necessariamente una cosa negativa? Le altre preoccupazioni potrebbero essere valide, ma ci sono soluzioni per loro. Sfortunatamente un commento è troppo breve per indirizzarli correttamente.
Philipp,

1
@Jefffrey L'uso dell'approccio in cui i componenti stessi non hanno alcuna logica e i "sistemi" sono responsabili della gestione di tutta la logica non crea dipendenze tra i componenti né richiede un sistema di messaggistica super complesso (almeno, non altrettanto complesso) . Vedi ad esempio: gamadu.com/artemis/tutorial.html

1

Prima di tutto, prova a implementare le funzionalità in modo che gli oggetti rimangano indipendenti l'uno dall'altro, quando possibile. Soprattutto vuoi farlo per il multi threading. Nel tuo primo esempio di codice, l'insieme di tutti gli oggetti potrebbe essere diviso in insiemi corrispondenti al numero di core della CPU e aggiornato in modo molto efficiente.

Ma come hai detto, per alcune funzionalità è necessaria l'interazione con altri oggetti. Ciò significa che lo stato di tutti gli oggetti deve essere sincronizzato in alcuni punti. In altre parole, l'applicazione deve attendere prima il completamento di tutte le attività parallele, quindi applicare i calcoli che comportano l'interazione. È utile ridurre il numero di questi punti di sincronizzazione, poiché implicano sempre che alcuni thread devono attendere il completamento di altri.

Pertanto, suggerisco di memorizzare nel buffer quelle informazioni sugli oggetti necessari all'interno di altri oggetti. Dato un tale buffer globale, è possibile aggiornare tutti gli oggetti indipendentemente l'uno dall'altro ma dipendenti solo da se stessi e dal buffer globale, che è sia più veloce che più facile da mantenere. Ad un timestep fisso, ad esempio dopo ogni frame, aggiorna il buffer con lo stato attuale degli oggetti.

Quindi ciò che fai una volta per frame è 1. buffer lo stato degli oggetti correnti a livello globale, 2. aggiorna tutti gli oggetti in base a se stessi e al buffer, 3. disegna i tuoi oggetti e ricomincia da capo con il rinnovo del buffer.


1

Usa un sistema basato su componenti, in cui hai un GameObject barebone che contiene 1 o più componenti che ne definiscono il comportamento.

Ad esempio, supponiamo che alcuni oggetti si muovano sempre a destra e sinistra (una piattaforma), è possibile creare un componente del genere e collegarlo a un GameObject.

Ora supponiamo che un oggetto di gioco debba ruotare lentamente tutto il tempo, potresti creare un componente separato che fa proprio questo e collegarlo a GameObject.

E se volessi avere una piattaforma mobile anche ruotata, in una tradizionale gerarchia di classe che diventa difficile da fare senza duplicare il codice.

Il bello di questo sistema è che invece di avere una classe Rotatable o MovingPlatform, si collegano entrambi quei componenti a GameObject e ora si dispone di una MovingPlatform che AutoRotates.

Tutti i componenti hanno una proprietà, 'richiedeUpdate' che, sebbene sia vero, GameObject chiamerà il metodo 'update' su detto componente. Ad esempio, supponiamo di avere un componente trascinabile, questo componente al passaggio del mouse (se si trovava sopra il GameObject) può impostare "requireUpdate" su true, quindi al passaggio del mouse su false. Permettendo che segua il mouse solo quando il mouse è inattivo.

Uno degli sviluppatori di Tony Hawk Pro Skater ha il defacto scritto su di esso, e vale la pena leggerlo: http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/


1

Favorire la composizione rispetto all'eredità.

Il mio consiglio più forte a parte questo sarebbe: non lasciarti trascinare dalla mentalità di "Voglio che questo sia estremamente flessibile". La flessibilità è ottima, ma ricorda che a un certo livello, in qualsiasi sistema finito come un gioco, ci sono parti atomiche che vengono utilizzate per costruire il tutto. In un modo o nell'altro, l'elaborazione si basa su quei tipi atomici predefiniti. In altre parole, il catering per "qualsiasi" tipo di dati (se ciò fosse possibile) non ti aiuterebbe a lungo termine, se non hai il codice per elaborarli. Fondamentalmente, tutto il codice deve analizzare / elaborare i dati in base a specifiche note ... il che significa un set predefinito di tipi. Quanto è grande questo set? Sta a te.

Questo articolo offre una panoramica del principio di Composizione sull'ereditarietà nello sviluppo di giochi attraverso un'architettura componente-entità solida e performante.

Costruendo entità da sottoinsiemi (diversi) di alcuni superset di componenti predefiniti, offri ai tuoi IA modi concreti e frammentari per comprendere il mondo e gli attori che li circondano, leggendo gli stati dei componenti di quegli attori.


1

Personalmente raccomando di mantenere la funzione draw fuori dalla stessa classe Object. Consiglio anche di mantenere la posizione / coordinate degli oggetti fuori dall'oggetto stesso.

Tale metodo draw () si occuperà dell'API di rendering di basso livello di OpenGL, OpenGL ES, Direct3D, il livello di wrapping su tali API o un'API di motori. È possibile che sia necessario passare da uno all'altro (ad esempio, se si desidera supportare OpenGL + OpenGL ES + Direct3D.

Quel GameObject dovrebbe contenere solo le informazioni di base sul suo aspetto visivo come una mesh o forse un pacchetto più grande che include input di shader, stato di animazione e così via.

Inoltre vorrai una pipeline grafica flessibile. Cosa succede se si desidera ordinare oggetti in base alla loro distanza dalla fotocamera. O il loro tipo di materiale. Cosa succede se vuoi disegnare un oggetto "selezionato" di un colore diverso. Che dire se invece di eseguire il rendering in modo così semplice come si chiama una funzione di disegno su un oggetto, invece lo inserisce in un elenco di comandi di azioni da eseguire per il rendering (potrebbe essere necessario per il threading). Puoi fare quel genere di cose con l'altro sistema ma è un PITA.

Quello che raccomando è invece di disegnare direttamente, si associano tutti gli oggetti desiderati a un'altra struttura di dati. Tale associazione deve solo avere un riferimento alla posizione degli oggetti e alle informazioni di rendering.

I tuoi livelli / blocchi / aree / mappe / hub / mondo intero / qualunque cosa ricevano un indice spaziale, questo contiene gli oggetti e li restituisce sulla base di query coordinate e potrebbe essere un semplice elenco o qualcosa come un Octree. Potrebbe anche essere un involucro di qualcosa implementato da un motore fisico di terze parti come una scena fisica. Ti permette di fare cose come "Esegui query su tutti gli oggetti che sono nella vista della videocamera con un po 'di spazio in più intorno a loro" o per giochi più semplici in cui puoi semplicemente eseguire il rendering di tutto afferrare l'intero elenco.

Gli indici spaziali non devono contenere le informazioni sul posizionamento effettivo. Funzionano immagazzinando oggetti in strutture ad albero in relazione alla posizione di altri oggetti. Possono essere considerati come una sorta di cache con perdita che consente una rapida ricerca di un oggetto in base alla sua posizione. Non è necessario duplicare le coordinate X, Y, Z effettive. Detto questo, se lo volessi mantenere potresti farlo

In effetti i tuoi oggetti di gioco non devono nemmeno contenere le informazioni sulla loro posizione. Ad esempio, un oggetto che non è stato messo in un livello non dovrebbe avere coordinate x, y, z, che non ha senso. Puoi contenerlo nell'indice speciale. Se è necessario cercare le coordinate dell'oggetto in base al suo riferimento effettivo, sarà necessario disporre di un'associazione tra l'oggetto e il grafico della scena (i grafici delle scene servono per restituire gli oggetti in base alle coordinate ma sono lenti nel restituire le coordinate in base agli oggetti) .

Quando aggiungi un oggetto a un livello. Farà quanto segue:

1) Creare una struttura di posizione:

 class Location { 
     float x, y, z; // Or a special Coordinates class, or a vec3 or whatever.
     SpacialIndex& spacialIndex; // Note this could be the area/level/map/whatever here
 };

Questo potrebbe anche essere un riferimento a un oggetto in un motore fisico di terze parti. Oppure potrebbe essere un coordinate di offset con un riferimento a un'altra posizione (per una telecamera di localizzazione o un oggetto o un esempio allegato). Con il polimorfismo potrebbe dipendere dal fatto che si tratti di un oggetto statico o dinamico. Mantenendo un riferimento all'indice spaziale qui quando le coordinate vengono aggiornate, anche l'indice spaziale può essere.

Se si è preoccupati dell'allocazione dinamica della memoria, utilizzare un pool di memoria.

2) Un legame / collegamento tra l'oggetto, la sua posizione e il grafico della scena.

typedef std::pair<Object, Location> SpacialBinding.

3) Il legame viene aggiunto all'indice spaziale all'interno del livello nel punto appropriato.

Quando ti stai preparando per il rendering.

1) Ottieni la videocamera (sarà solo un altro oggetto, tranne per il fatto che la posizione traccerà il personaggio dei giocatori e il tuo renderer avrà un riferimento speciale ad esso, infatti è tutto ciò di cui ha davvero bisogno).

2) Ottieni il SpacialBinding della videocamera.

3) Ottieni l'indice spaziale dalla rilegatura.

4) Interroga gli oggetti che sono (possibilmente) visibili alla telecamera.

5A) È necessario che le informazioni visive siano elaborate. Trame caricate sulla GPU e così via. Questo sarebbe meglio farlo in anticipo (come ad esempio sul carico di livello) ma forse potrebbe essere fatto in fase di esecuzione (per un mondo aperto, potresti caricare roba quando ti stai avvicinando a un pezzo ma dovrebbe comunque essere fatto in anticipo).

5B) Se lo si desidera, creare un albero di rendering memorizzato nella cache, se si desidera ordinare in profondità / materiale o tenere traccia degli oggetti vicini, potrebbe essere visibile in un secondo momento. Altrimenti puoi semplicemente interrogare l'indice spaziale ogni volta che dipenderà dai tuoi requisiti di gioco / prestazioni.

Il tuo renderer avrà probabilmente bisogno di un oggetto RenderBinding che si collegherà tra l'Oggetto, le coordinate

class RenderBinding {
    Object& object;
    RenderInformation& renderInfo;
    Location& location // This could just be a coordinates class.
}

Quindi quando esegui il rendering, esegui solo l'elenco.

Ho usato i riferimenti sopra ma potrebbero essere puntatori intelligenti, puntatori non elaborati, handle di oggetti e così via.

MODIFICARE:

class Game {
    weak_ptr<Camera> camera;
    Level level1;

    void init() {
        Camera camera(75.0_deg, 1.025_ratio, 1000_meters);
        auto template_player = loadObject("Player.json")
        auto player = level1.addObject(move(player), Position(1.0, 2.0, 3.0));
        level1.addObject(move(camera), getRelativePosition(player));

        auto template_bad_guy = loadObject("BadGuy.json")
        level1.addObject(template_bad_guy, {10, 10, 20});
        level1.addObject(template_bad_guy, {10, 30, 20});
        level1.addObject(move(template_bad_guy), {50, 30, 20});
    }

    void render() {
        camera->getFrustrum();
        auto level = camera->getLocation()->getLevel();
        auto object = level.getVisible(camera);
        for(object : objects) {
            render(objects);
        }
    }

    void render(Object& object) {
        auto ri = object.getRenderInfo();
        renderVBO(ri.getVBO());
    }

    Object loadObject(string file) {
        Object object;
        // Load file from disk and set the properties
        // Upload mesh data, textures to GPU. Load shaders whatever.
        object.setHitPoints(// values from file);
        object.setRenderInfo(// data from 3D api);
    }
}

class Level {
    Octree octree;
    vector<ObjectPtr> objects;
    // NOTE: If your level is mesh based there might also be a BSP here. Or a hightmap for an openworld
    // There could also be a physics scene here.
    ObjectPtr addObject(Object&& object, Position& pos) {
        Location location(pos, level, object);
        objects.emplace_back(object);
        object->setLocation(location)
        return octree.addObject(location);
    }
    vector<Object> getVisible(Camera& camera) {
        auto f = camera.getFtrustrum();
        return octree.getObjectsInFrustrum(f);
    }
    void updatePosition(LocationPtr l) {
        octree->updatePosition(l);
    }
}

class Octree {
    OctreeNode root_node;
    ObjectPtr add(Location&& object) {
        return root_node.add(location);
    }
    vector<ObjectPtr> getObjectsInRadius(const vec3& position, const float& radius) { // pass to root_node };
    vector<ObjectPtr> getObjectsinFrustrum(const FrustrumShape frustrum;) {//...}
    void updatePosition(LocationPtr* l) {
        // Walk up from l.octree_node until you reach the new place
        // Check if objects are colliding
        // l.object.CollidedWith(other)
    }
}

class Object {
    Location location;
    RenderInfo render_info;
    Properties object_props;
    Position getPosition() { return getLocation().position; }
    Location getLocation() { return location; }
    void collidedWith(ObjectPtr other) {
        // if other.isPickup() && object.needs(other.pickupType()) pick it up, play sound whatever
    }
}

class Location {
    Position position;
    LevelPtr level;
    ObjectPtr object;
    OctreeNote octree_node;
    setPosition(Position position) {
        position = position;
        level.updatePosition(this);
    }
}

class Position {
    vec3 coordinates;
    vec3 rotation;
}

class RenderInfo {
    AnimationState anim;
}
class RenderInfo_OpenGL : public RenderInfo {
    GLuint vbo_object;
    GLuint texture_object;
    GLuint shader_object;
}

class Camera: public Object {
    Degrees fov;
    Ratio aspect;
    Meters draw_distance;
    Frustrum getFrustrum() {
        // Use above to make a skewed frustum box
    }
}

Quanto a rendere le cose "consapevoli" l'una dell'altra. Questo è il rilevamento delle collisioni. Sarebbe probabilmente implementato nell'ottobre. Dovresti fornire qualche richiamata nel tuo oggetto principale. Questa roba è gestita al meglio da un motore fisico adeguato come Bullet. In tal caso, sostituisci Octree con PhysicsScene e Position con un link a qualcosa come CollisionMesh.getPosition ().


Wow, sembra molto bello. Penso di aver afferrato l'idea di base, ma senza un altro esempio non riesco a capirne la visione esteriore. Hai altri riferimenti o esempi dal vivo su questo? (Continuerò a leggere questa risposta per un po 'nel frattempo).
Scarpa

Non ho davvero alcun esempio, è proprio quello che sto pensando di fare quando avrò tempo. Aggiungerò qualche altra lezione generale e vedrò se questo aiuta, c'è questo e questo . riguarda più le classi di oggetti che il modo in cui si relazionano o il rendering. Dato che non l'ho implementato da solo, potrebbero esserci delle insidie, dei bit che necessitano di allenamento o di prestazioni ma penso che la struttura complessiva sia ok.
David C. Bishop,
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.