W jaki sposób obiekty gry powinny być sobie świadome?

18

Trudno mi znaleźć sposób na zorganizowanie obiektów w grze, aby były polimorficzne, ale jednocześnie nie były polimorficzne.

Oto przykład: zakładając, że chcemy, aby wszystkie nasze obiekty były update()i draw(). W tym celu musimy zdefiniować klasę podstawową, GameObjectktóra ma te dwie wirtualne czyste metody i wpuszcza polimorfizm:

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

Metoda aktualizacji ma za zadanie dbać o to, jaki stan konkretny obiekt klasy musi zaktualizować. Faktem jest, że każdy przedmiot musi wiedzieć o otaczającym go świecie. Na przykład:

  • Kopalnia musi wiedzieć, czy ktoś się z nią koliduje
  • Żołnierz powinien wiedzieć, czy żołnierz innego zespołu znajduje się w pobliżu
  • Zombie powinien wiedzieć, gdzie znajduje się najbliższy mózg w promieniu

W przypadku interakcji pasywnych (jak ta pierwsza) myślałem, że wykrycie kolizji może przekazać zadanie do wykonania w konkretnych przypadkach kolizji samemu obiektowi za pomocą on_collide(GameObject*).

Większość innych informacji (podobnie jak pozostałe dwa przykłady) może być po prostu zapytana przez świat gry przekazany tej updatemetodzie. Teraz świat nie rozróżnia obiektów na podstawie ich typu (przechowuje wszystkie obiekty w pojedynczym polimorficznym pojemniku), więc to, co w rzeczywistości zwróci z ideałem, world.entities_in(center, radius)to pojemnik GameObject*. Ale oczywiście żołnierz nie chce atakować innych żołnierzy ze swojej drużyny, a zombie nie dba o inne zombie. Musimy więc odróżnić zachowanie. Rozwiązaniem może być:

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
}

ale oczywiście liczba dynamic_cast<>klatek na sekundę może być strasznie wysoka i wszyscy wiemy, jak powolny dynamic_castmoże być. Ten sam problem dotyczy również on_collide(GameObject*)delegata, o którym mówiliśmy wcześniej.

Jaki jest więc idealny sposób na uporządkowanie kodu, aby obiekty były świadome innych obiektów i mogły je ignorować lub podejmować działania w zależności od ich typu?

But
źródło
1
Myślę, że szukasz uniwersalnej niestandardowej implementacji C ++ RTTI. Niemniej jednak twoje pytanie nie dotyczy wyłącznie rozsądnych mechanizmów RTTI. Rzeczy, o które prosisz, są wymagane przez prawie każde oprogramowanie pośrednie, którego będzie używać gra (system animacji, fizyka, żeby wymienić tylko kilka). W zależności od listy obsługiwanych zapytań możesz oszukiwać się w RTTI, używając identyfikatorów i indeksów w tablicach, lub w końcu opracujesz pełnoprawny protokół do obsługi tańszych alternatyw dla Dynamic_cast i type_info.
teodron
Odradzam używanie systemu typów do logiki gry. Na przykład zamiast zależeć od wyniku dynamic_cast<Human*>, zaimplementuj coś w rodzaju a bool GameObject::IsHuman(), który zwraca falsedomyślnie, ale jest zastępowany, aby zwrócić truew Humanklasie.
congusbongus
dodatek: prawie nigdy nie wysyłasz ton obiektów do siebie nawzajem, które mogłyby być nimi zainteresowane. To oczywista optymalizacja, którą musisz naprawdę rozważyć.
teodron
@congusbongus Używanie vtable i niestandardowych IsAprzesłonięć okazało się dla mnie tylko nieznacznie lepsze niż casting dynamiczny. Najlepszym rozwiązaniem jest posortowanie list danych, o ile to możliwe, zamiast ślepego iterowania całej puli jednostek.
teodron
4
@Jefffrey: najlepiej nie piszesz kodu specyficznego dla typu. Piszesz kod specyficzny dla interfejsu („interfejs” w sensie ogólnym). Twoja logika dla TeamASoldieri TeamBSoldierjest naprawdę identyczna - zastrzelony na kogokolwiek z innego zespołu. Wszystkim, czego potrzebuje od innych bytów, jest GetTeam()metoda w najbardziej swoistej formie, a na przykładzie congusbongus można ją jeszcze bardziej przekształcić w IsEnemyOf(this)rodzaj interfejsu. Kod nie musi dbać o klasyfikacje taksonomiczne żołnierzy, zombie, graczy itp. Skoncentruj się na interakcji, a nie typach.
Sean Middleditch

