共计 7148 个字符,预计需要花费 18 分钟才能阅读完成。
原文:从零到一,撸一个在线斗地主 (下篇) | AlloyTeam
作者:TAT.vorshen
上篇回顾:我们说了斗地主游戏的 渲染展示部分 ,最后也讲了下 canvas 中交互的情况,下篇的重点就是 游戏逻辑。
逻辑主要分成两块:流程逻辑和扑克牌对比逻辑。
github 地址:https://github.com/vorshen/landlord
流程逻辑
分析
这里流程上的逻辑分为两部分,一个是场景切换,还有一个就是房间页中游戏进行的流程
先简单说下场景切换,我们这个斗地主游戏有如下三种场景切换
- 首页 -> 大厅页
- 大厅页 -> 房间页
- 房间页 -> 大厅页
我们这里偷了懒,首页和大厅页没有用 canvas,直接上了 dom,写起来也很奔放,没有用框架。如果游戏想正式一点,千万不要这样。看起来首页和大厅页逻辑很简单,那是因为我们漏掉了很多点(时间真的不够。。)。
用一张图表现一下,我们漏掉的点:
在我们如此简化的背景下,如果说还有什么需要注意的,可能就两点
1、是否提前加载模块
比如当我们进入首页的时候,要不要把大厅页和房间页都初始化完毕?
这里我没有选择初始化,一定是真正使用到才会初始化。理由主要就是后面用到再初始化的开销并不大,可以接受。
如果当遇到,某一个场景很复杂,切换需要较大的开销,可以考虑提前进行一些初始化的工作。
2、销毁是真销毁还是隐藏
大厅页和房间页存在来回切换的情况,当发生大厅切换到房间的时候,可以选择将大厅页隐藏,也可以选择将大厅页销毁,后面用到再初始化。
这里我们选择只是将页面隐藏,也就是说当房间页第一次展示的时候,需要进行初始化(较大开销),以后再展示,就是很少的性能开销了。
大概代码如下:
/**
* 房间展示,主要是生成 stage
* @param info
*/
private _show(info: i_RoomShowOptions) {
this._roomId = info.roomId;
if (this._inited) {
// 初始化过了,stage 肯定初始化过了,直接展示
this._stage.show();} else {
// 第一次展示,初始化 stage
this._initStage();
this._inited = true;
}
……
}
具体代码在 Hall.ts 和 Room.ts 中
因为我们页面简单而且小,常驻的话对性能影响不大,如果打算常驻的页面展示率低或者隐藏运行也很占用资源,那还是推荐把真的干掉。
房间中流程
消息驱动
首先我们认为在房间中,流程的变化都是事件驱动,具体可以看下图:
注意:右侧如果有箭头,意味着可能该阶段自己切换到该阶段(只是该阶段主角玩家发生变化)
在每个阶段,前端只能有对应的操作。那么每个阶段的切换,事件的发起者是谁呢?写代码的时候,我发现可以有两种模式进行阶段切换。
1、前端控制
以「叫地主阶段」->「抢地主阶段」为例,首先前端肯定知道游戏的轮转顺序(必须知道,因为布局就得考虑),轮转顺序是逆时针的。
当服务器下发一条「xx 叫地主的消息」后,前端可以知道
- 接下来要进行抢地主阶段了
- xx 的下一个是 yy
那么前端可以主动将状态转为「yy 进行抢地主状态」。
这个没有问题,逻辑上也讲得通,而且乐观 UI 的思想,能让用户最快的感知到变化,理论上体验最佳。甚至!可以节省与后台的传输,因为后台只需要下发「xx 叫地主」,都不需要下发「yy 进入到抢地主状态」了。
不过情况不是这么简单……写代码过程中发现了些问题。
「叫地主阶段」->「抢地主阶段」没问题,走的通;「抢地主状态」->「抢地主状态」也没问题,走得通;「抢地主阶段」->「出牌阶段」怎么办?
我们可以在前端将每个玩家叫地主、抢地主的结果记录下来,然后保证和后端一样的逻辑,也可以得到这局游戏的地主是谁。但是地主获得的三张牌呢?这是一定要得从服务器获得的,出现了冲突,或者说前端无法完整实现的地方。
更明显的还有「准备阶段」->「叫地主阶段」,前端完全不知道谁是叫地主的,因为这个可能不按轮转顺序来。
到这里,是不是我们也可以前端 + 后台配合的方式?尝试了一下,并不好,这种组合的形式让代码变得难写,我不推荐这种方式。
不过也不敢保证,也许是我写法上的问题,如果对这里有建议和想法,可以一起讨论。
2、后台控制
所以我最后采用了后端精准控制的方式,一切都是以后台下发为准。
我选择「叫地主」之后,理论上可以将前端状态转到「下个人抢地主」,但是并没有,我一定得等到后台状态变化的消息才进行转换。
注意:但是按钮,还是得提前反馈啊,否则网络延迟会让用户抓狂的。
所以房间逻辑这里,整个流程,是靠后台消息进行驱动的。代码大概:
private _addMessageListener() {
// 对手进入
this._app.network.addEventListener('Room.PlayerEnterRoom', this._playerEnterRoom);
// 对手离开
this._app.network.addEventListener('Room.PlayerLeaveRoom', this._playerLeaveRoom);
// 监听玩家准备
this._app.network.addEventListener('Room.PlayerReady', this._playerReady);
// 进入叫地主阶段
this._app.network.addEventListener('Room.EnterAskLandlord', this._enterAskLandlord);
// 对手叫地主
this._app.network.addEventListener('Room.PlayerAskLandlord', this._playerAskLandlord);
// 进入抢地主阶段
this._app.network.addEventListener('Room.EnterGrabLandlord', this._enterGrabLandlord);
// 对手抢地主
this._app.network.addEventListener('Room.PlayerGrabLandlord', this._playerGrabLandlord);
// 游戏开始
this._app.network.addEventListener('Room.GameStart', this._gameStart);
// 出牌
this._app.network.addEventListener('Room.PlayerShotPukes', this._playerPukes);
// 继续出牌
this._app.network.addEventListener('Room.LoopPukes', this._loopPukes);
// 游戏结束
this._app.network.addEventListener('Room.GameOver', this._gameOver);
}
具体代码在 Room.ts 中
客户端同步
稍微延伸一下,刚刚说的那种情况,很类似游戏中,客户端同步的两种方式:帧同步和状态同步
帧同步(行为同步)
帧同步的核心就是 不同的客户端 + 相同的输入(行为)= 相同的输出(状态)
如果能一直保证这个公式成立,那么服务器只需要推下发行为,无需下发状态,行为的开销肯定远远小于状态,优势在于性能。这一般用于实时性要求高的游戏中,比如格斗类、fps 类游戏。
状态同步
状态同步就好理解了,客户端以服务器下发的状态为准,客户端就像一个播放器一样。这种优势在于服务器掌握绝对控制权,一般用于实时性要求不高的游戏中。
与服务器对接
游戏和传统 web 开发在网络上的差距也是很大的,传统 web 开发,资源加载完毕后,也就是 cgi 拉取一些数据或者上传一些数据会与后台对接,总而言之就是前端与后台的交流并不密切。
但是游戏不一样,游戏是需要频繁交换数据的,而且必须要有后台主动推送的能力。斗地主这款游戏算是上行很少的游戏了,理论上其实 cgi+ 长轮询也能满足我们的需求,但是现在 websocket 这么好用,不可能不用啊。
websocket
websocket 这里我们也是裸写的,没用开源的库,也没写重连啥的逻辑,如果在线游戏想正规一点,一定要考虑重连啊。
如果还不了解 websocket 的同学,可以找介绍看下,很简单。
但是 websocket 也有尴尬的地方,主要有两点:
- 下行消息一个通道,没有回调的概念
- 无法携带 session
先说 1,我们用 websocket 进行 send 调用,调用就调用了,没有回调函数的概念的。后台如果想针对我们的请求进行回报,也得走统一的下发消息,对于前端来说,就是触发了 onmessage。
这样肯定是不行的,既然底层不支持,我们就得进行一次封装,其实核心就是版本号控制一下。
原理如下图:
我们发送消息的时候,如果有回调函数,就会记录一下(自增 id 标示),然后这个自增 id 会发送给后台。
后台下发消息的时候,有两种,一种是带着回调 id,如果发现是这种消息,就拿着 id 去回调函数池子里面找到对应的函数执行。如果没有回调 id,意味着是单纯的推送,对应执行。
大概代码如下,具体代码在 Network.ts 中
class Network extends EventDispatcher {
// 收到服务器下发消息
private _processMessage(msg: any) {
// response 消息
if (msg.id) {let cb = this._callbacks[msg.id];
delete this._callbacks[msg.id];
if (typeof cb !== 'function') {console.error('callback is not a function for request:', msg.id);
return;
}
cb(msg.body);
return;
}
// 服务器推送消息
let route = msg.route;
if (!route) {console.error('no route in message');
return;
}
this.dispatchEvent(route, msg.data);
}
// 想服务器推送消息
notify(msg: any, callback?: Function) {if (!this._ws) {return;}
if (typeof callback === 'function') {
msg.id = ++this._callbackIndex;
this._callbacks[msg.id] = callback;
}
this._ws.send(JSON.stringify(msg));
}
至于无法携带 session,这个就没办法了,只能相当于每次手动将 uid 带上去,服务器会根据 uid 拿到用户信息。
扑克牌对比逻辑
到了斗地主最核心逻辑部分了,那就是扑克牌大小的对比,也是我们使用 webassembly 的地方。
webassembly
对不了解 webassembly 的同学先简单介绍一下 webassembly,可以理解为:将其他的语言(比如 C ++,go,java 等)写的代码,跑在浏览器上。其他基础知识就不在这里提了哈,可以自行查阅。
外界看好 wasm 的优势在于快!虽然 js 有 v8,但是相比较那些静态语言老流氓们,还是有些差距的。目前 wasm 应用场景最多的应该在于音视频的解析、字符串操作、大量数学计算等一些高 cpu 操作上。
我觉得 wasm 不仅仅有速度上的优势,还有 代码复用 这个被忽视的特性。在游戏上,这个特性帮助会很大。
以我们这个斗地主为例,核心部分是扑克牌对比逻辑。这个逻辑,前端要用把,判断是否可以出牌的时候,如图
但是后端不能无脑信任前端的牌吧,后台也必须得校验一次。一份逻辑,写一次总比写两次好吧,况且还是一个比较复杂的逻辑。wasm 的出现解决了这种场景的痛点,主要也是游戏开发中,这种情况也比较多,很常见的就是碰撞检测。
具体一份代码是怎么用的,我们稍后再说,我们先把扑克牌对比的逻辑用 C ++ 写出来,否则其他都是白搭。
如何对比
因为比较简单,我没有去网上搜实现,自己写了一套,目前看来应该没啥问题,是不是最优思想不清楚。原理如下
我们先把扑克牌分类一下,如下图:
对应的枚举:
enum PukeType {
ERROR, // 无法匹配
EMPTY, // 空张
SINGLE, // 单张
DOUBLE, // 对子
THREE, // 三不带
BOOM, // 炸
THREE_SINGLE, // 三带一
THREE_DOUBLE, // 三带二
DOUBLE_ROW, // 连对
THREE_ROW, // 连三不带
THREE_SINGLE_ROW, // 三带一飞机
THREE_DOUBLE_ROW, // 三带二飞机
};
这里「炸弹」是比较特殊的,因为 它可以和其他类型进行大小比对,其他类型,必须相同类型进行对比,可以理解为对 2 也打不过一单张 3
因为类型多,看起来同类型对比复杂,其实并不是,因为同类型对比,核心比的是某一单张牌。
- 3 带 1 /2,比的是 3 张中的牌谁大
- 连对,无论连了几对,比的是最大的那对中的牌谁大
- 炸弹,其实比单张
- 其他的就不罗列了,其实都是这样
那么我们就可以这样
- 格式化传入的 pukes
- 得到 pukes 的类型 和 这个类型下,能代表最大的那张牌
- 除了炸弹,如果类型对不上,认为比不过
- 类型一样,比核心牌
代码如下,具体代码在 puke-compare.h 中
/**
* 对比两组牌的大小
*/
bool PukeCompare(std::vector<Puke>& pukesA, std::vector<Puke>& pukesB) {
// 先格式化两组牌
Parse(pukesA);
Parse(pukesB);
// 分析牌的类型
PukeCompareResult bResult = GetCore(pukesB);
PukeCompareResult aResult = GetCore(pukesA);
// 不合法,直接认为出牌小
if (bResult.type == PukeType::ERROR) {return false;}
// 如果本身牌为空,则也认为出牌小
if (bResult.type == PukeType::EMPTY) {return false;}
// 对比的牌为空,则认为出牌大
if (aResult.type == PukeType::EMPTY) {return true;}
// 一方是炸弹,另一方不是炸弹
if (bResult.type == PukeType::BOOM && aResult.type != PukeType::BOOM) {return true;}
if (bResult.type != PukeType::BOOM && aResult.type == PukeType::BOOM) {return false;}
// 如果类型不一致,也认为小
if (bResult.type != aResult.type) {return false;} else {
// 类型一致,比核心牌
return (pukesB[bResult.core]) > (pukesA[aResult.core]);
}
}
格式化牌和分析牌类型这两块,也不复杂,稍微有些细节,感兴趣的话可以看,代码都在 puke-compare.h 中
js 调用 c ++ 函数
代码写完了,服务器端 ok 了,我们就得让前端能跑起来 C ++ 的代码。借助 emscripten,其实调用起来也挺方便的,这里没有时间和篇幅说具体怎么弄的,但可以说的抽象一些。
js 调用 C ++ 代码有两种方向
一种是直接调用 C ++ 函数
还有一种是在 js 环境下,new 出 C ++ 对象,这个不好画图,我就不画了哈
二者的区别主要也是写法上的区别,只调用函数的方式控制力较弱;new 对象的方式,控制能力强,但是如果设计的不好,容易玩坏,而且麻烦些。
注意要考虑垃圾回收,在 js 侧 new 出来的 C ++ 对象,v8 可不会帮你垃圾回收,得自己实现一个简单的引用计数的垃圾回收(代码在 my_glue_wrapper.cpp 中)。所以说,如果选择 new 对象的方式,一定要考虑周全。
我们这里相当于两者结合使用了,毕竟本来就是为了练手,涉及到 webidl 相关的知识(将 C ++ 对象,转换为 js 可以理解的对象)。具体代码在 assembly 下 puke.idl 和 my_glue_wrapper.cpp 中
webassembly 这里,本来打算多写点,但是发现不好下手,如果写的详细,内容会较多。感觉又能开一篇文章了,但最近实在是比较忙,能抽出空写这两篇已经到极限了……不过现在网上 webassembly 相关的文章资料已经很多了,感兴趣的同学可以带着一起看,应该就很有助于理解了。
思考
写这个游戏期间,因为不同于平时业务开发,只考虑自己前端的那部分,这次从产品到前端后台都是一个人,有一些非前端的感触。
- 产品流程图很重要,能提前理清楚一些逻辑坑点,防止无脑撸代码然后返工。这里吃了不少亏
- 设计大大们是真的牛皮
- 联调过程保证后端稳定性,尽量少改代码了,后台重新编译、重启的成本高很多
- 时间关系,没有弄单元测试,但能准备单元测试,还是要准备,很重要
- 扑克对比,是否可以引用配置的方式,这样就可以很好的支持其他扑克模式的对比了
结尾
终于到结尾了,感谢阅读到这里的同学。这个游戏本来是一个无心之作,不过也起到了练手的作用。
两篇文章更侧重于思路和宏观的一些东西,加上可能一些小坑。斗地主算是一个简单的游戏,但是我低估了他完成基本闭环需要的时间,所以很多地方都在赶,如果发现有写的不好的、考虑的不好的地方,欢迎斧正~
大家一起交流沟通~
AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯 AlloyTeam 招募 Web 前端工程师(社招)