# 前言

锁帧同步,据说是以前局域网联机游戏常用的一种同步方式,其原理是每帧 (或者指定间隔) 同步同一个『房间』的玩家操作指令,由于玩家们的初始状态相同,后续操作变化相同即可实现一致性表现。

缺点是由于每次都必须等待所有玩家指定都收集完成,才算这一帧 (次) 完成,然后将指令列表同步给所有玩家,因此网络不好的情况下会导致一人卡顿而众人卡顿 —— 因为就算不卡的人,也得等待网络最差的人指令上传完毕,才能进行下一次更新。

本次使用到的工具主要有:

  • Unity2020.1.0
  • Box2DSharp
  • KCPSharp
  • C#

服务器同样采用 C#,.NET Core3.1,物理系统为 Box2DSharp,玩家匹配之前使用 TCP 协议,匹配成功后以基于 UDP 的 KCP 进行数据同步。

虽然说是简单实现,不过前置条件还是有的,例如:一个能用的网络框架

这里个人使用了 CrySimpleNet,这是之前就提到过的,当初想搞个远程打包工具而写的。后面抽象出来形成了一个个人小框架。

注:并未对物理引擎作定点数改造,暂未测试物理引擎的浮点数误差问题。

# 表现

由于这次并非一边实现一边写,在写本篇文章的时候,已经实现了个样子了,因此就先贴个表现:

帧同步设计到同步的就只有玩家相关指令,其它都由本地计算。

例如:其中,子弹不属于玩家,而是通过玩家『指令』而被实例化,因此这种基本上没有所谓同步数量限制,算是帧同步的一个好处。

# 执行流程

这里直接以上述表现为例:

  • 在服务器启动后,等待客户端连接
  • 客户端连接成功,发送匹配请求:包括匹配需求人数,客户端 UDP 端口等信息
  • 服务器接收到匹配情况,根据客户端请求匹配人数将其放入相应的匹配队列
  • 若匹配人数达到需求,创建一个新的『房间』,将申请匹配的几个客户端放进去
  • 『房间』中,服务器创建一个锁帧同步『世界』,并初始化对应数据,然后根据客户端 UDP 端口创建 KCP 连接,将服务器对应 UDP 端口及初始化数据同过 TCP 协议发给客户端
  • 客户端接收到数据后,创建『世界』,并使用服务器传入初始数据初始化,注册相关事件 (如『世界』生成新对象)
  • 客户端一切准备就绪:初始化、加载、或者创建模型,直接以 KCP 向后台发送 C2S_PrepareComplete 准备就绪协议
  • 服务器判断所有客户端准备就绪,向客户端发送『开始』协议
  • 客户端接收协议,发送一个空操作给服务器
  • 服务器接收到客户端操作,若所有客户端操作都已接收到,则将操作列表同步给所有客户端,并更新服务器存在的『世界』(该世界可以用于最终结果的验证)
  • 循环上一步操作,不停更新使世界前进

这里个人画了个草图:

大概这么个流程。

# 实现

在个人的网络小框架中,采用反射支持类的自动序列化,因此相对来说数据通信会比较简单 (虽然这里其实也没涉及到几个数据通信就是了)。

# 服务器

首先是用于匹配的:

s
/// <summary>
/// 战斗房间 - 匹配组件
/// </summary>
public class BattleMatchingComponent : Component
{
    private BattleLockStepComponent _battle;
    private Dictionary<int, List<MatchingData>> _matchingData = new Dictionary<int, List<MatchingData>>();
    protected override void OnStart()
    {
        base.OnStart();
        _battle = AddComponent<BattleLockStepComponent>();
    }
    public void ReqireMatch(MatchingData data)
    {
        lock (this)
        {
            Log.Debug($"开始执行 {data.Channel.RemoteAddress} 申请的匹配...");
            List<MatchingData> list;
            if (!_matchingData.ContainsKey(data.MaxNum))
                _matchingData[data.MaxNum] = new List<MatchingData>();
            list = _matchingData[data.MaxNum];
            list.Add(data);
            // 达到匹配人数
            if (list.Count >= data.MaxNum)
            {
                // 判断是否正式匹配成功
                list.RemoveAll((x) => !x.Channel.IsConnect);
                if (list.Count == data.MaxNum)
                {
                    _battle.Create(list.ToArray());
                    list.Clear();
                }
            }
        }
    }
}

已匹配完成的对象及『房间』管理:

public class BattleLockStepComponent : Component
{
    private List<BattleScene> _battleSceneList = new List<BattleScene>();
    private List<BattleScene> _remvoeScene = new List<BattleScene>();
    protected override void OnStart()
    {
        EventSystemDispacher.GetInstance.RegisterEvent(EventSystemDispacher.EventOnDisconnect, OnDisconnect);
    }
    private void OnDisconnect(object[] obj)
    {
        BaseChannel c = obj[0] as BaseChannel;
        foreach (var item in _battleSceneList)
        {
            if (item.CheckDisconnect(c))
            {
                _remvoeScene.Add(item);
            }
        }
    }
    public void Create(MatchingData[] dataList)
    {
        _battleSceneList.Add(new BattleScene(dataList));
    }
    protected override void OnUpdate()
    {
        // 移除该移除的
        if (_remvoeScene.Count > 0)
        {
            foreach (var item in _remvoeScene)
            {
                _battleSceneList.Remove(item);
            }
            _remvoeScene.Clear();
        }
        // 更新
        foreach (var item in _battleSceneList)
        {
            item.Update();
        }
    }
}

