isabel

isabel 查看完整档案

上海编辑  |  填写毕业院校腾讯  |  产品经理 编辑填写个人主网站
编辑

专注于游戏行业的云服务产品 & 解决方案

个人动态

isabel 发布了文章 · 1月4日

如何利用状态同步开发一款联机游戏

游戏状态同步

1.前言

目前市场上单机游戏占比高,因为相对联机游戏开发周期短、成本低,但联机游戏的社交属性强,玩家粘性高。总体来说,开发联机游戏有一定的技术门槛。

2.帧同步和状态同步

  • 帧同步过程为各客户端实时上传操作指令集;服务端保存这些操作指令集,并在下一帧将其广播给所有客户端;客户端收到指令集后分别按帧序执行指令集中的操作。同步的是玩家的操作指令,该方式多用于对实时性要求很高的网络游戏。
  • 状态同步过程为客户端上传操作到服务端,服务端收到后计算游戏行为的结果,即技能逻辑。战斗计算都由服务端运算,然后以广播的方式下发游戏中各种状态,客户端收到状态后,更新自己本地的动作状态、Buff 状态、位置等。同步的是游戏中的各种状态,该方式多用于回合制游戏。

本文将以状态同步技术为主,使用游戏联机对战引擎,帮助开发者短期低成本实现一款联机游戏。

游戏联机对战引擎

1.简介

游戏联机对战引擎(Mobile Game Online Battle Engine,MGOBE)为游戏提供房间管理、队组管理、在线匹配、帧同步、状态同步等对战服务,帮助开发者快速搭建多人交互游戏。

  • 直接通过 SDK 调用后端服务,无需后台代码。
  • 无需关心后台网络架构、网络通信技术、帧同步、服务器扩缩容、运维等复杂技术。
  • 获得就近接入、低延迟、实时扩容的高性能对战服务,让玩家在网络上互通、对战、自由畅玩。
  • 适用于回合制、策略类、实时会话(休闲对战、MOBA、FPS)等游戏。

1.png

2.接口概览

MGOBE 客户端 SDK 的接口可以分为五类,包括房间管理、匹配、消息发送、帧同步、广播接口。

  • 房间管理类的接口主要是用于将不同玩家组成一个对局,这个过程中可以通过创建房间、邀请他人加入房间等方式将玩家聚合在一起。此外,还提供了如踢人、修改房间属性、查询房间信息等基本的房间管理方法。
  • 匹配类的接口主要是用于将不同玩家通过匹配的方式组成对局,开发者可以根据需要定制匹配规则,实现根据玩家等级、积分等属性进行匹配。
  • 帧同步和消息发送接口可以用于玩家消息的交互,通过帧同步、状态同步方式实现玩家游戏逻辑的同步。
  • 广播类接口主要是用于处理上述接口调用产生的广播事件,比如玩家加房、退房广播、帧消息广播等等。

2.png
本文游戏案例讲解将会使用玩家匹配 matchPlayers,与实时服务器的交互 sendToGameSvr 以及相应的广播处理。

3.状态同步-实时服务器

实时服务器实现了对客户端游戏逻辑的扩展,当玩家进入房间以后,对于房间进行的任何操作,都会通过 MGOBE 的房间服务器同步给实时服务器。这样,实时服务器可以拿到最新的房间信息、房间状态,比如玩家进房、玩家退房、掉线,开始帧同步等,这些广播都会同步给实时服务器。
客户端通过 sendToGameSvr 请求接口和相应的广播接口 onRecvFromGameSvr 来实现和实时服务器的交互,通过这种方式实现游戏的状态同步。在实际应用中有多种用法:把实时服务器当成仲裁,计算最终的游戏结果;当玩家掉线时可用实时服务器来执行一些特殊逻辑,如使用机器人托管掉线玩家的操作;实时服务器还可以实现将玩家的数据保存在开发者腾讯云/云开发的数据库上。
4.png

游戏案例背景

1.状态同步 - 答题游戏《题题对战》

  • 《题题对战》是一款使用游戏联机对战引擎 MGOBE 实时服务器实现状态同步的组队答题类游戏。
  • 玩家通过随机匹配组成对局,然后与实时服务器进行交互,获取游戏状态(题目信息、玩家信息)。
  • demo包含六个页面:授权页、首页、匹配页、房间页、答题页、结算页。玩家在首页通过三种匹配方式(1V1、2V2、3V3)进入房间,玩家向实时服务器发送准备指令后会进入答题页,选择答案后提交到实时服务器,由实时服务器的逻辑判断答案的正误,并且下发新的游戏状态给每个玩家客户端。
  • 涉及到的 MGOBE 接口有玩家匹配(matchPlayers)、查询指定房间信息(getRoomByRoomId)、退出房间(leaveRoom)、发送实时服务器消息(sendToGameSvr)、实时服务器广播(onRecvFromGameSvr)。

5.png
6.png
2.《题题对战》体验二维码
感兴趣的开发者可以扫码体验

7.png

游戏案例开发实践

一、实时服务器基础知识

1.开通实时服务器
开发者在使用实时服务器之前,需要上 MGOBE 控制台 新建一个游戏,然后创建实时服务器。
12.png
开发者需上传代码 zip 包;可选择是否打通腾讯云的 VPC 网络,打通后可以访问 VPC 下的数据库和存储;底层实例自动调节、弹性伸缩。
13.png
2.MGOBE 提供 NodeJS 框架可供下载,该框架文件夹里最核心的文件为 index.js。
3.index.js 文件主要作用为导出 mgobexsCode 对象,该对象有四个属性:logLevel、logLevelSDK、gameInfo、gameServer。

  • logLevel 表示开发者使用 MGOBE 提供的接口打印日志、logLevelSDK 表示 SDK 内部的日志打印、gameInfo 填写该游戏的ID和后端密钥信息、gameServer 对象最重要,开发者大部分逻辑都在这个对象里。
exports.mgobexsCode = {
    logLevel: 'error+',
    logLevelSDK: 'error+',
    gameInfo: {
        gameId: "请填写游戏ID",
        serverKey: "请填写后端密钥",
    },
    onInitGameServer,
    gameServer
};

gameServer 对象类型为 GameServer.IGameServer ,主要包括 mode 属性、onInitGameData 接口和一些广播。

export interface IGameServer {
           mode?: 'async' | 'sync';
           onInitGameData: (args: { room: IRoomInfo; }) => GameData;
           onRecvFromClient: onRecvFromClient;
           onCreateRoom?: onCreateRoom;
           onJoinRoom?: onJoinRoom;
           onLeaveRoom?: onLeaveRoom;
           onRemovePlayer?: onRemovePlayer;
           onChangeRoom?: onChangeRoom;
           onChangeCustomPlayerStatus?: onChangeCustomPlayerStatus;
           onChangePlayerNetworkState?: onChangePlayerNetworkState;
           onStartFrameSync?: onStartFrameSync;
           onStopFrameSync?: onStopFrameSync;
           onDestroyRoom?: onDestroyRoom;
       } 

(1)mode 表示实时服务器处理玩家消息的方式:

  • 'async'代表异步,依赖于 gameServer 的事件循环去处理消息。
  • 'sync'代表串行,采用串行方式去处理玩家发来的消息,处理完一条再处理下一条。

(2)onInitGameData 初始化游戏数据:游戏数据与房间生命周期一致,随房间销毁而销毁,可认为是对战式的游戏数据。 触发时机为在第一次收到玩家消息之前会调用一次。使用时 return 一个对象即可,比如返回游戏状态 players 的数组。

