请选择 进入手机版 | 继续访问电脑版

[设计心得] 小游戏-贪吃蛇(2)帧同步

[复制链接]
128 |0
剑寂万古 发表于 2024-5-17 10:30:07 | 显示全部楼层 |阅读模式

什么是帧同步

帧同步同步的是客户端的操作指令。客户端上传操作到服务器,并且服务器并不做过多的处理,然后将当前帧间隔内收集到的操作指令广播给每一个客户端,各个客户端在一致环境下,处理同样的操作输入,则会得到同样的结果,这样服务器的计算压力、传输数据量就相对小很多,能更好的满足游戏对高实时性、高一致性的要求。

帧同步的要点与细节

  1. 运算结果一致性

帧同步的逻辑运行环境在客户端,这要求在客户端运行时候运算结果要一样,为了达到这种目的,需要做到

  • 随机数一致
  • 浮点数一致
  • 命令顺序一致
  • 逻辑一致
  • 物理引擎一致(暂无)
  • 随机数一致

通过设置同一个随机种子,确保多端结果一致

export class IOMath {

    public static _seed: number = 20240118;

    /**
     * 设置随机数种子
     * @param seed 
     */
    public static setSeed(seed: number) {
        this._seed = seed;
    }

    /**
     * 获取随机数
     */
    public static random(): number {
        const a = 1664525;
        const c = 1013904223;
        this._seed = (a * this._seed + c) & 0x7fffffff; // 使用位运算确保结果为正整数
        return this._seed / 0x7fffffff; // 将结果归一化到 [0, 1) 范围内
    }
}
  • 浮点数一致

typescript浮点数在传输的时候没法保证一致性,为了保持一致性,在传输时,将浮点数转化为整数

       /**
     * 获取当前状态
     * @param playerId 当前需要获取游戏状态的玩家,这个id用于做增量状态下发。
     */
    getState(playerId: number): string {

        let state = this.gameState.clone();
        state.playerState.forEach(e => {

            e.headPos.x = Math.ceil(e.headPos.x * 1000)
            e.headPos.y = Math.ceil(e.headPos.y * 1000)

            e.dir.x = Math.ceil(e.dir.x * 1000)
            e.dir.y = Math.ceil(e.dir.y * 1000)

        })

        let ioNetS = ModuleService.getModule(IONetModuleS);

        if (ioNetS.playerIsFirstSync(playerId)) {
            state.playerState.forEach(e => {
                if (ioNetS.playerIsFirstSync(e.playerId)) {
                    e.nodePos = []
                } else {
                    e.nodePos.forEach((v, i) => {
                        v.x = Math.ceil(v.x * 1000)
                        v.y = Math.ceil(v.y * 1000)
                    })
                }
            })
        } else {
            state.playerState.forEach(e => {
                e.nodePos.forEach((v, i) => {
                    v.x = Math.ceil(v.x * 1000)
                    v.y = Math.ceil(v.y * 1000)
                })
            })
        }

        return JsonEx.stringify(state)

    }

        /**
     * 回滚状态
     * @param jsonStr
     */
    backState(jsonStr: string): void {

        if (jsonStr == null || jsonStr == "") {
            return;
        }

        let newState = new SnackGameState()
        try {
            if (JsonEx.parse(jsonStr, newState) != null) {
                //console.error("backState KB : " + jsonStr.length / 1024);
            }
        } catch (e) {
            console.error("json err : " + jsonStr);
            return;
        }

        newState.playerState.forEach((value, index) => {

            let curState = this.gameState.playerState.find(e => e.playerId == value.playerId)

            if (curState == null && value.die == false && value.nodePos.length > 0) {

                let newState = SnackPlayerState.copy(value)

                newState.nodePos.forEach(e => {
                    e.x = e.x / 1000;
                    e.y = e.y / 1000;
                })

                newState.headPos.x = newState.headPos.x / 1000;
                newState.headPos.y = newState.headPos.y / 1000;

                newState.dir.x = newState.dir.x / 1000;
                newState.dir.y = newState.dir.y / 1000;

                this.gameState.playerState.push(newState);

                /**
                 * 回滚状态的时候判断,如果自己是第一次出生。则设置下摄像机位置
                 */
                if (newState.playerId == mw.Player.localPlayer.playerId) {
                    mw.Camera.currentCamera.worldTransform.position = new mw.Vector(newState.headPos.x, newState.headPos.y, mw.Camera.currentCamera.worldTransform.position.z)
                }

            } else {

                if (curState == null) return;

                curState.headPos.x = value.headPos.x / 1000;
                curState.headPos.y = value.headPos.y / 1000;

                curState.dir.x = value.dir.x / 1000;
                curState.dir.y = value.dir.y / 1000;

                curState.moveSpeed = value.moveSpeed;
                curState.score = value.score;
                curState.die = value.die;

            }

        });

        let newGameState = [];
        this.gameState.playerState.forEach(e => {
            if (newState.playerState.find(e2 => e2.playerId == e.playerId)) {
                newGameState.push(e)
            }
        })

        this.gameState.playerState = newGameState;

        this.gameState.spawnCount = newState.spawnCount

    }
  • 命令顺序一致 这里保持一致性的方式是通过rpc的顺序传输,mw的rpc传输是必定到达且有顺序的传输,传输队列的顺序的一致性是可以保证的
  • 逻辑一致

