Вопрос игровой архитектуры / дизайна - создание эффективного движка, избегая глобальных примеров (игра C ++)

28

У меня возник вопрос об игровой архитектуре: как лучше связать друг с другом разные компоненты?

Я действительно извиняюсь, если этот вопрос уже задавался миллион раз, но я не могу найти ничего с той информацией, которую я ищу.

Я пытался создать игру с нуля (C ++, если это имеет значение) и наблюдал за некоторым вдохновляющим игровым программным обеспечением с открытым исходным кодом (Super Maryo Chronicles, OpenTTD и другие). Я заметил, что во многих игровых проектах повсеместно используются глобальные экземпляры и / или синглтоны (для таких вещей, как очереди рендеринга, менеджеры сущностей, менеджеры видео и т. Д.). Я пытаюсь избегать глобальных экземпляров и синглетонов и создавать движок, который настолько слабо связан, насколько возможно, но я сталкиваюсь с некоторыми препятствиями, которые связаны с моей неопытностью в эффективном дизайне. (Часть мотивации для этого проекта состоит в том, чтобы заняться этим :))

Я построил дизайн, в котором у меня есть один главный GameCoreобъект, члены которого аналогичны глобальным экземплярам, ​​которые я вижу в других проектах (т. Е. У него есть менеджер ввода, видео менеджер, GameStageобъект, который контролирует все сущности и игру). за какой этап загружен в данный момент и т. д.). Проблема в том, что, поскольку в GameCoreобъекте все централизовано, у разных компонентов нет простого способа взаимодействия друг с другом.

Например, если взглянуть на Super Maryo Chronicles, всякий раз, когда компонент игры должен обмениваться данными с другим компонентом (т. Е. Вражеский объект хочет добавить себя в очередь рендеринга, которая будет отрисовываться на этапе рендеринга), он просто обращается к глобальный экземпляр.

Мне нужно, чтобы мои игровые объекты передавали соответствующую информацию обратно GameCoreобъекту, чтобы GameCoreобъект мог передавать эту информацию другим компонентам системы, которые в ней нуждаются (т. Е. Для вышеуказанной ситуации, каждому вражескому объекту). будет передавать свою информацию рендеринга обратно GameStageобъекту, который соберет все это и передаст его обратно GameCore, что, в свою очередь, передаст ее диспетчеру видео для рендеринга). Это похоже на действительно ужасный дизайн как таковой, и я пытался придумать решение этой проблемы. Мои мысли о возможных конструкциях:

  1. Глобальные экземпляры (дизайн Super Maryo Chronicles, OpenTTD и т. Д.)
  2. Наличие GameCoreобъекта в качестве посредника, через которого все объекты взаимодействуют (текущий дизайн описан выше)
  3. Присвойте указатели компонентов всем другим компонентам, с которыми им нужно будет общаться (т. Е. В приведенном выше примере с Maryo вражеский класс будет иметь указатель на видеообъект, с которым ему нужно общаться)
  4. Разбейте игру на подсистемы - например, в GameCoreобъекте есть управляющие объекты, которые управляют связью между объектами в их подсистеме.
  5. (Другие опции? ....)

Я полагаю, что вариант 4, представленный выше, является лучшим решением, но у меня возникли некоторые проблемы при его разработке ... возможно, потому, что я думал о проектах, которые я видел, которые используют глобальные переменные. Такое ощущение, что я беру ту же проблему, которая существует в моем текущем проекте, и копирую ее в каждой подсистеме, только в меньшем масштабе. Например, GameStageобъект, описанный выше, является своего рода попыткой сделать это, но GameCoreобъект все еще участвует в процессе.

Кто-нибудь может предложить какие-либо советы по дизайну здесь?

Благодарность!

