原文: 从零到一,撸一个在线斗地主(上篇) | AlloyTeam
做者:TAT.vorshen
背景:朋友来深圳玩,若说到在深圳有什么好玩的,那固然是宅在家里斗地主了!但是天算不如人算,扑克牌丢了几张不全……大热天的,谁愿意出去买牌啊。不过问题不大,做为移动互联网时代的程序猿,固然是撸一个手机在线斗地主来代替实体牌了。html
github地址:https://github.com/vorshen/landlord前端
阅读前注意:
本文分为上下两篇,本篇讲准备工做以及前端一些布局相关的知识;下一篇讲webassembly实现核心逻辑和server端相关。c++
因为源码在github上所有都有,因此文章更偏向于思路的讲解。git
业余时间有限,游戏样式丑= =,有些细节也没打磨,敬请谅解。不过仍是达到了闭环,线下开黑娱乐应该没有问题。github
游戏大概样式web
typescript + canvas + webassembly + c++(server)
首先确定是Web的,人齐有个局域网server端启动,而后QQ、微信、浏览器访问,直接就开干了啊。既然是Web的,那必须是typescript啊,我以为写过ts的,这辈子应该不会再想写js了吧……算法
斗地主做为一个元素很少、没炫酷场景的游戏,其实dom彻底能够吃得住。可是作个Web游戏,不用个canvas做为舞台,总感受哪里不对劲。因此最终咱们仍是用canvas来渲染。这里咱们就没有用成熟的渲染引擎了,锻炼锻炼本身。typescript
既然做为练手做品,总要折腾点,webassembly做为目前很火的技术,咱们固然要尝试一下啦,因此游戏的一些核心逻辑采用了webassembly实现,这里会在下一篇详细讲解。canvas
既然是本身从零到一,产品设计开发都得是本身,咱们先简单梳理一下游戏的流程。咱们这个斗地主不一样于QQ斗地主,QQ斗地主是随机进入房间,没法开黑。而咱们追求的是一块儿玩,因此游戏房间的概念是一大不一样。浏览器
简单列了一下咱们游戏的流程:
传统的斗地主逻辑以下:
虽然这里贴出来了,但本身真正开始写的时候,压根没梳理,就是一把梭,上来就撸码。结果发现了很多逻辑上的冲突点和细节点,斗地主看起来是一个小游戏,不过逻辑还蛮复杂的,再加上在线非单机,彻底低估了游戏的复杂度,一把辛酸泪……
设计没啥好说的,从网上找了几个图就看成基本的元素了(难看就难看了……没办法)
下面就正式开始了
首先斗地主这个游戏是横屏的,这个蛋疼了,由于web对横屏的控制太弱了一点。咱们没法强制横版,所有依赖系统的行为。
既然横屏限制多很差用,那么咱们能不能直接用竖屏来模拟横屏呢?也就是手机保持竖屏状态,而后咱们整个页面旋转一下,就模拟了竖屏了,写样式布局啥的,彻底能够按照横屏的来写,仍是挺方便的。
原理以下:
大概代码
// 获取旋转元素父元素的宽高 let width = this._app.root.offsetWidth; let height = this._app.root.offsetHeight; this._box = document.createElement('div'); this._box.className = 'room-box'; // 宽高反转 this._box.style.width = `${height}px`; this._box.style.height = `${width}px`; this._box.style.transform = `translateX(${width}px) rotate(90deg)`;
注意!这样的横屏,会致使没法直接使用点击事件的clientX/Y,这里也须要进行一下转换,具体代码在Stage.ts中,这里再也不展开。
不过这种方案在模拟器上看起来没啥问题,真机上仍是有缺陷的,就是标题栏的问题,如图
不过我以为这个还行,无伤大雅,因此就采起了这种方式
游戏分为三个场景页面:首页,大厅页,房间页。其中首页和大厅页其实也就是走个流程,咱们很随意,房间页就是对战相关,最为复杂,这里就以房间页来讲。下面是经典的QQ斗地主的房间页:
咱们大体划分一下模块,如图所示:
不考虑细节的状况下仍是比较简单的,能够看出,主要就是六大区域:
咱们这就不考虑出牌特效啥的了(找几个基础的素材就要了我命了),若是用dom实现,那直接flex就安排的明明白白,以下(只是举例子,没有用前面横屏的方式)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title></title> <style> html, body { margin: 0; padding: 0; height: 100%; } .root { height: 100%; display: flex; flex-direction: column; justify-content: space-between; } .top-area { height: 45px; background-color: #1ca4fc; display: flex; flex-grow: 0; } .side-player { height: 125px; display: flex; flex-direction: row; justify-content: space-between; flex-grow: 1; } .left-player { width: 266px; background-color: #f7b92b; display: flex; } .right-player { width: 266px; background-color: #f7b92b; display: flex; } .main-player { height: 187.5px; background-color: #fc6554; display: flex; flex-grow: 0; } </style> </head> <body> <div class="root"> <div class="top-area"></div> <div class="side-player"> <div class="left-player"></div> <div class="right-player"></div> </div> <div class="main-player"></div> </div> </body> </html>
上面是flex的实现,很轻松,可是,咱们使用canvas渲染,该如何针对不一样屏幕尺寸进行适配呢?
这里有两种大的考虑方向:
众所周知咱们用原生canvas接口,绘制元素,都是用绝对定位的形式,不支持flex。看了下业界一些游戏渲染引擎,alloyrender、erget、easelJS也都是用x,y坐标控制显示对象的位置。
个人理解是既然你采用canvas了,天然是会出现频繁重绘,弹性布局更偏向于静止的页面场景,对于游戏上需求不大,不必花大功夫吃力不讨好。不过咱们这个斗地主是一个偏页面静止的游戏,感兴趣的同窗能够尝试尝试,针对上面那五个模块用固定大小+百分比的方式来实现一下弹性布局。因为时间和篇幅关系,这里就不贴效果图和代码了。
这种方式的优点是能够把屏幕使用率拉满,也不会有变形;
劣势就是太麻烦了,光是这五个区域的布局还好,可是还涉及到区域里面细节的时候,实在是hold不住了,因此我最终也没有采用这种方式。若是有那些简单的布局场景,仍是能够试试。
看名字就知道是采用「缩放」来抹平不一样屏幕尺寸的差别了。怎么缩放,也是有不少种方案,我罗列两个我以为比较好的,应该也是用的比较多的
二者的原理以下所示:
两者的针对的场景也不太相同
「所有展现+黑边」:全部内容都必须展现出来,黑边能够用大背景掩盖住
「核心展现+无黑边」:整个舞台能够很大,用户只须要聚焦核心区域
综上所述,咱们确定要采用的是第一种方式了
整个页面不是很复杂,为了练手,咱们也没有用业界成熟的渲染引擎。可是总不能用canvas原生的写法,因此首先咱们封装了几个基础的组件
以上是此次游戏中须要用到的渲染相关的基类,咱们具体的展现对象(扑克牌),或者容器(手牌)都是继承它们,再进行一些扩充。具体的代码github上都能看到。
下面用张图表示一下整个项目中组件状况
这里假设咱们要正式开发一个游戏,借助渲染引擎,意味着不须要考虑base部分了。那么大概流程是以下的。
流程基本上就是如此。
这里咱们用页面上最重要的一个组件为例,讲一下
BasePukesContainer是很是重要的一个组件,如其名,它是负责扑克牌展现的。玩家的手牌(HandPukes)、玩家出的牌(DesktopPukes)都是继承于它,因此BasePukesContainer抽象就很重要了
首先,咱们肯定下BasePukesContainer做为一个扑克牌展现承载容器,须要哪些方法
列个图,看了BasePukesContainer已有的,和须要补充的
红色部分是目前继承base下来缺失的,那么咱们就要扩充
最终代码如此(完整源码看github)
class BasePukesContainer extends Container { // 扑克牌宽度 protected _pukeWidth: number; // 扑克牌高度 protected _pukeHeight: number; // 扑克牌水平对齐方式 protected _horizontalAlign: PUKE_HORIZONTAL_ALIGN; // 扑克牌垂直对齐方式 protected _verticalAlign: PUKE_VERTICAL_ALIGN; // 扑克牌之间两两的覆盖大小 private _interval: number; /** * 移除某张扑克牌 * @param {*} object */ protected _deletePuke(object: BasePuke) {} /** * 加入单张扑克牌 * @param {*} puke */ protected _postPuke(puke: BasePuke, zIndex?: number) {} /** * 触发更新维护的扑克牌的位置 */ protected _updatePukes() {} constructor(options: i_BasePukesContainerOptions) {} /** * 移除部分扑克牌 * @param {string[]} pukes */ deletePukes(pukes: string[]) {} /** * 添加部分扑克牌 * @param {string[]} pukes */ postPukes(pukes: string[]) {} /** * 删除全部牌 */ deleteAll() {} }
渲染引擎的组件和使用思想都讲完了,固然细节和基础组件确定远远不止这些,好比动画、粒子等等,感兴趣的能够看下业界渲染引擎的源码,带着理解去读,应该仍是挺易懂的。
静态渲染相关的都讲完了,下面咱们说说游戏开发中的交互
扑克牌排列渲染好了,玩家得出牌啊,touchstart和touchmove都应该触发选牌。问题是canvas不是dom,无论展现啥,理论上要不是fill出来的,要否则是stroke出来的,都无法绑定交互事件啊。
其实这个问题也不算是问题了,基本上你们应该都知道解决方案。
虽然fill出来的东西咱们没法绑定事件,可是,咱们能够给canvas标签绑上事件啊。而后根据event的clientX/Y相对于canvas的位置,找到对应渲染的元素啊。
具体原理以下
(x3, y3)就是clientX/Y
它是全局坐标,咱们先减去(x1, y1),获得相对于canvas舞台的坐标(x', y')
此时一切都是相对于canvas舞台的坐标系了,咱们用(x', y')去和[x2, y2, w, h]这个矩形对比,判断点在不在矩形中,若是在,就意味着点击到了元素
若是页面比较简单,确实解决了。而后有些事情并不是那么简单……
有两个元素(扑克)存在重叠,玩家点击在了重叠的区域,该如何响应?
刚刚只有两个坐标系,屏幕坐标系和canvas坐标系,若是再引入一个container呢,是否是又多了一个相对坐标?茫茫无尽的嵌套,该怎么办呢?
一个点是否在矩形中,很好判断;是否在圆中,也好判断,但若是是不规则图形呢?
针对元素重叠,首先咱们确定是不能触发层级低元素的点击事件的,那么就是咱们判断点是否在矩形中的时候,必定要按顺序来。正好Container也保证了这个顺序,代码相似以下。
/** * touchstart,touchmove的时候触发 */ private _touch = (data: { x: number, y: number }) => { let { x, y } = data; let len = this._children.length; let i; let temp: BasePuke; let puke: BasePuke | undefined; for (i = len - 1; i >= 0; i--) { temp = <BasePuke>this._children[i]; if (temp.contain(x, y)) { puke = temp; break; } } if (puke) { this._choosePuke(puke); } }
组件嵌套就稍微麻烦了些,这里的核心冲突是鼠标点击的位置是绝对坐标,而canvas舞台里面的元素,都是相对坐标。要对比的话,要么将绝对坐标转为相对的,要么把相对的转成绝对坐标。
这里咱们采用的是将绝对坐标转为相对的,好比当点击坐标为(x1, y1)时,须要判断是否点击中了[x2, y2, w, h]这个矩形(注意:这个x2, y2是通过层层嵌套的)
咱们就须要求出(x1, y2)这个全局坐标,转换到(x2, y2)坐标系的矩阵,而后变化一下便可
代码以下:
// DisplayObject.ts /** * 判断是否在AABB中 * 注意,这里x,y是global的坐标,没有通过transform * 因此要进行逆矩阵计算 * @param {*} x * @param {*} y */ contain(x: number, y: number) { let point = new Point(x, y); let matrix: Matrix2D; // 先求出完整的矩阵 if (this._parent) { matrix = this._parent._getGlobalMatrix(); } else { matrix = new Matrix2D(); } // 再求逆矩阵 matrix.invert(); // 点进行矩阵变换 point.transformWithMatrix(matrix); let rect = this._getAABB(); return rect.contains(point); }
变化矩阵就是根据须要判断的元素,先获取其全局的变换矩阵,而后求逆矩阵便可。若是了解矩阵的同窗,应该很好理解,不了解的同窗,能够查阅一下相关资料,这里篇幅缘由,就不详细说明了。
绝对转相对是如此的,相对转绝对也是相似的作法。
最后一个就是不规则图形,规则图形咱们均可以用几何法甚至代数法判断其是否在元素内部,其实判断的核心在于「边」。可是不规则图形,单纯的想用「边」的方式来判断,太难了,因此就有了像素级别的判断法:反画家算法。仍是篇幅问题,这里不进行展开,感兴趣的同窗自行查阅(咱们这个斗地主游戏也没有使用)。
到这里,上文就要结束了。咱们从需求开始分析,将游戏中展现相关的工做都准备完毕,解决了横屏问题,本身封装了个简易的渲染引擎,肯定好了上层组件,也准备好了交互手势,能够说非逻辑部分都已经搞定了,已经能够单机展现出来了。
那么该如何接收他人消息?游戏的同步是什么样的?用户进出房间有什么注意事项?出牌核心逻辑部分该如何编写?Webassembly用在了哪里,如何使用?
敬请期待下篇。
AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)