ECS架构--MMO服务器开发技术点记录
本篇是踏入MMO服务器开发的第一步,在设计服务器的各种状态服务之前,我想先介绍一个设计模式:ECS设计模式。ECS是游戏客户端的经典设计模式,读者可能会好奇,客户端的设计模式为什么要在服务器这里使用呢?这是为了方便实现客户端和服务端的逻辑同步,方便状态的同步。也就是我们需要在服务端也实现一套EcS系统,和客户端的ESC保持一致。
ECS架构 / Entity - Component - System
基本思想:游戏内每一个基本单元都是一个实体 Entity,比如玩家角色、Npc、敌人等。每个实体又由多个组件构成,每个组件仅仅包含代表其特性的数据(组件没有方法,只有数据,可以被实体修改)。例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
Entity / 实体
实体只是一个概念上的定义,指的是存在你游戏世界中的一个独特物体,是一系列组件的集合。为了方便区分不同的实体,在代码层面上一般用一个ID来进行表示。所有组成这个实体的组件将会被这个ID标记,从而明确哪些组件属于该实体。由于其是一系列组件的集合,因此完全可以在运行时动态地为实体增加一个新的组件或是将组件从实体中移除。比如,玩家实体因为某些原因(可能陷入昏迷)而丧失了移动能力,只需简单地将移动组件从该实体身上移除,便可以达到无法移动的效果了。
从上面一段话我们可以知道实体类如何实现。通常ECS系统会实现得通用化,供业务层继承使用。
首先是ID和组件的增删查改,这是基本功能。实体类需要开放序列化的接口供继承的对象实现,或者自己实现,通过外部输入的数据更新自己组件的状态。实现中的BitStream类会之后说明什么作用,目前就把它当作一个数据源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Entity {
public:
Entity();
virtual ~Entity();
// 用于网络同步
virtual void net_serialize(OutputBitStream& bs, bool to_self) const {}
virtual bool net_delta_serialize(OutputBitStream& bs, bool to_self) { return false; }
virtual void reset_dirty() { _dirty_flag = 0; }
virtual std::string get_type() { return std::string{ "Entity" }; }
void entity_net_serialize(OutputBitStream& bs, bool to_self) const;
bool entity_net_delta_serialize(OutputBitStream& bs, bool to_self);
void entity_reset_dirty();
// Id相关
inline int get_eid() const { return _eid; }
inline void set_eid(int eid) { _eid = eid; }
// 组件的增删查改
void add_component(Component c);
void remove_component();
Component* get_component();
bool has_component(Component c);
private:
int _eid; // 实体Id
std::unordered_map<std::string, IComponent*> _components; // 实体拥有的组件
bool _dirty_flag; // 是否需要网络同步
};
实体本身不具备能力,是通过热插拔组件赋予其能力。其中的net_serialize之类的接口是用于网络同步的,这个会单开一篇文章讲。组件的增删查改实现起来比较简单,这里就不多赘述。
Component / 组件
一个组件是一堆数据的集合,可以使用C语言中的结构体来进行实现。它没有方法,即不存在任何的行为,只用来存储状态。一个经典的实现是:每一个组件都继承(或实现)同一个基类(或接口),通过这样的方法,我们能够非常方便地在运行时动态添加、识别、移除组件。每一个组件的意义在于描述实体的某一个特性。例如,PositionComponent(位置组件),其拥有x、y两个数据,用来描述实体的位置信息,拥有PositionComponent的实体便可以说在游戏世界中拥有了一席之地。当组件们单独存在的时候,实际上是没有什么意义的,但是当多个组件通过系统的方式组织在一起,才能发挥出真正的力量。同时,我们还可以用空组件(不含任何数据的组件)对实体进行标记,从而在运行时动态地识别它。如,EnemyComponent这个组件可以不含有任何数据,拥有该组件的实体被标记为“敌人”。
根据实际开发需求,这里还会存在一种特殊的组件,名为 Singleton Component (单例组件),顾名思义,单例组件在一个上下文中有且只有一个。
组件是数据的集合,同时要知道所属实体的存在;实体的功能由其拥有的组件赋予。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class IComponent {
public:
IComponent(Entity* entity) : _owner(entity) {}
virtual ~IComponent() {}
// 挂载实体行为
inline void set_owner(Entity* entity) { _owner = entity; }
inline Entity* get_owner() { return _owner; }
// 组件的初始化和销毁接口
virtual void init() {}
virtual void destroy() {}
// 网络同步
virtual void net_serialize(OutputBitStream& bs, bool to_self) const {}
virtual bool net_delta_serialize(OutputBitStream& bs, bool to_self) { return false; }
virtual void reset_dirty() { _dirty_flag = 0; }
protected:
Entity* _owner; // 所属的Entity
uint32_t _dirty_flag = 0; // 是否需要进行同步
};
实体、组件的基本功能就是上面这一些,可能会有点抽象,需要结合具体的实体组件来说明才更好理解。下一篇会介绍我们MMO服务器中的场景服务,其中会设计我们的玩家实体,读者阅读后就会理解。其中关于网络同步的部分,也会单独写一篇文章说明。