Architektura gry / pytanie projektowe - budowa wydajnego silnika przy jednoczesnym unikaniu globalnych wystąpień (gra C ++)

28

Miałem pytanie dotyczące architektury gry: Jaki jest najlepszy sposób na komunikowanie się różnych komponentów?

Naprawdę przepraszam, jeśli to pytanie zostało już zadane milion razy, ale nie mogę znaleźć niczego z dokładnie taką informacją, jakiej szukam.

Próbowałem zbudować grę od podstaw (C ++, jeśli ma to znaczenie) i obserwowałem inspirację dla niektórych gier typu open source (Super Maryo Chronicles, OpenTTD i inne). Zauważam, że wiele z tych projektów gier korzysta z globalnych instancji i / lub singletonów w każdym miejscu (np. Do renderowania kolejek, menedżerów encji, menedżerów wideo i tak dalej). Staram się unikać globalnych instancji i singletonów i buduję silnik, który jest tak luźno sprzężony, jak to możliwe, ale napotykam przeszkody, które wynikają z mojego braku doświadczenia w efektywnym projektowaniu. (Częścią motywacji tego projektu jest zajęcie się tym :))

Zbudowałem projekt, w którym mam jeden główny GameCoreobiekt, który ma elementy analogiczne do instancji globalnych, które widzę w innych projektach (tj. Ma menedżera wprowadzania danych, menedżera wideo, GameStageobiekt, który kontroluje wszystkie jednostki i grę dla dowolnego aktualnie załadowanego etapu itp.). Problem polega na tym, że ponieważ wszystko jest scentralizowane w GameCoreobiekcie, nie mam łatwego sposobu, aby różne komponenty mogły się ze sobą komunikować.

Patrząc na Super Maryo Chronicles, na przykład, ilekroć element gry musi komunikować się z innym składnikiem (tj. Obiekt wroga chce się dodać do kolejki renderowania, która zostanie narysowana na etapie renderowania), po prostu rozmawia z instancja globalna.

Dla mnie muszę sprawić, aby moje obiekty gry przekazywały odpowiednie informacje z powrotem do GameCoreobiektu, aby GameCoreobiekt mógł przekazać te informacje do innego komponentu systemu, który ich potrzebuje (tj .: w powyższej sytuacji każdy obiekt wroga przekażą informacje o renderowaniu z powrotem do GameStageobiektu, który zebrałby je wszystkie i przekazałby je z powrotem GameCore, co z kolei przekazałoby je menedżerowi wideo do renderowania). Wydaje mi się, że to naprawdę okropny projekt i starałem się znaleźć rozwiązanie tego problemu. Moje przemyślenia na temat możliwych projektów:

  1. Instancje globalne (projekt Super Maryo Chronicles, OpenTTD itp.)
  2. Posiadanie GameCoreobiektu działa jak pośrednik, przez który komunikują się wszystkie obiekty (obecny projekt opisany powyżej)
  3. Podaj wskaźniki komponentów wszystkim pozostałym komponentom, z którymi będą musiały porozmawiać (tj. W powyższym przykładzie Maryo klasa wroga miałaby wskaźnik do obiektu wideo, z którym musi porozmawiać)
  4. Podziel grę na podsystemy - na przykład obiekty menedżera w GameCoreobiekcie obsługujące komunikację między obiektami w ich podsystemie
  5. (Inne opcje? ....)

Wyobrażam sobie, że opcja 4 powyżej jest najlepszym rozwiązaniem, ale mam problem z zaprojektowaniem jej ... być może dlatego, że myślałem o projektach, które widziałem, które wykorzystują globale. Wydaje mi się, że biorę ten sam problem, który istnieje w moim obecnym projekcie i powielam go w każdym podsystemie, tylko na mniejszą skalę. Na przykład GameStageopisany powyżej obiekt jest w pewnym sensie próbą tego, ale GameCoreobiekt jest nadal zaangażowany w proces.

Czy ktoś może tutaj zaoferować jakieś porady dotyczące projektowania?

Dzięki!