其中的 BattleScene,就代表维护着一个完整『房间』的场景,并负责协议的接收、处理及分发。

当然,由于为了简单起见,这里直接通过一个 Switch 判断客户端协议 (客户端也差不多),数据也非常简单。

class BattleScene
{
    private MatchingData[] _matchingDataList;
    // 战斗世界
    private BattleLockStepWorld _world;
    // 连接列表
    private KcpChannel[] _channels;
    // 准备列表,当玩家场景加载完毕时,发回对应协议后置为 true,全部准备完成才能开始战斗
    // 后续则用于判断当前帧是否接收完所有玩家命令,下标可使用 PlayerID,PlayerID 被下标所初始化
    private bool[] _prepareList;
    // 是否准备完成,当_prepareList 全部为 true 时,该值置为 true
    private bool _isPrepareSuccess = false;
    // 当前阶段当前帧,等待的玩家命令列表
    private FrameExternalActionData _playerAction = new FrameExternalActionData();
    public BattleScene(MatchingData[] dataList)
    {
        _matchingDataList = dataList;
        _world = new BattleLockStepWorld();
        _channels = new KcpChannel[dataList.Length];
        _prepareList = new bool[_channels.Length];
        _playerAction.ActionList = new ExternalAction[_channels.Length];
        //_stepList = new C2S_BattleStep[_prepareList.Length];
        // 随便初始化一下初始数据
        BattleRawData battleData = new BattleRawData();
        // 玩家数据
        BattleRawRole[] roles = new BattleRawRole[dataList.Length];
        battleData.Seed = new Random().Next();
        for (int i = 0; i < roles.Length; i++)
        {
            roles[i] = new BattleRawRole() { PlayerID = i, Power = new Random().Next(300, 800), PosX = i * 2, PosY = 18 };
        }
        battleData.RoleList = roles;
        _world.InitData(battleData);
        for (int i = 0; i < dataList.Length; i++)
        {
            // 创建 对应 KCP 连接通道
            KcpChannel channel = new KcpChannel();
            channel.OnReceiveProto = OnReceiveProto;
            _channels[i] = channel;
            //KCP 连接客户端
            channel.Connect(dataList[i].Channel.RemoteAddress.Address, dataList[i].ClientUdpPort);
            // 表示服务器匹配机制处理完毕
            S2C_BattleMatchingSuccess res = ProtocolFactor.GetInstance.Get<S2C_BattleMatchingSuccess>();
            res.BattleData = battleData;
            res.SelfPlayerID = i;
            res.ServerUdpPort = channel.LocalAddress.Port;
            dataList[i].Channel.Send(res);
        }
    }
    public bool CheckDisconnect(BaseChannel c)
    {
        foreach (var item in _matchingDataList)
        {
            if (item.Channel.RemoteAddress == c.RemoteAddress)
            {
                _world.Destroy();
                foreach (var channel in _channels)
                {
                    CryToolUtility.DisposeTarget(channel);
                }
                return true;
            }
        }
        return false;
    }
    public void Update()
    {
        // 未准备好
        if (!_isPrepareSuccess)
        {
            // 全部准备好了
            _isPrepareSuccess = Array.FindAll(_prepareList, x => x).Length == _prepareList.Length;
            if (_isPrepareSuccess)
            {
                SendAll(ProtocolFactor.GetInstance.Get<S2C_BattleStart>());
                _world.Start();
            }
        }
        if (_playerAction.IsComplete())
        {
            _world.Update(_playerAction);
            S2C_BattleStep step = ProtocolFactor.GetInstance.Get<S2C_BattleStep>();
            step.FrameAction = _playerAction;
            _playerAction = new FrameExternalActionData();
            _playerAction.ActionList = new ExternalAction[_channels.Length];
            SendAll(step);
        }
        //SendAll(new S2C_BattleStep() { FrameAction = new FrameExternalActionData() { ActionList = new ExternalNullAction[0] } });
    }
    private void OnReceiveProto(BaseChannel channel, CryProtocol proto)
    {
        C2S_BattleProtoBase pb = proto as C2S_BattleProtoBase;
        switch (pb.GetType().Name)
        {
            case "C2S_PrepareComplete":
                _prepareList[pb.PlayerID] = true;
                break;
            case "C2S_BattleStep":
                _playerAction.ActionList[pb.PlayerID] = (proto as C2S_BattleStep).Action;
                break;
            default:
                break;
        }
    }
    private void SendAll(CryProtocol proto)
    {
        foreach (var item in _channels)
        {
            item.Send(proto);
        }
    }
}

