TPS游戏网络同步总结

  欢迎参与讨论,转载请注明出处。

前言

  因友人的项目要做TPS联机对战游戏,本人遂对此进行了一番研究,经过四回的辗转反侧,Demo总算是做出来了。本次Demo是C/S一体化的设计,即服务端也是Unity做的(可选择1P兼任服务器或者将Unity以命令行模式运行于服务器)。网络模块采用了UDP+KCP,即先前BNB的强化版,而之所以没用UNet是因为之前搞出了乌龙所以换了现在这套,但序列化部分还是用的UNet。以上只是背景交代,本文仅聚焦于网络同步方面的细节。

实现思想

  如果你对这方面有所涉猎,想必大致了解何为状态同步。市面上的大多文章将其与帧锁定同步对立而论,但本人认为两者并非是对立的存在,关于这点这篇文章讲的非常清楚,希望读者不要拘泥于形式。在阐述详细的实现思想之前,我们先来看看FPS/TPS游戏的需求:

  • 非常迅速的操作反馈(若采用服务器应答后方有反馈的设计,很难达到要求,尤其是操作镜头) → 本地先行
  • 个人体验第一(对于是否命中敌人与被命中不是很敏感) → 玩家之间看到画面情况不一致
  • ACT元素低(不存在ACT游戏的打击控制链,不需要帧判定) → 不需要精确到帧的同步
  • 服务器权威(命中判定由服务器决定) → 服务端模拟游戏世界、同步验证
  • 房间战斗(玩家人数不多) → 与MMORPG同步不同
  • 相对同步(玩家之间的时间差不可拉得太大) → 追赶进度

  Well done,由以上几点需求已经得出了TPS游戏同步的实现思想,下文将根据实现思想阐述具体实现细节。

快照

  在探究同步流程之前,首先要了解同步的核心:快照。换言之,也就是我们所同步的内容。快照(Snapshot)通俗来讲就是玩家的操作指令与相关数据的集合,由于需要做同步验证,所以将数据分为必要数据(Must)与验证数据(Check),先来看看移动的快照数据结构吧:

1
2
3
4
5
6
7
8
9
// Actor/Common.cs
public class Move {
public string fd; // Address:Port(Must)
public int frame; // Game Frame(Must)
public bool fromServer; // It is from server, or client?(Must)
public Vector3 velocity; // Moving Velocity(Must)
public Vector3 position; // Position before moving(Check)
}

  如上文所示,position为移动前的坐标,像这类数据客户端是不需要上传的,仅用于与服务端传来的快照作对比,以进行同步验证。

同步流程

  由于服务端模拟游戏世界,所以采用了C/S一体化的设计。在代码层面上则是分为ServerMgrClientMgr两个MonoBehaviour,ServerMgr负责收集客户端的快照并整合下发,而ClientMgr负责发送快照与模拟来自服务端的快照以驱动同步单位的运行。如下图所示:
flow
  图中所说的同步快照,是一种特殊的快照列表,它由服务端每帧打包,包括了多个客户端的一帧快照,客户端模拟它们即可驱动其他客户端代表的对象。采用这种同步流程只能保证在客户端是同一帧生成的快照,在服务端也会打包到同一个同步快照里。除此之外都不会保证(不会考虑到快照之间的帧间隔执行情况),即不需要精确到帧的同步

追赶进度

  在正常的同步过程中情况总是理想的,但是一旦出现网络延迟或卡住的话,在恢复之时便会面临大量的快照,那么按照现有的做法便会导致与其他玩家的时间轴拉得太远(看到的画面是很久以前的了),这便需要设计追赶进度的机制。需要注意的是,追赶进度是服务端与客户端都需要的(服务器也有网络延迟和卡住的可能),客户端的追赶处理相当简单,同步快照超过一个数量则循环模拟:

1
2
3
4
5
6
7
8
9
10
// ClientMgr.cs
if (this.syncList.Count > 0) {
this.Simulate();
// SYNCMAX = 15
while(this.syncList.Count > SYNCMAX) {
this.Simulate();
}
}

  服务端方面则较为复杂,简而言之就是要知道每个客户端快照列表有多少帧(如4个快照,帧号分别为1, 2, 2, 3,则为3帧),当某个每个客户端快照的帧数过高,则循环打包到同步快照列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ServerMgr.cs
var list = new List<Snapshot>(); // sync-snapshot
// Foreach all clients.
foreach (var i in this.unitMap) {
int frame = -1;
var sl = i.Value.list;
// INTERVAL = 10, i.Value.count that is count of frame.
while (sl.Count > 0 && (i.Value.count > INTERVAL || (frame == -1 || sl[0].frame == frame))) {
var s = sl[0];
list.Add(s);
sl.RemoveAt(0);
if (frame != s.frame) {
frame = s.frame;
i.Value.count--;
}
}
}

本地先行

  本地先行可谓这类同步最玄学之处,不过只要了解其原理倒也无甚。需要本地先行的理由在上文已经阐述,由于是以服务端权威且不那么介意判定的问题,所以是可以允许玩家之间看到画面情况不一致这种情况的。况且在大多数场合下,玩家先行并不会造成什么问题(最终的结果趋于一致),但假设在这么一个场合下:玩家A一直行走,在玩家B的视角里对玩家A进行了眩晕。如此便会造成不同步了,所以需要进行同步验证以将问题修正。
  要实现同步验证的思路倒也朴素:就是用一个验证列表将快照保存,当收到同步快照列表时就进行逐个对照(对比它们的验证数据,见前文),一旦发现不一致之处,就以当前位置开始,循环模拟同步快照,然后再继续循环模拟验证列表里进度比目前快的快照,追上最新进度:

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
// ClientMgr.cs
// Compare sync list and check list.
for (int i = 0; i < list.Count; i++) {
if (!list[i].Equals(this.checkList[i])) {
index = i;
print(i);
break;
}
}
if (index == list.Count) { // Agreement
this.checkList.RemoveRange(0, list.Count);
}
else { // Need to fix.
var frame = list[list.Count - 1].frame;
// Remove useless snapshots.
for (int i = this.checkList.Count - 1; i >= 0; i--) {
if (this.checkList[i].frame <= frame) {
this.checkList.RemoveAt(i);
}
}
// Loop simulate.
ClientMgr.Resolve(this.fd, list, index);
ClientMgr.Resolve(this.fd, this.checkList, 0);
}

服务端权威

  从上文可以看出,本地先行会修正的范围只有本地玩家而已,回到之前的例子:在玩家B的视角里对玩家A进行了眩晕,假设这个行为在服务端上并没有达成(玩家A闪现走了),那么该如何修正呢?很显然可以选择搞个更大的修正系统,但我认为这样并不符合业界的常规做法,所以我给出的答案是: 眩晕行为需要在服务端触发了,然后由服务端将其作为快照,以正常同步的形式在诸客户端上展示。事实上在网络正常的情况下,这样的间隔最多也只是0.1x秒左右而已,完全可以接受。当然这么做对于玩家B而言肯定会发生修正(眩晕按理来说是之前的事了),所以我对此作了个措施: 为快照设计了fromServer属性,一旦是fromServer = true且属于本地玩家的快照,本地玩家会直接模拟而不会将其进行修正对比。这也可以看出这套同步的一个规则:会影响他人的操作,都需要由服务端发起

后记

  很显然,目前这个demo仍很不成熟,不少地方在业界应该会有更好的处理,如CS的射击纠正(服务端根据客户端的射击时间回滚之前的场景进行判定)。如此只能算是一个雏形,还是缺少实战项目的淬炼,先根据接下来的项目看看效果吧。