# 前言
说起网游,多人在线时每个玩家不同的操作是如何同步到不同的机器上的呢,以 MOBA 游戏为例,玩家移动释放技能是如何做到准确一致的,这就涉及到数据同步问题了,这里就简单地讲一讲 LockStep 吧。
# 同步模型
# P2P
再讲帧同步之前,回忆到了小学逃课去网吧(好孩子不要学)打星际的那段时光,那时和朋友开上一排机子,以其中一台主机创建房间,其他机子通过局域网搜索加入游戏,这就是传统的 P2P 模型,将局域网内一台主机当做服务端,负责数据转发给其他连接上的主机,省去了服务器成本,而且数据传输更快。当然,这也是有坏处的,当作为服务端的主机断线了,那么 GG,其他玩家也只能被迫断开游戏,而且基于玩家机器的数据转发,是肯定无法进行数据校验的,那么作弊手段就有很多了,金手指修改内存等等行为是阻止不了滴。
# 帧同步
帧同步其实很简单,当玩家进行操作的时候记录下这个操作指令发送给服务端,然后服务端再把操作指令转发给各个客户端,客户端本地再对每个指令进行解析就好啦,只要保证解析流程是一致的,那么结果自然就是一致的啦。听上去很简单,但要做起来要处理的事情可不少,如随机数问题,浮点数转定点数问题,客户端预测,数据回滚断线重连等等 balabala。当然由于帧同步的战斗逻辑都在客户端,没有服务端的校验,所以也避免不了外挂的产生。
# 状态同步
状态同步,字如其名,就是服务端计算结果,然后将结果同步给各个客户端,客户端本地去掉了计算操作(客户端同学少写代码大叫好耶),由于计算端是由服务器进行的,那么计算结果就是绝对正确,能有效打击外挂,当然缺点也是有不少的,由于计算都是放在服务端的,那么必然导致服务器压力过大,当要同步的状态比较多的时候会导致传输的包体过大,受到带宽的限制,流量消耗巨大
z 总之,几种模型各有优劣,具体还是需要根据项目去决定改使用哪种解决方案
# LockStep
好了,主角登场啦~LockStep 又称锁步同步算法,早起用于军队表示齐步行军,用于机器上来说就是让所有的机器同时执行相同的一系列操作,那么得到的结果就一定是一致的,由于 LockStep 与时间无关,而是依赖于动作的一致性,因此可以忽略网络延迟达到结果的一致。
客户端 LockStep 通常会分成逻辑层和表现层,逻辑层会设置一个逻辑周期,通常为 100ms。玩家在客户端的操作,并不会立刻发送给服务端,而是先加入到本地的一个操作队列中,设定一个延时时长(如逻辑周期的一半 50ms),当达到延时时长时,再将本地操作队列中的一系列操作指令打包打给服务端进行转发。
参考链接
# Unity 小 Demo
项目地址: gitee
# 服务端
服务端基于 nodejs 开发,为了方便快速,长连采用 wss,消息直接采用 json (懒)
- npm 包之
nodejs-websocket
一个好用的 wss 包
npm install nodejs-websocket --save |
- 创建一个本地 server
function createWsServer(port) { | |
console.log(`创建本地websocket,port: ${port}`); | |
//websocket | |
wsServer = ws.createServer(conn => { | |
console.log("一个客户端连接了,当前连接数:", wsServer.connections.length); | |
conn.on('error', err => { | |
console.log("client error:", err); | |
}); | |
conn.on('close', msg => { | |
console.log("一个客户端断开了连接,当前连接数:", wsServer.connections.length); | |
}); | |
conn.on('text', function (result) { | |
jsonArr.push(result); | |
}) | |
}).listen(port); | |
wsServer.on('error', err => { | |
console.log("server error: ", err); | |
}); | |
} |
- 服务端消息推送时长,50ms 推送一次当前逻辑帧收到的所有消息
// 每 50ms 广播一次驱动 | |
setInterval(() => { | |
var emptyFrame = { | |
Id:frameIdx, | |
Type:0, | |
FrameData:null | |
} | |
var msg = JSON.stringify(emptyFrame); | |
if(jsonArr.length > 0){ | |
msg =jsonArr[0]; | |
jsonArr.splice(0,1); | |
console.log(`广播: ${msg}`) | |
} | |
serverBroadcast(msg); | |
}, 50); |
- 完整代码
server.js
var ws = require("nodejs-websocket"); | |
var wsServer; | |
var jsonArr = []; | |
var frameIdx = 0; | |
function main() { | |
// 本地 7373 端口开启监听 | |
createWsServer(7373) | |
// 每 50ms 广播一次驱动 | |
setInterval(() => { | |
var emptyFrame = { | |
Id:frameIdx, | |
Type:0, | |
FrameData:null | |
} | |
var msg = JSON.stringify(emptyFrame); | |
if(jsonArr.length > 0){ | |
msg =jsonArr[0]; | |
jsonArr.splice(0,1); | |
console.log(`广播: ${msg}`) | |
} | |
serverBroadcast(msg); | |
}, 50); | |
} | |
function createWsServer(port) { | |
console.log(`创建本地websocket,port: ${port}`); | |
//websocket | |
wsServer = ws.createServer(conn => { | |
console.log("一个客户端连接了,当前连接数:", wsServer.connections.length); | |
conn.on('error', err => { | |
console.log("client error:", err); | |
}); | |
conn.on('close', msg => { | |
console.log("一个客户端断开了连接,当前连接数:", wsServer.connections.length); | |
}); | |
conn.on('text', function (result) { | |
jsonArr.push(result); | |
}) | |
}).listen(port); | |
wsServer.on('error', err => { | |
console.log("server error: ", err); | |
}); | |
} | |
function serverBroadcast(data) { | |
if (typeof data == 'string') { | |
serverBroadcastJson(data); | |
} else if (data instanceof Uint8Array) { | |
serverBroadcastBinary(data); | |
} else { | |
console.log('未设置转发数据类型: ', data); | |
} | |
} | |
function serverBroadcastJson(json) { | |
wsServer.connections.forEach(conn => { | |
conn.send(json); | |
}); | |
} | |
function serverBroadcastBinary(binary) { | |
wsServer.connections.forEach(conn => { | |
conn.sendBinary(binary); | |
}); | |
} | |
function closeServer() { | |
console.log("server close"); | |
wsServer.close(); | |
onCloseProcess(); | |
} | |
main(); |
# 客户端
也因为懒,Untiy 端 wss 也采用 BestHTPP
包开发 (诶嘿,下次又可以混一篇原生 C# 的 wss 帖子啦)
# net 相关
net 相关代码均在 Scripts->Net
底下,由于是依赖于 besthttp 的而且也不是本次的重点,有兴趣的就自己去看啦,主要就是用于接收服务端广播的帧数据
# NetFrameData.cs
网络采用 json 传输,为了快速使用,定义了一个类反编译收发使用
public class NetFrameMsg | |
{ | |
// 帧 Id | |
public int Id; | |
// 帧类型 | |
public ENetFrameType Type; | |
// 帧信息 | |
public string FrameData; | |
} | |
public enum ENetFrameType | |
{ | |
None = 0, | |
Move, | |
AddRole, | |
} |
# LockStepMgr.cs
- 客户端延时发送操作数据,定义一个发送列表 and 添加操作帧方法
private List<NetFrameMsg> _sendList = new List<NetFrameMsg>(); | |
public void AddFrame(ENetFrameType type, string data) | |
{ | |
// 过滤同一个人同一种类型操作只能存在一次,发送完后才能继续同类操作 | |
for (int i = 0; i < _sendList.Count; i++) | |
{ | |
var sendData = _sendList[i]; | |
if (sendData.Type == type) | |
return; | |
} | |
//TODO 检测数据有效性 | |
var msg = new NetFrameMsg(); | |
msg.Type = type; | |
msg.Id = FrameIdx; | |
msg.FrameData = data; | |
_sendList.Add(msg); | |
} |
- 收到网络帧时的消息处理
public void NormalFrame(NetFrameMsg msg) | |
{ | |
var frameIdx = msg.Id; | |
if (frameIdx - FrameIdx > 1) | |
{ | |
// 跳帧了,需要去追帧 | |
_state = ELockStepState.LoseFrame; | |
//TODO 向服务端请求丢失数据 | |
Debug.LogWarning("==============>跳帧了,需要去追帧:" + frameIdx); | |
return; | |
} | |
FrameIdx = frameIdx; | |
_frameBuffDic[frameIdx] = msg; | |
var lastFrameIdx = FrameIdx - 1; | |
while (lastFrameIdx > 0) | |
{ | |
// 看看当前帧执行过没,如果上一帧没数据那无需理会 | |
if (!_frameBuffDic.ContainsKey(lastFrameIdx)) | |
break; | |
if (_frameExcuteDic.ContainsKey(lastFrameIdx)) | |
break; | |
Debug.LogError("==============>确实有帧同步帧处理延迟了:" + lastFrameIdx); | |
lastFrameIdx--; | |
} | |
for (int startFrame = lastFrameIdx + 1; startFrame <= frameIdx; startFrame++) | |
{ | |
var realMsg = _frameBuffDic[startFrame]; | |
// 发送搜集指令 | |
CheckAndSendData(startFrame); | |
OnExcuteFrame?.Invoke(realMsg); | |
_frameExcuteDic[startFrame] = true; | |
} | |
} |
- 丢帧和追帧
由于网络传输过程可能会出现数据包丢失或数据错误,导致丢帧和追帧的情况,这时需要根据帧索引重新向服务器请求一次丢失的帧数据,由于此项目服务端存在本地,因此基本不可能出现这种情况,就没处理啦,,但追帧函数还是写了哒~
//frameList: 缺失的帧数据 | |
public void ChaseFrame(List<NetFrameMsg> frameList) | |
{ | |
_state = ELockStepState.ChasingFrame; | |
for (int i = 0; i < frameList.Count; i++) | |
{ | |
var msg = frameList[i]; | |
if (msg == null) | |
continue; | |
// 帧缺失 | |
if (msg.Id - FrameIdx > 1) | |
{ | |
//TODO 向服务端请求丢失数据 | |
Debug.LogWarning("==============>跳帧了,需要去追帧:" + msg.Id); | |
return; | |
} | |
// 容错 | |
if (msg.Id < FrameIdx) | |
continue; | |
var startFrame = msg.Id; | |
_frameBuffDic[startFrame] = msg; | |
FrameIdx = startFrame; | |
CheckAndSendData(startFrame); | |
OnExcuteFrame?.Invoke(msg); | |
_frameExcuteDic[startFrame] = true; | |
} | |
_state = ELockStepState.Normal; | |
} |
# Test.cs
- 建立连接,绑定帧接收事件
// 随机 roleid 用于用户区分 | |
TimeSpan mTimeSpan = DateTime.Now.ToUniversalTime() - new DateTime(1970, 1, 1, 0, 0, 0); | |
// 得到精确到秒的时间戳(长度 10 位) | |
_roleId = mTimeSpan.TotalSeconds.ToString(); | |
LockStepWebsocket.Instance.Connect() | |
LockStepMgr.Instance.OnExcuteFrame += OnExcuteFrame; |
- 移动插值、用户输入消息绑定
void Update() | |
{ | |
// 移动插值 | |
foreach (var kv in _roleDic) | |
{ | |
if (kv.Value == null) | |
continue; | |
if (!_rolePosDic.TryGetValue(kv.Key, out var pos)) | |
continue; | |
kv.Value.position = Vector3.Lerp(kv.Value.position, new Vector3(pos.X, pos.Y, pos.Z), Time.deltaTime); | |
} | |
if (Input.GetKeyDown(KeyCode.Q)) | |
{ | |
if (!_roleDic.ContainsKey(_roleId)) | |
{ | |
var msg = new AddRoleMsg | |
{ | |
RoleId = _roleId, | |
Pos = new PosMsg | |
{ | |
X = UnityEngine.Random.Range(0, 10), | |
Y = UnityEngine.Random.Range(0, 10), | |
Z = UnityEngine.Random.Range(0, 10), | |
}, | |
}; | |
LockStepMgr.Instance.AddFrame(ENetFrameType.AddRole, JsonMapper.ToJson(msg)); | |
_roleDic.Add(_roleId, null); | |
} | |
} | |
else if (Input.GetKey(KeyCode.W)) | |
{ | |
var msg = new RoleMoveMsg | |
{ | |
RoleId = _roleId, | |
Pos = new PosMsg | |
{ | |
X = 0, | |
Y = 0, | |
Z = 1, | |
}, | |
}; | |
LockStepMgr.Instance.AddFrame(ENetFrameType.Move, JsonMapper.ToJson(msg)); | |
} | |
else if (Input.GetKey(KeyCode.S)) | |
{ | |
var msg = new RoleMoveMsg | |
{ | |
RoleId = _roleId, | |
Pos = new PosMsg | |
{ | |
X = 0, | |
Y = 0, | |
Z = -1, | |
}, | |
}; | |
LockStepMgr.Instance.AddFrame(ENetFrameType.Move, JsonMapper.ToJson(msg)); | |
} | |
else if (Input.GetKey(KeyCode.A)) | |
{ | |
var msg = new RoleMoveMsg | |
{ | |
RoleId = _roleId, | |
Pos = new PosMsg | |
{ | |
X = -1, | |
Y = 0, | |
Z = 0, | |
}, | |
}; | |
LockStepMgr.Instance.AddFrame(ENetFrameType.Move, JsonMapper.ToJson(msg)); | |
} | |
else if (Input.GetKey(KeyCode.D)) | |
{ | |
var msg = new RoleMoveMsg | |
{ | |
RoleId = _roleId, | |
Pos = new PosMsg | |
{ | |
X = 1, | |
Y = 0, | |
Z = 0, | |
}, | |
}; | |
LockStepMgr.Instance.AddFrame(ENetFrameType.Move, JsonMapper.ToJson(msg)); | |
} | |
} |
- 网络帧消息业务处理逻辑
private void OnExcuteFrame(NetFrameMsg msg) | |
{ | |
// 创建角色 | |
if (msg.Type == ENetFrameType.AddRole) | |
{ | |
var roleMsg = JsonMapper.ToObject<AddRoleMsg>(msg.FrameData); | |
var cubeTrans = GameObject.CreatePrimitive(PrimitiveType.Cube).GetComponent<Transform>(); | |
cubeTrans.position = new Vector3(roleMsg.Pos.X, roleMsg.Pos.Y, roleMsg.Pos.Z); | |
_rolePosDic[roleMsg.RoleId] = roleMsg.Pos; | |
_roleDic[roleMsg.RoleId] = cubeTrans; | |
} | |
// 移动 | |
else if (msg.Type == ENetFrameType.Move) | |
{ | |
var roleMsg = JsonMapper.ToObject<RoleMoveMsg>(msg.FrameData); | |
var posData = _rolePosDic[roleMsg.RoleId]; | |
posData.X += roleMsg.Pos.X; | |
posData.Y += roleMsg.Pos.Y; | |
posData.Z += roleMsg.Pos.Z; | |
} | |
} |
- 浮点数转定点数
由于不同机器在处理浮点数的时候会根据硬件设备产生不同的误差,所以我们需要将浮点数定长,这里就暴力一点直接转 int 了
public static int FixInt(float num) | |
{ | |
return Mathf.RoundToInt(num); | |
} |