原文:从零到一,撸一个在线斗地主(上篇) | AlloyTeam
做者:TAT.vorshenhtml
背景:朋友来深圳玩,若说到在深圳有什么好玩的,那固然是宅在家里斗地主了!但是天算不如人算,扑克牌丢了几张不全……大热天的,谁愿意出去买牌啊。不过问题不大,做为移动互联网时代的程序猿,固然是撸一个手机在线斗地主来代替实体牌了。前端
github地址:github.com/vorshen/lan…c++
阅读前注意: 本文分为上下两篇,本篇讲准备工做以及前端一些布局相关的知识;下一篇讲webassembly实现核心逻辑和server端相关。git
因为源码在github上所有都有,因此文章更偏向于思路的讲解。github
业余时间有限,游戏样式丑= =,有些细节也没打磨,敬请谅解。不过仍是达到了闭环,线下开黑娱乐应该没有问题。web
游戏大概样式 算法
typescript + canvas + webassembly + c++(server) 首先确定是Web的,人齐有个局域网server端启动,而后QQ、微信、浏览器访问,直接就开干了啊。既然是Web的,那必须是typescript啊,我以为写过ts的,这辈子应该不会再想写js了吧……typescript
斗地主做为一个元素很少、没炫酷场景的游戏,其实dom彻底能够吃得住。可是作个Web游戏,不用个canvas做为舞台,总感受哪里不对劲。因此最终咱们仍是用canvas来渲染。这里咱们就没有用成熟的渲染引擎了,锻炼锻炼本身。canvas
既然做为练手做品,总要折腾点,webassembly做为目前很火的技术,咱们固然要尝试一下啦,因此游戏的一些核心逻辑采用了webassembly实现,这里会在下一篇详细讲解。浏览器
既然是本身从零到一,产品设计开发都得是本身,咱们先简单梳理一下游戏的流程。咱们这个斗地主不一样于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]这个矩形对比,判断点在不在矩形中,若是在,就意味着点击到了元素
若是页面比较简单,确实解决了。而后有些事情并不是那么简单……
针对元素重叠,首先咱们确定是不能触发层级低元素的点击事件的,那么就是咱们判断点是否在矩形中的时候,必定要按顺序来。正好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前端工程师(社招)