const gameServer: mgobexsInterface.GameServer.IGameServer = {
       mode: 'sync',
       onInitGameData: function (): mgobexsInterface.GameData {
          return {
              players:[],
          };
       },

(3)onRecvFromClient 收到玩家消息和房间操作的广播
(4)onCreateRoom 创建房间广播
(5)onJoinRoom 加入房间广播
(6)onLeaveRoom 退出房间广播
(7)onRemovePlayer 踢人广播
(8)onChangeRoom 修改房间广播
(9)onChangeCustomPlayerStatus 修改房间玩家状态广播
(10)onChangePlayerNetworkState 玩家网络状态变化广播,用来监听是否有玩家掉线
(11)onStartFrameSync 开始帧同步广播
(12)onStopFrameSync 停止帧同步广播
(13)onDestroyRoom 房间销毁广播

onJoinRoom: function ({ actionData, gameData, SDK, room, exports }) {
        SDK.logger.debug(
            'onJoinRoom',
            'actionData:', actionData,
            'gameData:', gameData,
            'room:', room
        );
    },

以上广播的内设参数:

  • actionData 参数表示实际广播数据,对于不同广播来说数据类型不同。
  • gameData 参数表示这个房间的游戏数据,是 onInitGameData 返回的对象。
  • SDK 参数是实时服务器提供的一些方法:

(1)sendData 给房间内玩家发送数据,发送配置包括超时时间和最大重试次数。
(2)dispatchActio 模拟玩家发消息给实时服务器。
(3)clearAction 在串行模式下,有些消息会被放置在队列里,调用该方法可以清空这个队列,所有没有被处理的消息都会被清空。
(4)exitAction 表示当前消息已被处理完毕,可以处理下一条房间消息。
(5)logger 日志打印方法。

SDK: {
            sendData: (data: { playerIdList: string[]; data: UserDefinedData; }, resendConf?: { timeout: number; maxTry: number; }) => void;
            dispatchAction: (actionData: UserDefinedData) => void;
            clearAction: () => void;
            exitAction: () => void;

            getRoomByRoomId: (getRoomByRoomIdPara: IGetRoomByRoomIdPara, callback?: ReqCallback<IGetRoomByRoomIdRsp>) => void;
            changeRoom: (changeRoomPara: IChangeRoomPara, callback?: ReqCallback<IChangeRoomRsp>) => void;
            changeCustomPlayerStatus: (changeCustomPlayerStatusPara: IChangeCustomPlayerStatusPara, callback?: ReqCallback<IChangeCustomPlayerStatusRsp>) => void;
            removePlayer: (removePlayerPara: IRemovePlayerPara, callback?: ReqCallback<IRemovePlayerRsp>) => void;

            logger: {
                debug: (...args: any[]) => void;
                info: (...args: any[]) => void;
                error: (...args: any[]) => void;
            };
        };
  • room 参数表示当前房间内信息,每个广播接口里都有这个参数。
  • exports 参数用来修改 gameData。

4.以上是实时服务器的接口简介,开发者将示例代码发布至实时服务器上,可选择“停服发布”或“不停服发布”。发布完成可以点击“查看日志”去日志页面,在调试过程中通过查看日志来调试。
14.png

二、《题题对战》开发实践
《题题对战》该游戏使用 LayaAir 引擎开发,本文将跳过UI构建过程和具体游戏逻辑,侧重于介绍 SDK 的关键调用步骤。
1.代码里客户端/src/script/scene 文件里,每一个界面对应一个脚本:Answer.ts答题页、Auth.ts授权页、Finish.ts结算页、Main.ts主页、MatchRoom.ts匹配页、VSRoom.ts房间页,这五个页面都继承于 Base.ts 里的类。
15.png
2.Base.ts 里有一些公共方法:初始化场景设置、加载进度条、打开场景、登陆等,还有 onAwake 方法。其他页面在触发 onAwake 时都会调用 Base.ts 里的 onAwake 方法。

  • 在 onAwake 方法里做了一些资源初始化:初始化场景、设置广播、关闭对话框、加载进度条、判断玩家有无授权、判断玩家有无登陆。
async onAwake() {
        this.initScene();
        this.setBroadcast();

        Base.dialog && Base.dialog.close();

        await this.loadProgressBar();

        if (!Global.userInfo) {
            this.openScene("AuthScene");
            return;
        }

        if (!Global.openId) {
            await this.login();
        }
    }
  • 判断玩家有无授权,决定能否拿到玩家信息,如无授权则跳转到授权页,授权页会调用微信的 getUserInfo 方法。失败则会创建“授权按钮”,玩家点击后拿到用户信息,就会调转至主页。
wx.getUserInfo({
            fail: () => {
                const button = wx.createUserInfoButton({ type: "image", style, image: "image/auth_button.png" });

                button.onTap(data => {
                    handleUserInfo(data.userInfo);
                    button.destroy();
                });
            },
            success: data => {
                handleUserInfo(data.userInfo);
            }
        });
  • 判断玩家有授权后,紧接着判断玩家有无登陆,本文将登陆和初始化 SDK 放在一起实现。先去缓存里拿到游戏基本信息:gameId、玩家openId、密钥secretKey、服务地址server、匹配matchCodes。
// 微信登录态
    async login(): Promise<boolean> {

        // 已登录
        if (Global.openId && MGOBE.Player.id) return Promise.resolve(true);

        this.showProgressBar(true);

        let gameInfo = getGameInfoFromStorage();

        if (!gameInfo) {
            gameInfo = {
                gameId: "",
                openId: Date.now() + "_" + Math.random(),
                secretKey: "",
                server: "",
                matchCodes: {
                    1: "", // 1V1 匹配Code
                    2: "", // 2V2 匹配Code
                    3: "", // 3V3 匹配Code
                }
            };
            setGameInfoToStorage(gameInfo);
        }

        const res = await initSDK(gameInfo);// 初始化SDK

        this.showProgressBar(false);

        if (res) {
            this.setBroadcast();//登陆成功设置广播
        } else {
            Base.dialog.showDialog("提示", "初始化失败");
        }

        return Promise.resolve(res);//返回登陆是否成功信息
    }
  • matchCode 是在 MGOBE 控制台创建的在线匹配,本文以1v1匹配为例,其中规则集在控制台上已经有示例,如1v1、2v2、3v3、5v5随机匹配,添加分段、添加误差,会根据玩家属性去匹配。

16.png

  • 如上图,1v1只有 version、teams 两个属性,teams 里有四个属性:队伍名称name、队伍最大玩家数量maxPlayers、队伍最小玩家数量minPlayers、队伍个数number。此示例表示有2个队,每个队伍的玩家数量都为1。

17.png

  • 创建好规则集后,可以选择开启机器人,当超过配置的超时时间还未匹配到真人时,可以匹配机器人形成对局房间。全部填好创建匹配后可以获得 matchCode 。
  • 获取到游戏基本信息后,初始化 SDK ,返回一个 Promise ,初始化成功返回 true 。
function initSDK(initGameInfo: GameInfo): Promise<boolean> {
    const { Room, Listener, ErrCode, ENUM, DebuggerLog } = MGOBE;

    Global.gameId = initGameInfo.gameId;
    Global.openId = initGameInfo.openId;
    Global.secretKey = initGameInfo.secretKey;
    Global.server = initGameInfo.server;
    Global.matchCodes = initGameInfo.matchCodes;
    
    const gameInfo: MGOBE.types.GameInfoPara = {
        gameId: Global.gameId,
        openId: Global.openId,
        secretKey: Global.secretKey,
    };

    const config: MGOBE.types.ConfigPara = {
        url: Global.server,
        reconnectMaxTimes: 5,
        reconnectInterval: 4000,
        resendInterval: 2000,
        resendTimeout: 20000,
        isAutoRequestFrame: true,
    };

    return new Promise(resolve => {
        MGOBE.Listener.init(gameInfo, config, event => {
            if (event.code === MGOBE.ErrCode.EC_OK) {
                Global.room = new Room();
                MGOBE.Listener.add(Global.room);

                return resolve(true);
            }

            return resolve(false);
        });
    });
}
  • 本示例封装了 SDK 操作,把这些接口都封装为 Promise,通过这种方法可以很好的结合 async 和 await 语法,这里封装了查询房间信息、退房、发起匹配、取消匹配、发送消息给实时服务器、获取自己的队伍、获得敌人的队伍,设置广播处理。

3.玩家授完权后会进入主页,Main.ts 里有三个按钮,在 initListener 里为它们绑定了点击事件。
18.png

  • 这三个按钮对应的点击事件都需要实现,比如第一个按钮点击时,将匹配模式设为1,代表1v1,同理设置第二三个按钮为2v2,3v3。
initListener() {
        this.btn1.on(Laya.Event.CLICK, this, () => {
            Global.matchMode = 1;
            !this.isInProgress() && this.openMatch();
        });

        this.btn2.on(Laya.Event.CLICK, this, () => {
            Global.matchMode = 2;
            // todo
        });

        this.btn3.on(Laya.Event.CLICK, this, () => {
            Global.matchMode = 3;
            // todo
        });
    }
  • 首页点击按钮进入匹配页,发起匹配会调用 matchPlayers。
async openMatch() {
        this.showProgressBar(true);//打开进度条
        let res;
        if (!MGOBE.Player.id) {//判断SDK有无初始化

            res = await this.login();//如未初始化调用login方法
            if (!res) {
                return;//如登陆失败,直接返回
            }
        }
        res = await this.getUserRoom();//查询玩家是否在房间里
        this.showProgressBar(false);//隐藏进度条
        if (res) {
            // 玩家已经在房间内
            Global.room.initRoom(res);
            this.handleInRoom();//弹窗提示
            return;
        }
        // 开始匹配
        this.openScene("MatchRoom");//玩家不在房间里
    }

4.玩家发起匹配后进入匹配页面,匹配页面逻辑在 MatchRoom.ts 里实现。
22.png

  • 触发 onAwake 之后调用 Base.ts 里的 onAwake 方法。
async onAwake() {
        await super.onAwake();

        this.isDisable = false;

        Global.room.onUpdate = () => {
            if (this.isInRoom()) {
                // 已经在房间内
                this.openVSScene();//打开房间页,跳过匹配环节
            }
        };
    }
  • 触发 onEnable 时,会初始化页面、初始化点击事件、调用 callMatch 方法。
onEnable(): void {
        this.initView();
        this.initListener();

        this.showProgressBar(false);
        this.callMatch();
    }
  • 玩家点击“取消”退出匹配,调用 Base.ts 里的 callCancelMatch 方法。
async callCancelMatch() {
        let res = await this.cancelMatch();
        if (this.isDisable) { return; }//判断页面是否被激活
        
        // 判断是否取消成功
        if (res === MGOBE.ErrCode.EC_OK) {
            this.openScene("Main");//返回主页
            return;
        }

        // 判断玩家是否已经在房间内
        if (res === MGOBE.ErrCode.EC_ROOM_PLAYER_ALREADY_IN_ROOM) {//已在房间返回错误码
            this.openVSScene();//不在房间,打开房间页
            return;
        }
    }
  • 进入场景后会进行匹配,调用 callMatch 方法实现匹配。
async callMatch() {
        this.setTimer();//打开计时器
        let res;
        res = await this.match();//等待匹配
        if (this.isDisable) { return; }
        // 已经在匹配中
        if (res === MGOBE.ErrCode.EC_MATCH_PLAYER_IS_IN_MATCH) {//判断玩家是否多次发起匹配,如多次则返回错误码
            Base.dialog.showDialog("提示", "已经在匹配中,请等待");//弹窗提示
            return;
        }
        this.clearTimer();//清除定时器

        // 判断玩家是否已经在房间内
        if (res === MGOBE.ErrCode.EC_ROOM_PLAYER_ALREADY_IN_ROOM) {
            this.openVSScene();//打开房间页
            return;
        }

        // 判断是否匹配成功
        if (res === MGOBE.ErrCode.EC_OK) {
            this.openVSScene();//打开房间页
            return;
        }

        Base.dialog.showDialog("提示", "超时未匹配到对手,请您重新匹配",//匹配失败原因
            { confirmCallback: () => this.callMatch() },//点击确定,重新发起匹配
            { cancelCallback: () => this.openScene("Main") }//点击取消,回到主页
        );
    }
  • 匹配超时后,调用 handleMatchTimeou 方法取消匹配。
async handleMatchTimeout() {

        if (this.isInRoom()) {
            return;
        }

        let res = await this.cancelMatch();

        if (this.isDisable) { return; }

        // 取消成功
        if (res === MGOBE.ErrCode.EC_OK) {
            Base.dialog.showDialog("提示", "超时未匹配到对手,请您重新匹配",
                { confirmCallback: () => this.callMatch() },
                { cancelCallback: () => this.openScene("Main") }
            );
            return;
        }

        // 已经在房间内
        if (res === MGOBE.ErrCode.EC_ROOM_PLAYER_ALREADY_IN_ROOM) {
            this.openVSScene();
            return;
        }

        Base.dialog.showDialog("提示", "超时未匹配到对手,请您重新匹配",
            { confirmCallback: () => this.callMatch() },
            { cancelCallback: () => this.openScene("Main") }
        );
    }

5.匹配成功进入VS房间页,VSRoom.ts 里实现方法:发起准备指令给实时服务器,实时服务器初始化游戏信息,并将游戏信息发给客户端。
19.png

  • 触发 onEnable 事件时调用 this.ready 方法,用来发消息给实时服务器。
async ready() {
        this.showProgressBar(true);//打开进度条

        // 获取房间
        let roomRes = await this.getUserRoom();
    //判断房间是否还存在
        if (!roomRes) {
            return this.readyFail();//如果不存在,调用readyFail,弹窗提示准备失败
        }

        Global.room.initRoom(roomRes);//初始化房间信息

        // 发送准备消息
        let res = await this.sendToGameSvr({ cmd: CMD.READY });//发消息给实时服务器

        if (res !== MGOBE.ErrCode.EC_OK) {
            return this.readyFail();//发送失败,调用readyFail
        }
        
        // 超时弹框重试
        this.timer = setTimeout(() => this.readyFail(), 15000);
    }
  • 收到实时服务器消息时,触发 onRecvFromGameSvr,做一些相应的逻辑处理。
async onRecvFromGameSvr(event: MGOBE.types.BroadcastEvent<MGOBE.types.RecvFromGameSvrBst>) {
        const err = await super.onRecvFromGameSvr(event);//调用Base.ts里的onRecvFromGameSvr方法,统一处理实时服务器消息
        if (err) { return; }
        clearTimeout(this.timer);//清除定时器,定时器用来记录玩家等待时间
        this.showProgressBar(false);//关闭进度条

        // 跳转
        if (Global.gameState.finish) { return this.openScene("Finish") };//游戏已结束,跳转至结算页
        if (Global.gameState.curQueId >= 0) { return this.openScene("Answer") };//游戏进行中,跳转至答题页
    }

6.匹配成功后进入答题页面,具体逻辑在 Answer.ts 脚本里实现。
20.png

  • Answer.ts 脚本里不断显示游戏状态,需要绑定4个选择按钮的点击事件,点击后提交答案。
initListener() {
        this.ans.onSelect = (index) => this.submit(index);
    }
async submit(ans: number) {

        if (this.isSubmiting) { return; }//判断玩家当前是否正在提交答案

        this.isSubmiting = true;

        await this.sendToGameSvr({ cmd: CMD.SUBMIT, ans });//发送给实时服务器命令字

        this.isSubmiting = false;
    }
  • onRecvFromGameSvr 方法表示每次收到实时服务器消息广播,就设置 this.setQue 游戏答题信息(题目和选项)。
async onRecvFromGameSvr(event: MGOBE.types.BroadcastEvent<MGOBE.types.RecvFromGameSvrBst>) {
        if (await super.onRecvFromGameSvr(event)) { return; }

        this.setQue();
    }
  • 玩家点击按钮,将结果成功提交给实时服务器,实时服务器根据该结果计算一个分数,计算完分数后将其写入游戏状态,整个游戏状态都下发至客户端,客户端拿到游戏状态直接更新画面即可。本游戏案例结果计算逻辑与时间相关,点击越早分数越高。

7.玩家答题结束后进入结算页,除展示游戏结果外,还有两个按钮“再来一局”和“回到首页”,选择“再来一局”则重新发起匹配,选择“回到首页”直接返回。
21.png

  • Finish.ts 脚本触发 onEnable 事件时调用一次退房操作,initListener 里有两个点击事件:重试和返回。
onEnable() {
        this.initView();
        this.initListener();
        this.leaveRoom();
    }
initListener() {
        this.againBth.offAll();
        this.backBth.offAll();

        this.againBth.on(Laya.Event.CLICK, this, () => !this.isInProgress() && this.OnAgain());//点击重试
        this.backBth.on(Laya.Event.CLICK, this, () => !this.isInProgress() && this.OnBack());//点击返回
    }
  • 点击重试,会调用 leaveRoom 退房,直接打开匹配页。
async OnAgain() {
        this.showProgressBar(true);

        let res = await this.leaveRoom();
        this.showProgressBar(false);

        if (!res) {
            Base.dialog.showDialog("提示", "操作失败");
            return;
        }

        return this.openScene("MatchRoom");//打开匹配页
    }
  • 点击返回,会调用 leaveRoom 退房,直接打开主页。
async OnBack() {
        this.showProgressBar(true);

        let res = await this.leaveRoom();
        this.showProgressBar(false);

        if (!res) {
            Base.dialog.showDialog("提示", "操作失败");
            return;
        }

        return this.openScene("Main");
    }

8.到目前为止,基本上已经实现了整个游戏1v1的逻辑,客户端未涉及到实时服务器上具体的逻辑。在 MGOBE 官方提供的框架 index.ts 里,实时服务器的逻辑很简单,只需处理玩家准备和提交答案两个指令,下发游戏状态。

  • 所有逻辑都在 onRecvFromClient 接收客户端消息广播里实现。
onRecvFromClient: function onClientData({ actionData, gameData, SDK, sender, room, exports }: mgobexsInterface.ActionArgs<mgobexsInterface.UserDefinedData>) {

        let cmd = actionData.cmd;//取出cmd命令字

        if (!room) {//判断当前房间是否存在
            SDK.sendData({ playerIdList: [], data: { err: " ERROR NO_ROOM ", cmd: SER_PUSH_CMD.ERR, gameState: null } });//房间不存在返回错误码
            return SDK.exitAction();
        }

        if (!cmd || !msgHandler[cmd]) {
            SDK.sendData({ playerIdList: [], data: { err: " ERROR NO_CMD ", cmd: SER_PUSH_CMD.ERR, gameState: null } });//cmd不存在或无对应处理函数,返回错误码
            return SDK.exitAction();
        }

        try {
            msgHandler[cmd](arguments[0]);//调用相应的处理函数
        } catch (e) {
            SDK.sendData({ playerIdList: [], data: { err: " ERROR " + e + sender, cmd: SER_PUSH_CMD.ERR, gameState: null } });
            SDK.exitAction();
        }

        return;

    },
  • 处理函数逻辑在 msgHandler.ts 脚本里实现,主要方法为 readyHandler 和submitHandler。
function readyHandler({ actionData, gameData, SDK, room, sender }: mgobexsInterface.ActionArgs<AnsActionData>) {//玩家发送准备指令

    let gData = gameData as AnsGameData;

    if (!gData.gameState) {//判断当前游戏有无状态
        initGameData(gData, room);//如无则初始化
        return setTimeout(() => pushHandler.newGame(arguments[0]), 1000);//在1s后将新游戏信息下发出去
    }

    // 发送最新游戏信息
    pushHandler.curGame(arguments[0], pushHandler.SER_PUSH_CMD.CURRENT, [sender]);//如已初始化则将当前游戏信息下发给客户端
}
function submitHandler({ actionData, gameData, SDK, room, sender }: mgobexsInterface.ActionArgs<AnsActionData>) {
    pushHandler.checkSubmit(arguments[0], sender, actionData.ans);
}
  • 玩家发送 Submit 指令,submitHandler 检查玩家提交的答案。
// 检查提交的答案
function checkSubmit({ gameData, SDK, room }: mgobexsInterface.ActionArgs<null>, playerId: string, ans: number) {
        let gData = gameData as AnsGameData;

        // 超过时间
        if (gData.gameState.time <= 0) {
            return curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);;
        }

        // 超过题目数量
        if (gData.gameState.curQueId >= ANS_COUNT) {
            return curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);
        }

        let player: Player = null;
        let que: Que = gData.gameState.que;

        gData.gameState.playerGroup.forEach(group => group.forEach(p => p.playerId === playerId && (player = p)));

        // 异常
        if (!player || player.score > 0 || player.ans >= 0 || !que) {
            return curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);
        }

        player.ans = ans;

        if (que.ans !== ans) {
            // 答错
            player.score = 0;//玩家分数置为0
        } else {
            // 答对

            let scale = 1;

            if (gData.gameState.curQueId === ANS_COUNT - 1) { scale = 2; }

            let score = calcScore(ANS_FULL * scale, Date.now() - gData.startRoundTime);//根据该题目开始时间到当前时间,计算分数

            player.score = score;//写入player,即为游戏状态
            player.sumScore += score;
        }
    
        // 所以玩家全部提交就结束一局
        if (isAllSubmit(arguments[0])) {
            clearTimeout(gData.roundTimer);
            return endRound(arguments[0]);//结束一局
        }

        return curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);
    }
  • 结束这道题的一局时会去判断整个游戏是否结束,如果已经结束则直接调用 endGame。
