一、先搞懂为啥会有时间同步误差

做过Unity多人联机游戏的开发者都知道,用Mirror做客户端和服务器的连接后,经常会遇到各种时间相关的bug——比如客户端显示的技能冷却时间比服务器快了两秒,或者服务器判定的玩家动作时间比客户端晚,导致明明按了闪避却被判定没按。这些问题的根源,几乎都是客户端和服务器的时间不同步。 首先得明确,Mirror本身只是一个网络框架,它只负责把客户端的信息传给服务器,或者把服务器的状态同步给客户端,本身并不自带时间同步的功能。那为啥会产生误差呢?主要有三个原因: 第一,硬件时钟的差异。每个客户端的手机、电脑,还有服务器的主机,它们的硬件时钟都有自己的误差,比如有的手机时间走得快一点,有的慢一点,就算联网自动校准,也会因为网络延迟有几秒钟的差距; 第二,网络延迟的波动。客户端发消息给服务器,或者服务器发消息给客户端,中间的网络延迟不是固定的,比如你连WiFi的时候延迟可能是100毫秒,连手机流量可能变成300毫秒,这种波动会让时间戳的传递出现偏差; 第三,处理延迟的差异。客户端和服务器的性能不一样,比如服务器要处理上百个玩家的请求,处理时间可能比客户端长,客户端要渲染画面,也会占用一部分时间,导致双方处理同一个事件的时间点不一样。

二、常用的时间同步方法

2.1 服务器时间主导法

这是最常用的一种方法,核心思路就是让所有客户端都以服务器的时间为准,客户端自己的时间只用来做本地的小计算,比如显示动画进度,所有和游戏逻辑相关的时间,比如技能冷却、动作判定、任务倒计时,都用服务器的时间来算。 举个例子,玩家在客户端按了技能按钮,客户端不能自己判断技能是否冷却好,而是要把这个操作传给服务器,服务器用自己的时间来判断技能是否冷却,然后把结果传给客户端,客户端再显示技能的冷却时间。 具体的实现步骤是:客户端第一次连接服务器的时候,服务器把自己的当前时间戳发给客户端,客户端记录下这个时间,之后客户端就可以用这个初始时间戳,加上自己本地的运行时间,来模拟服务器的时间。比如服务器发的时间戳是10000毫秒,客户端连接后运行了500毫秒,那客户端模拟的服务器时间就是10000+500=10500毫秒。 这种方法的优点是逻辑简单,不容易出错,所有客户端的逻辑都以服务器为准,能有效避免时间不一致的问题;缺点是客户端的显示会有一点点延迟,因为要等服务器的结果,不过对于大部分游戏来说,这个延迟是可以接受的。

2.2 时间戳补偿法

如果游戏对时间的精度要求比较高,比如需要同步玩家的动作时间,或者做实时的对战,那可以用时间戳补偿法。这种方法的核心是计算客户端和服务器之间的网络延迟,然后对时间戳进行补偿。 具体的做法是:客户端每隔一段时间,就发一个带本地时间戳的ping消息给服务器,服务器收到ping消息后,立刻发一个pong消息给客户端,pong消息里包含服务器收到ping消息的时间戳,还有服务器发pong消息的时间戳。客户端收到pong消息后,就可以计算出往返延迟,也就是客户端收到pong消息的时间戳减去客户端发ping消息的时间戳,然后把往返延迟除以2,就是单向延迟。客户端再用服务器发pong消息的时间戳,加上单向延迟,就可以得到当前的服务器时间。 举个例子,客户端发ping的时间是T1,服务器收到ping的时间是T2,服务器发pong的时间是T3,客户端收到pong的时间是T4。往返延迟就是T4-T1,单向延迟就是(T4-T1)/2,当前服务器时间就是T3 + (T4-T1)/2。 这种方法的优点是精度比较高,能有效补偿网络延迟的影响;缺点是实现起来比较复杂,需要频繁地发ping消息,会占用一定的网络带宽,而且如果网络延迟波动很大,补偿的结果也会有误差。

2.3 滑动窗口同步法

