在一局王者荣耀游戏中,玩家们先选择战斗类型进行匹配,进行选择英雄等战前准备,接着开始进入加载界面,等全部玩家游戏就位,进入游戏后还要给「敌人到达战场」 留出5秒钟。本文讨论多人竞技游戏为什么大多都如此设计,以及如何设计、 实现战斗服务器控制逻辑。
帧同步原理
帧同步与回合制游戏
在回合制游戏中,玩家并不十分在意延迟,也不会质疑公平性。一些围棋、 战棋的对弈,一个回合可能允许在一天甚至数月之内完成。对于普通回合制游戏来说,一回合在数十秒内完成,因为回合指令提交后,战斗过程完全可在服务器进行计算,每一回合结算后,将结果广播到客户端进行渲染播放即可。
如果我们设定,每回合仅持续很短的时间,并允许玩家在回合内待机不操作,那么快到一定的程度,看起来游戏就不像一来一往的回合制,玩家的操作指令能够很快地看到结果,就有实时的感觉了。在实践中,我们将一回合的时间设定为66毫秒,并由服务器强制进入下一回合;也就是大约每秒15个回合。听上去像 FPS 一样,这也是帧同步中「帧」的由来。
战斗由客户端计算
但实时竞技对于实时和公平要求到了极致,战场局势瞬息万变,卡顿或者结果与目测判断不一致,玩家可就要骂娘了。
在状态同步的战斗服务器中,玩家向前移动一步,进入敌人攻击范围,受到攻击,失血过多而死亡,这个过程要等待玩家将移动数据发送到服务器,服务器再将其最新位置转发给其他玩家,玩家释放攻击技能,服务器计算伤害结果判定死亡,并将结果发送给玩家。服务器要计算整个战场的数据,玩家的全部操作指令都要等服务器处理后再才能得知结果,立即由客户端渲染,然后玩家再次据此决定下一步的策略。如果参战单位很多,战斗逻辑很复杂,包括粒子碰撞计算,服务器需要为每一场战斗投入计算资源,这个过程想起来就不太流畅。
将战斗放在客户端计算,能够使得客户端渲染更加流畅;服务器不计算战斗细节,也就不需要保存完整的战场数据,只需记录玩家的指令,和关键数据即可。
一致性原理
我们相信,相同的输入,通过相同的算法,应该得到相同的结果。对于客户端对战斗的计算来说:
对于第一帧结束时的状态来说,是取决于战场初始状态,以及第一帧中玩家们做出的全部操作指令。
因此,只要战场初始状态 、每回合玩家动作 在所有客户端中均一致,客户端只要根据相同的算法逐帧计算,即可保证战斗结束后每一个玩家看到相同的战果。
一致性的细节
在实践中,为了保持绝对的一致性,除了游戏内的战斗逻辑之外,其他一切算法均需要使用完全一致的实现。
随机
两个客户端的计算结果应该完全一致,比如某一次 A 对 B 的攻击是否发生暴击,这可能是决定胜负的关键。若每家都计算是自己赢了,玩家也是要骂娘的。所以,客户端中需要使用战场初始状态中的统一随机数种子来生成随机数。实践中,还需要使用一致的随机数算法,比如梅森旋转。
浮点数
浮点数在不同的平台上,并不能获得一致的计算结果,因此所有数值均需要转为定点数。我们可以约定小数点的位置,或者约定缩放系数1。定点数的运算更快,可移植性更多,
其他
战斗逻辑中使用的碰撞检测等算法、物理引擎,基础数据表等均需要保持一致。
一致性检测
客户端在计算完一帧的指令后,将此帧的状态计算 hash,并发送给服务器;服务器根据投票结果决定标准 hash 并广播。客户端发现差异后,重新查询相关帧指令,重新计算战场数据。
客户端渲染
战斗过程中的玩家操作,可以实时地或经过精简处理后,发送给服务器。服务器接受该操作之后,会在下一帧广播给所有玩家;玩家收到指令确认后再进行计算渲染。
也可以根据进行进行乐观预测,提前渲染表现,以提升玩家体验。一旦预测错误,再进行纠正补偿。
补帧纠正
当玩家因为网络变动或数据、算法问题,而发现自己错过某一帧时,可根据本地的战场状态快照,向服务器申请自其之后的全部指令,执行加速计算和渲染,尽量追上服务器的游戏进度。
战斗结算
结算时,由各个客户端上报胜负结果,服务器取多数共识即可。
作弊检测
检测游戏时,并不需要加载游戏的地图和人物角色等渲染资源,可以快速地计算整个战斗过程。因此,在战斗开始后,即可使用客户端同版的纯数值算法,比真实游戏进度稍慢地,验证整个战斗,这样也能在关键节点作出快照(),以便加快战斗中的断线重连。
一些增强
- 每帧的客户端回传数据中,可包含一些关键的事件,比如击杀,以便成就等附加系统的计算统计。
- 使用 UDP/KCP 可以获得更低的延迟。
其他
帧同步的游戏服务器,只是改善了延迟的情况,在网络质量低时,玩家能感受到操作动作需要更长时间才能看到反馈,这是不可避免的,但这并不影响最终的一致性。 服务器能够探测到某个玩家长时间「待机」,可能是玩家本人暂时离开,或者网络问题,可以判定其由机器人接管。
通用帧同步服务器
帧同步的游戏服务器,完全可以脱离具体游戏玩法而存在。通过合理的设计通讯协议的结构,一旦实现,即可满足多种的游戏的战斗。游戏服务器并不需要知道客户端按下了编号为 1 的按钮代表什么释放了哪个技能,也许是炸弹人放下了一个炸弹,也许是街机对战游戏按下了 A 键,也许是英雄联盟的一个大招。可参考以下内容实现通用帧同步服务器。
通讯协议
// 定点数
message FixedPoint{
int64 value = 1; // 值
}
// 参数值
message AnyValue{
oneof value{
FixedPoint fixed_point = 1; // 定点数
int32 number = 2; // 数字
bool boolean = 3; // 布尔
string string = 4;
}
}
// 服务器推送:战斗已创建
message BattleCreated{
int32 my_unit_id = 1; // 玩家主控单位标识
string battle_id = 2; // 战斗标识
}
// 客户端:加载完毕
message BattleLoaded{}
// 数据结构:战斗单位属性
message UnitAttribute{
oneof name{// 操作编号
DefinedAttributeName defined_name = 1; //预设的
int32 other_name = 2; // 其他
}
repeated AnyValue values = 3; // 值
enum DefinedAttributeName{
UNKNOWN = 0;
HP = 1; // 生命值
}
}
// 数据结构:战斗单位资料
message Profile{
repeated UnitAttribute attributes = 5; // 单位属性
}
// 数据结构:战斗单位
message BattleUnit{
int32 id = 1; // 战斗单位标识
int32 team = 2; // 阵营
Profile profile = 3; // 战斗单位资料属性
}
// 服务器广播消息:战场数据
message BattleData{
repeated BattleUnit units = 1; // 全部战斗单位
int32 init_random_seed = 2; // 局内初始随机数种子
int32 turn_duration_millis = 3; // 帧周期(毫秒)
int32 turn_idle_max = 4; // 客户端可空闲(不发指令)最大帧数
int32 turn = 5; // 战场数据所属帧数
int32 turn_max = 6; // 战斗持续总帧数
// 客户端主动请求战斗数据,服务器直接返回 BattleData
message Request{
}
}
// 数据结构:战斗指令
message BattleOperation{
oneof operation{// 操作编号
DefinedOperation defined_operation = 1; //预设的操作
int32 other_operation = 2; // 其他操作
}
repeated AnyValue params = 3; // 参数
enum DefinedOperation{
UNKNOWN = 0;
AI = 1; // AI 状态改变
}
}
// 数据结构:战斗单位操作指令
message BattleUnitOperation{
int32 unit_id = 1; // 战斗单位标识
repeated BattleOperation operations = 2; // 战斗指令序列
}
// 客户端:帧的操作指令
message BattleTurnOperate{
repeated BattleUnitOperation operations = 1; // 指令集合
int32 prev_turn = 2; // 上一帧
int32 prev_turn_check_code = 3; // 上一帧结果校验码
}
// 服务器:帧数据合集
message BattleTurn{
int32 turn = 1;
repeated BattleUnitOperation operations = 2; // 指令集合
int32 prev_turn_check_code = 3; // 上一帧结果校验码
message Request{
int32 from = 1; // 起始帧
repeated int32 turn = 2; //指定帧
}
message Response{
repeated BattleTurn turns = 1;
}
}
其他协议如战斗结算等略。整个战斗过程如下图:
具体的战场数据结构和战斗结算,可由战斗服务器之外的节点决定;具体的指令结构,则由战斗逻辑模块决定。
结语
无论战场有多么复杂,在客户端与服务器之间只传输战场初始数据和战斗指令,战斗由客户端计算,服务器只负责调度和记录关键数据,大大降低了服务器的负载和延迟。同时还带来一些额外的好处:
- 战斗回放仅需要初始数据和战斗指令,录像文件体积小;
- 客户端可以加速和跳过画面渲染,实现加速播放战斗;
- 方便做观战与直播的功能。
还有一些有意思的副作用:
- 不同版本的客户端战斗逻辑可能不同,不能匹配战斗;
- 进入战斗前,全部玩家需提前准备战场数据,加载战斗场景;
- 客户端为了本地流畅会对玩家的操作做实时的渲染,但与服务器不一致时会有回退,表现为网络不佳时玩家位置和状态发生回溯;
- 网络不佳后恢复时,客户端为追赶游戏进度,玩家会看到遗漏的游戏过程加速播放;
- 客户端闪退后重新进入战斗,需要重新 Loading 战场数据和战斗帧数据。
评论表单