在客户端和服务端分别运行一样的逻辑,保证不同端的内容,保持一致

////////////////// 服务端会运行预测帧逻辑 /////////////////  

    /**
     * 滚动逻辑
     * @param frameNumber 
     */
    private onTick(frameNumber: number, param: InputParamsObj<InputCommanderBase>) {

        /**
         * 预测客户端逻辑
         */

        this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, true)

    }
////////////////// 客户端也会执行逻辑帧,这里因为拉扯等问题直接返回了 /////////////////  
   /**
     * 滚动逻辑
     * @param frameNumber 
     */
    private onTick(frameNumber: number, param: InputParamsObj<any>, isPreLogic: boolean) {

        return;

        /**
         * 向远端发送param
         */
        if (param != undefined && param != null && MapEx.has(this._inputParamsAck, frameNumber)) {

            //this.server.net_OnInputField(param);

        } else {
            let paramIns: InputParamsObj<InputCommanderBase> = new InputParamsObj<InputCommanderBase>();
            let params = {};
            params["frameCount"] = frameNumber;
            MapEx.set(paramIns.pCmds, mw.Player.localPlayer.playerId.toString(), params);
            MapEx.set(this._inputParams, frameNumber.toString(), paramIns);
            this.server.net_OnInputField(paramIns);
            MapEx.set(this._frameDelay, this._clientFrameNumber, Date.now());

            param = paramIns;

        }

        /**
         * 预测客户端逻辑
         */

        if (isPreLogic) {
            this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, false)
        }

    }

////////////////// 客户端得到服务端命令之后回滚帧,并重新执行相关逻辑 /////////////////  

        public net_OnServerTick(remoteFrameNumber: number, state: string, inputParams: InputParamsObj<InputCommanderBase>, seed: number, isComplate: boolean): void {

        this.reviceState += state.toString();

        if (isComplate == false) {
            return;
        }

        state = this.reviceState.toString()
        this.reviceState = ""

        if (this.init == false) return;

        /**
         * 回滚状态
         */
        this.gameLogic.backState(state);

        /**
         * 设置服务器帧号
         */
        this.serverFrameNumber = remoteFrameNumber;

        let clientFrameNum = 0;

        let preReciveClientFrameNumber = this._reciveClientFrameNumber;

        if (this._reciveClientFrameNumber + 1 < this.clientFrameNumber) {
            preReciveClientFrameNumber++
        }

        if (MapEx.has(inputParams.pCmds, mw.Player.localPlayer.playerId.toString())) {

            let info = MapEx.get(inputParams.pCmds, mw.Player.localPlayer.playerId.toString());
            if (!Number.isNaN(info.frameCount)) {
                if (info.frameCount >= this._reciveClientFrameNumber) {
                    preReciveClientFrameNumber = info.frameCount
                }
            }
        }

        this._reciveClientFrameNumber = preReciveClientFrameNumber

        /**
         * 读取上次投递的帧号,好做回滚预测。
         * 如果空帧的情况,就递增一帧
         */
        clientFrameNum = this._reciveClientFrameNumber

        /**
         * 移除上一帧的输入
         */
        if (clientFrameNum > 0) {
            MapEx.del(this._inputParams, clientFrameNum - 1);
            MapEx.del(this._inputParamsAck, clientFrameNum - 1);
        }

        /**
         * 修正当前帧输入
         */
        MapEx.set(this._inputParams, clientFrameNum, inputParams);

        /**
         * 设置当前帧输入参数已下发
         */
        MapEx.set(this._inputParamsAck, clientFrameNum, true);

        /**
         * 设置随机数种子
         */
        IOMath.setSeed(seed)

        /**
         * 获取帧延迟
         */
        let frameDelay = MapEx.get(this._frameDelay, clientFrameNum);
        MapEx.del(this._frameDelay, clientFrameNum);
        let now = Date.now();
        if (frameDelay == undefined) {
            frameDelay = 0;
        } else {
            frameDelay = now - frameDelay;
        }

        /**
         * 滚动预测逻辑
         */
        Event.dispatchToLocal("updateFrame", clientFrameNum, this.serverFrameNumber, this._clientFrameNumber, frameDelay);

        /**
         * 补偿逻辑帧
         */

        let param = MapEx.get(this._inputParams, clientFrameNum);
        this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, true);
        return;

        for (let i = clientFrameNum; i < this._clientFrameNumber; i++) {
            let param = MapEx.get(this._inputParams, i);
            this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, i == clientFrameNum);
        }

    }
  1. 显示与逻辑分离