滑动窗口同步法是时间戳补偿法的升级版,核心是用一段时间内的多个时间戳,来计算平均延迟,从而提高同步的精度。 具体的做法是:客户端维护一个滑动窗口,窗口里存储最近N次ping-pong的往返延迟和时间戳,每次收到新的pong消息,就把新的延迟加入窗口,同时把窗口里最旧的延迟去掉。然后用窗口里的平均延迟,来计算当前的服务器时间。 比如窗口大小是10,最近10次的往返延迟分别是100、120、110、130、105、115、125、108、112、118毫秒,平均延迟就是(100+120+110+130+105+115+125+108+112+118)/10=114.3毫秒,单向延迟就是114.3/2=57.15毫秒,然后用服务器发pong的时间戳加上单向延迟,就是当前的服务器时间。 这种方法的优点是能有效减少网络延迟波动的影响,精度比时间戳补偿法更高;缺点是实现起来更复杂,窗口大小的选择也很重要,窗口太大的话,平均延迟会跟不上网络延迟的变化,窗口太小的话,又会受到个别延迟波动的影响。

三、具体实现示例(技术栈:Unity + Mirror)

下面我们用Unity和Mirror来实现一个简单的时间同步功能,用的是服务器时间主导法,适合大部分游戏场景。 首先,我们需要在服务器端写一个脚本,用来发送时间戳给客户端。

using Mirror;
using UnityEngine;

// 服务器端的时间同步脚本,继承自NetworkBehaviour,用于Mirror的网络通信
public class ServerTimeSync : NetworkBehaviour
{
    // 标记这个方法只在服务器端执行
    [Server]
    private void Start()
    {
        // 服务器启动后,每隔5秒给所有客户端发送一次时间戳,防止客户端时间漂移
        InvokeRepeating(nameof(SendTimeStampToClients), 0f, 5f);
    }

    // 服务器端发送时间戳的方法
    [Server]
    private void SendTimeStampToClients()
    {
        // 获取服务器当前的时间戳,单位是毫秒
        long serverTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        // 把时间戳传给所有客户端,SyncVar是Mirror的同步变量,会自动同步给客户端
        syncVarServerTime = serverTime;
    }

    // 同步变量,服务器端修改后会自动同步给所有客户端
    [SyncVar]
    private long syncVarServerTime;

    // 给客户端提供的获取服务器时间的方法
    public long GetServerTime()
    {
        // 如果是服务器端,直接返回当前时间戳
        if (isServer)
        {
            return System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        }
        // 如果是客户端,返回同步过来的服务器时间戳
        return syncVarServerTime;
    }
}

然后,我们在客户端写一个脚本,用来接收服务器的时间戳,并且模拟服务器的时间。

using Mirror;
using UnityEngine;

// 客户端的时间同步脚本
public class ClientTimeSync : NetworkBehaviour
{
    // 引用服务器端的时间同步脚本
    private ServerTimeSync serverTimeSync;

    // 客户端连接服务器后,获取服务器端的时间同步脚本
    private void Start()
    {
        // 服务器端的时间同步脚本通常挂载在一个全局的游戏对象上,比如NetworkManager
        serverTimeSync = FindObjectOfType<ServerTimeSync>();
    }

    // 获取当前的服务器时间,单位是毫秒
    public long GetCurrentServerTime()
    {
        // 如果还没连接服务器,或者还没获取到服务器的时间戳,返回0
        if (serverTimeSync == null || !serverTimeSync.isServer && serverTimeSync.syncVarServerTime == 0)
        {
            return 0;
        }
        // 如果是服务器端,直接返回当前时间戳
        if (serverTimeSync.isServer)
        {
            return System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        }
        // 如果是客户端,用同步过来的服务器时间戳,加上客户端本地的运行时间,来模拟服务器时间
        // 这里的Time.time是客户端从启动到现在的运行时间,单位是秒,转换成毫秒
        return serverTimeSync.syncVarServerTime + (long)(Time.time * 1000);
    }
}

最后,我们可以写一个测试脚本,用来测试时间同步的效果。

using UnityEngine;