Odpowiedzi:

11

Zamiast wdrażać proces decyzyjny każdego podmiotu sam w sobie, możesz alternatywnie wybrać wzorzec kontrolera. Miałbyś klasy centralnego kontrolera, które są świadome wszystkich obiektów (które są dla nich ważne) i kontrolują ich zachowanie.

MovementController obsługiwałby ruch wszystkich obiektów, które mogą się poruszać (wyszukiwanie trasy, aktualizowanie pozycji na podstawie bieżących wektorów ruchu).

MineBehaviorController sprawdzałby wszystkie miny i wszystkich żołnierzy i kazał kopalni eksplodować, gdy żołnierz zbliży się zbyt blisko.

ZombieBehaviorController sprawdziłby wszystkie zombie i żołnierzy w ich pobliżu, wybrał najlepszy cel dla każdego zombie i kazał mu się tam przenieść i zaatakować (sam ruch jest obsługiwany przez MovementController).

SoldierBehaviorController przeanalizuje całą sytuację, a następnie zaproponuje taktyczne instrukcje dla wszystkich żołnierzy (ruszasz się tam, strzelasz do niego, leczysz tego faceta ...). Rzeczywiste wykonanie tych poleceń wyższego poziomu byłyby również obsługiwane przez kontrolery niższego poziomu. Kiedy włożysz w to trochę wysiłku, możesz sprawić, że sztuczna inteligencja będzie w stanie podejmować całkiem sprytne decyzje kooperacyjne.

Philipp
źródło
1
Prawdopodobnie jest to również znane jako „system”, który zarządza logiką dla niektórych typów komponentów w architekturze Entity-Component.
teodron
To brzmi jak rozwiązanie w stylu C. Komponenty są pogrupowane w std::mapsi, a encje są tylko identyfikatorami, a następnie musimy stworzyć jakiś rodzaj systemu typów (być może z komponentem znacznika, ponieważ renderer będzie musiał wiedzieć, co narysować); a jeśli nie chcemy tego robić, będziemy musieli mieć komponent rysunkowy: ale potrzebuje on komponentu pozycji, aby wiedzieć, gdzie zostać narysowanym, dlatego tworzymy zależności między komponentami, które rozwiązujemy za pomocą bardzo złożonego systemu przesyłania komunikatów. Czy to właśnie sugerujesz?
But
1
@Jefffrey „To brzmi jak rozwiązanie w stylu C” - nawet jeśli byłoby to prawdą, dlaczego koniecznie byłoby to złe? Inne obawy mogą być uzasadnione, ale istnieją na nie rozwiązania. Niestety komentarz jest zbyt krótki, aby poprawnie odpowiedzieć na każde z nich.
Philipp
1
@Jefffrey Zastosowanie podejścia, w którym same komponenty nie mają żadnej logiki, a „systemy” są odpowiedzialne za obsługę całej logiki, nie tworzy zależności między komponentami ani nie wymaga bardzo złożonego systemu przesyłania komunikatów (przynajmniej nie tak skomplikowanego) . Patrz na przykład: gamadu.com/artemis/tutorial.html
1