Awesomania
źródło
1
Rozumiem twój instynkt, że singletony nie są świetnym projektem. Z mojego doświadczenia wynika, że ​​były najprostszym sposobem zarządzania komunikacją między systemami
Emmett Butler
4
Dodanie jako komentarz, ponieważ nie wiem, czy to najlepsza praktyka. Mam centralnego GameManagera, który składa się z podsystemów takich jak InputSystem, GraphicsSystem itp. Każdy podsystem bierze GameManager jako parametr w konstruktorze i przechowuje odniesienie do prywatnego członka klasy. W tym momencie mogę odwoływać się do dowolnego innego systemu, uzyskując do niego dostęp poprzez odniesienie GameManager.
Inisheer,
Zmieniłem tagi, ponieważ to pytanie dotyczy kodu, a nie projektu gry.
Klaim
ten wątek jest trochę stary, ale mam dokładnie ten sam problem. Używam OGRE i staram się używać jak najlepiej, moim zdaniem opcja nr 4 jest najlepszym podejściem. Zbudowałem coś w rodzaju Advanced Ogre Framework, ale nie jest to bardzo modułowe. Myślę, że potrzebuję obsługi danych wejściowych podsystemu, która odbiera tylko uderzenia klawiatury i ruchy myszy. Nie wiem, jak mogę stworzyć takiego menedżera „komunikacji” między podsystemami?
Dominik2000,
1
Cześć @ Dominik2000, to jest strona pytań i odpowiedzi, a nie forum. Jeśli masz pytanie, powinieneś zamieścić aktualne pytanie, a nie odpowiedź na istniejące. Zobacz najczęściej zadawane pytania .
Josh

Odpowiedzi:

19

Coś, czego używamy w naszych grach do organizowania naszych globalnych danych, to wzorzec projektowy ServiceLocator . Zaletą tego wzorca w porównaniu do wzorca Singleton jest to, że implementacja danych globalnych może ulec zmianie w czasie wykonywania aplikacji. Również obiekty globalne można zmieniać również w czasie wykonywania. Kolejną zaletą jest to, że łatwiej jest zarządzać kolejnością inicjowania obiektów globalnych, co jest bardzo ważne, szczególnie w C ++.

np. (kod C #, który można łatwo przetłumaczyć na C ++ lub Java)

Powiedzmy, że masz interfejs renderowania renderowania, który ma kilka typowych operacji do renderowania rzeczy.

public interface IRenderBackend
{
    void Draw();
}

I że masz domyślną implementację renderowania zaplecza

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

W niektórych projektach wydaje się, że globalny jest dostęp do zaplecza renderowania. We wzorze Singleton oznacza to, że każda implementacja IRenderBackend powinna być implementowana jako unikalna instancja globalna. Ale użycie wzorca ServiceLocator nie wymaga tego.

Oto jak:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

Aby uzyskać dostęp do globalnego obiektu, musisz go najpierw zainicjalizować.

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

Aby pokazać, jak różne implementacje mogą się różnić w czasie wykonywania, powiedzmy, że Twoja gra ma minigrę, w której renderowanie jest izometryczne, a Ty implementujesz IsometricRenderBackend .

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

Po przejściu z bieżącego stanu do stanu minigry wystarczy zmienić globalny backend renderowania dostarczony przez lokalizator usług.

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

Kolejną zaletą jest to, że możesz również korzystać z usług zerowych. Na przykład, gdybyśmy mieli usługę ISoundManager , a użytkownik chciałby wyłączyć dźwięk, moglibyśmy po prostu zaimplementować NullSoundManager, który nic nie robi, gdy wywoływane są jego metody, więc ustawiając obiekt usługi ServiceLocator na obiekt NullSoundManager, który możemy osiągnąć ten wynik przy prawie żadnej pracy.

Podsumowując, czasami eliminacja globalnych danych może być niemożliwa, ale nie oznacza to, że nie można ich odpowiednio uporządkować i zorientować obiektowo.

Vdaras
źródło
Patrzyłem na to wcześniej, ale nie wdrożyłem go w żadnym z moich projektów. Tym razem planuję. Dziękuję :)
Awesomania
3
@Erevis Zasadniczo opisujesz globalne odniesienie do obiektu polimorficznego. To z kolei jest tylko podwójną pośrednią (wskaźnik -> interfejs -> implementacja). W C ++ może być łatwo zaimplementowany jako std::unique_ptr<ISomeService>.
Shadows In Rain
1
Możesz zmienić strategię inicjalizacji na „inicjowanie przy pierwszym dostępie” i uniknąć konieczności przydzielania zewnętrznej sekwencji kodu i wysyłania usług do lokalizatora. Możesz dodać listę „zależy od” do usług, aby po zainicjowaniu jedna automatycznie skonfigurowała inne usługi, których potrzebuje, i nie modliła się, aby ktoś pamiętał o tym w main.cpp. Dobra odpowiedź z elastycznością dla przyszłych ulepszeń.
Patrick Hughes,
4

Istnieje wiele sposobów zaprojektowania silnika gry i tak naprawdę wszystko sprowadza się do preferencji.

Aby pozbyć się podstaw, niektórzy programiści wolą zaprojektować go jak piramidę, w której istnieje najwyższa klasa rdzeni, często nazywana klasą jądra, rdzenia lub frameworka, która tworzy, jest właścicielem i inicjuje szereg podsystemów, takich jak jako audio, grafika, sieć, fizyka, sztuczna inteligencja oraz zarządzanie zadaniami, bytem i zasobami. Ogólnie rzecz biorąc, podsystemy te są dla ciebie narażone przez tę klasę frameworka i zwykle przekazujesz tę klasę frameworku swoim klasom jako argument konstruktora, gdzie jest to właściwe.

