跳至正文

联机同步(一)基本概念

之前写弹幕AI的时候我们提到了神奇的AI数组,我曾经说过是因为它能更好的进行变量存储和联机同步。具体来说就是,AI数组的内容会在需要同步的时候与服务器端、客户端进行同步,确保在两个终端里面AI数组的值都一致。同时,TR内部也会同步弹幕的位置,速度等基础属性。在自动机的AI模式下,如果AI数组里面的值一致,我们就可以认为这个物体它在行为上基本是一致的。但是只同步ai值就足够吗?事情远没有那么简单。

根据NPC行为的不同,我们很可能需要用不同的手段去进行联机同步。此外,为了妥协正确性和流畅性,我们经常要进行取舍。而本系列教程将会讨论如何正确的进行联机同步,包括基础概念、游戏机制和实现方案。由于内容较多,本系列教程会分为很多个章节。本章我会介绍联机同步的基本概念以及背后的原理,同时还会介绍一部分TML提供的联机同步API。提醒一下,联机同步的代码其实不是很容易debug,因为你需要一个客户端和一个服务器端同时运行代码,错会出在哪一个终端上不好判断。而且有些时候甚至需要不止一个客户端来观察行为,所以这部分代码编写的时候难度是比较大的,需要仔细思考以及对原版联机模式的深入理解。

本章还是使用1.3的TML而不是1.4,如有差异请麻烦告知一下(懒)。


联机同步基础

为什么要同步

对于联机游戏来说,为了让玩家感觉到所有的玩家都在同一个世界里,需要“欺骗”玩家的眼睛,保证每个玩家在屏幕上看到的东西大体是一样的。客户端的玩家认为他们和其他人一样都在同一个世界中,但是事实并非如此。玩家自己的客户端只是对“同一个”虚拟世界的近似模拟。换言之,他们的游戏世界每一个都是独一无二的,只不过通过各种交互反馈和同步手段来让玩家感觉他们在同一个世界里。就如同这个动画所示,玩家的移动在所有的客户端中显示的位置应该都差不多。

如果不进行同步,那么就等同于在玩单机游戏。如果同步的策略不对或者遇到高延迟,那么就等同于在玩灵异游戏

网络延迟怎么产生的

为什么我们只能达到近似呢?这是因为无论我们设计多么精妙的联机同步算法,都不得不面对一个共同的敌人——网络延迟。他们会使我们每个客户端的运行结果看起来像是这样:

以同步玩家的操作为例,当一个客户端中的玩家进行了一次移动操作以后,我们需要把这次操作的信息发送给服务器。我们通常会把这个数据处理成一个数据包,然后通过一系列的网络协议和中继设备传输到目标服务器的进程。而数据包从一个主机传输到另一个主机是需要时间的,这个时间的长短取决于这四种类型的延迟:

  1. 处理延迟(Processing delay):把数据打包成数据包的过程也是要消耗时间的,同时,路由器接收到数据包的时候也会去解析封包的数据。这部分的延迟会跟目标的硬件(如CPU)有较大关系。同时要注意,大部分情况下从原点到目的地不仅仅只经过原点的路由器和目标的路由器,而是中间会经过很多中继路由器,它们会将收到的封包转发到下一个地点。每次经过中继路由器都要进行处理,如果其中有一个路由器处理速度很慢,或者带宽很低,那就会显著拖累整个传输的时间。
  2. 队列延迟(Queuing delay):在网络中不止有你一个用户在发送数据,甚至在主机端也有很多个应用程序同时在发送数据,这个时候你的封包可能就需要等待一段时间直到在你之前的封包都已经送出或者调度系统认为轮到你了。对于中继路由器和目的地来说,它们也需要同时接收很多个数据包,什么时候才轮到你不好说。一个很形象的比喻是堵车。。。
  3. 传输延迟(Transmission delay):也叫发送延迟,是将数据包推送到链路所花的时间。这个时间是由数据量和路由器的带宽决定,假设一个路由器的带宽是100Mbits/s,那么它一秒内大约能推送100兆个比特的数据进入传输媒介。如果你有一个200Mbits的数据包,那么大概就需要两秒钟才能把它完全推送进传输媒介。
  4. 传播延迟(Propagation delay):这是数据传输的物理瓶颈,因为光、电信号在有线介质上的传播速度不会超过光速。一个数据包从中国发到美国,即使使用一根海底光缆连接,也需要100ms以上的时间才能到达目的地。这个延迟没有办法避免。