Przede wszystkim spróbuj zaimplementować funkcje, aby obiekty pozostały niezależne od siebie, o ile to możliwe. Szczególnie chcesz to zrobić dla wielowątkowości. W pierwszym przykładzie kodu zestaw wszystkich obiektów można podzielić na zestawy pasujące do liczby rdzeni procesora i bardzo skutecznie zaktualizować.

Ale, jak powiedziałeś, interakcja z innymi obiektami jest potrzebna w przypadku niektórych funkcji. Oznacza to, że stan wszystkich obiektów musi być zsynchronizowany w niektórych punktach. Innymi słowy, aplikacja musi najpierw poczekać na zakończenie wszystkich równoległych zadań, a następnie zastosować obliczenia wymagające interakcji. Dobrze jest zmniejszyć liczbę tych punktów synchronizacji, ponieważ zawsze implikują, że niektóre wątki muszą czekać na zakończenie innych.

Dlatego sugeruję buforowanie tych informacji o obiektach, które są potrzebne z wnętrza innych obiektów. Biorąc pod uwagę taki bufor globalny, możesz aktualizować wszystkie obiekty niezależnie od siebie, ale tylko od siebie i od bufora globalnego, który jest zarówno szybszy, jak i łatwiejszy w utrzymaniu. Po ustalonym czasie, powiedzmy po każdej klatce, zaktualizuj bufor bieżącym stanem obiektów.

Więc raz na klatkę robisz: 1. buforuj globalnie stan bieżącego obiektu, 2. zaktualizuj wszystkie obiekty na podstawie siebie i bufora, 3. narysuj obiekty, a następnie zacznij od odnowienia bufora.

danijar
źródło
1

Użyj systemu opartego na komponentach, w którym masz podstawowy GameObject zawierający 1 lub więcej komponentów, które określają ich zachowanie.

Na przykład powiedz, że jakiś obiekt ma się cały czas poruszać w lewo i prawo (platforma), możesz utworzyć taki komponent i dołączyć go do GameObject.

Powiedzmy teraz, że obiekt gry powinien się cały czas powoli obracać, możesz utworzyć osobny komponent, który to robi i dołączyć go do GameObject.

Co jeśli chcesz mieć ruchomą platformę, która również się obraca, w tradycyjnej heirarchii klasowej, która staje się trudna bez powielania kodu.

Zaletą tego systemu jest to, że zamiast mieć klasę obrotową lub MovingPlatform, dołączasz oba te komponenty do GameObject, a teraz masz MovingPlatform, która automatycznie się obraca.

Wszystkie komponenty mają właściwość „wymaga aktualizacji”, która, gdy jest prawdziwa, GameObject wywoła metodę „aktualizacji” na tym komponencie. Na przykład, powiedzmy, że masz komponent Przeciągalny, ten komponent po najechaniu myszką (jeśli był ponad GameObject) może ustawić „wymaga aktualizacji” na true, a następnie przy najechaniu myszką ustawić na false. Zezwolenie na podążanie za myszą tylko wtedy, gdy mysz jest opuszczona.

Jeden z twórców Tony Hawk Pro Skater napisał o tym defacto i warto przeczytać: http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/

onedayitwillmake
źródło
1

Preferuj kompozycję zamiast dziedziczenia.

Moja najsilniejsza rada poza tym brzmiałaby: nie daj się wciągnąć w sposób myślenia „Chcę, aby było to wyjątkowo elastyczne”. Elastyczność jest świetna, ale pamiętaj, że na pewnym poziomie, w dowolnym systemie skończonym, takim jak gra, istnieją części atomowe, które są używane do budowy całości. Tak czy inaczej, twoje przetwarzanie opiera się na tych predefiniowanych typach atomowych. Innymi słowy, uwzględnienie „dowolnego” typu danych (jeśli byłoby to możliwe) nie pomogłoby ci na dłuższą metę, jeśli nie masz kodu do ich przetworzenia. Zasadniczo cały kod musi analizować / przetwarzać dane w oparciu o znane specyfikacje ... co oznacza predefiniowany zestaw typów. Jak duży jest ten zestaw? Zależy od Ciebie.

