【源码分享】帧同步框架DEMO之 nodejs版

许久不见,本帧为帧同步版本,之前有一篇状态同步的:


还有人问,为什么官方有MGOBE还需要自己写联机方案呢,简单的告诉你,mgobe最便宜的收费是0.0025元/人/天,而实际上小游戏之边的收益大概在0.02-0.04分之前,这样的话,相当于游戏收益的10%需要交服务器费用,而且这是按天收费。很明白了吧。

本工程的写法部分参照于unity的UNet
大致的同步方案为:框架只同步主角信息(角度,位置),由主角而产生的消息(比如说发子弹,换枪)等操作,均由自己发消息和分发。
本框架代码并没有对消息进行压缩加密操作,如果需要通过protobuf加密等操作,请参考商店插件
客户端逻辑跟本篇差不多,只是加入了protobuf,服务器改为了golang,为了更加极致的性能。
https://store.cocos.com/app/detail/2881

框架外代码简单易懂,下面是简单解读

RoomControl

房间控制类,主要是功能就是登陆和进入,进行指定房间

onLoad(){
        UNetMgr.model = UNetMgr.SyncModel.SelfNoDelay;

        UNetMgr.event.on('join',(v)=>{
            console.log('加入房间:',v);
            this.node.active = false;
            cc.find('Canvas/Game').active = true;
        },null)

        UNetMgr.event.on('loginSuccess',function(v){
            console.log('登陆完成:',v);
            UNetMgr.JoinRoom('1','0')
        },null)
    }
    start(){
    }

    onClickEnter(e,d){
        var loginData = {id:LOCAL_USER[d]}

        UNetMgr.StartConnect({
            isSSL : false, // wss 时参数为true,并且提供证书的cert路径
            cert:'', //相对于resource的路径
            host:'localhost', 
            port : '8001',
            autoConnect:true //非人为断开时自动重连
        },loginData)

        //角色预制件托管 默认添加在Canvas
        UNetMgr.room.rolePrefab = this.rolePrefab;
    }

Game.ts

进入房间成功后就进入了游戏场景
游戏主代码也很简单,默认直接准备开始,然后只监听帧事件,然后做事件分发,除了帧事件外的一些事件监听都放在框架内的UNetMgr里

onLoad(){
        Game.s_instance = this;
        UNetMgr.SendRoomMsg('ready',{})
        UNetMgr.event.on('frameUpdate',this.OnFrameUpdate,this)
        UNetMgr.event.on('close',()=>{
            console.log('重连失败,退出游戏')
            this.node.destroy();
        },this)
        UNetMgr.event.on('reconnect',()=>{
            console.log('断线重连中')
        },this)
    }
    
    removeBullet(fs:FrameStep){
        var idx = this.frameUnits.findIndex(v=>{return v._uuid == fs._uuid});
        if(idx >= 0){
            this.frameUnits.splice(idx,1);
            fs.node.destroy();
        }
    }
    
    addBullet(uuid:string,type:number,x:number,y:number){
        var node = cc.instantiate(this.bulletPrefab);
        this.node.getChildByName('Bullet').addChild(node);
        var fs = node.getComponent(FrameStep)
        fs.initFrame(uuid,UNetMgr.room.frame,{type:type,x:x,y:y})
        this.frameUnits.push(fs)
    }

    OnFrameUpdate(frame:number){
        for(var i=0; i<this.frameUnits.length; i++){
            var fs = this.frameUnits[i];
            fs.OnSyncFrame(UNetMgr.room.frame);
        }
    }

    onDestroy(){
        UNetMgr.event.off(this);
    }

CharacterControllers

第一人称控制器,这个没啥好说,也不用帖代码

Player.ts

重点是这个角色类,继承UNetIdentity(框架角色类)
其主要就是继承了一些接口,自己翻一下代码就能看懂,

FrameStep.ts

除主角外其他需要进去帧同步的基类,比如子弹,地上的物品,场景上的元件,由于这个只是一个接口,怕不太会用,于是还写了一个实现了一个子弹类

