|
在上一篇文章中,我们利用腾讯联机对战引擎(MGOBE)建立了一个能够匹配玩家进行1V1对战的系统。现在,我们将在此基础上开发一个双人联机游戏。
游戏同步方式
在实现游戏逻辑前,首先需要确定游戏同步的方式。所谓游戏同步,也就是确保不同客户端上的游戏数据和显示保持一致。常用的同步方式有两种:帧同步和状态同步。
帧同步:玩家A有规律地向玩家B发送“帧数据”,帧数据会包含玩家A的操作(如移动,攻击等),玩家B收到帧数据后,根据其中的指令,在本机上进行模拟操作,从而使得玩家A的操作在B的游戏程序中也产生效果。
双人联机游戏中的帧同步
状态同步:玩家A发出某个指令时(如移动,攻击等),将该指令上报给服务器。服务器根据该指令更新游戏的整体状态,然后将游戏状态发送给所有玩家,A和B利用游戏状态再分别进行屏幕渲染。
双人联机游戏中的状态同步
从以上描述中,我们就可以知道两种方式的主要区别:
首先,帧同步不需要服务器,只需要客户端之间彼此交换数据就可以了;而状态同步需要服务器参与作为整局游戏的“裁判”,它保存着游戏的所有状态,客户端只是向它报告玩家行为,然后在它的指导下,在玩家的屏幕上渲染画面。总结:帧同步实现起来更简单,状态同步的整个通讯系统、数据交换会更加复杂庞大。
其次,在帧同步中,每个玩家的机器上都运行着一个独立的游戏状态。可以设想——如果玩家A的指令因为网络问题没有成功发送给玩家B,就会导致A、B两端游戏不一致。而在状态同步中,只有一个存储在服务器上的游戏状态,所有客户端拥有的只是服务端数据的复制。当玩家A的指令因为网络问题没有成功发送给服务器时,所有玩家(包括A自己)都不会受到这个传丢了的指令影响,这就能够保证所有玩家在屏幕上看到的游戏内容永远是一致的。总结:帧同步对操作顺序和网络要求更高(即稳定性较差);而状态同步更加安全。
关于游戏同步和状态同步的更详细知识参考:[1],[2] 和[3]。 在接下来的案例中,我们采用开发起来较简单的帧同步。
<hr/>实现单机游戏逻辑
在这个案例中,我们将写一个非常简单的联机小游戏:场上共有9张牌,其中有1张炸弹牌。双方轮流翻牌,哪方先翻中炸弹,就输掉游戏,同时另一方获得胜利。
本案例的完整项目代码可以通过“焕焕骑士”公众号后台回复“联机案例”获取。 我们已经知道,帧同步中的每一个客户端都是一个独立运行的游戏状态。因此,让我们暂时忘掉“联机游戏”这回事儿,先专注编写好单机游戏的基本逻辑,然后在执行玩家指令的位置插入“发送帧数据”的代码即可。
换句话说,如果你已有一个单机小游戏,通过添加帧同步就可以很容易的将它变成联机游戏!但如果使用状态同步来做联机,就需要从开发初期便设计好前后端的通信架构,这需要丰富的开发经验。所以,我建议联机开发新手先学习帧同步。 首先,让我们添加一些场景节点,如下布置。
Cocos Creator场景
在总脚本(挂在Game Manager节点上)中,添加一些初始的游戏数据到onLoad()末尾。
// 初始化游戏数据(0表示安全牌,1表示炸弹)
this.cards = [0, 0, 0, 0, 0, 1, 0, 0, 0];
this.node.getChildByName(&#34;EndPanel&#34;).active = false; // 隐藏结束界面
然后,给所有9个Card均绑定一个Button组件,并使它们被点击后触发如下函数:
reportClick (event, data) {
// data 是 card 被点击时传入的参数
/ #3. 添加帧同步# 部分要插入的代码位置
data = parseInt(data); // 将字符串形式的数字转化为Number类型
if (this.cards[data-1]==-1) {
// 若该序号的卡牌数据为-1,表示这张牌已经被翻过了
return;
}
if (this.cards[data-1]==0) {
// 卡牌数据为0,表示是安全牌
this.cards[data-1] = -1; // 将数据置为-1,表明已翻
// 将该牌颜色变为绿色
this.node.getChildByName(&#34;Cards&#34;).children[data-1].color = new cc.Color(179,255,174);
} else if (this.cards[data-1]==1) {
// 卡牌数据为1,表示选中了炸弹!
this.cards[data-1] = -1; //
// 将该牌颜色变为绿色
this.node.getChildByName(&#34;Cards&#34;).children[data-1].color = new cc.Color(255,87,0);
// #5. 回合控制# 部分要插入的“结束页调整”代码位置
this.node.getChildByName(&#34;End Panel&#34;).active = true;
}
// #5. 回合控制# 部分要插入的“切换回合”代码位置
},
打开调试进行测试。点击第六张卡片就会结束游戏,其他卡片都是安全的。
现在我们已经实现了一个客户端上的游戏逻辑了!下一步,我们需要在脚本中添加发送帧和接收帧的函数。
添加帧同步
添加帧同步的思路很简单。
首先,每当玩家进行一次操作后,我们需要将该操作报告到帧数据里,发送给其他玩家。在这个游戏中,点击卡片显然是最容易想到的玩家操作。在函数reportClick()中相应位置添加如下用于发送帧的代码:
if (event!=undefined) {
// #5. 回合控制# 部分要插入的“判断回合”代码位置
// 此处增加对 event 的判断:
// 若 event 有值,表明是用户的人为操作触发了此函数,那么需要告诉对方在它的程序中也执行此操作;
// 若 event==undefined,表明是接收方需要同步此操作,不要再反复向对方回发帧数据了。
let framePara = {
data: { msg: &#34;clickCard&#34;, number: data }, // msg说明这一帧的操作类型,number告知了所点击的卡片序号
};
this.room.sendFrame(framePara, res => console.log(&#34;点击卡牌帧发送成功&#34;, res));
}
代码中的framePara.data需要给出足够的信息,至少能够让对方接收后可以在它的程序中精确复制这一操作。
然后,我们还需要设置一个监听函数来不断接收来自他人的帧数据。编写一个这样的函数:
_onRecvFrame (event) {
//console.log(&#34;帧广播&#34;, event.data.frame);
let frameItems = event.data.frame.items;
if (frameItems.length>0) { // frame 是一个数组,包括所有玩家的frame数据。1v1则至多包含2个,绝大多数时间为空。
//console.log(&#34;帧广播&#34;, frameItems);
for (let i=0; i<frameItems.length; i++) {
// 跳过playerId等于本客户端id的帧,避免重复执行动作。
// 不响应自己提交的帧广播,只处理来自其他人传来的帧广播。
if (frameItems.playerId != MGOBE.Player.id) {
if (frameItems.data.msg == &#34;clickCard&#34;) {
cc.find(&#34;Canvas/Game Manager&#34;).getComponent(&#34;gameManager&#34;).reportClick(undefined, frameItems.data.number);
}
}
}
}
},
这个函数接收的event.data包含当前时间点的所有帧信息。
MGOBE每隔一段时间搜集所有玩家上传的帧数据,把它们集合打包、增加封装,再发送给所有玩家(包括每个发送者自己)。你可以打印出函数里的event参数和frameItems变量的值,找找我们之前发送的framePara内容被藏在event这个大数据包的哪个角落了? 把这个监听函数绑定到room对象上即可使之生效(紧跟在上篇教程代码中初始化MGOBE部分的插入位置之后即可):
self.room.onRecvFrame= self._onRecvFrame;
至此,点击卡片的操作就同步完成了!
最后,再优化一下游戏流程:让游戏启动后先展示“匹配”相关按钮,匹配成功后再进入游戏界面,同时启动帧数据的交换(在startMatch()中匹配成功的位置添加如下代码)。
// NOTE: 此部分在#4. 回合控制和数据初始化#中要被删除
// 开始帧同步(程序无需主动停止帧同步,房间解散后帧同步自动停止)
this.room.startFrameSync({}, event => {
if (event.code === 0) {
console.log(&#34;开始帧同步成功&#34;);
// 隐藏匹配按钮界面,露出游戏界面
cc.find(&#34;Canvas/Buttons&#34;).active= false;
}
});
好了,现在先后打开两个调试窗口,试试操作是否同步了!
数据初始化
在以上的成果中,我们会发现两个严重的问题:说好的轮流翻牌呢?而且只要一个人选中炸弹,双方都会判定失败!
要解决这些问题,我们只需要增加回合控制。回合是联机游戏中常见的概念,要在这个案例中设置回合,第一步要考虑的便是:哪一方先开始回合?
我们已经知道,在帧同步中,每一个客户端都是一个独立且平等的游戏进程,那么我们便需要一个第三方裁判——服务端,由它来决定首个回合的分配!
让服务器做决策并分发游戏数据,这其实是状态同步的思想了。 另外,既然我们已经邀请服务器来决定起始回合了,为什么不顺便让它也打乱一下卡片的顺序呢?每局比赛开始时,也可由服务器确定九张牌中炸弹牌的位置,将该数据下发给各个客户端,这样一来,双方每新开一局都能在同一张“未知的新地图”上进行游戏了。
我们来看看服务端代码。服务端代码中的gameData对象就是游戏数据容器,它在gameServer的onInitGameData()中初始化,这个函数return的内容就会被赋给gameData。
// 服务端初始化游戏数据:随机洗牌、确定先后手
onInitGameData:function () {
// 随机确定炸弹卡在九张卡中的位置
let cards = [0,0,0, 0,0,0, 0,0,0];
let bomb_index = Math.floor(Math.random()*9);
cards[bomb_index] = 1;
return {
cards: cards, // 卡牌数据
startPlayer: Math.floor(Math.random()*2), // 随机确定先后手为0或1
playerCnt: 0,
};
},
现在,让我们重新梳理一下匹配成功后到游戏正式开始前需做的工作:
匹配成功后,双方客户端均向服务端发起一次通信,要求获取服务端的游戏起始数据(这个案例里就是起始回合和卡片顺序);服务端收到客户端来信后,将已经生成好的数据下发给各个客户端。客户端收到数据后,再依照该数据执行本地的初始化,并开始帧同步。
游戏初始化流程
图中的4个函数中onInitGameData()已经写好了,接下来让我们依次落实其他三个。
1. 匹配成功后,客户端向服务端发送请求。将原先的startMatch()中sendToGameServerPara变量如下修改,并删除原本启动游戏的相关代码:
const sendToGameServerPara = {
data: {
msg: &#34;requestGameData&#34;, // 写明是从服务器请求游戏数据
},
};
2. 服务端代码的onRecvFromClient()函数修改如下:
// 监听客户端数据
onRecvFromClient:function onRecvFromClient({ sender, actionData, gameData, SDK, room, exports }){
// 接收到的参数为 ActionArgs 类型,详见 https://cloud.tencent.com/document/product/1038/34992
// sender 是发信者id,actionData是发信内容
// 根据客户端消息的 msg 属性确定消息的目的
if (actionData.msg == &#34;requestGameData&#34;) {
var startTurn; // 定义一个布尔变量,用于告知客户端是否拥有首个回合
if (gameData.playerCnt==gameData.startPlayer) {
startTurn = true;
} else {
startTurn = false;
}
var response = {
msg: &#34;initGameData&#34;,
data: {
cards: gameData.cards,
startTurn: startTurn,
},
};
// 回复消息给客户端
SDK.sendData({ playerIdList: [sender], data: response }, { timeout:2000, maxTry: 3 });
gameData.playerCnt += 1;
}
// 结束 gameServer.onRecvFromClient 方法。在 sync mode下,只有显示调用 SDK.exitAction() 才能继续处理下一条收到的消息。
SDK.exitAction();
},
3. 服务器消息发回到客户端后,客户端的消息接收接口onRecvFromGameSvr()也应增加处理代码。我们将该处理函数单独写成一个函数:
_onRecvFromGameSvr(event) {
// 收到了服务器的消息
console.log(&#34;新自定义服务消息&#34;, event);
if (event.data.data.msg==&#34;initGameData&#34;){
// 初始化卡片数据
let cards = event.data.data.data.cards;
console.log(cards);
// cards替换为服务器生成的cards
let gameManager = cc.find(&#34;Canvas/Game Manager&#34;).getComponent(&#34;gameManager&#34;);
gameManager.cards = cards;
// 是否获得回合
gameManager.inTurn = event.data.data.data.startTurn;
// #5. 回合控制# 部分要插入的“setTurnStatus()”代码位置
// 开始帧同步(程序无需主动停止帧同步,房间解散后帧同步自动停止)
gameManager.room.startFrameSync({},event => {
if (event.code === 0) {
console.log(&#34;开始帧同步成功&#34;);
// 隐藏匹配按钮界面,露出游戏界面
cc.find(&#34;Canvas/Buttons&#34;).active= false;
}
});
}
},
最后,将MGOBE初始化处的room.onRecvFromGameSvr替换成这个函数:
// 服务器消息回调
self.room.onRecvFromGameSvr= self._onRecvFromGameSvr;
至此,游戏的初始化也已经全部完成了!我们可以重复刷新几次调试窗口,看看每局的炸弹牌位置是否都不一样;并检查下console结果中的startTurn值是否是随机的一方为true、另一方为false。
回合控制
我们已经实现了分配起始回合,客户端再加上一点点代码,就可以实现回合交替!
1. 在客户端使用一个inTurn的布尔变量就可以实现回合控制。思考一下回合切换的时机:那就是每次发生卡片被翻之后。我们在reportClick()的相应位置添加以下代码:
// 切换回合
this.inTurn =! this.inTurn;
this.setTurnStatus();
函数setTurnStatus()是为了增加视觉提示,告知玩家是否轮到ta的回合了,定义如下:
setTurnStatus () {
// 添加提示:获得回合的游戏区变亮,结束回合的变暗
if (this.inTurn) {
this.node.getChildByName(&#34;Cards&#34;).color = new cc.Color(210,210,60);
} else {
this.node.getChildByName(&#34;Cards&#34;).color = new cc.Color(60,60,60);
}
},
记得在_onRecvFromGameSvr()中的相应位置也添加调用这一函数。
2. 当前客户端是否具有回合行动权,还会决定玩家是否能翻牌、以及翻牌后的胜败情况。这些控制点也可以加在reportClick()中实现:
// 判断回合:若非本客户端的回合,不响应玩家的点击
if (!this.inTurn){
return;
}
根据回合情况修改结束页文本:
// 结束页调整:当前回合的(翻到炸弹)失败;非当前回合的胜利
if(!this.inTurn) {
cc.find(&#34;/End Panel/Label&#34;,this.node).getComponent(cc.Label).string = &#34;游戏胜利!&#34;;
} else {
cc.find(&#34;/End Panel/Label&#34;,this.node).getComponent(cc.Label).string = &#34;游戏失败!&#34;;
}
<hr/>至此,所有基本功能就全部实现了。我们已经成功用帧同步实现了一个双人联机小游戏!
最后,还有一些其他拓展主题也值得联机游戏开发新手学习掌握,包括机器人、回合限时、断线处理、房间管理等,它们将被整理在本系列的“补充篇”中。
欢迎关注我的微信公众号“焕焕骑士”,分享我的游戏资讯,以及有用的开发经验!
参考
- ^帧同步和状态同步该怎么选(上)https://zhuanlan.zhihu.com/p/104932624
- ^帧同步和状态同步该怎么选(下)https://zhuanlan.zhihu.com/p/105826545
- ^两种同步模式:状态同步和帧同步https://zhuanlan.zhihu.com/p/36884005
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|