65.9K
CodeProject 正在变化。 阅读更多。
Home

实体-组件-系统 - 赏金猎人游戏(第 2 部分)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2017 年 11 月 22 日

CPOL

10分钟阅读

viewsIcon

14159

本文是关于一个基于系列第一部分中 ECS 实现的游戏。

这是我上一篇文章的延续,我在其中讨论了实体-组件-系统 (ECS) 设计模式。现在我想向您展示如何实际使用它来构建游戏。如果您还没有看过,请查看我使用我的 ECS 构建的游戏类型。

我承认这看起来不怎么样,但是如果您曾经在没有大型且花哨的游戏引擎(如 UnityUnreal)的帮助下构建过自己的游戏,您可能会在这里给我一些赞誉。;) 因此,为了演示我的 ECS,我只需要这么多。如果您仍然不明白这个游戏(BountyHunter)是关于什么的,让我通过下图帮助您理解

BH_GameRules

图-01:赏金猎人目标和规则。

左边的图片可能看起来很熟悉,因为它是在视频片段中您看到的游戏的更抽象视图。重点放在游戏实体上。在右侧,您将找到游戏目标和规则。这应该是不言自明的。正如您所看到的,在这个游戏世界中生活着许多实体类型,现在您可能想知道它们实际上是由什么组成的?当然是组件。虽然某些类型的组件对于所有这些实体都是通用的,但少数组件对于其他实体是独一无二的。请查看下一张图片。

BH_Comps

图-02:实体及其组件。

通过这张图片,您可以轻松看到实体与其组件之间的关系(这不是一个完整的描述!)。所有游戏实体都有共同的“变换组件”。因为游戏实体必须位于世界中的某个位置,所以它们具有描述实体位置、旋转和缩放的变换。这可能是附加到实体的唯一组件。例如,摄像机对象不需要更多组件,特别是“材质组件”,因为它永远不会对玩家可见(如果您将其用于后处理效果,这可能不正确)。另一方面,`Bounty` 和 `Collector` 实体对象确实具有视觉外观,因此需要“材质组件”才能显示。它们还可以在游戏世界中与其他对象发生碰撞,因此附加了一个“碰撞组件”,它描述了它们的物理形态。`Bounty` 实体附加了另一个组件;“生命周期组件”。此组件表示 `Bounty` 对象的剩余生命周期,当其生命周期结束后,赏金将消失。

那么接下来呢?拥有所有这些具有各自组件集合的不同实体并不能构成完整的游戏。我们还需要有人知道如何驱动它们中的每一个。我当然说的是系统。系统很棒。您可以使用系统将整个游戏逻辑拆分为更小的部分。每个部分处理游戏的不同方面。可能会或实际上应该有一个“输入系统”来处理所有玩家输入。或者一个“渲染系统”来将所有形状和颜色显示到屏幕上。一个“重生系统”来重生已死亡的游戏对象。我想您明白了。下图显示了《赏金猎人》中所有具体实体、组件和系统类型的完整类图。

ClassDiagram1

图-03:赏金猎人 ECS 类图。

现在我们有了实体、组件和系统 (ECS),但是等等,还有更多……事件!为了让系统和实体相互通信,我提供了一个包含 38 种不同事件的集合

  • 游戏初始化事件 (GameInitializedEvent)
  • 游戏重启事件 (GameRestartedEvent)
  • 游戏开始事件 (GameStartedEvent)
  • 游戏暂停事件 (GamePausedEvent)
  • 游戏恢复事件 (GameResumedEvent)
  • 游戏结束事件 (GameoverEvent)
  • 游戏退出事件 (GameQuitEvent)
  • 暂停游戏事件 (PauseGameEvent)
  • 恢复游戏事件 (ResumeGameEvent)
  • 重启游戏事件 (RestartGameEvent)
  • 退出游戏事件 (QuitGameEvent)
  • 鼠标左键按下事件 (LeftButtonDownEvent)
  • 鼠标左键抬起事件 (LeftButtonUpEvent)
  • 鼠标左键按住事件 (LeftButtonPressedEvent)
  • 鼠标右键按下事件 (RightButtonDownEvent)
  • 鼠标右键抬起事件 (RightButtonUpEvent)
  • 鼠标右键按住事件 (RightButtonPressedEvent)
  • 按键按下事件 (KeyDownEvent)
  • 按键抬起事件 (KeyUpEvent)
  • 按键按住事件 (KeyPressedEvent)
  • 切换全屏事件 (ToggleFullscreenEvent)
  • 进入全屏模式事件 (EnterFullscreenModeEvent)
  • 藏匿已满 (StashFull)
  • 进入窗口模式事件 (EnterWindowModeEvent)
  • 游戏对象已创建 (GameObjectCreated)
  • 游戏对象已销毁 (GameObjectDestroyed)
  • 玩家离开 (PlayerLeft)
  • 游戏对象已生成 (GameObjectSpawned)
  • 游戏对象已击杀 (GameObjectKilled)
  • 相机已创建 (CameraCreated)
  • 相机已销毁 (CameraDestroyed)
  • 切换调试绘制事件 (ToggleDebugDrawEvent)
  • 窗口最小化事件 (WindowMinimizedEvent)
  • 窗口恢复事件 (WindowRestoredEvent)
  • 窗口大小调整事件 (WindowResizedEvent)
  • 玩家加入 (PlayerJoined)
  • 碰撞开始事件 (CollisionBeginEvent)
  • 碰撞结束事件 (CollisionEndEvent)

