上节讲述了大地图同步的基本思路以及ECS框架的基础概念,这一节主要关注具体的实现手段。
up在实际项目中采用服务器C++实现,客户端lua实现的方式,在本篇中出现的代码(或伪代码)主要是C++风格
首先是ECS(Entity+Component+System)这几个关键类的定义
- 实体类(实体类一般不需要继承,它就是一个id+组件的容器)
class Entity
{
主要数据
unit64 m_entityID;//实体id
list<Component> m_components;//组件容器
主要方法
//添加组件
template<class T>
T* AddComponent();
//移除组件
void RemoveComponent(Component* c);
//获取实体上的某个组件
template<class T>
T* Component();
};
- 组件基类
class Component
{
主要数据
Entity* m_owner;//组件所有者的实体
DeltaHelper m_deltas;//记录组件数据的变化情况,用于差量同步
LinkHelper m_linkNode;//链表节点,同类型的组件可以通过一个链表遍历
主要方法
//获取所有者
Entity* GetOwner();
//查找同一个owner下的其他组件
template<class T>
T* Sibling();
};
- 系统基类
class System
{
主要方法
//按固定的帧率执行更新逻辑
void Update()
};
- 游戏世界类
class World
{
//主要数据
list<System*> m_allSystems;//所有系统的容器
unordered_map<uint64, Entity*> m_allEntitys;//所有实体的容器
unordered_map<const SuperClass*, ComponentHead*> m_allCompnentHeads;//各种类型组件链表的表头
主要方法
//创建一个新的空实体
Entity* CreateEntity();
//销毁实体
void DestroyEntity(Entity* e);
//通过实体id获取实体
Entity* FindEntity(const uint64& id);
//获取组件迭代器(相当于获取了目标类型的所有组件)
template<class T>
ComponentItr<T> GetComponent();
};
定义出这些关键类之后,我们可以尝试写一下基于ECS结构的系统更新的逻辑代码
void SystemA::Update()
{
auto ComItr = g_world->GetComponent<ComponentA>();
for(auto it : ComItr)
{
DoComponentLogic(it);
}
}
服务器和客户端可以各自按这套结构来驱动游戏逻辑
接下来我们看一下如何将服务器上的地图实体数据同步给看到它们的客户端
同步地图实体的基础逻辑:
1.判断玩家视窗覆盖了哪些网格
2.从每一个被覆盖的网格中取出可见的实体,塞到可见实体列表中
3.判断这些实体的数据是否发生过变化,如果有,则将变化的数据写入网络同步包中
简化版的代码如下
list<Entity*> viewEntityList;//可见实体列表
for(auto& tileIt: allMapTiles)
{
if(CheckViewWindow(tileIt))
{
AppentEntityList(viewEntityList, tileIt.entityList);//过滤可见实体
}
}
NetPacket packet;//网络同步数据包
for(auto& entityIt: viewEntityList)
{
if(!CheckDataChange(entityIt)) continue;//只同步数据有变化的实体
for(auto& comIt : entityIt.components)
{
if(CheckDataChange(comIt))//只同步数据有变化的组件
{
for(auto& fieldIt: comIt.fieldArr)
{
if(CheckDataChange(fieldIt))//只同步数据有变化的字段
{
WriteToPacket(packet. fieldIt);
}
}
}
}
}
遍历地图块筛选出可见实体是很容易的一件事,同步的难点在于如何判断数据发生了变化
对于一个实体来说,同步应该发生在一下两种情况:
1.第一次进入玩家视野时
2.在玩家的视野内,并且组件的数据发生变化时
而为了达到这样的目的我们需要在实体和玩家数据上分别维护数据版本信息
每个玩家缓存已经同步的实体数据
每个玩家缓存的数据并不是完整的数据,而是记录每个组件的变化情况
实体中每个组件的变化情况由一个uint32的版本号+一个uint64的位变化标记组成,降低记录成本
真正的组件在发生修改变化时,生成一个版本号及该版本号对应的位变化标记
组件最多只维护最近的N个版本,更旧的版本变化需要全同步
整体上的实现思路如上,不过实现细节着实很多,算是整个ECS同步中最复杂的一块
C/S之间的数据传输当然也绕不开序列化的问题,实际项目中我们将需要同步的组件定义在json文件中,客户端使用C++提供的接口,按json文件中定义的同步id和数据类型解析数据包,再以table的形式传递给lua层
json文件的大致格式如下:
[
{
"name" : "ComponentA",
"serializedID" : 1,
"field" : [
{
"name" : "data1",
"type" : 1,
"id" : 0,
},
{
"name" : "data2",
"type" : 2,
"id" : 1,
}
]
},
{
"name" : "ComponentB",
"serializedID" : 2,
"field" : [
{
"name" : "data1",
"type" : 1,
"id" : 0,
},
{
"name" : "data2",
"type" : 2,
"id" : 1,
}
]
}
]
一个完整的数据包格式大致如下
实体个数
//下面开始是第一个实体的数据
组件个数
//下面开始是第一个组件的数据
字段数
第一个字段数据
第二个字段数据
...
//下面开始是第二个组件的数据
...
//第一个实体数据结束
//下面开始是第二个实体的数据
...
在下一节中我们将讨论一下这套同步方案的局限性和实际工作中遇到的问题
网友评论