在一局王者荣耀游戏中,玩家们先选择战斗类型进行匹配,进行选择英雄等战前准备,接着开始进入加载界面,等全部玩家游戏就位,进入游戏后还要给「敌人到达战场」 留出5秒钟。本文讨论多人竞技游戏为什么大多都如此设计,以及如何设计、 实现战斗服务器控制逻辑。

帧同步原理

帧同步与回合制游戏

在回合制游戏中,玩家并不十分在意延迟,也不会质疑公平性。一些围棋、 战棋的对弈,一个回合可能允许在一天甚至数月之内完成。对于普通回合制游戏来说,一回合在数十秒内完成,因为回合指令提交后,战斗过程完全可在服务器进行计算,每一回合结算后,将结果广播到客户端进行渲染播放即可。

如果我们设定,每回合仅持续很短的时间,并允许玩家在回合内待机不操作,那么快到一定的程度,看起来游戏就不像一来一往的回合制,玩家的操作指令能够很快地看到结果,就有实时的感觉了。在实践中,我们将一回合的时间设定为66毫秒,并由服务器强制进入下一回合;也就是大约每秒15个回合。听上去像 FPS 一样,这也是帧同步中「帧」的由来。

sequenceDiagram participant Client A loop One Frame /66ms Client A-)Server: Press Move up Client B-)Server: Attack A Note over Client A, Client B: Round n: A's HP -100 end

战斗由客户端计算

但实时竞技对于实时和公平要求到了极致,战场局势瞬息万变,卡顿或者结果与目测判断不一致,玩家可就要骂娘了。

在状态同步的战斗服务器中,玩家向前移动一步,进入敌人攻击范围,受到攻击,失血过多而死亡,这个过程要等待玩家将移动数据发送到服务器,服务器再将其最新位置转发给其他玩家,玩家释放攻击技能,服务器计算伤害结果判定死亡,并将结果发送给玩家。服务器要计算整个战场的数据,玩家的全部操作指令都要等服务器处理后再才能得知结果,立即由客户端渲染,然后玩家再次据此决定下一步的策略。如果参战单位很多,战斗逻辑很复杂,包括粒子碰撞计算,服务器需要为每一场战斗投入计算资源,这个过程想起来就不太流畅。

将战斗放在客户端计算,能够使得客户端渲染更加流畅;服务器不计算战斗细节,也就不需要保存完整的战场数据,只需记录玩家的指令,和关键数据即可。

一致性原理

我们相信,相同的输入,通过相同的算法,应该得到相同的结果。对于客户端对战斗的计算来说:

S:战场状态F:战斗计算A:动作Sn=S(n)=F(S(n1),An)S: 战场状态 \\ F: 战斗计算 \\ A: 动作 \\ S_n = S(n) = F(S(n-1), A_n)

对于第一帧结束时的状态来说,是取决于战场初始状态,以及第一帧中玩家们做出的全部操作指令。

S1=S(1)=F(S(0),A1)S_1 = S(1) = F(S(0), A_1)

因此,只要战场初始状态 S0S_0、每回合玩家动作 A1,A2...AnA_1, A_2 ... A_n 在所有客户端中均一致,客户端只要根据相同的算法逐帧计算,即可保证战斗结束后每一个玩家看到相同的战果。

一致性的细节

在实践中,为了保持绝对的一致性,除了游戏内的战斗逻辑之外,其他一切算法均需要使用完全一致的实现。

随机

两个客户端的计算结果应该完全一致,比如某一次 A 对 B 的攻击是否发生暴击,这可能是决定胜负的关键。若每家都计算是自己赢了,玩家也是要骂娘的。所以,客户端中需要使用战场初始状态中的统一随机数种子来生成随机数。实践中,还需要使用一致的随机数算法,比如梅森旋转。

浮点数

浮点数在不同的平台上,并不能获得一致的计算结果,因此所有数值均需要转为定点数。我们可以约定小数点的位置,或者约定缩放系数1。定点数的运算更快,可移植性更多,

其他

战斗逻辑中使用的碰撞检测等算法、物理引擎,基础数据表等均需要保持一致。

一致性检测

客户端在计算完一帧的指令后,将此帧的状态计算 hash,并发送给服务器;服务器根据投票结果决定标准 hash 并广播。客户端发现差异后,重新查询相关帧指令,重新计算战场数据。

客户端渲染

战斗过程中的玩家操作,可以实时地或经过精简处理后,发送给服务器。服务器接受该操作之后,会在下一帧广播给所有玩家;玩家收到指令确认后再进行计算渲染。

sequenceDiagram participant C as Client participant S as Server C -) S: Press Up S -) C: 'Client' Press Up Note over C: Draw

也可以根据进行进行乐观预测,提前渲染表现,以提升玩家体验。一旦预测错误,再进行纠正补偿。

补帧纠正

当玩家因为网络变动或数据、算法问题,而发现自己错过某一帧时,可根据本地的战场状态快照,向服务器申请自其之后的全部指令,执行加速计算和渲染,尽量追上服务器的游戏进度。

战斗结算

结算时,由各个客户端上报胜负结果,服务器取多数共识即可。

作弊检测

检测游戏时,并不需要加载游戏的地图和人物角色等渲染资源,可以快速地计算整个战斗过程。因此,在战斗开始后,即可使用客户端同版的纯数值算法,比真实游戏进度稍慢地,验证整个战斗,这样也能在关键节点作出快照(SxS_x),以便加快战斗中的断线重连。

一些增强

  • 每帧的客户端回传数据中,可包含一些关键的事件,比如击杀,以便成就等附加系统的计算统计。
  • 使用 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;
  }
}

其他协议如战斗结算等略。整个战斗过程如下图:

sequenceDiagram participant A as Client A participant S as Server participant B as Client B Note over A, B: BattleCreated activate A activate B A -) S: BattleLoaded deactivate A B -) S: BattleLoaded deactivate B Note over A, B: BattleTurn(turn=1) loop A -) S: BattleTurnOperate(operations=[...], prev_turn=pt1) B -) S: BattleTurnOperate(operations=[...], prev_turn=pt2) end loop /66ms Note over A, B: BattleTurn(operations=[...], turn=x) end Note over A, B: End

具体的战场数据结构和战斗结算,可由战斗服务器之外的节点决定;具体的指令结构,则由战斗逻辑模块决定。

结语

无论战场有多么复杂,在客户端与服务器之间只传输战场初始数据和战斗指令,战斗由客户端计算,服务器只负责调度和记录关键数据,大大降低了服务器的负载和延迟。同时还带来一些额外的好处:

  1. 战斗回放仅需要初始数据和战斗指令,录像文件体积小;
  2. 客户端可以加速和跳过画面渲染,实现加速播放战斗;
  3. 方便做观战与直播的功能。

还有一些有意思的副作用:

  1. 不同版本的客户端战斗逻辑可能不同,不能匹配战斗;
  2. 进入战斗前,全部玩家需提前准备战场数据,加载战斗场景;
  3. 客户端为了本地流畅会对玩家的操作做实时的渲染,但与服务器不一致时会有回退,表现为网络不佳时玩家位置和状态发生回溯;
  4. 网络不佳后恢复时,客户端为追赶游戏进度,玩家会看到遗漏的游戏过程加速播放;
  5. 客户端闪退后重新进入战斗,需要重新 Loading 战场数据和战斗帧数据。

参考

  1. WikiPedia. 定点数运算