// 结束一局
function endRound({ gameData, SDK, room }: mgobexsInterface.ActionArgs<null>) {
        let gData = gameData as AnsGameData;
        gData.gameState.time = -100;
        curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);
        // 2秒后结束游戏
        if (!gData.gameState.finish && gData.gameState.curQueId >= ANS_COUNT - 1) {
            return setTimeout(() => endGame(arguments[0]), 2000);
        }
        // 2秒后新一局
        return setTimeout(() => newRound(arguments[0]), 2000);
    }
// 结束游戏
async function endGame({ gameData, SDK, room }: mgobexsInterface.ActionArgs<null>) {
        let gData = gameData as AnsGameData;
        gData.gameState.curQueId = 100000;
        gData.gameState.finish = true;//重新修改游戏的结束状态
        curGame(arguments[0], SER_PUSH_CMD.GAME_STEP);//将游戏最终状态下发出去
    }

9.到此,该案例游戏的客户端和实时服务器上的代码都已介绍完毕。

三、总结

1.首先介绍了游戏联机对战引擎 MGOBE 的基本功能。
2.结合游戏案例介绍了客户端和实时服务器 API。
3.通过《题题对战》演示状态同步游戏接入游戏联机对战引擎 MGOBE 的方法。

参考文章

游戏联机对战引擎产品介绍:
https://cloud.tencent.com/pro...
游戏联机对战引擎官网文档:https://cloud.tencent.com/doc...
游戏联机对战引擎控制台:https://console.cloud.tencent...
《题题对战》源码下载和LayaAir 引擎开发实践:https://cloud.tencent.com/doc...
状态游戏案例教程--《题题对战》:https://cloud.tencent.com/edu...