延迟例子

图中就是一个典型的延迟现象,客户端做的操作要一段时间后才能到达服务器,但是此时在客户端上你已经开始做别的操作了,于是玩家就会误判操作的实际效果,就有可能出现各种奇怪现象,这取决于游戏是如何同步的。

除此之外,如果我们把结果交给服务器端去验证,延迟的出现就会让客户端的判定和服务器端的判定失去同步。假设你朝着敌人开了一枪,在客户端上你打中了敌人,但是服务器端中敌人处在不同的位置,这就有可能会出现客户端打中但是敌人没死的情况。但是如果你把伤害判定交给客户端,那么就有可能出现高延迟玩家乱杀的情况,还会各种外挂可乘之机。所以这中间的取舍还是有很多学问的。

其中有些延迟可以通过花钱降低(比如拉一根海底光缆,换高端路由器),但是无论如何我们都无法越过物理规律。所以我们必须接受延迟的存在,并且在编写联机同步代码的时候充分考虑他们。

为什么会丢包

相比于延迟,丢包会造成更大的影响,延迟会让你的数据包经过很长一段时间才到达服务器,而丢包就是完全无法到达服务器。而丢包现象产生的原因可能比延迟还要复杂,比如因为噪声干扰使得包无法通过完整性校验、交换设备的队列满了,你的包没法被转发、物理传输设备故障等等。

一旦出现丢包,我们可能就需要重新发包,好在像TCP这样的可靠传输协议帮我们进行了这样的操作,但是这也会加剧延迟。所以总而言之,延迟和丢包这两个因素对于设计可靠的联机同步系统是很大的挑战。我们要保证在出现这两种故障的时候不出大问题。

简单案例(反面)

我们的第一个联机同步例子是一个跟随鼠标运动的弹幕,就像魔法导弹一样。如果你从来没有考虑过联机同步,那么写出来的代码可能是这样:

public override void AI()
{
    projectile.velocity = Chase(Main.MouseWorld);
}

如果你真的这么做了,那么你就会在联机的时候发现一个神奇的现象:别人的魔法飞弹是跟着你的鼠标走的。由此会产生各种奇怪的现象,因为你、你队友和服务器端里这个弹幕会出在三个完全不同的位置。我们在某个端中让这个弹幕打中了一个敌人,而别的端里这个弹幕并没有打中会怎么样呢?

造成这个现象的原因是 Main.MouseWorld 只会保存当前端的鼠标位置,而弹幕的AI函数会在所有的端中运行。

鼠标从一个客户端划到另一个客户端

这时候我们就出现了逻辑错误,因为从逻辑上分析这个弹幕不是由你控制的,而是弹幕主人控制的位置。所以我们需要让弹幕的位置在所有终端中都处在弹幕主人的鼠标位置。

但是如果这个弹幕是沿着直线运动的,那么我们除了同步初始发射位置等基本属性以外,可能没有必要去对运动路径做任何同步。

另一个例子就是之前写过的随机游荡的弹幕,下图就是开了两个客户端在同一个服务器上运行的效果,可以看到这个弹幕轨迹在两个客户端上完全不同,这就是一个很大的问题了。

那么我们如何解决这个问题呢?我在这里先卖个关子,等到讲完联机同步方法论以后我们再来看看。


联机同步方法论

Client/Server 模型

因为目前使用的最广泛的多人游戏通信模型是客户端/服务器端(Client/Server)模型,并且这也是TR所使用的模型,所以本文只会关注CS模型,像P2P等其他分布式架构不在讨论范围内,有兴趣可以自行了解。

Client/Server 模型由一个服务器端和多个客户端组成,并且客户端们只会和那唯一的服务器端联网,而不会互相之间联网,所有消息即使是传给另一个客户端也是要先经过服务器端。

CS模型

客户端主要负责读取用户输入,并且渲染游戏内的表现,有时候还会进行一部分的逻辑计算,不过这部分细节比较多。

服务器端主要负责接收客户端发过来的操作,验证并且模拟结果,最后向客户端们发送同步状态。