Uważam, że jesteś na dobrej drodze, myśląc o opcji nr 4.

Pamiętaj, jeśli chodzi o samą komunikację, która nie zawsze musi oznaczać bezpośrednie wywołanie funkcji. Istnieje wiele pośrednich sposobów komunikacji, niezależnie od tego, czy odbywa się to za pomocą jakiejś pośredniej metody przy użyciu, Signal and Slotsczy przy użyciu Messages.

Czasami w grach ważne jest, aby umożliwić asynchroniczne wykonywanie akcji, aby nasza pętla gry poruszała się tak szybko, jak to możliwe, aby liczba klatek na sekundę była płynna gołym okiem. Gracze nie lubią powolnych i niespokojnych scen, dlatego musimy znaleźć sposoby na utrzymanie płynności dla nich, ale logikę płynącą, ale pod kontrolą i uporządkowaną. Chociaż operacje asynchroniczne mają swoje miejsce, nie są one również odpowiedzią na każdą operację.

Po prostu wiedz, że będziesz mieć połączenie zarówno komunikacji synchronicznej, jak i asynchronicznej. Wybierz to, co jest właściwe, ale wiedz, że będziesz musiał obsługiwać oba style w swoich podsystemach. Projektowanie wsparcia dla obu będzie dobrze służyć w przyszłości.

Naros
źródło
1

Musisz tylko upewnić się, że nie ma żadnych zależności odwrotnych lub cyklicznych. Na przykład, jeśli masz klasę Core, Corektóra ma Level, a Levellista zawiera Entity, drzewo zależności powinno wyglądać następująco:

Core --> Level --> Entity

Biorąc pod uwagę to początkowe drzewo zależności, nigdy nie powinieneś Entitypolegać na Levellub Core, i Levelnigdy nie powinieneś polegać Core. Jeżeli jedna Levellub Entitymuszą mieć dostęp do danych, które znajduje się wyżej w drzewie zależności, powinien on być przekazany jako parametr przez odniesienie.

Rozważ następujący kod (C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

Korzystając z tej techniki, możesz zobaczyć, że każdy Entityma dostęp do Leveli Levelma dostęp do Core. Zauważ, że każdy Entityprzechowuje odniesienie do tej samej Level, marnując pamięć. Po zauważeniu tego, powinieneś zapytać, czy każdy Entitynaprawdę potrzebuje dostępu do Level.

Z mojego doświadczenia wynika, że ​​albo A) Naprawdę oczywiste rozwiązanie pozwala uniknąć odwrotnych zależności, albo B) Nie ma możliwości uniknięcia globalnych instancji i singletonów.

Jabłka
źródło
Czy coś brakuje? Wspominasz „nigdy nie powinieneś mieć Istoty zależnej od Poziomu”, ale następnie opisujesz jej ctor jako „Istotę (Poziom i poziom W)”. Rozumiem, że zależność jest przekazywana przez referen, ale nadal jest zależnością.
Adam Naylor
@AdamNaylor Chodzi o to, że czasami naprawdę potrzebujesz odwrotnych zależności i możesz uniknąć globalizacji poprzez przekazywanie referencji. Ogólnie jednak najlepiej jest całkowicie unikać tych zależności i nie zawsze jest jasne, jak to zrobić.
Jabłka
0

Więc w zasadzie chcesz uniknąć globalnego stanu zmiennego ? Możesz ustawić go jako lokalny, niezmienny lub wcale nie być stanem. Później jest najbardziej wydajny i elastyczny, imo. Jest to znane jako ukrywanie suplementacji.

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Shadows In Rain
źródło
0

Wydaje się, że w rzeczywistości chodzi o to, jak zredukować sprzężenie bez poświęcania wydajności. Wszystkie globalne obiekty (usługi) zwykle tworzą rodzaj kontekstu, który można modyfikować w czasie wykonywania gry. W tym sensie wzorzec lokalizatora usług rozprasza różne części kontekstu na różne części aplikacji, które mogą być lub nie być tym, czego chcesz. Innym podejściem w świecie rzeczywistym byłoby zadeklarowanie takiej struktury:

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

I przekażcie go jako nie posiadający surowy wskaźnik sEnvironment*. Wskaźniki wskazują tutaj interfejsy, więc sprzężenie jest zredukowane w podobny sposób, jak w przypadku lokalizatora usług. Jednak wszystkie usługi są w jednym miejscu (co może, ale nie musi być dobre). To tylko inne podejście.

Sergey K.
źródło