属性同步
unity/c++ mmo大型多人在线游戏开发系列教程,本节代码在此。
在之前的实现中,由于没有属性同步的机制,导致服务端每有属性发生变化时,都需要单独的rpc来告知客户端。每新建一个rpc都需要在proto文件中定义,然后在客户端定义处理函数,过程并不愉快。同时,每个属性变化都用一条单独的rpc进行通信,对性能和带宽利用率来说都不是好事。
所以更好的方案是提供属性同步的支持,当属性发生改变时自动同步给客户端,本节我们就来实现这个方案。
流程
属性同步主要分两步。
第一步是找出要同步的属性,一般有两种方案。一种是程序员自己在修改了属性之后,主动标记该属性为脏,需要同步。另一种是引擎自动对比检测,找出脏数据。
第二步自然是序列化脏数据,然后发送给客户端,客户端再进行反序列化,应用到本地。实现逻辑会根据第一步方案的选择有所变化。
之前有写过这方面的介绍,详见此文。
对于mmo来说,通常会选择第二种方案,毕竟引擎自动对比检测太耗性能了,玩家数量一多就受不了。当然第一种的方案也有坏外,毕竟要写的代码就多起来了。无论如何,这里选第一种方案。
实现
属性同步是以Player
为单位的,Player
需要提供两个接口:
class Player {
public:
// 序列化全量属性
void net_serialize(OutputBitStream& bs) const;
// 序列化增量属性变化
bool net_delta_serialize(OutputBitStream& bs);
};
分别用于首次同步,以及后续的增量同步。
OutputBitStream
类似于标准库的各种stream,支持序列化c++的基础类型,以及满足concept NetSerializable
的类型:
template <typename T>
concept NetSerializable =
std::is_class_v<T> &&
requires (T t, OutputBitStream & bs) {
{ t.net_serialize(bs) } -> std::same_as<void>;
{ t.net_delta_serialize(bs) } -> std::same_as<bool>;
};
也就是说自定义类型想要支持属性同步的话,也要像Player
一样提供那两个接口的实现,例如:
class SkillInfo
{
public:
void net_serialize(OutputBitStream& bs) const;
bool net_delta_serialize(OutputBitStream& bs);
};
相对应的,在客户端的代码中,也要提供这两个接口的反序列化实现:
public class SkillInfo
{
public void NetSerialize(BinaryReader br);
public void NetDeltaSerialize(BinaryReader br);
}
因此,怎么实现这两个接口其实是每个类自己的事情。
不过对增量同步来说,通用一点的做法还是前面提到的标脏,用一个dirty_flag
记录哪些数据发生了变化,然后在net_delta_serialize
一样逐个对比写入,例如Player
是这么实现的:
class Player {
public:
enum class DirtyFlag {
name = 1,
};
private:
uint32_t _dirty_flag = 0;
STR_PROPERTY(name);
};
void Player::net_serialize(OutputBitStream& bs) const
{
bs.write(_name);
for (auto& [name, comp] : _components) {
bs.write(name);
comp->net_serialize(bs);
}
}
bool Player::net_delta_serialize(OutputBitStream& bs)
{
bool dirty = false;
bs.write(_dirty_flag);
if (_dirty_flag) {
dirty = true;
WRITE_IF_DIRTY(name);
_dirty_flag = 0;
}
for (auto& [name, comp] : _components) {
bs.write(name);
dirty |= comp->net_delta_serialize(bs);
}
return dirty;
}
STR_PROPERTY
是一个宏,用来声明属性以及属性的get/set方法,其它还有类似INT_PROPERTY
之类的。
数组
数组是一种需要特殊处理类型,毕竟跟类和结构体相比,本身就是两种不同的内存结构。原生的数组或者std::vector
是不支持增量同步的,就算改了数组中的其中一个元素,也只能全量同步。
为了实现增量同步,我封装了一下std::vector
,记录了针对数组的每个操作,例如push_back
之类。并且提供了mark_dirty
接口,可以让用户主动告知哪个元素发生了改变。
在同步时,只需要把这些操作完整写进去,然后在客户端重放一次即可:
enum class SyncArrayOperation : uint8_t {
update,
push_back,
pop_back,
insert,
erase,
clear,
resize,
replace
};
template<typename T>
class TSyncArray {
public:
// ...
void push_back(const T& value) {
_dirty_log.write((uint8_t)SyncArrayOperation::push_back);
_dirty_log.write(value);
_vec.push_back(value);
}
void mark_dirty(size_type pos) {
_dirty_log.write((uint8_t)SyncArrayOperation::update);
_dirty_log.write((uint16_t)pos);
_dirty_log.net_delta_serialize(_vec[pos]);
}
void net_serialize(OutputBitStream& bs) const {
bs.write(_vec);
}
bool net_delta_serialize(OutputBitStream& bs) {
size_t dirty_size = _dirty_log.tellp();
bs.write((uint32_t)dirty_size);
if (dirty_size) {
bs.write(_dirty_log.get_buffer(), dirty_size);
_dirty_log.seekp(0);
return true;
}
return false;
}
// ...
private:
std::vector<T> _vec;
OutputBitStream _dirty_log;
};
使用
有了上面的支持之后,同步就很轻松了。在space::update
中,遍历玩家,序列化同步数据,然后分发即可:
void Space::update()
{
std::unordered_map<int, std::string> entity_dirty_properties;
for (auto& iter : _eid_2_player) {
int eid = iter.first;
Player* player = iter.second;
OutputBitStream bs;
if (player->net_delta_serialize(bs)) {
auto result = entity_dirty_properties.insert(std::make_pair(eid, std::string{bs.get_buffer(), bs.tellp()}));
// 同步属性变化给自己
space_service::PlayerDeltaInfo delta_info;
delta_info.set_eid(eid);
delta_info.set_data(result.first->second);
send_proto_msg(player->get_conn(), "sync_delta_info", delta_info);
}
}
std::vector<AOIState> aoi_state = _aoi->fetch_state();
for (auto& state : aoi_state) {
int eid = state.eid;
Player* player = find_player(eid);
if (!player)
continue;
// ...
// 同步视野内玩家的变化给player
space_service::AoiUpdates aoi_updates;
for (int interest_eid : state.interests) {
Player* p = find_player(interest_eid);
if (p) {
space_service::AoiUpdate* aoi_update = aoi_updates.add_datas();
aoi_update->set_eid(interest_eid);
space_service::Movement* new_move = aoi_update->mutable_transform();
get_movement_data(p, new_move);
auto dirty_property_iter = entity_dirty_properties.find(interest_eid);
if (dirty_property_iter != entity_dirty_properties.end()) {
aoi_update->set_data(dirty_property_iter->second);
}
}
}
send_proto_msg(player->get_conn(), "sync_aoi_update", aoi_updates);
}
}
小结
目前勉强算是支持属性同步了,虽然是个半自动的版本。还有一些改进是可以加上去的,例如属性单独指定同步权限,仅同步给自己、同步给所有人之类,但先用着吧。接下来先回到游戏玩法的开发中,我想加入怪物、NPC之类,都是常规需求了。显然,这需要开发一个AI系统,无论是行为树或者状态机都行。但是,AI又依赖了一系列的底层系统,例如寻路、物理之类,逐个来吧。