由于只有一个服务器端,并且一般来说服务器端包含了最权威的游戏逻辑内容,所以有时候服务器端也叫做权威服务器(Authoritative Server),所有的逻辑都向着服务器端看齐。但是对于像鼠标操控的弹幕而言,我们得以客户端的数据为准,所以服务器也没有那么权威,具体服务器有多权威还是要看需求。

网络传输协议

在主机之前传输数据有两个经常被提起的网络协议:TCPUDP。TR使用的通信协议是 TCP 协议,它的优点是稳定可靠,可靠指的是在发现丢包以后可以重新发送。除此之外,TCP还提供流量控制顺序控制等等功能来保证稳定性。其中顺序控制是个很重要的作用:它能保证先发出去的包一定能比后发出去的包在目标服务器的队列里靠前。

主机每次发送数据时,TCP就给每个数据包分配一个序列号并且在一个特定的时间内等待接收主机对分配的这个序列号进行确认,如果发送主机在一个特定时间内没有收到接收主机的确认,则发送主机会重传此数据包。接收主机利用序列号对接收的数据进行确认,以便检测对方发送的数据是否有丢失或者乱序等,接收主机一旦收到已经顺序化的数据,它就将这些数据按正确的顺序重组成数据流并传递到高层进行处理。

而流量控制也很重要:TCP能够根据当前网络状态选择一个合适的发送速率,既要足够快地发送数据报,以便使用网络容量,但又不能引起网络拥塞。总而言之,TCP为了保证这些优点进行了繁杂的操作(比如三次握手四次挥手),是一个非常有趣的协议,但是缺点是启动和对于延迟和丢包的响应会很慢。

三次握手示例

另一种常用的协议是 UDP 协议,优点就是延迟低而且允许自定义,但是由于它并不保证发出的包一定能被收到,所以需要程序员去手动纠正,所以想要保证正确性工作量很大。

一般来说我们会喜欢TCP的稳定性和可靠性,但是对于延迟敏感的逻辑可能得使用UDP来确保丝滑的操作。

帧同步和状态同步

对于联机同步模型,现在主流的方法有两种:帧同步状态同步

帧同步是指服务器端只转发操作,而不做任何处理,也就是说,服务器端不会维护一个世界,而是把玩家发过来的操作广播给所有客户端,每个玩家的客户端收到消息以后就在自己的客户端进行逻辑计算。

帧同步方法是基于一个原则:相同的输入+相同的时机=正确的行为。相同的输入很好理解,就是说同一个操作引发的逻辑计算都是相同的,比如一个玩家移动指令将玩家移动到某坐标 (x, y),那么每个终端里的运算都应该把玩家以相同的方式、路径移动到 (x, y)。

相同的时机这个概念就不是那么好实现了,为了让所有操作都在正确的时间发生,我们需要让客户端和服务器端协调进行锁帧。只有当服务器端收集完客户端这一帧的操作并转发以后,才可以通知所有客户端去运行下一个逻辑帧的计算。而客户端的操作就是在这一帧收集玩家的操作,并且告诉服务器端,然后根据服务器传过来的操作去模拟这一帧的逻辑运算。

帧同步演示

帧同步的优点在于需要传输的只是操作,所以传输量一般会比较小,服务器端的负担也很小。同时因为只同步操作,所以制作游戏回放会很容易。

然而,因为帧同步把逻辑运算放到了客户端,反外挂的难度大大增加,对于非对称信息的游戏,帧同步会导致开全图挂十分容易。除此之外,遇到断线重连的时候服务器端需要把这段时间玩家的所有操作都同步给客户端才能还原游戏状态。由于锁帧的存在,延迟也会导致整个联机服务器连接的所有客户端都会卡顿。还有一个开发难度来自于难以确保所有操作在所有客户端中的一致性,比如随机数序列和浮点数。


状态同步与帧同步相反,它把大部分逻辑运算都放在服务器端,服务器端就是绝对的权威,然后在必要的时候向客户端传输这些属性,客户端会通过传过来的状态复刻和展示服务器端的世界。同时,客户端向服务器端发送的信息可以是属性也可以是操作,服务器端会把这些信息拿来进行验证和计算。