客户端逻辑、渲染分离主要解决以下问题:

1,抗网络抖动导致的渲染抖动(各种表现不平滑),不分离的话网络抖动导致逻辑抖动进而影响渲染

2,新建线程处理逻辑层,避免逻辑、渲染共用线程导致拖累渲染(因为是js没有做独立的逻辑线程)

3,逻辑层独立出来,可做服务端验算(帧同步因为数据都在本地,很容易出现透视等作弊操作,并且难被发现)

    /**
     * 本地tick
     */

    protected onUpdate(dt: number): void {

        if (this.init == false) {
            return;
        }

        /**
         * 以服务端的帧率进行预测,如果帧率低的情况下 也会追帧。
         */
        this.curFrameDt += dt;
        while (this.curFrameDt >= IONetDef.FrameTickNumber) {

            this.curFrameDt -= IONetDef.FrameTickNumber;

            /**
             * 服务器必须要先跑一帧 客户端才能开始跑
             */
            if (this.serverFrameNumber > 0 &&
                (this.clientFrameNumber - this._reciveClientFrameNumber) < IONetDef.maxPreLogicCount) {

                //if (this._clientFrameNumber < this._reciveClientFrameNumber + 10) {
                /**
                 * 1秒误差可以进行预测
                 */

                /**
                 * 完成预测的同时要向远端发送输入
                 */
                let params = MapEx.get(this._inputParams, this._clientFrameNumber);

                /**
                 * 预测逻辑
                 */
                this.onTick(this._clientFrameNumber, params, true);

                /**
                 * 帧号增加
                 */
                this._clientFrameNumber++;

            } else {

                /**
                 * 网络不稳定弹窗,正常发帧包,但是不预测输入
                 */

                //this.onTick(this._clientFrameNumber, null, false);

            }

        }

        /**
         * 更新游戏渲染tick
         */
        this.gameLogic.onViewTick(dt);

    }


    /**
     * 服务端Tick
     * @param dt 
     */
    protected onUpdate(dt: number): void {

        if (this.init == false) return;

        // npc
        NpcHelper.npcMap.forEach(npc => {
            npc.onTick(dt);
        })

        /**
         * 以服务端的帧率进行预测,如果帧率低的情况下 也会追帧。
         */
        this.curFrameDt += dt;
        while (this.curFrameDt >= IONetDef.FrameTickNumber) {

            this.curFrameDt -= IONetDef.FrameTickNumber;

            /**
             * 解析指令
             */
            let handleCommand: InputParamsObj<InputCommanderBase> = new InputParamsObj<InputCommanderBase>();
            for (let key in this.frameInputParams) {
                let cmdList = this.frameInputParams[key];
                let cmd = cmdList.shift();
                if (cmd != null) {
                    MapEx.set(handleCommand.pCmds, key, cmd)
                }
                MapEx.del(this.playerFrameIsInputed[key], this._frameNum.toString());
            }

            const seed = IOMath._seed;

            /**
             * 预测逻辑
             */
            this.onTick(this._frameNum, handleCommand);

            const curFrameNum = this._frameNum;

            /**
             * 广播当前状态和本次输入。
             */

            this.allPlayers.forEach(e => {
                this.addWaitFrameContent(e.playerId, curFrameNum, this.gameLogic.getState(e.playerId), handleCommand, seed);
                //this.getClient(e.playerId).net_OnServerTick(curFrameNum, this.gameLogic.getState(e.playerId), handleCommand, seed);
            });

            this.allPlayers.forEach(e => {
                this.isFirstSync.set(e.playerId, true);
            });

            this.gameLogic.onFrameEnd()

            /**
             * cache 历史帧,后面想了下 实际不用记录了,因为有完整的状态回滚
             */
            //MapEx.set(this.historyFrameInputCache, this.frameNum, handleCommand);
            //MapEx.set(this.historyFrameStateCache, this.frameNum, state);

            /**
             * 服务器帧号递增
             */
            this._frameNum++;

        }

        this.handleWaitFrameContent();

        this.gameLogic.onViewTick(dt);

    }
回复

使用道具 举报

热门版块
快速回复 返回顶部 返回列表