查看原文

赞 1 收藏 1 评论 1

isabel 发布了文章 · 2020-11-18

纯前端如何利用帧同步做一款联机游戏?

一、游戏帧同步

1.简介
·现代多人游戏中,多个客户端之间的通讯大多以同步多方状态为主要目标,为了实现这一目标,主要有两个技术方向:状态同步、帧同步。
·状态同步的思想中不同玩家屏幕上的一致性的表现并不是重要指标,只要每次操作的结果相同即可。所以状态同步对网络延迟的要求并不高。
·帧同步主要依赖客户端的能力,服务器仅仅是做一个转发,甚至客户端可以无需服务器,通过P2P方式来转发数据。由于只是转发游戏的行为,所以广播的数据量比状态同步要小很多。
本文将以帧同步技术为主来介绍如何实现一款联机游戏。
2.小游戏案例
·本次我们在《街霸小游戏》中利用腾讯云的游戏联机对战引擎实现了玩家之间的PVP玩法。
image.png
感兴趣的同学可以扫码体验:
image.png

二、游戏联机对战引擎:Mgobe

1.引擎简介
·Mgobe是由腾讯云提供的游戏联机对战引擎,可以为游戏提供房间管理、在线匹配、帧同步、状态同步等网络通信服务,帮助开发者快速搭建多人交互游戏。
·Mgobe可以让我们在没有后台开发人力的情况下也能实现游戏的帧同步。
Cocos Creator嵌入了MGOBE,在v2.3.4及以上版本,各位开发者可以通过Cocos Service服务面板,一键开通腾讯云服务MGOBE。
image.png
Unity Editor也嵌入了MGOBE,在Unity Editor 2019.1.9及以上版本,各位开发者可以通过服务面板,一键开通腾讯云服务MGOBE。
image.png
image.png
·官网:https://cloud.tencent.com/product/mgobe
2.开发语言
·Mgobe支持使用 JavaScript 或 TypeScript 来进行前端开发。
3.支持平台
·Mgobe目前支持:微信小游戏、QQ小游戏、百度小游戏、OPPO小游戏、vivo小游戏、字节小游戏;H5小游戏和手游。