还是以玩家移动为例子,一个典型的状态同步过程差不多是这样的:

  1. 玩家输入移动指令,客户端将移动指令发送给服务器端
  2. 服务器收到玩家的移动指令,在这一帧计算玩家应该处在的位置和状态,然后把这个状态发给所有客户端
  3. 客户端收到玩家的位置状态,把目标玩家的属性设置或者插值到目标状态,并且显示出来
状态同步示例

看到这里,你可能会发现这个状态同步会对延迟非常敏感,因为在按下移动键的时候需要等待封包发到服务器端,并且计算出结果以后返回才能看到玩家的移动。这就会导致操作出现卡顿感,这就是朴素状态同步模型会出现的问题,不过在联机同步(二)里我们会讲到这个模型是如何改进来避免这个问题的。

状态同步的优点之一就是安全性,只有服务器端是全知全能的,负责计算逻辑,此外服务器端还可以选择必要的状态去同步给客户端,这样也避免了开全图挂的可能性。还有一个优点是断线重连、丢包的恢复非常好做,只要把服务器端的状态同步给客户端就好了。

状态同步的缺点也非常多,同步状态的数据量可能要比同步操作大很多,服务器端也要承担逻辑计算,所以压力非常大。此外,制作游戏回放也不像帧同步一样轻松。对于延迟的情况,状态同步的表现会和帧同步不同,状态同步会引发瞬移,穿墙,打人不掉血等等问题。但是话又说回来,只要出现延迟怎么可能不影响游戏体验呢?

TR使用的同步模型混合了这两种模式,但是更类似于状态模式,因为没有锁帧操作。有些情况下你只需要把操作同步到所有终端(比如物品使用),有时候却需要同步一个物体的所有属性(弹幕、NPC),我们需要根据具体情况去决定用哪种同步方式。


补充一个单词叫做Ad-hoc,当我们认为对于一类问题没有最优的通解,即需要根据问题具体情况才能给出最优解决方案的时候就说这个问题得用adhoc解决方案。翻译成中文可以说是:具体问题具体分析。


由于TR并不算是一个竞技游戏,所以反作弊有时候不那么重要,我们并不一定需要在什么时候都用严格的状态同步。但是状态同步的整体思想是非常适合泰拉瑞亚这样的游戏的。

哪些信息需要同步

并不是游戏中的每个元素都要同步的,就比如说渲染和表现部分,服务器不关心客户端用的是低画质渲染还是高画质渲染,客户端也不会关心其他客户端用的哪种光照模式。我们真正需要关心的是影响游戏逻辑部分的同步,比如玩家的位置、血量,物品的掉落,弹幕的发射等等。

比如说,一个随机转向的弹幕就要实时同步它的位置,速度,否则因为随机数序列的不同,在你的客户端中弹幕是在一个位置,而别人的客户端中弹幕是另一个位置,这时就出现不同步了,所以这部分就不能交给客户端计算,而是完全让服务器端计算这个弹幕的属性,然后告诉客户端。弹幕所喷射出的粒子效果尽管也是随机的,但是由于大部分情况下不会影响任何游戏判定,所以没有必要同步,只要在正确的时间播放了就行。因为服务器端没有UI界面,所以服务器端并不需要任何绘制效果,所以这部分代码肯定也是不用同步的,我们就交给客户端计算就好。

在这里我还想再提出一个概念叫做同步依赖关系。假设你有一个NPC,它的冲刺轨迹会朝向玩家的位置。那么此时NPC轨迹的正确性就会依赖玩家位置的正确性。再比如追踪弹幕,追踪弹幕的运动轨迹会跟NPC的位置有关,所以NPC位置的正确同步也会影响追踪弹幕的正确性。

如果我们能够满足依赖关系的上位物体的正确性,比如NPC位置的正确性,那么追踪弹幕也就不需要严格的同步了,因为他的计算结果在每个终端中应该都是差不多的。

如果出现相互依赖怎么办?那么除非有外部验证方式,否则它们的误差会随着时间被无限放大。具体可参考:为什么三体问题“无解”?