还有更多,我还需要什么才能制作《赏金猎人》

  • 通用应用程序框架 - SDL2 用于获取玩家输入和设置基本应用程序窗口
  • 图形 - 我使用了一个自定义的 OpenGL 渲染器,以便能够在应用程序窗口中进行渲染
  • 数学 - 对于扎实的线性代数,我使用了 glm
  • 碰撞检测 - 对于碰撞检测,我使用了 box2d 物理引擎
  • 有限状态机 - 用于简单的 AI 和游戏状态

显然,我不会讨论所有这些机制,因为它们本身就值得写一篇文章,我可能会在稍后的时间点这样做。;) 但是,如果您无论如何都热衷于了解,我不会阻止您,并留下这个链接给您。看看我上面提到的所有功能,您可能会意识到它们是您自己的小型游戏引擎的一个很好的起点。这里还有一些我待办列表上的事情,但实际上并没有实现,只是因为我想把事情完成。

  • 编辑器 - 一个管理实体、组件、系统等的编辑器
  • 存档 - 使用一些 ORM 库(例如 codesynthesis)将实体及其组件持久化到数据库中
  • 回放 - 在运行时记录事件并在稍后回放
  • GUI - 使用 GUI 框架(例如 librocket)构建交互式游戏菜单
  • 资源管理器 - 通过自定义资源管理器同步和异步加载资产(纹理、字体、模型等)
  • 网络 - 通过网络发送事件并设置多人模式

我将把这些待办事项留给您作为挑战,以证明您是一位了不起的程序员。;)

最后,让我为您提供一些代码,演示我的 ECS 的用法。还记得 Bounty 游戏实体吗?赏金是那些随机出现在世界中心的小黄色、大红色和介于两者之间的方形物体。以下代码片段显示了 Bounty 实体类声明的代码。

// Bounty.h

class Bounty : public GameObject<bounty>
{
private:

    // cache components
    TransformComponent*   m_ThisTransform;
    RigidbodyComponent*   m_ThisRigidbody;
    CollisionComponent2D* m_ThisCollision;
    MaterialComponent*    m_ThisMaterial;
    LifetimeComponent*    m_ThisLifetime;

   // bounty class property
   float                 m_Value;

public:

    Bounty(GameObjectId spawnId);
    virtual ~Bounty();

    virtual void OnEnable() override;
    virtual void OnDisable() override;

    inline float GetBounty() const { return this->m_Value; }

    // called OnEnable, sets new randomly sampled bounty value
    void ShuffleBounty();
};

这段代码非常直观。我通过继承 GameObject<T>(它又继承自 ECS::Entity<T>创建了一个新的游戏实体,其中类 (Bounty) 本身作为 T。现在 ECS 知道这种具体的实体类型,并且将创建一个唯一的(静态)类型标识符。我们还将获得方便的方法 AddComponent<U>GetComponent<U>RemoveComponent<U>。除了我稍后向您展示的组件之外,还有一个属性;赏金值。我不确定为什么我没有将该属性放入一个单独的组件中,例如 BountyComponent 组件,因为那才是正确的方法。相反,我只是将赏金值属性作为成员放入 Bounty 类中,我感到羞愧。但是,嘿,这只向您展示了这种模式的巨大灵活性,对吗?;) 对,那些组件……

 // Bounty.cpp