Awesomania
источник
1
Я понимаю твой инстинкт, что синглтоны - не лучший дизайн По моему опыту, они были самым простым способом управления связью между системами
Эммет Батлер,
4
Добавление в качестве комментария, так как я не знаю, является ли это лучшей практикой. У меня есть центральный GameManager, который состоит из подсистем, таких как InputSystem, GraphicsSystem и т. Д. Каждая подсистема принимает GameManager в качестве параметра в конструкторе и сохраняет ссылку на закрытый член класса. В этот момент я могу сослаться на любую другую систему, обратившись к ней через ссылку GameManager.
Inisheer
Я изменил теги, потому что этот вопрос касается кода, а не дизайна игры.
Klaim
эта ветка немного старая, но у меня точно такая же проблема. Я использую OGRE и стараюсь использовать наилучший способ, на мой взгляд, вариант № 4 - лучший подход. Я создал что-то вроде Advanced Ogre Framework, но это не очень модульно. Я думаю, что мне нужна обработка ввода подсистемы, которая получает только нажатия клавиш и движения мыши. Чего я не понимаю, так это как создать такой «коммуникационный» менеджер между подсистемами?
Dominik2000
1
Привет @ Dominik2000, это сайт вопросов и ответов, а не форум. Если у вас есть вопрос, вы должны опубликовать фактический вопрос, а не ответ на существующий. Смотрите FAQ для более подробной информации.
Джош

Ответы:

19

В наших играх для организации глобальных данных мы используем шаблон проектирования ServiceLocator . Преимущество этого шаблона по сравнению с шаблоном Singleton состоит в том, что реализация ваших глобальных данных может измениться во время выполнения приложения. Кроме того, ваши глобальные объекты также могут быть изменены во время выполнения. Другое преимущество состоит в том, что легче управлять порядком инициализации ваших глобальных объектов, что очень важно, особенно в C ++.

например (код C #, который можно легко перевести на C ++ или Java)

Допустим, у вас есть интерфейс рендеринга, который имеет некоторые общие операции для рендеринга.

public interface IRenderBackend
{
    void Draw();
}

И что у вас есть реализация по умолчанию рендеринга Backend

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

В некоторых проектах кажется законным иметь возможность доступа к бэкэнду рендеринга глобально. В шаблоне Singleton это означает, что каждая реализация IRenderBackend должна быть реализована как уникальный глобальный экземпляр. Но использование шаблона ServiceLocator не требует этого.

Вот как:

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

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

Чтобы получить доступ к вашему глобальному объекту, вам нужно сначала его инициализировать.

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

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

Просто чтобы продемонстрировать, как реализации могут меняться во время выполнения, допустим, что в вашей игре есть мини-игра, в которой рендеринг является изометрическим, а вы реализуете IsometricRenderBackend .

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

При переходе из текущего состояния в состояние мини-игры вам просто нужно изменить глобальный сервер рендеринга, предоставляемый локатором сервиса.

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

Еще одним преимуществом является то, что вы также можете использовать нулевые сервисы. Например, если у нас была служба ISoundManager и пользователь хотел отключить звук, мы могли бы просто реализовать NullSoundManager, который ничего не делает при вызове его методов, поэтому, установив объект службы ServiceLocator в объект NullSoundManager, мы могли бы достичь этот результат почти без труда.

Подводя итог, иногда бывает невозможно исключить глобальные данные, но это не значит, что вы не можете организовать их должным образом и объектно-ориентированным способом.

vdaras
источник
Я смотрел на это раньше, но на самом деле не реализовал это ни в одном из моих проектов. На этот раз я планирую. Спасибо :)
Awesomania
3
@Erevis Итак, вы описываете глобальную ссылку на полиморфный объект. В свою очередь, это просто двойная косвенность (указатель -> интерфейс -> реализация). В C ++ это может быть легко реализовано как std::unique_ptr<ISomeService>.
Shadows In Rain
1
Вы можете изменить стратегию инициализации так, чтобы она «инициализировалась при первом доступе» и не требовала, чтобы какая-то внешняя последовательность кода выделяла и передавала сервисы локатору. Вы можете добавить список «зависит от» к службам, чтобы при инициализации он автоматически настраивал другие службы, в которых он нуждался, и не молился о том, чтобы кто-то запомнил это в main.cpp. Хороший ответ с гибкостью для будущей настройки.
Патрик Хьюз
4

Есть много способов создать игровой движок, и все сводится к предпочтениям.

