在游戏中处理实体

12
作为一个小练习,我正在尝试编写一个非常简单的游戏引擎,只处理实体(移动、基本AI等)。
因此,我正在考虑游戏如何处理所有实体的更新,并且我有点困惑(可能是因为我的方法有误)。
所以我决定在这里发布这个问题,展示一下我当前的思考方式,并看看是否有人能建议我更好的做法。
目前,我有一个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语句来检查实体类型,像这样:
for(entityVectorIter = entityVector.begin(); entityVectorIter != entityVector.end(); entityVectorIter++) {
    if((*entityVectorIter)->get_type() == PLAYER) {
        (*entityVectorIter)->handle_input();
    }
}

但是我不知道人们通常如何编写这些内容,所以我不确定最好的方式是什么。

我写了很多东西,却没有提出任何具体问题,因此我将澄清我在寻找什么:

  • 我的代码布局/设计是否可以?是否实用?
  • 是否有更好、更高效的方法更新我的实体并调用其他实体可能没有的函数?
  • 使用枚举来跟踪实体类型是识别实体的好方法吗?
5个回答

13

你已经接近大多数游戏实际处理方式了(尽管性能专家克莱姆·迈克·阿克顿经常抱怨这一点)。

通常你会看到类似这样的东西

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()函数,让它执行ai操作,而玩家的update()函数则通过轮询全局变量来进行输入处理。

另一种方法是使用监听器模式的某种变体,使所有关心输入的对象都继承自一个公共的监听器类,并将所有这些监听器注册到一个InputManager中。然后,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..
}

还有其他更复杂的方法。但是所有这些方法都可行,我在实际销售的产品中见过每一种方法。


4
伙计,那个链接里的人真的很糟糕。 - Puppy
6
也许是这样,但他也是导致《拉捷特与克拉恩》从未低于60帧的原因。 - Crashworks
1
@jalf:性能建议只有在分析之后才是确凿的。我不是在声称那段代码是设计的顶峰 - 它其实相当糟糕。但愤怒也并不完全理性化,并没有指出真正的问题所在。 - Puppy
1
@DeadMG:部分的优化是可以做的(但要记住,他正在查看的代码旨在作为库代码供高性能应用的第三方使用。他们不会轻易地修复库代码。它必须从一开始就是高效的。而且他的许多(不是全部)优化特别是编译器无法为您解决的问题,这些优化结合起来可能会导致所示函数的性能差异高达数量级。(当然,需要进行性能分析才能确定其对整个应用程序的性能影响) - jalf
2
@zebrabox:在单元格开发中,我们称之为“缓存未命中运算符”。 - Crashworks
显示剩余8条评论

8
你应该考虑使用组件,而不是继承来解决这个问题。例如,在我的引擎中,我有以下组件(简化):
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

这些组件可以添加到游戏对象中以引发行为。它们可以通过消息系统通信,并在主循环期间需要更新的内容注册帧侦听器。它们可以独立地运作,并且可以在运行时安全地添加/移除。我认为这是一个非常可扩展的系统。

7
您可以使用虚函数来实现此功能:
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
我更喜欢使用“update()”作为名称,而不是“do_stuff()”,但我同意你的观点! - Philipp
我永远不会给一个函数/方法命名为“do_stuff”或类似的名称。我只是将“do_ai_stuff”的命名更改为更通用的名称。所以,我也同意你的看法!无论如何,还有很多潜力可以进一步改善设计。 ;) - Flinsch

2

1 一个小问题 - 为什么要更改实体的ID?通常情况下,这是在构造过程中初始化并保持不变的:

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

对于其他事情,有不同的方法,选择取决于有多少类型特定函数(以及您能否很好地预测它们)。


添加到所有

最简单的方法就是将所有方法添加到基本接口中,并在不支持它的类中实现它们为无操作。这可能听起来像是错误的建议,但如果没有很多不适用的方法,并且您可以假设方法集不会随着未来需求的增长而显著增加,则是一种可接受的去规范化方法。

