实体-组件-系统 - 赏金猎人游戏(第 2 部分)
本文是关于一个基于系列第一部分中 ECS 实现的游戏。
这是我上一篇文章的延续,我在其中讨论了实体-组件-系统 (ECS) 设计模式。现在我想向您展示如何实际使用它来构建游戏。如果您还没有看过,请查看我使用我的 ECS 构建的游戏类型。
我承认这看起来不怎么样,但是如果您曾经在没有大型且花哨的游戏引擎(如 Unity 或 Unreal)的帮助下构建过自己的游戏,您可能会在这里给我一些赞誉。;) 因此,为了演示我的 ECS,我只需要这么多。如果您仍然不明白这个游戏(BountyHunter
)是关于什么的,让我通过下图帮助您理解
左边的图片可能看起来很熟悉,因为它是在视频片段中您看到的游戏的更抽象视图。重点放在游戏实体上。在右侧,您将找到游戏目标和规则。这应该是不言自明的。正如您所看到的,在这个游戏世界中生活着许多实体类型,现在您可能想知道它们实际上是由什么组成的?当然是组件。虽然某些类型的组件对于所有这些实体都是通用的,但少数组件对于其他实体是独一无二的。请查看下一张图片。
通过这张图片,您可以轻松看到实体与其组件之间的关系(这不是一个完整的描述!)。所有游戏实体都有共同的“变换组件”。因为游戏实体必须位于世界中的某个位置,所以它们具有描述实体位置、旋转和缩放的变换。这可能是附加到实体的唯一组件。例如,摄像机对象不需要更多组件,特别是“材质组件”,因为它永远不会对玩家可见(如果您将其用于后处理效果,这可能不正确)。另一方面,`Bounty` 和 `Collector` 实体对象确实具有视觉外观,因此需要“材质组件”才能显示。它们还可以在游戏世界中与其他对象发生碰撞,因此附加了一个“碰撞组件”,它描述了它们的物理形态。`Bounty` 实体附加了另一个组件;“生命周期组件”。此组件表示 `Bounty` 对象的剩余生命周期,当其生命周期结束后,赏金将消失。
那么接下来呢?拥有所有这些具有各自组件集合的不同实体并不能构成完整的游戏。我们还需要有人知道如何驱动它们中的每一个。我当然说的是系统。系统很棒。您可以使用系统将整个游戏逻辑拆分为更小的部分。每个部分处理游戏的不同方面。可能会或实际上应该有一个“输入系统”来处理所有玩家输入。或者一个“渲染系统”来将所有形状和颜色显示到屏幕上。一个“重生系统”来重生已死亡的游戏对象。我想您明白了。下图显示了《赏金猎人》中所有具体实体、组件和系统类型的完整类图。
现在我们有了实体、组件和系统 (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
,则实体会被入队等待重生,否则将被忽略。在 RespawnSystem
的 update
方法中,该方法每一帧都会被调用,系统将减少队列中实体的 RespawnComponent
的初始重生时间(不确定我在这里的单引号是否正确?)。如果重生时间降至零以下,实体将被重生并从重生队列中移除。
我知道这是一次快速的旅程,但我希望我能给您一个关于 ECS 世界中事物如何运作的粗略概念。在结束这篇文章之前,我想与您分享一些我自己的经验。使用我的 ECS 是一种极大的乐趣。添加新内容,甚至第三方库,都出奇地容易。我只是简单地添加了新的组件和系统,它们将新功能链接到我的游戏中。我从未感到走投无路。将整个游戏逻辑拆分为多个系统是直观的,并且使用 ECS 可以免费获得。代码看起来更清晰,并且更易于维护,因为所有这些指针面条式依赖混淆都消失了。事件源对于系统间/实体间/...通信非常强大和有用,但它也是一把双刃剑,最终可能会给您带来一些麻烦。我指的是事件引发条件。如果您曾经使用过 Unity 或 Unreal Engine 的编辑器,您会很高兴拥有它们。这些编辑器无疑会提高您的生产力,因为您可以在比手动编写所有这些代码行少得多的时间里创建新的 ECS 对象。但是一旦您建立了实体、组件、系统和事件对象的丰富基础,将它们组合起来并从中构建一些很酷的东西几乎是小菜一碟。我想我可以继续谈论 ECS 有多酷,但我将在这里打住。
感谢您的光临并坚持到这里。:)
祝好!
历史
- 2017年11月22日:初始版本