# 前言
锁帧同步,据说是以前局域网联机游戏常用的一种同步方式,其原理是每帧 (或者指定间隔) 同步同一个『房间』的玩家操作指令,由于玩家们的初始状态相同,后续操作变化相同即可实现一致性表现。
缺点是由于每次都必须等待所有玩家指定都收集完成,才算这一帧 (次) 完成,然后将指令列表同步给所有玩家,因此网络不好的情况下会导致一人卡顿而众人卡顿 —— 因为就算不卡的人,也得等待网络最差的人指令上传完毕,才能进行下一次更新。
本次使用到的工具主要有:
- Unity2020.1.0
- Box2DSharp
- KCPSharp
- C#
服务器同样采用 C#,.NET Core3.1,物理系统为 Box2DSharp,玩家匹配之前使用 TCP 协议,匹配成功后以基于 UDP 的 KCP 进行数据同步。
虽然说是简单实现,不过前置条件还是有的,例如:一个能用的网络框架
这里个人使用了 CrySimpleNet,这是之前就提到过的,当初想搞个远程打包工具而写的。后面抽象出来形成了一个个人小框架。
注:并未对物理引擎作定点数改造,暂未测试物理引擎的浮点数误差问题。
# 表现
由于这次并非一边实现一边写,在写本篇文章的时候,已经实现了个样子了,因此就先贴个表现:
帧同步设计到同步的就只有玩家相关指令,其它都由本地计算。
例如:其中,子弹不属于玩家,而是通过玩家『指令』而被实例化,因此这种基本上没有所谓同步数量限制,算是帧同步的一个好处。
# 执行流程
这里直接以上述表现为例:
- 在服务器启动后,等待客户端连接
- 客户端连接成功,发送匹配请求:包括匹配需求人数,客户端 UDP 端口等信息
- 服务器接收到匹配情况,根据客户端请求匹配人数将其放入相应的匹配队列
- 若匹配人数达到需求,创建一个新的『房间』,将申请匹配的几个客户端放进去
- 『房间』中,服务器创建一个锁帧同步『世界』,并初始化对应数据,然后根据客户端 UDP 端口创建 KCP 连接,将服务器对应 UDP 端口及初始化数据同过 TCP 协议发给客户端
- 客户端接收到数据后,创建『世界』,并使用服务器传入初始数据初始化,注册相关事件 (如『世界』生成新对象)
- 客户端一切准备就绪:初始化、加载、或者创建模型,直接以 KCP 向后台发送 C2S_PrepareComplete 准备就绪协议
- 服务器判断所有客户端准备就绪,向客户端发送『开始』协议
- 客户端接收协议,发送一个空操作给服务器
- 服务器接收到客户端操作,若所有客户端操作都已接收到,则将操作列表同步给所有客户端,并更新服务器存在的『世界』(该世界可以用于最终结果的验证)
- 循环上一步操作,不停更新使世界前进
这里个人画了个草图:
大概这么个流程。
# 实现
在个人的网络小框架中,采用反射支持类的自动序列化,因此相对来说数据通信会比较简单 (虽然这里其实也没涉及到几个数据通信就是了)。
# 服务器
首先是用于匹配的:
/// <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 接口即可:
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); | |
} | |
} |