三、纯前端打造帧同步实现联机对战

·接下来会从前端的角度来一步一步讲解使用Mgobe的方法,借助Mgobe我们可以不用知晓后台和运维知识,就可以构建起一套性能优越的帧同步游戏。
1.控制台配置
·首先我们需要在Mgobe的控制台中创建游戏实例,以获取游戏ID、游戏Key和域名等信息,我们会在初始化SDK时使用到游戏ID和游戏Key。
image.png
·出于安全考虑,微信小游戏会限制请求域名,所有的 HTTPS、WebSocket、上传、下载请求域名都需要在微信公众平台进行配置。因此,在正式接入游戏联机对战引擎 SDK 前,还需要开发者在微信公众平台配置合法域名。
·需要配置的域名包含一条 request 域名和两条 socket 域名记录,配置如下:

 // request 域名
report.wxlagame.com
// socket 域名
xxx.wxlagame.com
xxx.wxlagame.com:5443 

2.SDK
2.1.下载
·SDK下载地址:https://cloud.tencent.com/document/product/1038/33406
2.2.引入SDK
·SDK文件包含 MGOBE.js 和 MGOBE.d.ts,即源代码文件和定义文件。在 MGOBE.js 中,SDK接口被全局注入到 window 对象下。因此,只需要在使用SDK接口之前执行 MGOBE.js 文件即可。
·以微信为例,只需将 MGOBE.js 放到项目下任意位置,在 game.js 中 import SDK 文件后即可使用 MGOBE 的方法。当然也可以使用 import/from、require 语法显式导入 MGOBE 模块。
2.3.直接使用密钥进行初始化
·用这种方式可以快速初始化SDK,可以最快的速度使用引擎的帧同步功能,但这种方式会在前端暴露游戏Key。

var gameInfo = 
{
    openId: 'xxxxxx', //玩家的openID
    gameId: "xxxxxx", //游戏id,在控制台中的“游戏ID”中获取
    secretKey: 'xxxxxx' //游戏密钥,在控制台中的“游戏key”获取
};
 var config = 
{
    url: 'xxx.wxlagame.com',//游戏域名,在控制台中的“域名”获取
    reconnectMaxTimes: 5, //重连接次数
    reconnectInterval: 1000, //重连接时间间隔
    resendInterval: 1000, //消息重发时间间隔
    resendTimeout: 10000 //消息重发超时时间
};
Listener.init(gameInfo, config, function()
{
    if (event.code === 0) 
    {
        // 初始化成功
    }
});

·Listener 对象为 MGOBE 的子属性,该对象方法全为静态方法,不需要实例化。Listener对象主要用于给 Room 对象的实例绑定广播事件监听。
·初始化 Listener 成功后才能继续调用 Mgobe 引擎的其他接口。
2.4.利用签名来进行初始化(在前端隐藏游戏Key)
·用2.3的方法初始化 SDK 时,会在前端暴露游戏的密钥,为了避免在客户端泄露游戏的密钥,我们也可以使用签名的方式来初始化 SDK。
·在开发者服务器通过游戏 ID、游戏 Key、玩家 openId 等信息计算出游戏签名,然后再下发给客户端。客户端在初始化 SDK 时,需要实现一个 createSignature 签名函数,从服务端获取签名信息然后回调给 SDK。也就是在 gameInfo 中,将2.3中 的 secretKey 字段改为 createSignature 字段。
//这里仅列出与2.3不同的gameInfo, config和Listener.init与2.3一致,不再赘述。

var gameInfo = 
{
    gameId: "xxxxx", //游戏id,在控制台中的“游戏ID”中获取
    openId: "xxxxxx", //玩家的openID

    // 实现签名函数
    createSignature: callback => 
    {
        //假设https://example.com/sign就是我们后台计算签名的接口
        fetch("https://example.com/sign").then(rsp => rsp.json()).then(json => 
        {
            const sign = json.sign;
            const nonce = json.nonce;
            const timestamp = json.timestamp;
            return callback({ sign, nonce, timestamp });
        });
    },
};

签名过程详见:https://cloud.tencent.com/document/product/1038/38863
3.房间
·在开发游戏的过程中,大部分接口都位于 Room 对象中。由于每个玩家只能加入一个房间,在游戏生命周期中可以只实例化一个 Room 对象来进行接口的调用。
3.1.实例化Room

var roomInfo = 
{
    id: "xxx" //房间ID
};
var room = new MGOBE.Room(roomInfo);

·创建房间、加入房间、匹配等接口调用直接使用 room 实例即可。但有3个接口例外:getMyRoom、getRoomList、getRoomByRoomId 接口是 Room 对象的静态方法,需要使用 Room.getMyRoom、Room.getRoomList、Room.getRoomByRoomId 来调用。
**3.2.几个常用属性
3.2.1.roomInfo 属性**
·roomInfo 为 Room 实例的属性,保存房间的相关信息,调用 Room 相关的接口会导致该属性发生变化。可以从 roomInfo 中获得房间的id、名称和玩家列表等。
3.2.2.networkState 属性
·用于获取客户端本地 SDK 的网络状态。注意 networkState 的网络状态与玩家信息 Player 中的网络状态概念不同,room.networkState 表示本地 socket 的状态,而 Player.commonNetworkState 和 Player.relayNetworkState 表示玩家在 Mgobe 后台中的状态。
·networkState 网络状态发生变化时,room.onUpdate 将被触发。

