Обработка сущностей в игре

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

Таким образом, я пытаюсь думать о том, как игра обрабатывает обновления для всех сущностей, и я немного запутался (возможно, потому, что я поступаю неправильно)

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

В настоящее время у меня есть класс CEngine, который берет указатели на другие классы, которые ему нужны (например, класс CWindow, класс CEntityManager и т. Д.)

У меня есть игровой цикл, который в псевдокоде будет выглядеть следующим образом (в классе CEngine)

while(isRunning) {
    Window->clear_screen();

    EntityManager->draw();

    Window->flip_screen();

    // Cap FPS
}

Мой класс CEntityManager выглядел так:

enum {
    PLAYER,
    ENEMY,
    ALLY
};

class CEntityManager {
    public:
        void create_entity(int entityType); // PLAYER, ENEMY, ALLY etc.
        void delete_entity(int entityID);

    private:
        std::vector<CEntity*> entityVector;
        std::vector<CEntity*> entityVectorIter;
};

И мой класс CEntity выглядел так:

class CEntity() {
    public:
        virtual void draw() = 0;
        void set_id(int nextEntityID);
        int get_id();
        int get_type();

    private:
        static nextEntityID;
        int entityID;
        int entityType;
};

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

Например:

class CEnemy : public CEntity {
    public:
        void draw(); // Implement draw();
        void do_ai_stuff();

};

class CPlayer : public CEntity {
    public:
        void draw(); // Implement draw();
        void handle_input();
};

Все это работало нормально только для рисования спрайтов на экране.

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

В приведенном выше примере псевдокода do_ai_stuff(); и handle_input();

Как вы можете видеть из моего игрового цикла, есть вызов EntityManager->draw(); Это просто повторялось по entityVector и вызывало draw (); функция для каждой сущности - которая работала нормально, поскольку все сущности имеют draw (); функция.

Но потом я подумал, а что, если это игрок, который должен обрабатывать ввод? Как это работает?

Я не пробовал, но я предполагаю, что я не могу просто пройти через цикл, как я это делал с функцией draw (), потому что такие объекты, как враги, не будут иметь функцию handle_input ().

Я мог бы использовать оператор if для проверки entityType, например так:

for(entityVectorIter = entityVector.begin(); entityVectorIter != entityVector.end(); entityVectorIter++) {
    if((*entityVectorIter)->get_type() == PLAYER) {
        (*entityVectorIter)->handle_input();
    }
}

Но я не знаю, как люди обычно пишут эти вещи, поэтому я не уверен, что это лучший способ сделать это.

Я много писал здесь и не задавал никаких конкретных вопросов, поэтому поясню, что я ищу здесь:

  • Является ли способ, которым я выложил / спроектировал мой код в порядке, и это практично?
  • Есть ли лучший, более эффективный способ для меня обновить свои сущности и вызвать функции, которые могут отсутствовать у других сущностей?
  • Является ли использование enum для отслеживания типа сущностей хорошим способом идентификации сущностей?

5 ответов

Решение

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

Как правило, вы увидите что-то вроде этого

class CEntity {
  public:
     virtual void draw() {};  // default implementations do nothing
     virtual void update() {} ;
     virtual void handleinput( const inputdata &input ) {};
}

class CEnemy : public CEntity {
  public:
     virtual void draw(); // implemented...
     virtual void update() { do_ai_stuff(); }
      // use the default null impl of handleinput because enemies don't care...
}

class CPlayer : public CEntity {
  public:
     virtual void draw(); 
     virtual void update();
     virtual void handleinput( const inputdata &input) {}; // handle input here
}

а затем менеджер сущностей проходит и вызывает update(), handleinput() и draw() для каждой сущности в мире.

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

Одним из них является сохранение, например, входных данных в глобальном (или в качестве члена глобального интерфейса, или в виде синглтона и т. Д.). Затем переопределите функцию врагов update (), чтобы они делали do_ai_stuff(). и update () игроков, чтобы он выполнял обработку ввода, опрашивая глобальный.

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

class CInputManager
{
  AddListener( IInputListener *pListener );
  RemoveListener( IInputListener *pListener );

  vector<IInputListener *>m_listeners;
  void PerFrame( inputdata *input ) 
  { 
     for ( i = 0 ; i < m_listeners.count() ; ++i )
     {
         m_listeners[i]->handleinput(input);
     }
  }
};
CInputManager g_InputManager; // or a singleton, etc

class IInputListener
{
   virtual void handleinput( inputdata *input ) = 0;
   IInputListener() { g_InputManager.AddListener(this); }
   ~IInputListener() { g_InputManager.RemoveListener(this); }
}

class CPlayer : public IInputListener
{
   virtual void handleinput( inputdata *input ); // implement this..
}

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

Вы должны смотреть на компоненты, а не наследование для этого. Например, в моем движке я (упрощенно):

class GameObject
{
private:
    std::map<int, GameComponent*> m_Components;
}; // eo class GameObject

У меня есть различные компоненты, которые делают разные вещи:

class GameComponent
{
}; // eo class GameComponent

class LightComponent : public GameComponent // represents a light
class CameraComponent : public GameComponent // represents a camera
class SceneNodeComponent : public GameComponent // represents a scene node
class MeshComponent : public GameComponent // represents a mesh and material
class SoundComponent : public GameComponent // can emit sound
class PhysicsComponent : public GameComponent // applies physics
class ScriptComponent : public GameComponent // allows scripting