/** 创建帧数 */
    /** 创建帧数 */
    createFrame : number = 0;

    /** 创建时的数据 */
    createData : any = null;

    /** 上次更新的帧值 */
    lastUpdateFrame : number = 0;

    lockStepTween : cc.Tween = null

    _uuid : string = '';

    initFrame(id:string,frame:number,data:any){
        this._uuid = id;
        this.createFrame = frame;
        this.createData = data;
        this.lastUpdateFrame = frame
    }

    /** 帧同步主要函数 */
    abstract OnSyncFrame(frame:number);

Bullet.ts

最关键的帧同步代码,根据服务器下发的当前的帧数与创建时的帧相对比,计数当前的位置,如果相关在2帧以类,可暂时不管,否则说明有网络延时。就需要修改其位置

        var dframe = frame - this.lastUpdateFrame
        if(dframe <= 0){
            return
        }
        var time = (frame - this.createFrame) * UNetMgr.delay
        if(time >= this.life){
            Game.Instance.removeBullet(this)
            return;
        }
        var stepx = UNetMgr.delay * this.speed.x
        var stepy = UNetMgr.delay * this.speed.y
        var x = this.createData.x + (frame - this.createFrame)*stepx
        var y = this.createData.y + (frame - this.createFrame)*stepy

        if((this.node.x - x) / stepx > 2 || (this.node.y - y) / stepy > 2){
            this.node.x = x;
            this.node.y = y;
        }

话说了这么,还不如直接上代码 开源工程地址:
https://gitee.com/pabble_561/CreatorUNet_Nodejs.git

44赞

这不得先赞一个再看 :grinning:

1赞

马克马克马克

收藏等于学会

大佬orz

大佬,请问浮点数是如何处理的呀

大佬牛皮 .

大佬,我看见好几次你问这个问题了

马克~~~~

其实最优解是该怎么算怎么算,最后的结果直接保留前几位小数(一般4位,3、2、1、0位都行)即可

啥也不说。先赞一个在看。

想看看论坛各位大佬都是如何优雅解决这个问题的

看到好多人是这么处理的

看了你的前后端代码,发现这个跟帧同步关系不大啊。帧同步是只同步输入,相同时机 + 相同的输入算出相同的结果。

而你的通信的数据包是这样的
var v = {
frame:UNetMgr.room.frame+1, //发送的帧id
cmds:[], //子弹的数据数组 格式 {c:"blt’’,id:0,t:k,x:0,y:0},有多少子弹就发多少,除子弹外还包含一个这样的数据格式在数组里{c:"swd’’,v:0}
sync:{},//这个是玩家的同步数据格式为{p:{x:0,y:0,z:0},a:0,s:{x:0,y:0,z:0}},也就是位置,角度,缩放

    }。

说白了,你的做法就是A玩家把自己的位置方向,还有子弹位置信息发给B客户端,B客户端也同样的方式把自己位置方向子弹的信息发给A客户端。两边直接拿到对方的信息显示对方玩家的画面。
这和帧同步一点关联都没有。

暂且忽略是否是不是帧同步的做法,你发送数据的格式居然用json,数据量不是一般的大啊,而且这只是包含了,玩家的基本信息(位置,方向,角度),还有子弹。如果再给你加个背包系统,技能系统,攻防数据,血量,…。这些,你还要继续把你的数据包扩大,再通过服务端转发这些数据,你后端现在设置广播帧率为10帧/s,根本处理不过来。

你后端除了广播转发数据,居然还把用户不必要的信息也封装到json里广播
user = {
id:user.id,
nickname : user.nickname, //昵称
headImgUrl : user.headImgUrl, //头像
sex : user.sex //性别
}

像昵称,头像,性别这些没用的数据也丢进要同步的数据包里,额外增加通信负担。

别看你现在每个客户端互相广播玩家和子弹数据给另外所有的客户端,每个客户端的看起来画面好像一样了,但是每个状态出现时机肯定是有差别的,你加上子弹碰撞玩家的检测就知道了,很可能发生一个客户端发生了碰撞,另一个客户端miss。还有你无法做到画面平滑,动画画面完全跟着网络卡顿而卡顿。

给你参考一下我的帧同步数据包设计吧