room.onUpdate = function() 
{
    console.log("房间信息更新:", room.roomInfo);
};

3.3.初始化Room
room.initRoom();
·通过 room.initRoom 方法可以初始化一个房间,同时更新房间信息 roomInfo 。初始化可以更新 WebSocket 连接,这样才能及时收到房间的广播。此外,如果要加入指定ID的房间,也需要先对房间进行初始化,否则将无法使用 room.joinRoom 加入指定ID的房间。
3.4.为Room添加广播侦听
MGOBE.Listener.add(room);
·一个房间对象会有很多广播事件与其相关,例如该房间有新成员加入、房间属性变化、房间开始对战等广播。Room 实例需要在 Listener 中注册广播监听,之后可以通过 room.xxx 回调函数的形式来使用广播侦听,详见下文。
3.5.创建房间
·通过使用 room 实例的 createRoom 可以创建一个房间,创建成功后创建者会自动进入该房间。

var playerData = 
{
    name:nickname, //玩家昵称
    customPlayerStatus:playerStatus, //自定义玩家状态
    customProfile:figureURL //自定义玩家信息
};//玩家信息
            
var createRoomData = 
{
    roomName:"roomName", //房间名称
    roomType:"1v1", //房间类型
    maxPlayers:2, //房间最大玩家数量
    isPrivate:true, //是否为私有房间,属性为 true 表示该房间为私有房间,不能被 matchRoom 接口匹配到
    customProperties:roomStatus, //自定义房间属性
    playerInfo:playerData //房主信息
};//房间信息
            
room.createRoom(createRoomData, function(e)
{
    if(e.code === 0)
    {
        //创建房间成功
    }
});

·注意:创建房间的结果是通过回调异步返回的,而非派发事件。
3.6.加入房间
·通过使用 room 实例的 joinRoom 可以加入一个已经存在的房间。

var playerData = 
{
    name:nickname, //玩家昵称
    customPlayerStatus:playerStatus, //自定义玩家状态
    customProfile:figureURL //自定义玩家信息
};//玩家信息

var joinRoomInfo = 
{
    playerInfo:playerData 
};//加入房间的信息

room.initRoom({ id: "xxx" });//加入房间前需要先初始化room实例

room.joinRoom(joinRoomInfo, function(e)
{
    if(e.code === 0)
    {
        console.log("加入房间成功");
    }
});

·注意:加入房间的结果也是通过回调异步返回的,而非派发事件。加入房间前必须先初始化房间实例。
·对于已经存在于房间中的其他人,可以通过 room.onJoinRoom 来侦听新玩家的加入。

room.onJoinRoom = function(e) 
{
    console.log("新玩家加入,ID为:", e.data.joinPlayerId);
};

3.7.离开房间
·使用 room.leaveRoom 就可以退出房间。

room.leaveRoom({}, function(e)
{
    if(e.code === 0)
    {
        console.log("离开房间成功");    
    }            
});

·对于房间中的其他人,可以通过 room.onLeaveRoom 来侦听玩家的离开。

room.onLeaveRoom = function(e) 
{
    console.log("离开房间的玩家的ID:", e.data.leavePlayerId);
};

4.匹配
4.1.匹配规则
·要进行房间匹配,需要先在控制台创建匹配规则,匹配规则既可以满足按人数匹配、按队伍匹配,也可以按段位等特殊方式来匹配。成功创建规则后会获得一个匹配code,匹配code将会用于匹配的相关接口,表示用这个规则来匹配符合条件的玩家。
image.png
规则创建之后还需要将规则绑定到服务器中才能生效,在“新建匹配”中选择上一步创建的匹配集即可。
image.png
4.2.匹配玩家
·有了匹配code后我们就可以在前端进行玩家匹配了,只要是符合规则中定义的条件的玩家,就会被匹配进同一个房间中。

var matchPlayersData = 
{
    playerInfo:playerData, //发起匹配的玩家的信息,playerData在上文已多次出现,这里不再赘述
    matchCode:matchCode //匹配code,在4.1中获得
};//玩家匹配信息
            
room.matchPlayers(matchPlayersData, function(e)
{
    if(e.code === 0) 
    {
        console.log("匹配请求成功");
    }         
});

4.3.匹配房间
·matchPlayers 配合匹配code可以用来匹配玩家,那么通过使用 room.matchRoom 则可以进行房间的匹配。房间匹配是指按照传入的参数搜索现存的房间,如果存在,则将玩家加入该房间,如果不存在,则为玩家创建并加入一个新房间。
·matchRoom 不需要使用匹配code。

var playerInfo = 
{
    name: "Tom",
    customPlayerStatus: 1,
    customProfile: "https://xxx.com/icon.png",
};//发起匹配者的信息

const matchRoomPara = 
{
    playerInfo,
    maxPlayers: 5,
    roomType: "1",
};//房间匹配信息

room.matchRoom(matchRoomPara, function(e) 
{
    if (event.code === 0) 
    {
        console.log("匹配成功");
    }
});

·matchRoom 与 matchPlayers 最大的不同就是:matchRoom 一定会让匹配发起人进入一个房间,但 matchPlayers 则不一定,如果当前没有符合匹配规则的玩家,则 matchPlayers 会返回失败。
5.帧同步
·终于来到这一步了,如果玩家已经成功加入房间,就可以通过帧同步功能进行游戏对战。
5.1.开启帧同步
·使用 room.startFrameSync 接口就可以开启帧广播。房间内任意一个玩家成功调用该接口都将导致全部玩家开始接收帧广播。

room.startFrameSync({}, function(e)
{
    if(e.code === 0) 
    {
        console.log("开始帧同步成功");
    }
});

·调用成功后房间内全部成员都将收到 onStartFrameSync 广播。该接口会修改房间帧同步状态为“已开始帧同步”。

room.onStartFrameSync = function()
{
    //收到此广播后将持续收到 onRecvFrame 广播
    //注意,这里还不是玩家之间相互进行帧同步的信息内容,onRecvFrame 中才是我们拿到帧同步内容的地方,见下文
};

5.2.发送帧消息
·玩家收到帧同步开始广播后,才可以发送帧消息,后台会将每个玩家的帧消息组合后再广播给每个玩家。
·帧数据内容 data 类型为普通 Object,由开发者自定义,目前支持最大长度不超过1k。后台将集合全部玩家的帧数据,并以一定时间间隔(由房间帧率定义,可以在控制台配置)通过 onRecvFrame 广播给各客户端。调用结果将在 callback 中异步返回。

var frame = 
{
    cmd: "xxxxxxxx", 
    id: "xxxxxxxx" 
};//一帧的内容,由开发者自定义

var sendFramePara = 
{ 
    data: frame 
};//发送给Mgobe的帧内容

room.sendFrame(sendFramePara, function(e)
{
    console.log("发送帧同步数据");
});

5.3.接收帧广播
·开发者可设置 room.onRecvFrame 广播回调函数来获得帧广播数据。onRecvFrame 广播表示收到一个帧 frame,frame 的内容由多个 MGOBE.types.FrameItem 组成,即一帧时间内房间内所有玩家向服务器发送帧消息的集合。

room.onRecvFrame = function()
{
    console.log("收到帧同步消息=", e.data.frame);
    //我们就是从 e.data.frame.items 这个数组的每个元素的 data 属性来拿到我们在5.2中发送给Mgobe的帧内容的。
    //5.2的帧内容:var frame = {cmd: "xxxxxxxx", id:"xxxxxxxx"}
};

