cat
Shioho

# 前言

说起网游,多人在线时每个玩家不同的操作是如何同步到不同的机器上的呢,以 MOBA 游戏为例,玩家移动释放技能是如何做到准确一致的,这就涉及到数据同步问题了,这里就简单地讲一讲 LockStep 吧。

# 同步模型

# P2P

再讲帧同步之前,回忆到了小学逃课去网吧(好孩子不要学)打星际的那段时光,那时和朋友开上一排机子,以其中一台主机创建房间,其他机子通过局域网搜索加入游戏,这就是传统的 P2P 模型,将局域网内一台主机当做服务端,负责数据转发给其他连接上的主机,省去了服务器成本,而且数据传输更快。当然,这也是有坏处的,当作为服务端的主机断线了,那么 GG,其他玩家也只能被迫断开游戏,而且基于玩家机器的数据转发,是肯定无法进行数据校验的,那么作弊手段就有很多了,金手指修改内存等等行为是阻止不了滴。

# 帧同步

帧同步其实很简单,当玩家进行操作的时候记录下这个操作指令发送给服务端,然后服务端再把操作指令转发给各个客户端,客户端本地再对每个指令进行解析就好啦,只要保证解析流程是一致的,那么结果自然就是一致的啦。听上去很简单,但要做起来要处理的事情可不少,如随机数问题,浮点数转定点数问题,客户端预测,数据回滚断线重连等等 balabala。当然由于帧同步的战斗逻辑都在客户端,没有服务端的校验,所以也避免不了外挂的产生。

# 状态同步

状态同步,字如其名,就是服务端计算结果,然后将结果同步给各个客户端,客户端本地去掉了计算操作(客户端同学少写代码大叫好耶),由于计算端是由服务器进行的,那么计算结果就是绝对正确,能有效打击外挂,当然缺点也是有不少的,由于计算都是放在服务端的,那么必然导致服务器压力过大,当要同步的状态比较多的时候会导致传输的包体过大,受到带宽的限制,流量消耗巨大

z 总之,几种模型各有优劣,具体还是需要根据项目去决定改使用哪种解决方案

# LockStep

好了,主角登场啦~LockStep 又称锁步同步算法,早起用于军队表示齐步行军,用于机器上来说就是让所有的机器同时执行相同的一系列操作,那么得到的结果就一定是一致的,由于 LockStep 与时间无关,而是依赖于动作的一致性,因此可以忽略网络延迟达到结果的一致。
客户端 LockStep 通常会分成逻辑层和表现层,逻辑层会设置一个逻辑周期,通常为 100ms。玩家在客户端的操作,并不会立刻发送给服务端,而是先加入到本地的一个操作队列中,设定一个延时时长(如逻辑周期的一半 50ms),当达到延时时长时,再将本地操作队列中的一系列操作指令打包打给服务端进行转发。

参考链接

# Unity 小 Demo

项目地址: gitee

# 服务端

服务端基于 nodejs 开发,为了方便快速,长连采用 wss,消息直接采用 json (懒)

  • npm 包之 nodejs-websocket
    一个好用的 wss 包
h
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);
}
更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

汘帆 微信

微信

汘帆 支付宝

支付宝