另一个问题在于,游戏对正确性的敏感度到底如何?如果追踪弹幕的轨迹是错的,那么它中途可能会被别的敌人阻挡。但是身为玩家,在自己的客户端中看弹幕确实是被阻挡的,它的轨迹是合理的,即使之后服务器端给出不同的结果,你也大概率不会注意到。但是如果你的轨迹是正确的,但是在你的客户端中敌人不在那个位置,你就会发现自己的跟踪子弹貌似没有打中任何敌人,这显然不合理,你就会发现异常。所以从这里可以看出,正确性有时候并没有那么重要,我们做游戏归根结底还是要让玩家感觉对。

TML联机同步实现

TR的联机同步机制我们会在接下来的几篇文章去讲解,本章还是带你们熟悉一下TML的联机同步接口,以及如何修复常见联机bug。

如何判断代码跑在哪个端

首先我们要确认代码运行在客户端还是服务器端,这里我们要用到 Main.netMode 这个字段。我们可以在 NetmodeID 里面找到它的意思,0代表单机模式,1代表联机模式客户端,2代表的就是服务器端。为了搞清楚两个案例出错的原因,我们先要利用这个属性找到弹幕到底在哪个端上运行!

NetmodeID 的定义

于是我们可以在任何一个重写函数,比如弹幕的 AI() 里面这么写:

if (Main.netMode == NetmodeID.Server) {
    // 因为服务器是控制台,所以要把信息写进控制台里
    Console.WriteLine("服务器端运行");
} else {
    // 否则写在左下角文字即可
    Main.NewText("客户端运行");
}
服务器端和客户端都在运行

那么问题就很明显了,由于弹幕的AI在客户端和服务器端都会更新,像 Main.MouseWorld 这个字段在这里用就会出现逻辑错误。

代码到底运行在哪个终端很重要,如果不清楚,写代码之前要先检测一下!

值得一提的是, Main.netModeMain.MouseWorld 的性质其实是一样的,都是与终端有关的字段。类似的字段还有 Main.LocalPlayerMain.myPlayer 等等。Main.myPlayer 的值其实是你这个终端上的玩家ID,每个客户端上的 Main.myPlayer 都是玩家自己,但是服务器端的 Main.myPlayer 却是一个不存在实体的id:255。所以说,如果你让Boss跟踪 Main.myPlayer 那就出问题了,每个客户端跟踪的人都不一一样,而服务器端又在跟踪一个不存在的玩家,那会出什么事呢?

修复跟随鼠标弹幕

了解了出错原因以后,我们应该怎么修复它呢?先明确一下我们的需求,我们只想让弹幕跟踪弹幕主人的鼠标,其他人的鼠标不能影响到弹幕。

假设我们要修的弹幕叫做P1,而它的主人是A。一个很容易想到的办法就是A客户端每帧都向服务器端更新A的鼠标位置,然后由服务器端把这个鼠标位置广播到其他客户端上正在运行的P1弹幕上。这样即使每个客户端上运行的代码都相同,但是只有A的客户端可以修改跟踪的目的地,其他终端相当于在读取A客户端中的鼠标位置,自然就会有正确的跟踪目标了。

还记得我之前说过的把元素放在 ai 数组里面就能手动同步吗,我们只要判断当前的客户端是弹幕主人的客户端,然后把鼠标位置信息放在 ai 数组里就可以了。由此,我们可以写下这样的代码把鼠标位置放到 ai 数组里面:

public Vector2 ClientMousePos
{
    get
    {
        // ai[0] 是鼠标X坐标,ai[1] 是鼠标Y坐标
        return new Vector2(projectile.ai[0], projectile.ai[1]);
    }
    set
    {
        projectile.ai[0] = value.X;
        projectile.ai[1] = value.Y;
    }
}

至于手动同步 ai 数组,我们只需要一行代码就能完成,因为泰拉瑞亚已经自带了发送弹幕同步的功能,只需要在AI里写:projectile.netUpdate = true;

我们可以看看它的实现:

也就是说,在弹幕的 Update 结束以后,如果弹幕的 netUpdatetrue ,那么我们就要发送一个ID为27的包给服务器。

这里我要讲解一下 NetMessage.SendData 这个函数的用法:

msgType 很明显就是这个信息的类型,用来区别不同的功能。

remoteClient 如果在服务器端,这个参数的作用是如果它不为-1,那么封包就只会发给指定的那个客户端,否则就是发送给所有人。如果在客户端,只能发给服务器端。

ignoreClient 如果在服务器端,并且这个值不为-1,那么封包就会发给除了指定客户端以外的所有客户端,否则就没有需要忽略的客户端。