Чтобы уйти от основ, некоторые разработчики предпочитают проектировать их как пирамиду, где есть некоторый верхний базовый класс, часто называемый классом ядра, ядра или платформы, который создает, владеет и инициализирует ряд подсистем, таких как как аудио, графика, сеть, физика, ИИ, а также задачи, сущности и управление ресурсами. Как правило, эти подсистемы предоставляются вам этим базовым классом, и обычно вы передаете этот базовый класс своим собственным классам в качестве аргумента конструктора, где это уместно.

Я считаю, что вы на правильном пути, думая о варианте № 4.

Имейте в виду, когда речь идет о самой коммуникации, это не всегда подразумевает прямой вызов функции. Существует много косвенных способов общения, будь то с помощью какого-либо косвенного метода использования Signal and Slotsили использования Messages.

Иногда в играх важно разрешить асинхронное выполнение действий, чтобы игровой цикл двигался как можно быстрее, чтобы частота кадров была невооруженным глазом. Игрокам не нравятся медленные и изменчивые сцены, и поэтому мы должны найти способы, чтобы все было так, как нужно, но логика должна идти, но под контролем и в порядке. Хотя асинхронные операции имеют свое место, они не являются ответом и для каждой операции.

Просто знайте, что у вас будет сочетание как синхронной, так и асинхронной связи. Выберите подходящее, но знайте, что вам необходимо поддерживать оба стиля в своих подсистемах. Разработка поддержки для обоих будет хорошо служить вам в будущем.

Naros
источник
1

Вы просто должны убедиться, что нет обратных или циклических зависимостей. Например, если у вас есть класс Core, и у Coreнего есть Level, и у Levelнего есть список Entity, дерево зависимостей должно выглядеть так:

Core --> Level --> Entity

Итак, учитывая это начальное дерево зависимостей, вы никогда не должны Entityзависеть от Levelили Coreи Levelникогда не должны зависеть от Core. Если LevelилиEntity нужно иметь доступ к данным, которые находятся выше в дереве зависимостей, они должны быть переданы в качестве параметра по ссылке.

Рассмотрим следующий код (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;
}

Используя эту технику, вы можете увидеть , что каждый Entityимеет доступ к Level, и Levelимеет доступ к Core. Обратите внимание, что каждый Entityхранит ссылку на один и тот жеLevel потерянную память. Заметив это, вы должны спросить, действительно ли каждому Entityнужен доступ к Level.

По моему опыту, есть либо А) Действительно очевидное решение, позволяющее избежать обратных зависимостей, либо Б) Невозможно избежать глобальных экземпляров и синглетонов.

яблоки
источник
Я что-то пропустил? Вы упоминаете «у вас никогда не должно быть зависимости Entity от уровня», но затем вы описываете его ctor как «Entity (Level & levelIn)». Я понимаю, что зависимость передается по ссылке, но это все еще зависимость.
Адам Нейлор
@AdamNaylor Суть в том, что иногда вам действительно нужны обратные зависимости, и вы можете избежать глобальных переменных, передавая ссылки. В целом, однако, лучше избегать этих зависимостей полностью, и не всегда понятно, как это сделать.
яблоки
0

Итак, в принципе, вы хотите избежать глобального изменчивого состояния ? Вы можете сделать его либо локальным, неизменным, либо вообще не государственным. Последние наиболее эффективны и гибки, имо. Это известно как скрытие iplementation.

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

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Тени под дождем
источник
0

Вопрос на самом деле, кажется, заключается в том, как уменьшить связь без ущерба для производительности. Все глобальные объекты (сервисы) обычно образуют своего рода контекст, который может изменяться во время выполнения игры. В этом смысле шаблон локатора службы распределяет разные части контекста по разным частям приложения, что может быть или не быть тем, что вы хотите. Другой реальный подход состоит в том, чтобы объявить такую ​​структуру:

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

И передать его как необработанный необработанный указатель sEnvironment*. Здесь указатели указывают на интерфейсы, поэтому связывание уменьшается аналогичным образом по сравнению с локатором службы. Тем не менее, все услуги находятся в одном месте (что может или не может быть хорошо). Это просто другой подход.

Сергей К.
источник