// 测试脚本,用来显示客户端和服务器的时间
public class TimeSyncTest : MonoBehaviour
{
    private ClientTimeSync clientTimeSync;

    private void Start()
    {
        clientTimeSync = FindObjectOfType<ClientTimeSync>();
    }

    private void Update()
    {
        // 每隔1秒打印一次时间
        if (Time.frameCount % 60 == 0)
        {
            long serverTime = clientTimeSync.GetCurrentServerTime();
            long localTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
            Debug.Log($"服务器时间:{serverTime},客户端本地时间:{localTime},误差:{serverTime - localTime}毫秒");
        }
    }
}

这个示例的实现逻辑很简单,服务器每隔5秒给客户端发送一次时间戳,客户端收到时间戳后,用自己的本地运行时间来模拟服务器的时间,这样就能保证客户端的时间和服务器的时间基本一致。

四、应用场景、优缺点及注意事项

4.1 应用场景

时间同步的方法有很多,不同的方法适合不同的应用场景。服务器时间主导法适合大部分的游戏场景,比如RPG、休闲游戏、卡牌游戏等,这些游戏对时间的精度要求不是特别高,逻辑简单,不容易出错;时间戳补偿法适合对时间精度要求比较高的游戏,比如实时对战游戏、动作游戏、竞速游戏等,这些游戏需要同步玩家的动作时间,或者做实时的判定;滑动窗口同步法适合网络环境比较复杂的游戏,比如跨区域的联机游戏,网络延迟波动比较大,需要更高的精度。

4.2 技术优缺点

服务器时间主导法的优点是逻辑简单,实现容易,不容易出错,所有客户端的逻辑都以服务器为准,能有效避免时间不一致的问题;缺点是客户端的显示会有一点点延迟,因为要等服务器的结果,而且如果服务器长时间没有给客户端发送时间戳,客户端的时间会漂移。 时间戳补偿法的优点是精度比较高,能有效补偿网络延迟的影响,客户端的显示延迟比较小;缺点是实现起来比较复杂,需要频繁地发ping消息,会占用一定的网络带宽,而且如果网络延迟波动很大,补偿的结果也会有误差。 滑动窗口同步法的优点是能有效减少网络延迟波动的影响,精度比时间戳补偿法更高,适合网络环境复杂的场景;缺点是实现起来更复杂,窗口大小的选择也很重要,窗口太大的话,平均延迟会跟不上网络延迟的变化,窗口太小的话,又会受到个别延迟波动的影响。

4.3 注意事项

首先,时间戳的单位要统一,所有的时间戳都要用毫秒或者秒,不能有的用毫秒有的用秒,不然会出现很大的误差;其次,要处理客户端断开连接的情况,客户端断开连接后,要清除之前的时间戳和延迟数据,重新连接的时候再重新获取;第三,要考虑时区的问题,所有的时间戳都要用UTC时间,不能用本地时间,不然不同时区的客户端会出现时间不一致的问题;第四,要限制ping消息的发送频率,不能太频繁,不然会占用太多的网络带宽,也不能太不频繁,不然时间同步的精度会不够;第五,要处理时间戳溢出的问题,比如用int类型存储时间戳,时间长了会溢出,所以要用long类型存储时间戳。

五、文章总结

时间同步是多人联机游戏中非常重要的一个环节,时间同步的误差会导致各种bug,影响游戏的体验。本文介绍了三种常用的时间同步方法,分别是服务器时间主导法、时间戳补偿法和滑动窗口同步法,每种方法都有自己的优缺点和适用场景。服务器时间主导法适合大部分的游戏场景,逻辑简单,容易实现;时间戳补偿法适合对时间精度要求比较高的游戏,能有效补偿网络延迟的影响;滑动窗口同步法适合网络环境比较复杂的游戏,能有效减少网络延迟波动的影响。 在实现时间同步的时候,要根据自己的游戏场景选择合适的方法,同时要注意时间戳的单位、时区、溢出等问题,保证时间同步的精度和稳定性。本文还提供了一个基于Unity和Mirror的时间同步示例,适合大部分的游戏开发者参考。