这个发送方式也是有讲究的,如果你在客户端,那么你只要都设为-1,就是发送给服务器端,而服务器端需要设置这两个参数中的其中一个进行精确的发送,或者都设为-1来进行全体广播。

进一步跟踪到 NetMessage 里面,我们可以看到这个27号封包就是同步弹幕信息的封包,以及这个封包所发送的弹幕信息。

而这个封包的接收方法是在 MessageBuffer 这个类里面:

上面的代码总结起来就是,我们先给服务器端发送一个请求同步的消息,服务器端接收以后会先把弹幕信息同步到服务器端,然后把消息发给除了我们以外的所有客户端。而客户端接收到这个消息以后就会把指定的弹幕的数据改成接收到的数据。大家可以通过代码来理解一下。

所以理清楚原版的同步机制是很重要的,我们正是希望在客户端发给服务器端,并且服务器端同步给除了自己以外的所有客户端。为什么一定要除了自己?因为自己就是鼠标位置最权威的客户端,所以如果还让服务器端同步一遍那就有可能因为延迟而导致最终位置不准确了!

所以我们的代码可以写成这样:

// 如果当前在运行的客户端是弹幕的主人
if (projectile.owner == Main.myPlayer )
{
    // 就把鼠标位置这个状态设为当前客户端的鼠标位置
    // 这段代码只有客户端运行
    ClientMousePos = Main.MouseWorld;
    projectile.netUpdate = true;
}

// 然后跟踪鼠标位置,这段代码在服务器端和客户端里都会运行
projectile.velocity = Lerp(projectile.velocity,
        GetChaseVector(projectile.Center, ClientMousePos) * 10f, 0.2f);

最终效果大概这样

正确的效果

不过…

这个方法每帧都得同步一次鼠标位置,是不是有点浪费带宽呢?可不可以继续优化?当然可以!你可以让鼠标位置只有变化了以后才同步。

// 如果当前在运行的客户端是弹幕的主人
if (projectile.owner == Main.myPlayer)
{
    // 如果鼠标位置和上一帧不同
    if (Main.MouseWorld != oldMouseWorld)
    {
        // 就把鼠标位置这个状态设为当前客户端的鼠标位置
        ClientMousePos = Main.MouseWorld;
        projectile.netUpdate = true;
        oldMouseWorld = Main.MouseWorld;
    }
}

但是我们会发现这么一个现象:

左边是其他玩家,右边是弹幕的主人,可以看到左边弹幕十分不流畅

原版的魔法导弹类武器也有一样的问题,所不必太担心。在多人模式下看别人的魔法飞弹就会发现轨迹十分难看,这就是状态同步的问题了,因为延迟以及 projectile.netUpdate 也会同步弹幕的位置,导致瞬移使轨迹不连续,在下一章我们会讲到如何解决这个问题。

但是就目前来看,我们已经成功修好了(使其到达原版的同步水平)追踪鼠标的弹幕!

修复随机游走弹幕

修复随机游走弹幕的思想其实也类似,造成随机弹幕不同步的原因就是伪随机数的生成是由随机种子以及随机序列的位置决定的。而这两样东西某种程度让都跟当前终端的运行状态、时间有关。不同于鼠标操控弹幕,任何一个终端的结果都可以看作是对的,但是我们只能以一个为标准,所以要统一起来。

那我们不如让客户端只接受服务器端的计算结果?也就是说客户端的随机游荡部分代码完全失效,只接收来自服务器端的随机旋转结果,这样我们就没必要所有终端都同步自己的数据了。这当然是可以的,处理的时候也要多加小心,因为客户端和服务器端两种情况下的行为不同,同时还要注意哪些数据是可以在服务器端计算,哪些是不能的。好处就是虽然客户端看到的都不是原来的轨迹了,但是起码每个客户端看到的轨迹都一样了。

遗憾的是,虽然可以这样做,但是TR并没有提供很方便的服务器端发起的同步方案,所以只能强行使用 NetMessage.SendData 去同步这部分数据。

最终效果,可以看到虽然也有不连续,但是客户端之间很同步

代码如下:

private float rad = 0;
public override void AI()
{
    if (Main.netMode != NetmodeID.MultiplayerClient)
    {
        // 服务器端计算的内容
        // 圆心位置
        var center = projectile.Center + projectile.velocity * 3;
        // 角度随机增加变化
        rad += Main.rand.NextFloatDirection() * 1.5f;
        // 半径50,圆上的点
        var pos = center + rad.ToRotationVector2() * 50;
        projectile.velocity = Lerp(projectile.velocity, GetChaseVector(projectile.Center, pos) * 10f, 0.1f);
        // 强制同步
        NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null, projectile.whoAmI);
    }
    // 客户端中计算的数据
    projectile.rotation = projectile.velocity.ToRotation();
}

还有另一种方法去做随机运动弹幕的同步,那就是同步伪随机数种子,代码会复杂一点,但是只要每个弹幕初始的随机数生成器的状态一致,那么后续的运动轨迹也是一致的!

首先我们要在弹幕发射的时候赋予随机种子

由于 Projectile.NewProjectile 这个函数可以赋予最初的 ai 值,所以我们不妨在发射的时候就确认它的随机种子。然后我们只要在弹幕的第一帧根据种子初始化随机数生成器就好了。这个方法有点类似帧同步了,在合适的时机和正确的参数下运作,我们就认为它是同步的。

接下来是弹幕的代码:

private float rad = 0;
private UnifiedRandom RNG;
public override void AI()
{
    // 弹幕的 ai[1] 代表状态,0是还没生成种子,1是已经生成完毕
    if (projectile.ai[1] == 0)
    {
        // 让弹幕主人在弹幕的第一帧初始化随机数生成器
        RNG = new UnifiedRandom((int)projectile.ai[0]);
        projectile.ai[1] = 1;
    }
    // 服务器端计算的内容
    // 圆心位置
    var center = projectile.Center + projectile.velocity * 3;
    // 角度随机增加变化
    rad += RNG.NextFloatDirection() * 1.5f;
    // 半径50,圆上的点
    var pos = center + rad.ToRotationVector2() * 50;
    projectile.velocity = Lerp(projectile.velocity, GetChaseVector(projectile.Center, pos) * 10f, 0.1f);
    // 客户端中计算的数据
    projectile.rotation = projectile.velocity.ToRotation();
}

但是这个方法就会引发帧同步带来的问题:万一有玩家中途加入,或者有玩家客户端卡了一帧怎么办?

难题

这个问题同样也是以后会提到。


模组联机同步方法

对于上述的问题,我们会意识到如果仅仅使用原版TR提供的同步方案可能没法彻底解决他们。这时候我们必须要考虑一下TML提供给我们操作网络传输的接口。

对于NPC、弹幕来说,我们已经大概了解什么时候应当同步了。但是对于弹幕,尤其是复杂AI的弹幕来说,两个 ai 元素可能并不够用,当我们使用额外的变量的时候,就需要用到 ExtraAI 这类函数了,NPC和弹幕都有这两个重写函数:

public override void SendExtraAI(BinaryWriter writer) {
    base.SendExtraAI(writer);
}

public override void ReceiveExtraAI(BinaryReader reader) {
    base.ReceiveExtraAI(reader);
}

我们可以通过写入流和读入流的方式,往TR自带的同步封包里面加数据,这样就能解决ai数组不够用的问题了。

private float ai5;
private int ai6;
private string ai7;
public override void SendExtraAI(BinaryWriter writer) {
    writer.Write(ai5);
    writer.Write(ai6);
    writer.Write(ai7);
}

public override void ReceiveExtraAI(BinaryReader reader) {
    ai5 = reader.ReadSingle();
    ai6 = reader.ReadInt32();
    ai7 = reader.ReadString();
}

至于世界,物品,属性也有类似的重写函数,同时世界可以用 NetMessage.SendData(MessageID.WorldData) 来手动同步世界属性。

比较难处理的是玩家属性, 一般来说,因为玩家属性的多样性,我们需要根据不同的情况选择帧同步或者状态同步,所以没有一个统一的接口。 对于ModPlayer类,TML提供了好几种不同的同步方案。

首先就是 SyncPlayer 这个重写函数,这段代码会发生在玩家数据需要进行同步的时候,包括刚进入服务器的时候发生的初始同步,你需要在此处手动发送封包来同步数据。好在发给谁以及怎么发都已经包含在参数里了,还有一个 newPlayer 代表是不是刚进入服务器。