Bounty::Bounty(GameObjectId spawnId)
{
    Shape shape = ShapeGenerator::CreateShape<quadshape>();
    AddComponent<shapecomponent>(shape);
    AddComponent<respawncomponent>(BOUNTY_RESPAWNTIME, spawnId, true);

    // cache this components
    this->m_ThisTransform = GetComponent<transformcomponent>();
    this->m_ThisMaterial  = AddComponent<materialcomponent>
                            (MaterialGenerator::CreateMaterial<defaultmaterial>());
    this->m_ThisRigidbody = AddComponent<rigidbodycomponent>(0.0f, 0.0f, 0.0f, 0.0f, 0.0001f);
    this->m_ThisCollision = AddComponent<collisioncomponent2d>
                            (shape, this->m_ThisTransform->AsTransform()->GetScale(), 
                            CollisionCategory::Bounty_Category, 
                            CollisionMask::Bounty_Collision);
    this->m_ThisLifetime  = AddComponent<lifetimecomponent>
                            (BOUNTY_MIN_LIFETIME, BOUNTY_MAX_LIFETIME);
}
// other implementations ...

我使用了构造函数来附加 `Bounty` 实体所需的所有组件。请注意,这种方法创建了一个对象的预制件,并且不灵活,也就是说,您将始终获得一个附加了相同组件的 `Bounty` 对象。虽然这对于此游戏来说是一个足够好的解决方案,但在更复杂的游戏中可能不是。在这种情况下,您将提供一个工厂来生产定制的实体对象。正如您在上面的代码中看到的,`Bounty` 实体附加了相当多的组件。我们有 `ShapeComponent` 和 `MaterialComponent` 用于视觉外观。`RigidbodyComponent` 和 `CollisionComponent2D` 用于物理行为和碰撞响应。`RespawnComponent` 用于赋予 `Bounty` 在死亡后重生的能力。最后但同样重要的是,`LifetimeComponent` 将实体的存在绑定到一定的时间量上。`TransformComponent` 会自动附加到任何派生自 `GameObject<T>` 的实体。就这样。我们刚刚为游戏添加了一个新实体。

现在您可能想看看如何利用所有这些组件。让我举两个例子。首先是 RigidbodyComponent。此组件包含有关某些物理特性(例如摩擦力、密度或线性阻尼)的信息。此外,它还充当一个适配器类,用于将 box2d 物理引擎集成到游戏中。RigidbodyComponent 相当重要,因为它用于同步物理模拟的刚体变换(由 box2d 拥有)和实体 TransformComponent(由游戏拥有)。PhysicsSystem 负责此同步过程。

// PhysicsEngine.h

class PhysicsSystem : public ECS::System<physicssystem>, public b2ContactListener
{
public:
PhysicsSystem();
    virtual ~PhysicsSystem();

    virtual void PreUpdate(float dt) override;
    virtual void Update(float dt) override;
    virtual void PostUpdate(float dt) override;

    // Hook-in callbacks provided by box2d physics to inform about collisions
    virtual void BeginContact(b2Contact* contact) override;
    virtual void EndContact(b2Contact* contact) override;
}; // class PhysicsSystem
// PhysicsEngine.cpp

void PhysicsSystem::PreUpdate(float dt)
{
    // Sync physics rigidbody transformation and TransformComponent
    for (auto RB = ECS::ECS_Engine->GetComponentManager()->begin<rigidbodycomponent>(); 
    RB != ECS::ECS_Engine->GetComponentManager()->end<rigidbodycomponent>(); ++RB)
    {
        if ((RB->m_Box2DBody->IsAwake() == true) && (RB->m_Box2DBody->IsActive() == true))
        {
            TransformComponent* TFC = ECS::ECS_Engine->GetComponentManager()->GetComponent
                                      <transformcomponent>(RB->GetOwner());
            const b2Vec2& pos = RB->m_Box2DBody->GetPosition();
            const float rot = RB->m_Box2DBody->GetAngle();

            TFC->SetTransform(glm::translate(glm::mat4(1.0f), 
                 Position(pos.x, pos.y, 0.0f)) * glm::yawPitchRoll(0.0f, 0.0f, rot) * 
                 glm::scale(TFC->AsTransform()->GetScale()));
        }
    }
}

// other implementations ...