# 客户端

在客户端这里,同样是简单直接的处理:

private void Start()
{
    ProtoEventDispacher.GetInstance.RegisterEvent<S2C_BattleMatchingSuccess>(OnMatchingSuccess);
    if (GameManager.GetInstance.ConnectToServer())
    {
        _kcpChannel = new KcpChannel();
        _kcpChannel.OnReceiveProto = OnReceiveProto;
        C2S_RequreMatching mt = ProtocolFactor.GetInstance.Get<C2S_RequreMatching>();
        mt.MaxNum = 1;
        mt.ClientUdpPort = _kcpChannel.LocalAddress.Port;
        ClientNetManager.GetInstance.Send(mt);
        Log.Debug("Start Matching.....");
    }
}
private void Update()
{
    if (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow))
    {
        _moveActionTmp.DirectionX = -1f;
        _playerAction = _moveActionTmp;
    }
    else if (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow))
    {
        _moveActionTmp.DirectionX = 1f;
        _playerAction = _moveActionTmp;
    }
    else if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow))
    {
        _moveActionTmp.DirectionY = 1f;
        _playerAction = _moveActionTmp;
    }
    else if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow))
    {
        _moveActionTmp.DirectionY = -1f;
        _playerAction = _moveActionTmp;
    }
    else if (Input.GetKeyDown(KeyCode.Space))
    {
        _playerAction = _attackActionTmp;
    }
    // 战斗开始,还未初始化
    if (_world != null && !_world.IsStart && _lastStep != null)
        _world.Start();
    // 正常更新逻辑
    if (_world != null && _world.IsStart)
    {
        _world.DebugDraw();
        _lastDeltaTime += FrameTime;
        if (_lastDeltaTime > FrameTimeMis && _lastStep != null)
        {
            // 更新一波
            if (_lastStep.FrameAction != null)
                UpdateBattleWorld(_lastStep.FrameAction);
            // 定时发送操作,若操作为空,则发送空操作
            if (_playerAction == null)
                _playerAction = _nullActionTmp;
            SendBattleStep(_playerAction);
            _playerAction = null;
            _lastDeltaTime = 0;
            _lastStep = null;
        }
    }
}
private void UpdateBattleWorld(FrameExternalActionData frameAction)
{
    _world.Update(frameAction);
}
private void OnReceiveProto(BaseChannel arg1, CryProtocol arg2)
{
    switch (arg2.GetType().Name)
    {
        case "S2C_BattleStart":
            Debug.Log("战斗开始了!");
            _lastStep = new S2C_BattleStep();
            break;
        case "S2C_BattleStep":
            _lastStep = arg2 as S2C_BattleStep;
            break;
        default:
            break;
    }
}
private void OnMatchingSuccess(S2C_BattleMatchingSuccess obj)
{
    Log.Debug("匹配成功");
    _selfPlayerID = obj.SelfPlayerID;
    _nullActionTmp.PlayerID = _selfPlayerID;
    _moveActionTmp.PlayerID = _selfPlayerID;
    _attackActionTmp.PlayerID = _selfPlayerID;
    _world = new BattleLockStepWorld();
    _world.OnCreateBody = OnCreateBody;
    _world.InitData(obj.BattleData);
    _world.Drawer = this;
    _kcpChannel.Connect(ClientNetManager.GetInstance.Connection.RemoteAddress.Address, obj.ServerUdpPort);
    // 初始化表现层
    //InitBattleView(() =>
    //{
    _kcpChannel.Send(GetProto<C2S_PrepareComplete>());
    //});
}

在上述处理过程中,相信都看到服务器、客户端会创建的一个叫 World 的类:BattleLockStepWorld

BattleLockStepWorld 位于另一个程序集,代表了一个『真正』的最底层场景逻辑层,保证这个场景每帧命令一致,只要逻辑层更新情况一致,表现层就不会出现差异。

# 关于 Box2D 调试

由于 Box2D 不同于 Unity 的物理,甚至是看不到实际大小、形状,所以在此还为其碰撞问题纠结了些时间。
实际 Box2D 提供了接口,用于调试绘制形状.

让类实现 Box2DSharp.Common.IDrawer 接口即可:

s
DrawFlag IDrawer.Flags { get => DrawFlag.DrawShape | DrawFlag.DrawAABB; set => throw new NotImplementedException(); }
    void IDrawer.DrawPolygon(Span<System.Numerics.Vector2> vertices, int vertexCount, in Box2DSharp.Common.Color color)
    {
        for (int i = 0; i < vertices.Length; i++)
        {
            Vector2 nowPos = ToUVector(vertices[i]);
            Vector2 nextPos = ToUVector(vertices[0]);
            if (i + 1 < vertices.Length) 
                nextPos = ToUVector(vertices[i + 1]);
            Debug.DrawLine(nowPos, nextPos, UnityEngine.Color.red);
        }
    }