5.4.停止帧同步
·使用 room.stopFrameSync 接口可以停止帧广播。房间内任意一个玩家成功调用该接口将导致全部玩家停止接收帧广播。

room.stopFrameSync({}, function(e)
{
    if(e.code === 0)
    {
        console.log("停止帧同步成功");
    }                
});
·调用成功后房间内全部成员将收到 onStopFrameSync 广播。该接口会修改房间帧同步状态为“已停止帧同步”。
room.onStopFrameSync = function()
{
    //收到该广播后将不再收到 onRecvFrame 广播
};

·至此,利用Mgobe来进行帧同步开发的相关主要接口就介绍完毕了。下面将讲一些关于玩家信息的内容。
6.玩家信息
6.1.玩家ID
·玩家信息 Player 对象为 MGOBE 的子属性,用于访问玩家的基本信息,例如玩家 ID、openId 等。该对象记录了玩家的基本信息,默认全部为空。成功初始化 Listener 后,ID、openId 属性才生效。
·Player 中的 玩家 ID 是 MGOBE 后台生成的 ID,而 openId 是开发者初始化时候使用的 ID。需要注意,openId 只有初始化 Listener 的时候才使用,后续其它接口提到的“玩家 ID”均指后台生成的 ID,也就是 Player.id 属性,它不是 openId,切记!
·玩家进入房间后,Player 对象中的属性与 roomInfo.playerList 中的玩家信息是一致,通过两者任何一个都可以获得正确的玩家信息。
6.2.几个常用事件
·这里提两个经常用到的玩家事件:网络状态变化、玩家状态变化。
·在Mgobe中,玩家的网络状态分以下4种,但玩家的网络状态发生变化时均会触发。

room.onChangePlayerNetworkState = function(e)
{
    if(e.data.networkState === MGOBE.ENUM.NetworkState.COMMON_OFFLINE)
    {
        console.log("房间中玩家掉线");
    }
    else if(e.data.networkState === MGOBE.ENUM.NetworkState.COMMON_ONLINE)
    {
        console.log("房间中玩家在线");
    }
    else if(e.data.networkState === MGOBE.ENUM.NetworkState.RELAY_OFFLINE)
    {
        console.log("帧同步中玩家掉线");
    }
    else if(e.data.networkState === MGOBE.ENUM.NetworkState.RELAY_ONLINE)
    {
        console.log("帧同步中玩家在线");
    }

    //通过 e.data.changePlayerId 可以知道是哪个玩家的网络状态发生了变化
};

·如果修改了玩家的自定义信息(由开发者自定义的,也即上文多次提到的

playerInfo 中的 customPlayerStatus),则以下事件会被触发:
room.onChangeCustomPlayerStatus = function()
{
    //房间内 ID 为 changePlayerId 的玩家状态发生变化。玩家状态由开发者自定义。
    console.log("玩家自定义状态变化=", e.data.changePlayerId);
    console.log("自定义数据=", e.data.customPlayerStatus);
};

7.错误处理
·最后,如果在使用Mgobe的过程中如果发生客户端错误、系统逻辑错误、用户信息错误、房间错误、匹配错误、帧同步错误、参数错误、队伍团队错误时,均会发出错误码,可以通过以下文档查阅相关错误码对应的描述信息,以便排除和解决错误。
·错误码说明文档详见:https://cloud.tencent.com/document/product/1038/33317
四、结尾
· 本文仅从前端角度出发,介绍了利用 Mgobe 进行纯前端的帧同步开发,但 Mgobe 的功能远不止这些,Mgobe 也支持在后台编写自定义匹配逻辑来实现更加丰富的帧同步,感兴趣的同学可自行查阅官方文档。
或者关注公众号:
image.png

查看原文

赞 1 收藏 1 评论 0

isabel 关注了用户 · 2020-06-17

阿宝哥 @angular4

http://www.semlinker.com/
聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货

欢迎各位小伙伴关注本人公众号全栈修仙之路

关注 2414

isabel 发布了文章 · 2020-05-28

如何快速构建稳定、低延时的多人游戏的部署环境?

随着文娱行业的日益繁荣,游戏行业竞争日益激烈。想要游戏火爆,热度经久不衰,联机必不可少。而联机游戏对于游戏低延时、服务稳定、成本控制有很高的要求,对于研发、运维、跨地容灾、就近调度带来的服务器成本的挑战更大。

腾讯游戏服务器引擎(Game Server Engine,缩写GSE),支持有状态的游戏服务部署和扩缩容,实现服务发现、高效灵活的服务器伸缩和就近调度的能力,帮助开发者快速构建稳定、低延时的多人游戏的部署环境,并节约大量的运维成本。

一、联机对战类游戏的需要保证什么?

1. 游戏低延时,保障更多玩家流畅的体验

全球玩家分布广泛,服务器集中部署,使部分地区网络体验差,游戏体验受到影响,这可能是部分地区玩家数量相对较少的一个原因。

通常就近调度,或者全球加速(集中部署在一个点,各个区域到此点进行加速)可以让网络延时达到一个优化。对于实时性非常敏感的游戏来说,就近调度效果更明显。不过就近调度有几个棘手的问题:

方案一:业务部署在多个区域,玩家就近在一个区域完成匹配和对战

问题:某个区域的玩家相对较少,可能匹配不到相应等级的人,最后所有玩家都集中到某个大区去了,实际上又变成了集中部署。

1.png

方案二:匹配在一个大区进行,集中匹配,对战的时候就近分配到不同的地区。

问题:匹配时哪些区域会被匹配在一起是不确定的,而且也存在大量邀请好友一起玩的行为,每一天被分配到各个大区的玩家数量可能会非常不一样,各个大区的服务器需求量不能提前准确预估。准备少了不够,准备多了浪费资源。为了实时满足就近调度,可能每个区域都要最大量的准备服务器,致使服务器成本暴增。
2.png

最后实际方案:可能变成集中调度了,对于中国区来说,所有的服务部署到上海。

2. 服务稳定,保障玩家体验和多创营收

1)爆发式增长,不能及时扩容承接更多玩家

为了响应爆发式增长,研发和运维都需要提前做很多工作,确保服务能平行扩展,通过添加服务器,可以让游戏无上限的支撑玩家。

这是一个有状态的扩缩容场景:对于游戏服务,尤其是对战服务来说,不能是简单添加一个clb(负载均衡)就能搞定。在游戏服务里需要断线重连,能找到之前连接的服务器;另外游戏过程不能因为缩容中断游戏。

研发侧:服务注册、服务发现、服务调度,服务管理等工作,以确保服务能自动化的平行扩容,否则只能靠手工配置。为了保障稳定性需要检查服务健康状况,屏蔽不健康服务,以及服务保护工作避免游戏中的服务被中断。

运维侧:需要写一些脚本去添加更多服务器,需要写一些工具让服务器自动伸缩。自动化进行,需要制定服务器伸缩策略、研发服务器自动购买、故障服务器排除等工具。

即使上面做好准备工作的情况下,还会出现异常情况:在服务器分配过程中,调度指标一般以服务器的指标CPU、内存作为参考,这样可能导致一些低cpu、低内存的服务短时间被大量分配出去,服务器访问量瞬间爆发式上涨而挂掉。为了避免这种情况,通常CPU的利用率会维持在不高的状态。
这件事,无论是研发还是运维,都不是一件简单的事。工作量比较大,前期不确定游戏是否会爆发式增长,一般中小开发者不会提前做这些准备。

2)地域/服务器发生故障
服务器发生故障比较常见,通常做法就是监控服务器,出现故障立即剔除掉。