然后就是 clientCloneSendClientChanges 两兄弟,clientClone 的作用是把需要同步的玩家属性复制到一个用来做对比的clientPlayer实例里面,这样每帧可以通过比对属性的变化来确定是否需要向服务器同步信息,注意这里是客户端向服务器同步信息。而这个比对和同步的过程就由 SendClientChanges 重写函数完成,总而言之就是客户端玩家某些属性变了,但是别的玩家还不知道,所以需要告诉服务器,然后由服务器转发给其他玩家,这是一种帧同步方法。TR很多玩家属性都是这样子进行同步的,所以大部分情况下你不需要去同步,比如buff所带来的玩家属性,都是自动同步的。

此外还有用于控制周围环境和刷怪的 CustomBiomesMatchCopyCustomBiomesToSendCustomBiomesReceiveCustomBiomes 等重写函数,此处不多作介绍,可以看TML文档和源码。

能用TML提供的联机接口的时候,不要使用自己去手动同步。

自定义消息

我们先来实现一个最基础的联机同步实例:给服务器发一个自定义消息,这个消息包含一个参数x,服务器端会给你返回一个值,这个值是x^2。注意,服务器只会给你返回这个值,其他玩家看不到的。由于要使用自定义消息,所以我们一定要说一下TML自带的ModPacket。使用方法:

// 创建一个属于这个Mod的ModPacket
ModPacket packet = mod.GetPacket();
// 往里面写入数据
packet.Write(???);
// 发送出去
packet.Send(-1, -1);

注意,由于这个ModPacket只属于你当前正在写的这个Mod,所以里面的数据不会跟别的Mod冲突,但是你需要你个标识来确定这个封包的作用以及应该如何读取它的参数:

// 我们可以用一个enum(枚举)类型来实现封包ID,和状态机的状态类型异曲同工
public enum TemplateMessageType : int {
    TestMessage,
}

然后我们可以在随便一个会在客户端执行的重写函数中写下这样的代码:

// 从客户端发送
if (Main.netMode == NetmodeID.MultiplayerClient) {
    // 创建一个属于这个Mod的ModPacket
    ModPacket packet = mod.GetPacket();
    // 往里面写入一个封包ID,类型为int,占用4个字节
    packet.Write((int)TemplateMessageType.TestMessage);
    // 往里面写入一个参数x = 8
    packet.Write(8);
    // 发送出去
    packet.Send(-1, -1);
}

如果不知道这个函数是在哪里执行的可以用之前的方法去检测。封包本身就是个二进制流,所以我们需要像写文件一样写入数据。

那么如何在服务器端读取这个封包呢,我们需要转到TemplateMod.cs里面,或者你Mod的主类,然后使用这个重写函数:

public override void HandlePacket(BinaryReader reader, int whoAmI) {}

这个函数执行的时候流的位置已经移动到了封包的头部,也就是说,下一个读取的就是封包ID了,那么我们就可以判断封包ID然后给客户端重新发一个计算结果了:

public override void HandlePacket(BinaryReader reader, int whoAmI) {
    // 读取ID
    TemplateMessageType msgType = (TemplateMessageType)reader.ReadInt32();
    // 如果这个封包是TestMessage类型
    if (msgType == TemplateMessageType.TestMessage) {
        // 读取参数x
        int x = reader.ReadInt32();
        // 如果在服务器上收到的,我们就把x^2发回去
        if (Main.netMode == NetmodeID.Server) {
            ModPacket packet = GetPacket();
            packet.Write((int)TemplateMessageType.TestMessage);
            packet.Write(x * x);
            // 发回给发送者
            packet.Send(whoAmI, -1);

            // 这是发给所有人
            // packet.Send(-1, -1);

            // 这是发给所有人,除了发送者
            // packet.Send(-1, whoAmI);

        } else {
            // 否则我们就把这个值打印出来
            Main.NewText(x);
        }
    }
}
效果图

实现了这么个功能我们就已经具备独自设计联机同步流程的第一步了!

下一章我们会讲TR里同步玩家、弹幕的机制,以及如何缓解弹幕轨迹不连续的问题。

《联机同步(一)基本概念》有1个想法

发表回复