Ten artykuł oferuje wgląd w zasadę Composition over Inheritance w rozwoju gier dzięki solidnej i wydajnej architekturze encji i komponentów.

Budując byty z (różnych) podzbiorów jakiegoś nadzbioru predefiniowanych komponentów, oferujesz swoim SI konkretny, fragmentaryczny sposób pojmowania świata i otaczających go aktorów, poprzez czytanie stanów tych aktorów.

Inżynier
źródło
1

Osobiście zalecam trzymanie funkcji rysowania poza samą klasą Object. Polecam nawet trzymanie lokalizacji / współrzędnych obiektów poza samym obiektem.

Ta metoda draw () będzie zajmować się API renderowania niskiego poziomu OpenGL, OpenGL ES, Direct3D, warstwą owijania na tych interfejsach API lub interfejsem API silników. Może być tak, że musisz przełączać się między nimi (jeśli na przykład chcesz obsługiwać OpenGL + OpenGL ES + Direct3D).

GameObject powinien po prostu zawierać podstawowe informacje o jego wyglądzie, takie jak siatka lub może większy pakiet zawierający dane cieniowania, stan animacji i tak dalej.

Będziesz także potrzebować elastycznego potoku graficznego. Co się stanie, jeśli chcesz zamówić obiekty na podstawie ich odległości od kamery. Lub ich rodzaj materiału. Co się stanie, jeśli chcesz narysować „wybrany” obiekt innym kolorem. Co jeśli zamiast faktycznie renderować tak soo, jak wywołujesz funkcję rysowania na obiekcie, zamiast tego umieszcza ją na liście poleceń, które ma wykonać render (może być potrzebny do wątkowania). Możesz zrobić coś takiego z innym systemem, ale jest to PITA.

To, co zalecam, to zamiast bezpośredniego rysowania, wiążesz wszystkie obiekty, które chcesz, z inną strukturą danych. Wiązanie to naprawdę musi mieć tylko odwołanie do położenia obiektów i informacji o renderowaniu.

Twoje poziomy / fragmenty / obszary / mapy / huby / cały świat / cokolwiek dostanie indeks przestrzenny, zawiera obiekty i zwraca je na podstawie zapytań o współrzędne i może być prostą listą lub czymś w rodzaju Octree. Może to być także opakowanie czegoś zaimplementowanego przez silnik fizyki innej firmy jako scena fizyki. Pozwala ci robić rzeczy takie jak „Zapytaj wszystkie obiekty, które są w polu widzenia kamery z jakimś dodatkowym obszarem wokół nich”, lub dla prostszych gier, w których możesz po prostu renderować wszystko, chwytając całą listę.

Indeksy przestrzenne nie muszą zawierać rzeczywistych informacji o położeniu. Działają, przechowując obiekty w strukturach drzewnych w stosunku do położenia innych obiektów. Mogą być traktowane jako rodzaj stratnej pamięci podręcznej, która umożliwia szybkie wyszukiwanie obiektu na podstawie jego położenia. Nie ma potrzeby kopiowania rzeczywistych współrzędnych X, Y, Z. Powiedziawszy, że możesz, jeśli chcesz zachować

W rzeczywistości twoje obiekty gry nie muszą nawet zawierać własnych informacji o lokalizacji. Na przykład obiekt, który nie został umieszczony na poziomie, nie powinien mieć współrzędnych x, y, z, co nie ma sensu. Możesz to zawrzeć w specjalnym indeksie. Jeśli chcesz sprawdzić współrzędne obiektu na podstawie jego rzeczywistego odwołania, będziesz chciał powiązać obiekt z wykresem sceny (wykresy sceny służą do zwracania obiektów opartych na współrzędnych, ale powolne są zwracanie współrzędnych na podstawie obiektów) .