地域或整个机房发生故障不常见,但造成的影响面积非常宽广,一般游戏开发者不太会考虑这个点,因为要做服务器跨地域或者跨机房容灾,至少要2倍的服务器,投入产出相对较低。

那么有方案让游戏服务0成本跨地容灾吗?
3.png

3.成本节约

服务器空闲导致的成本,如以下这些情况:

· 每日&周末&节假日的高峰波谷

· 游戏稳定运营及下降期,服务器空闲资源

· 活动期间,爆发增长,活动过后需资源空闲

比起游戏运营成本来说,服务器成本算不了什么,但是,能省一点是一点,对不?

二、联机对战类游戏对研发和运维的挑战
如前文所述,为了提升游戏的一点体验,研发工作量大、运维工作量大、服务器成本大。

  • 研发工作量大:

​​​​​​​服务管理、就近调度、跨地容灾、不停服更新、自动伸缩,工作量大,大厂已经逐步在完善这些配套工具,对于一些创新工作室,或者创业者,会把更多精力放在打造游戏业务上,做这些工作是一种负担。

  • 运维工作量大:

​​​​​​​反复去做扩容、缩容、发布版本。如果不反复去做这些事,需要开发一些工具/脚本,是需要前期较大投入的事情。

  • 服务器成本大:

​​​​​​​一是空闲资源成本,二是按照传统方式就近调度、跨地容灾至少增加1倍服务器成本。

三、如何解决全球部署,弹性伸缩,就近调度,更新不停服的问题?

游戏服务器引擎(Game Server Engine,缩写GSE)提供专用游戏的服务器托管服务,支持有状态的游戏服务部署和扩缩容,实现服务发现、高效灵活的服务器伸缩和就近调度的能力,帮助开发者快速构建稳定、低延时的多人游戏的部署环境,并节约大量的运维成本。

支持Unity引擎、Unreal引擎和自定义游戏框架的部署和运行,应用于 FPS、MOBA、回合制、MMORPG、棋牌游戏中战斗服、消息 PUSH 等需要保持状态的场景。
4.png
1.弹性伸缩

每天有高峰、波谷,每年有节假日,周末等。玩家曲线动态变化。

游戏每日波动曲线
5.png

游戏每年波动曲线
6.png

(1)GSE能将服务器实时伸缩

GSE 可设置服务器实例类型和伸缩范围,实例将在这个范围内进行伸缩。游戏的访问每天都有高峰和低谷,通常在中午和晚上时,服务器实例的数量将有一个高峰,在午夜后,服务器实例的数量会降到最低。GSE 将根据每天每个时刻服务器的访问量进行自动伸缩。

(2)GSE能实现有状态的缩容

GSE 不会缩减有进程运行的实例,低负载触发缩容时通知游戏进程正在缩容该台服务器,并屏蔽新的游戏服务器会话分配到该台服务器上,但不强制缩减实例导致游戏无法进行,等待游戏进程上没有玩家在对局时,发起结束指令后,才真正触发停止进程和服务器的回收。

弹性伸缩的好处是:

· 提高灵活性

-就近调度,需要时去拿服务器,不需要时退回服务器。

-容灾也是一样,需要时去拿服务器,不需要时退回服务器。

· 节省成本

-减少每天、每周、每年空闲资源成本,经计算可节省20%-30%的成本

-减少就近调度的成本

-减少容灾的成本

2.就近调度

弹性伸缩是一个基本前提,强大的调度资源能力在此基础上延伸,可以随时调度腾讯云各个区域的资源,从而不需要在各个区域提前预留服务器资源,这让就近调度变得简单。

GSE提供客户端到服务端的测速,获得客户端到所有服务部署区域的延时,GSE通过这个延时情况进行就近调度

7.png

下面可以看到一组匹配好的玩家,会被分配到最近的服务器进行对战。北京、上海、广州、成都刚开始都可以部署1台服务器,和配置好伸缩策略,这样在需要时就能自动伸缩。

8.png

3.多地部署,跨区域容灾

弹性伸缩是一个基本前提,GSE可以随时调度腾讯云任何区域、任何机型的资源,从而轻松做到容灾。游戏服务器队列下面包含了各个区域的游戏服务器舰队(一组服务器),业务只需要请求游戏服务器队列,游戏服务器队列会根据每组游戏服务器舰队的健康状态和客户端到服务器的网络延时情况,会自动剔除到有问题的区域,选择正常的服务器来提供服务,正常区域如果需求旺盛,则会自动扩容。不需要提前在多个区域部署相同数量的服务器,从而达到0成本容灾的效果。

9.png

4.GSE和普通弹性伸缩的区别

GSE专注于有状态扩缩容场景。

游戏中两个特殊需求:断线重连、游戏中不能退出。一般游戏服务器都是有状态的,如何进行缩容呢?

GSE的设计对游戏服务器有三种保护策略:

1)全保护 :果有进程在运行的话,不会缩容

2)不保护:需要缩容时,立即缩容

3)时限保护:保护一定期限,比如1个小时

5. GSE更新不停服设计

GSE拥有极致的资源调度能力,能够轻松做到更新不停服。

客户端通过别名alias请求服务器舰队fleetA下的服务器,版本更新的时候,新建服务器舰队fleetB,把版本发布到fleetB上,并把别名alias指向新建的服务器舰队fleetB,客户端仍然调用同一个别名alias,但访问到fleetB的版本了。FleetB逐渐扩容,fleetA逐渐缩容。

10.png

以上内容就是游戏服务器引擎Game Server Engine的设计了。您可以单独使用弹性伸缩、弹性伸缩+就近调度、弹性伸缩+容灾。该产品不侵入游戏框架、逻辑代码,支持unity引擎、unreal引擎,自定义服务器框架、开源框架运行,支持C++、C#语言,支持JAVA、PHP、python、lua、Nodejs等支持grpc的语言。

产品详细信息传送门:https://cloud.tencent.com/product/gse

申请使用游戏服务器引擎,请移步:https://cloud.tencent.com/apply/p/k0b6pvbhs6

想理解更多游戏云服务相关的产品&方案,请关注腾讯游戏云公众号:

腾讯游戏云公众号.png

查看原文

赞 0 收藏 0 评论 0

isabel 关注了用户 · 2020-05-28

i校长 @ixiaozhang

抽象化是一种非常的不同于模糊化的东西,抽象的目的并不是为了模糊,而是为了创造出一种能让我们做到百分百精确的新语义。
github:https://github.com/ibaozi-cn
jetpack: http://jetpack.net.cn

关注 291

isabel 关注了专栏 · 2020-05-28

超级码力

公众号:卤蛋实验室。 不炒冷饭不哗众取宠,凭质量说话

关注 5042

isabel 关注了专栏 · 2020-05-28

码农田小齐

一起玩转算法和数据结构呀~ 公众号:码农田小齐

关注 2286

isabel 关注了专栏 · 2020-05-28

SegmentFault 思否观察

SegmentFault 思否对开发者行业的洞见、观察与报道

关注 27821

isabel 关注了专栏 · 2020-05-28

CodeGuide | 程序员编码指南

公众号:bugstack虫洞栈,回复:设计模式,可以下载《重学Java设计模式》PDF,全网下载量17万+ | 这是一本互联网真实案例实践书籍。以落地解决方案为核心,从实际业务中抽离出,交易、营销、秒杀、中间件、源码等22个真实场景,来学习设计模式的运用。

关注 16316

isabel 关注了用户 · 2020-05-28

PerfMa @perfma

专业 Java 应用性能社区 https://club.perfma.com/
欢迎关注微信公众号:PerfMa 社区

关注 661

认证与成就

  • 获得 2 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-05-28
个人主页被 404 人浏览