Эти компоненты могут быть добавлены к игровому объекту, чтобы вызвать поведение. Они могут связываться через систему обмена сообщениями, а вещи, которые требуют обновления во время основного цикла, регистрируют прослушиватель кадров. Они могут действовать независимо и могут быть безопасно добавлены / удалены во время выполнения. Я считаю, что это очень расширяемая система.

РЕДАКТИРОВАТЬ: Извинения, я немного уточню это, но я сейчас в середине чего-то:)

Вы можете реализовать эту функциональность, используя виртуальную функцию:

class CEntity() {
    public:
        virtual void do_stuff() = 0;
        virtual void draw() = 0;
        // ...
};

class CEnemy : public CEntity {
    public:
        void do_stuff() { do_ai_stuff(); }
        void draw(); // Implement draw();
        void do_ai_stuff();

};

class CPlayer : public CEntity {
    public:
        void do_stuff() { handle_input(); }
        void draw(); // Implement draw();
        void handle_input();
};

1 Маленькая вещь - зачем вам менять ID сущности? Обычно это константа и инициализируется во время построения, вот и все:

class CEntity
{ 
     const int m_id;
   public:
     CEntity(int id) : m_id(id) {}
}

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


Добавить ко всем

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

Вы можете даже реализовать базовый вид "механизма обнаружения", например,

 class CEntity
 {
   public:
     ...
     virtual bool CanMove() = 0;
     virtual void Move(CPoint target) = 0;
 }

Не переусердствуйте! Легко начать этот путь, а затем придерживаться его, даже если это создает огромный беспорядок в вашем коде. Это может быть приукрашено как "преднамеренная денормализация иерархии типов" - но в конечном итоге это просто хак, который позволяет быстро решить несколько проблем, но быстро повреждает его по мере роста приложения.


True Type discovery

используя и dynamic_cast, вы можете безопасно бросить свой объект из CEntity в CFastCat, Если субъект на самом деле CReallyUnmovableBoulder результатом будет нулевой указатель. Таким образом, вы можете исследовать объект на предмет его действительного типа и реагировать на него соответствующим образом.

CFastCat * fastCat = dynamic_cast<CFastCat *>(entity) ;
if (fastCat != 0)
   fastCat->Meow();

Этот механизм работает хорошо, если к методам, зависящим от типа, привязана лишь небольшая логика. Это не очень хорошее решение, если вы столкнетесь с цепочками, в которых будете проверять многие типы и будете действовать соответственно:

// -----BAD BAD BAD BAD Code -----
CFastCat * fastCat = dynamic_cast<CFastCat *>(entity) ;
if (fastCat != 0)
   fastCat->Meow();

CBigDog * bigDog = dynamic_cast<CBigDog *>(entity) ;
if (bigDog != 0)
   bigDog->Bark();

CPebble * pebble = dynamic_cast<CPebble *>(entity) ;
if (pebble != 0)
   pebble->UhmWhatNoiseDoesAPebbleMake();

Это обычно означает, что ваши виртуальные методы не выбраны тщательно.


Интерфейсы

Выше можно распространить на интерфейсы, когда функциональность, зависящая от типа, не отдельные методы, а группы методов. Они не очень хорошо поддерживаются в C++, но это терпимо. Например, ваши объекты имеют разные особенности:

class IMovable
{
   virtual void SetSpeed() = 0;
   virtual void SetTarget(CPoint target) = 0;
   virtual CPoint GetPosition() = 0;
   virtual ~IMovable() {}
}

class IAttacker
{
   virtual int GetStrength() = 0;
   virtual void Attack(IAttackable * target) = 0;
   virtual void SetAnger(int anger) = 0;
   virtual ~IAttacker() {}
}

Ваши разные объекты наследуются от базового класса и одного или нескольких интерфейсов:

class CHero : public CEntity, public IMovable, public IAttacker 

И снова, вы можете использовать dynamic_cast для поиска интерфейсов в любой сущности.

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


Шаблон посетителя

Шаблон посетителя требует много печатания, но он позволяет добавлять функциональность к классам без изменения этих классов.

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

Это может справиться практически с любым требованием к изменениям (при условии, что ваши сущности хорошо учтены).

(Я только ссылаюсь на него, потому что большинству людей требуется время, чтобы обернуть вокруг него голову, и я бы не рекомендовал использовать его, если вы не испытали ограничений других методов)

В целом, ваш код довольно хорош, как отмечали другие.

Чтобы ответить на ваш третий вопрос: в коде, который вы нам показали, вы не используете тип enum, за исключением создания. Там все выглядит нормально (хотя мне интересно, не проще ли читать методы createPlayer(), createEnemy() и т. Д.). Но как только у вас есть код, который использует if или даже переключается для выполнения различных действий в зависимости от типа, вы нарушаете некоторые принципы ОО. Затем вы должны использовать возможности виртуальных методов, чтобы убедиться, что они делают то, что должны. Если вам нужно "найти" объект определенного типа, вы можете также сохранить указатель на ваш специальный объект игрока прямо при его создании.

Вы можете также рассмотреть возможность замены идентификаторов необработанными указателями, если вам просто нужен уникальный идентификатор.

Пожалуйста, рассмотрите это как подсказки, которые МОГУТ быть подходящими в зависимости от того, что вам действительно нужно.

Другие вопросы по тегам