Po dodaniu obiektu do poziomu. Będzie wykonywać następujące czynności:

1) Utwórz strukturę lokalizacji:

 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
 };

Może to być również odniesienie do obiektu w silnikach fizyki innych firm. Lub może to być współrzędna przesunięcia z odniesieniem do innej lokalizacji (dla kamery śledzącej lub dołączonego obiektu lub przykładu). W przypadku polimorfizmu może to zależeć od tego, czy jest to obiekt statyczny czy dynamiczny. Zachowując tutaj odniesienie do indeksu przestrzennego, gdy współrzędne są aktualizowane, indeks przestrzenny może być również.

Jeśli martwisz się dynamicznym przydzielaniem pamięci, użyj puli pamięci.

2) Powiązanie / powiązanie między twoim obiektem, jego lokalizacją i wykresem sceny.

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

3) Wiązanie jest dodawane do indeksu przestrzennego wewnątrz poziomu w odpowiednim punkcie.

Kiedy przygotowujesz się do renderowania.

1) Zdobądź kamerę (będzie to po prostu inny obiekt, z wyjątkiem tego, że jego lokalizacja będzie śledzić postać gracza, a Twój renderer będzie miał do niego specjalne odniesienie, w rzeczywistości to wszystko, czego naprawdę potrzebuje).

2) Pobierz funkcję SpacialBinding kamery.

3) Pobierz indeks przestrzenny z wiązania.

4) Zapytaj obiekty, które są (prawdopodobnie) widoczne dla kamery.

5A) Musisz przetwarzać informacje wizualne. Tekstury przesłane do GPU i tak dalej. Najlepiej byłoby to zrobić z wyprzedzeniem (na przykład na poziomie obciążenia), ale być może można to zrobić w czasie wykonywania (w otwartym świecie można ładować rzeczy, gdy zbliżasz się do porcji, ale nadal powinno to być zrobione z wyprzedzeniem).

5B) Opcjonalnie zbuduj buforowane drzewo renderowania, jeśli chcesz uporządkować głębokość / materiał lub śledzić pobliskie obiekty, które mogą być widoczne później. W przeciwnym razie możesz po prostu zapytać o indeks przestrzenny za każdym razem, gdy będzie on zależał od wymagań gry / wydajności.

Twój renderer prawdopodobnie będzie potrzebował obiektu RenderBinding, który połączy obiekt, współrzędne

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

Następnie podczas renderowania wystarczy uruchomić listę.

Użyłem powyższych referencji, ale mogą to być inteligentne wskaźniki, surowe wskaźniki, uchwyty obiektów i tak dalej.

EDYTOWAĆ:

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
    }
}

Co do uczynienia siebie „świadomymi” siebie. To wykrywanie kolizji. Prawdopodobnie zostanie wdrożony w Oktree. Będziesz musiał podać zwrotny w swoim głównym obiekcie. Najlepiej radzić sobie z takim silnikiem fizyki, jak Bullet. W takim przypadku wystarczy zamienić Octree na PhysicsScene i Position na link do czegoś takiego jak CollisionMesh.getPosition ().

David C. Bishop
źródło
Wow, to wygląda bardzo dobrze. Wydaje mi się, że zrozumiałem podstawowy pomysł, ale bez większego przykładu nie mogę zrozumieć tego z zewnątrz. Czy masz jeszcze jakieś referencje lub przykłady na żywo? (W międzyczasie będę czytał tę odpowiedź).
But
Naprawdę nie mam żadnych przykładów, to jest to, co planuję zrobić, kiedy będę miał czas. Dodam jeszcze kilka ogólnych klas i zobaczę, czy to pomoże. Jest to i to . chodzi bardziej o klasy obiektów niż o ich relacje lub rendering. Ponieważ sam tego nie wdrożyłem, mogą wystąpić pułapki, bity, które wymagają ćwiczeń lub wydajności, ale myślę, że ogólna struktura jest w porządku.
David C. Bishop