《Brick & Ball》开发总结(一)——帧锁定同步

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

前言

  辗转反侧三个月,《Brick & Ball》(下称BNB)的开发工作总算告一段落了,游戏也顺利地在TapTap上架以及在Github开源了。接下来将会对帧锁定同步、服务端、游戏性三方面进行开发的总结,预计会用三篇完成,敬请关注。
  由TapTap上的介绍可知,BNB是拥有联机对战模式的,而联机对战的重点自然在于同步,本文便对同步的实现的相关问题做个总结。

实现思路

  在阅读此文之前,你需要对帧锁定同步有个大致的了解,关注我的博客应该知道在去年我已经对此做了个初步的探究。从现在来看当时的实现还不够好,于是很有必要重新梳理一遍。
  若是有尝试过实现帧锁定同步的朋友相信对于传统的帧锁定同步(Lockstep,所有玩家的延迟都是延迟最差的那位)实现还好说,但对于“乐观帧锁定(不会等待延迟高的玩家)”的实现,则是各说纷纭。除了上文所说的那种方式(服务端主动每隔一段时间广播,客户端输入数据随时上传),还有一种《Warcraft III》的实现(在采用传统帧锁定同步的基础上,服务端设定等待时长,超时则继续),个人认为这种实现更为靠谱,也通过这次实践证明了其可行性。

帧锁定

  顾名思义,帧锁定同步分为帧锁定与同步两大部分。所谓帧锁定,个人认为便是将Update部分严格管控起来,以此修正了玩家之间帧率不一致的问题以及收到多个输入包时的快进处理。接下来看看其具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void Update() {
this.updateTimer += Mathf.CeilToInt(Time.deltaTime * 1000);
//DT = 17
while (this.updateTimer >= DT) {
this.client.Update(); //Receive packet
if (this.playDataList.Count > 1) {
var lateFrame = this.frame;
this.sendInLoop = true;
do {
this.client.Update(); //Receive packet
this.LockUpdate(true);
} while(this.playDataList.Count == 1 && this.frame == lateFrame);
//Go to latest process
}
this.LockUpdate();
this.updateTimer -= DT;
}
}

  帧锁定的核心便在于此,在每次Update进行时间累积,只有累积到了额度(DT)后会进行真正的更新(LockUpdate),且每次更新后会扣除额度,以求最精确,这里对计时进行毫秒化也是为此。这种手段在Unity被称为FixedUpdate,当然我们的需求不仅于此,因此并没有使用它。
  除此之外,便是收到多个输入包进行快进的处理了。这里的this.frame代表当前进度下的帧号,每当进入下一个进度后便会清0。于是我们只要快进到最新进度下的当前帧即可,随后再进行一次正常的LockUpdate。当然不要忘记快进时也有必要进行接收数据包,因为在快进时仍可能有后续输入包的到来。

LockUpdate

  接下来便来看看LockUpdate的具体实现:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
private void LockUpdate(bool inLoop=false) {
//WAITTING_INTERVAL = 5
if (this.online && this.frame + 1 == WAITTING_INTERVAL && this.playDataList.Count == 0) {
return;
}
if (this.online) {
this.frame++;
if (this.frame == WAITTING_INTERVAL) {
var data = this.playDataList[0];
this.playDataList.RemoveAt(0);
if (Judge.IsRunning && data.addrs != null) {
//Apply later input
for (int i = 0; i < data.addrs.Length; i++) {
Judge.Input(data.addrs[i], data.inputs[i]);
}
}
this.playFrame++;
this.frame = 0;
if (!inLoop || (inLoop && this.sendInLoop)) {
//Send now input
var input = new Datas.Input() {
data = new InputData() {
movingValue = Networkmgr.MovingValue,
willElaste = Networkmgr.WillElaste
},
frame = this.playFrame
};
this.sendInLoop = false;
Networkmgr.WillElaste = false;
this.client.Send(EventCode.Input, input);
}
//Send comparison data
var comparison = new Datas.Comparison() {
playFrame = this.playFrame,
content = Judge.Comparison
};
this.client.Send(EventCode.Comparison, comparison);
}
}
//Game world update
Networkmgr.UpdateEvent();
Networkmgr.LateUpdateEvent();
}

  LockUpdate主要做的事情为每隔一定帧数(WAITTING_INTERVAL)上传当前的操作输入,以及应用上次的操作输入(来自服务端),如果到了关键帧时上次的操作输入包仍未抵达,则会陷入等待。也就是说,帧锁定同步其实就是一种每隔几帧的回合制而已。具体的流程图可参考Skywind的提供:
framelock

  当然这里还有个细节要注意:在处于快进的时候,上传输入只会在初次进行,因为在快进下实际上能响应到玩家的操作其实一开始就定下了,后续进行的上传也只会是相同的,所以没有意义。