您甚至可以实现一种基本的“发现机制”,例如

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

不要过度使用! 开始的时候很容易这样做,但是即使在代码变得一团糟的时候仍然坚持下去。它可以被包装成“有意识地非规范化类型层次结构”——但最终它只是一个让你快速解决一些问题的技巧,在应用程序增长时会迅速带来麻烦。


真正的类型发现

使用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在任何实体上探测接口。

这是非常可扩展的,通常是当你不确定时最安全的选择。它比上面的解决方案稍微冗长一些,但可以很好地应对未来意外的变化。将功能分解为接口并不容易,需要一些经验来感受它。


访问者模式

访问者模式 需要大量输入,但它允许您向类添加功能而无需修改这些类。

在您的上下文中,这意味着您可以构建实体结构,但单独实现其活动。这通常用于在实体上有非常明显的操作时使用,您不能自由修改类或将功能添加到类会严重违反单一职责原则。

只要实体本身被很好地分解,就可以应对几乎所有的更改需求。

(我只是提供链接,因为大多数人需要一段时间来理解它,除非您已经经历了其他方法的限制,否则我不建议使用它)


dynamic_cast<>可能存在问题,因为它们往往非常慢(每个需要一整个微秒或更长时间)。在一个游戏中,你可能有成千上万的实体和16.6毫秒来运行一帧,这会累积起来。 - Crashworks
@Crashworks:感谢指出这个问题 - 但在排除编译器之前,我会先测试一下。对于上面的“真实类型发现”,这不应该是一个问题(除非你的编译器对此进行了悲观优化 - 在这种情况下,机制很容易实现)。对于非常复杂的层次结构,可能需要几千个周期,但是对于有限的实体集合,可以使用更快的自定义实现。(是的,这很糟糕....)无论如何,有不同的方法是有原因的。 - peterchen
我在Crashworks工作。我从未遇到过在控制台运行时使用dynamic_cast,因为它需要启用RTTI并且通常具有过高的性能成本。大多数控制台开发者会编写自己的C ++自定义反射/内省系统。 - zebrabox

1
总的来说,你的代码还不错,正如其他人所指出的那样。
回答你的第三个问题:在你展示给我们的代码中,除了创建之外,你没有使用枚举类型。在这里似乎没什么问题(尽管我想知道是否使用"createPlayer()"、"createEnemy()"等方法会更容易阅读)。但是,一旦你有使用if或者switch根据类型做不同事情的代码,那么你就违反了一些面向对象的原则。你应该使用虚方法的威力来确保它们执行它们应该执行的操作。如果你必须“查找”某种类型的对象,那么在创建时存储一个指向你的特殊玩家对象的指针也是可以的。
如果你只需要一个唯一的ID,你也可以考虑用原始指针替换ID。
请将这些视为可能适用于你实际需求的提示。

出于好奇,为什么基于类型进行区分违反了面向对象的原则? - Ell
在面向对象编程中,你尝试将行为封装在对象中。因此,你不会使用if和switch语句,而是调用虚拟方法,因为它在当前活动类型的对象内部,这个方法可以执行应该执行的操作。这样更加灵活和类型安全(考虑在层次结构中添加一个新类型,你会得到编译时安全性而不是运行时问题)。 - Philipp
我认为理解虚方法的使用,但为什么允许其他对象知道另一个对象的类型会违反封装原则?让一个对象存储它的类型(或使用dynamic_cast)只是为了更容易地存储具有共同祖先的对象数组,还是这只是不好的设计? - Ell
我不确定你的意思。我只是认为使用“if(x是类型A)foo1(); else if(x是类型B)foo2();”通常看起来像是不好的设计。 - Philipp
是的,它看起来像是糟糕的设计,但有时也可以让事情变得更容易。我稍后会研究一下 :) - Ell

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接