数据用的是buffer格式,自己封包,当然也可以用protobuf,但是自己封包能优化得比protobuf的包更小,毕竟帧同步需要数据及时送达,所以数据包越小越好。
同步数据包只包含玩家的输入数据,如摇杆方向和按键状态,没有包含任何游戏数据。
每个客户端的游戏结果是根据各个客户端在每一帧的输入算出相同的结果。

var pkgLen = 10; //包头固定长度 10字节
var frameId = 4; // 帧id 长度 4字节
var idLen = 2; //用户数据长度 2字节
var axisLen = 8; //摇杆方向 x,y 4字节
var attackKeyLen = 1 //攻击按键 1 字节
var totalLen = pkgLen + frameId + idLen + axisLen + attackKeyLen; //总的要发送的字节长度,25字节

var arrbuf:ArrayBuffer = new ArrayBuffer(totalLen)
this.data = new DataView(arrbuf);
var offset = 10; //包头协议自己定义,先把索引跳出包头,写包体
dataView.setUint32(offset,1); //输入操作的帧索引
offset +=4;
dataView.setUint16(offset,7527); //用户id
offset +=2;
dataView.setFloat32(offset,0.707); //摇杆x
offset +=4;
dataView.setFloat32(offset,0.707); //摇杆y
offset +=4;
dataView.setUint8(offset,1); //攻击按键 0为释放,1为按下
offset +=1;
socket.send(dataView.buffer); //连接的websocket发送数据

每帧要广播转发的数据帧只有25字节,比用json同步各种玩家,弹幕状态的数据包小了很多。因为数据包不包含游戏数据,所以这个转发的包是可以用来开发各种不同的游戏的,无论游戏大小,就算场景有上万个角色单位都能支持

15赞

里面有golang+protobuf的版本,但是刚刚才过审,这里只是一个简单易懂的DEMO

通信协议是一部分,但是同步数据,不能把玩家和武器的数据同步出去啊,应该只同步你的操作输入。如果是星际争霸那种rts游戏,我方有30个单位,你要把这三十个单位的位置,方向,缩放,血量,攻防,这些信息每帧全部同步出去给其它玩家,这网络受得了吗

2赞

明显只是发了一次创建的消息,之后就没有再发过消息了,除非客户端上报了单位的数据修改,比如说转方向,或者死亡了,请查看服务器Room.js中的代码,每次分发完帧消息后,都把frameData = {}了,

frameUpdate(dt){
    this.frameTime ++;
    this.broadcast('frame',{t:this.frameTime,v:this.frameData});
    this.frameData = {};
 }

简单的说就是,服务器只是按帧分发了客户上报的帧事件,如果客户不上报,就不会有任何事件分发,就客户端而言,除非有人修改了当前的逻辑(比如说换武器,掉血),才会上报,否则只上报几个主角的position,angle,scale,具体代码可查看客户端UNetIdentity.ts中的updateLockStep函数

我说的RTS游戏,即时战斗时,基本每帧都有运动,30个单位,就要要每帧上报数据量 (位置 方向 缩放)* 30。

如果每个主角每一帧都是按照之前操作方式(角度和速度)走,就不用提交啊,如果人为修改数据变化才会上报上去。而且每个人只上报自己的修改操作,哪有角色x30这么大的数据?

你的理解就是每个客户端端算好结果,就上传改变的结果给其它客户端就好了。

但是有这样一个问题:

如果玩家A的攻击击中了玩家B,要扣对方的血,玩家B不发血量,防御值过来,A怎么知道该扣多少,血量还剩多少?

当然你会说初始化的时候已经发过一次了,假设是初始血量为100。
1,如果玩家A击中玩家B,扣了玩家B的10点血,A把B还剩90血的结果通知B。
2,但是B在没收到扣血数据之前,B自己先用了道具补血,这时血量有110,并把血量110发给A。
3,这时候A扣B的血量的消息送达了B,B自己最新的110血量被A发过来的90血量给覆盖了。
4,同时A也收到B给自己的补血血量110。
5,最终结果就是客户端B显示自己的血量为90,客户端A显示B的血量为110。

这种互相发送自己结果给对方的做法达到了同步了吗?你这种做法根本做不到帧同步

2赞