LockBehaviour

  由LockUpdate可知两行代码Networkmgr.UpdateEvent();Networkmgr.LateUpdateEvent();,它们是两个event,负责绑定执行整个游戏涉及到帧锁定的Update,毕竟Unity并不存在主宰一切的主Update,所以只好用这种方式实现。为此专门涉及了LockBehaviour:

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
29
30
31
32
33
34
35
public class LockBehaviour : MonoBehaviour {
public enum OrderType {
Normal,
Late
}
[SerializeField]
protected OrderType orderType = OrderType.Normal;
protected void Awake() {
if (this.orderType == OrderType.Normal) {
Networkmgr.UpdateEvent += this.LockUpdateWrap;
}
else {
Networkmgr.LateUpdateEvent += this.LockUpdateWrap;
}
}
protected void OnDestroy() {
if (this.orderType == OrderType.Normal) {
Networkmgr.UpdateEvent -= this.LockUpdateWrap;
}
else {
Networkmgr.LateUpdateEvent -= this.LockUpdateWrap;
}
}
private void LockUpdateWrap() {
if (this.isActiveAndEnabled) {
this.LockUpdate();
}
}
protected virtual void LockUpdate() {}
}

  LockBehaviour继承于MonoBehaviour,且设立了LockUpdate函数,启动后便会对UpdateEvent进行注册,同理销毁后便会去除。如此一来涉及到帧同步的组件只要继承LockBehaviour并将业务写在LockUpdate便可。当然由此可以看出,Unity官方实现的组件并不吃这套,所以为此我专门引入了一款第三方物理引擎——Jitter

同步优化

  由于BNB的操作方式并非点击鼠标、按下键盘这种间歇性操作,而是最为不适合用于联机的拖动。所以在正常情况下的表现效果很差(每隔5帧瞬移一下,形成顿顿的感觉),于是PVP模式下采取了与PVE不同的拖动表现:赋予拖动表现为流畅变速的运动,当然在那短短的时间里是不可能做出流畅的运动表现的,所以选择运动的时间基准其实更长(0.25秒)。当收到新的输入时便会直接完成运动(直接到目的坐标)且继续新的运动。使用这种方式达到了相对不错的效果,当然代价便是砖块的运动相应并非实时性的,变相增加了操作难度。这也是无奈之举,好在这实际上是公平的(双方皆如此)。
  除此之外便是为向上拖动做了缓存处理,只要你曾进行了此行为,便会标记你做了该行为,在下一个进度时生效。这样比之到了关键帧时才响应操作要好多了,增加了操作的命中率。

浮点数处理

  帧锁定同步的一大心病便是不同设备下浮点数的处理结果不一致导致的不同步,著名的解决方案有定点数和尾数截断。而BNB采用的方式为尾数截断,其实现方式为:

1
2
3
4
5
6
7
public static float ToFixed(this float value) {
return Mathf.Floor(Mathf.Abs(value * 1000)) * 0.001f * value.ToDirection();
}
public static int ToDirection(this float value) {
return value >= 0 ? 1 : -1;
}

  这种尾数截断的方式便是主动限制小数点范围,以减少精度的方式阻止错误的发生。如此运用在各种涉及到同步方面的浮点数进行处理即可。而在这方面最大的敌人便是物理引擎了,众所周知物理引擎拥有自己的生态环境,牵涉内容甚多,经过一番艰苦尝试后最终选择了放弃修改其内核,而是改为自己实现物理运动。毕竟物理引擎的两大组成为运动和判定,如此只使用其判定即可。
  当然这浮点数的处理总体而言仍是涉及甚广,所以需要进行专门的监控调试。LockUpdate里的comparison即是为此,其涉及属性Judge.Comparison内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static string Comparison {
get {
var sb = new StringBuilder();
var pos = Ball.Position;
var vel = Ball.Velocity;
sb.Append(pos.x + ",");
sb.Append(pos.y + ",");
sb.Append(pos.z + ",");
sb.Append(vel.x + ",");
sb.Append(vel.y + ",");
sb.Append(vel.z + ",");
sb.Append(INSTANCE.teamA.brick.transform.localScale.x + ",");
sb.Append(INSTANCE.teamA.brick.transform.position.x + ",");
sb.Append(INSTANCE.teamA.brick.transform.position.z + ",");
sb.Append(INSTANCE.teamB.brick.transform.localScale.x + ",");
sb.Append(INSTANCE.teamB.brick.transform.position.x + ",");
sb.Append(INSTANCE.teamB.brick.transform.position.z + ",");
sb.Append(INSTANCE.teamA.wall.transform.position.z + ",");
sb.Append(INSTANCE.teamB.wall.transform.position.z + ",");
return sb.ToString();
}
}

  是的,很粗暴的处理方式,将游戏影响同步的相关数据进行文本化,在每个关键帧都将其上传令服务器进行匹配,当然也可以选择做成MD5码,当然这样便无法知晓具体哪个部分出了问题,故直接上传。

后记

  总体来说帧锁定同步涉及的内容还是挺多的,另外还有诸如防作弊之类的问题没有探讨,因为我认为BNB没有做反作弊的必要(小游戏)。具体许多细节还是要亲力亲为去实践一遍方可出真知。