从上面的实现中,您可能已经注意到三个不同的更新函数。当系统更新时,首先调用所有系统的 `PreUpdate` 方法,然后是 `Update`,最后是 `PostUpdate` 方法。由于 `PhysicsSystem` 在任何其他涉及 `TransformComponent` 的系统之前调用,因此上面的代码确保了变换的同步。在这里,您还可以看到 `ComponentIterator` 的作用。我们不是询问世界中的每个实体是否具有 `RigidbodyComponent`,而是要求 `ComponentManager` 为我们提供 `RigidbodyComponent` 类型的 `ComponentIterator`。有了 `RigidbodyComponent`,我们可以轻松检索实体的 ID,并再次要求 `ComponentManager` 为该 ID 提供 `TransformComponent`,这太容易了。让我们看看我承诺的第二个例子。RespawnComponent 用于那些在死亡后需要重生的实体。此组件提供了五个属性,可用于配置实体的重生行为。您可以选择在实体死亡时自动重生它,重生前需要经过多长时间,以及一个重生位置和方向。实际的重生逻辑在 RespawnSystem 中实现。

// RespawnSystem.h
class RespawnSystem : public ECS::System<respawnsystem>, protected ECS::Event::IEventListener
{
private:

    // ... other stuff
    Spawns       m_Spawns;
    RespawnQueue m_RespawnQueue;

    // Event callbacks
    void OnGameObjectKilled(const GameObjectKilled* event);

public:

    RespawnSystem();
    virtual ~RespawnSystem();

    virtual void Update(float dt) override;

    // more ...
}; // class RespawnSystem
// RespawnSystem.cpp
// note: the following is only pseudo code!

voidRespawnSystem::OnGameObjectKilled(const GameObjectKilled * event)
{
    // check if entity has respawn ability
    RespawnComponent* entityRespawnComponent = 
    ECS::ECS_Engine->GetComponentManager()->GetComponent<respawncomponent>(event->m_EntityID);

    if(entityRespawnComponent == nullptr || 
      (entityRespawnComponent->IsActive() == false) || 
      (entityRespawnComponent->m_AutoRespawn == false))
        return;

    AddToRespawnQeueue(event->m_EntityID, entityRespawnComponent);
}

void RespawnSystem::Update(float dt)
{
    foreach(spawnable in this->m_RespawnQueue)
    {
        spawnable.m_RemainingDeathTime -= dt;
        if(spawnable.m_RemainingDeathTime <= 0.0f)
        {
            DoSpawn(spawnable);
            RemoveFromSpawnQueue(spawnable);
        }
    }
}

上面的代码不完整,但抓住了重要的代码行。RespawnSystem 持有并更新一个由 EntityId 和其 RespawnComponent 组成的队列。当系统收到 GameObjectKilled 事件时,新的条目会被入队。系统将检查被击杀的实体是否具有重生能力,也就是说,是否附加了 RespawnComponent。如果为 true,则实体会被入队等待重生,否则将被忽略。在 RespawnSystemupdate 方法中,该方法每一帧都会被调用,系统将减少队列中实体的 RespawnComponent 的初始重生时间(不确定我在这里的单引号是否正确?)。如果重生时间降至零以下,实体将被重生并从重生队列中移除。

我知道这是一次快速的旅程,但我希望我能给您一个关于 ECS 世界中事物如何运作的粗略概念。在结束这篇文章之前,我想与您分享一些我自己的经验。使用我的 ECS 是一种极大的乐趣。添加新内容,甚至第三方库,都出奇地容易。我只是简单地添加了新的组件和系统,它们将新功能链接到我的游戏中。我从未感到走投无路。将整个游戏逻辑拆分为多个系统是直观的,并且使用 ECS 可以免费获得。代码看起来更清晰,并且更易于维护,因为所有这些指针面条式依赖混淆都消失了。事件源对于系统间/实体间/...通信非常强大和有用,但它也是一把双刃剑,最终可能会给您带来一些麻烦。我指的是事件引发条件。如果您曾经使用过 Unity 或 Unreal Engine 的编辑器,您会很高兴拥有它们。这些编辑器无疑会提高您的生产力,因为您可以在比手动编写所有这些代码行少得多的时间里创建新的 ECS 对象。但是一旦您建立了实体、组件、系统和事件对象的丰富基础,将它们组合起来并从中构建一些很酷的东西几乎是小菜一碟。我想我可以继续谈论 ECS 有多酷,但我将在这里打住。

感谢您的光临并坚持到这里。:)

祝好!

历史

  • 2017年11月22日:初始版本
© . All rights reserved.