从零到一,撸一个在线斗地主(上篇)

原文:从零到一,撸一个在线斗地主(上篇) | AlloyTeam
做者:TAT.vorshenhtml

背景:朋友来深圳玩,若说到在深圳有什么好玩的,那固然是宅在家里斗地主了!但是天算不如人算,扑克牌丢了几张不全……大热天的,谁愿意出去买牌啊。不过问题不大,做为移动互联网时代的程序猿,固然是撸一个手机在线斗地主来代替实体牌了。前端

github地址:github.com/vorshen/lan…c++

阅读前注意: 本文分为上下两篇,本篇讲准备工做以及前端一些布局相关的知识;下一篇讲webassembly实现核心逻辑和server端相关。git

因为源码在github上所有都有,因此文章更偏向于思路的讲解。github

业余时间有限,游戏样式丑= =,有些细节也没打磨,敬请谅解。不过仍是达到了闭环,线下开黑娱乐应该没有问题。web

17张牌,你能秒我?

游戏大概样式 算法

游戏大概样式

准备

技术选型与准备

typescript + canvas + webassembly + c++(server) 首先确定是Web的,人齐有个局域网server端启动,而后QQ、微信、浏览器访问,直接就开干了啊。既然是Web的,那必须是typescript啊,我以为写过ts的,这辈子应该不会再想写js了吧……typescript

斗地主做为一个元素很少、没炫酷场景的游戏,其实dom彻底能够吃得住。可是作个Web游戏,不用个canvas做为舞台,总感受哪里不对劲。因此最终咱们仍是用canvas来渲染。这里咱们就没有用成熟的渲染引擎了,锻炼锻炼本身。canvas

既然做为练手做品,总要折腾点,webassembly做为目前很火的技术,咱们固然要尝试一下啦,因此游戏的一些核心逻辑采用了webassembly实现,这里会在下一篇详细讲解。浏览器

编码前

既然是本身从零到一,产品设计开发都得是本身,咱们先简单梳理一下游戏的流程。咱们这个斗地主不一样于QQ斗地主,QQ斗地主是随机进入房间,没法开黑。而咱们追求的是一块儿玩,因此游戏房间的概念是一大不一样。

简单列了一下咱们游戏的流程:

  1. 快速进入,即开即玩,无需注册
  2. 建立房间或搜索加入房间
  3. 进入房间以后,传统的斗地主逻辑

传统的斗地主逻辑以下:

传统斗地主逻辑

虽然这里贴出来了,但本身真正开始写的时候,压根没梳理,就是一把梭,上来就撸码。结果发现了很多逻辑上的冲突点和细节点,斗地主看起来是一个小游戏,不过逻辑还蛮复杂的,再加上在线非单机,彻底低估了游戏的复杂度,一把辛酸泪……

设计没啥好说的,从网上找了几个图就看成基本的元素了(难看就难看了……没办法)

下面就正式开始了

布局

横屏

首先斗地主这个游戏是横屏的,这个蛋疼了,由于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斗地主的房间页:

QQ斗地主

咱们大体划分一下模块,如图所示:

QQ斗地主,模块分析

不考虑细节的状况下仍是比较简单的,能够看出,主要就是六大区域:

  1. 顶部信息展现区
  2. 底部信息展现区
  3. 左侧玩家区域
  4. 右侧玩家区域
  5. 主视角玩家区域
  6. 特效区域

咱们这就不考虑出牌特效啥的了(找几个基础的素材就要了我命了),若是用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实现

上面是flex的实现,很轻松,可是,咱们使用canvas渲染,该如何针对不一样屏幕尺寸进行适配呢? 这里有两种大的考虑方向:

  1. canvas模拟弹性布局
  2. 缩放解决

canvas模拟弹性布局

众所周知咱们用原生canvas接口,绘制元素,都是用绝对定位的形式,不支持flex。看了下业界一些游戏渲染引擎,alloyrender、erget、easelJS也都是用x,y坐标控制显示对象的位置。

个人理解是既然你采用canvas了,天然是会出现频繁重绘,弹性布局更偏向于静止的页面场景,对于游戏上需求不大,不必花大功夫吃力不讨好。不过咱们这个斗地主是一个偏页面静止的游戏,感兴趣的同窗能够尝试尝试,针对上面那五个模块用固定大小+百分比的方式来实现一下弹性布局。因为时间和篇幅关系,这里就不贴效果图和代码了。

这种方式的优点是能够把屏幕使用率拉满,也不会有变形;

劣势就是太麻烦了,光是这五个区域的布局还好,可是还涉及到区域里面细节的时候,实在是hold不住了,因此我最终也没有采用这种方式。若是有那些简单的布局场景,仍是能够试试。

缩放解决

看名字就知道是采用「缩放」来抹平不一样屏幕尺寸的差别了。怎么缩放,也是有不少种方案,我罗列两个我以为比较好的,应该也是用的比较多的

  1. 所有展现+黑边
  2. 核心展现+无黑边

二者的原理以下所示:

所有展现+黑边
核心展现+无黑边

两者的针对的场景也不太相同

「所有展现+黑边」:全部内容都必须展现出来,黑边能够用大背景掩盖住

「核心展现+无黑边」:整个舞台能够很大,用户只须要聚焦核心区域

综上所述,咱们确定要采用的是第一种方式了

渲染

整个页面不是很复杂,为了练手,咱们也没有用业界成熟的渲染引擎。可是总不能用canvas原生的写法,因此首先咱们封装了几个基础的组件

  • DisplayObject 显示对象基类,只要对象要显示,必定要继承该类
  • Container 容器类
  • Bitmap 位图类
  • Text 文本类

以上是此次游戏中须要用到的渲染相关的基类,咱们具体的展现对象(扑克牌),或者容器(手牌)都是继承它们,再进行一些扩充。具体的代码github上都能看到。 下面用张图表示一下整个项目中组件状况

项目中组件状况

这里假设咱们要正式开发一个游戏,借助渲染引擎,意味着不须要考虑base部分了。那么大概流程是以下的。

  1. 咱们要先规划出场景,肯定有几个场景。
  2. 针对1中的场景,肯定每一个场景有哪些基于base的上层组件
  3. 组件抽象复用性判断(不一样场景相似的组件,是否是能够抽象成一个)
  4. 工具库、第三方库肯定

流程基本上就是如此。

这里咱们用页面上最重要的一个组件为例,讲一下

BasePukesContainer是很是重要的一个组件,如其名,它是负责扑克牌展现的。玩家的手牌(HandPukes)、玩家出的牌(DesktopPukes)都是继承于它,因此BasePukesContainer抽象就很重要了

首先,咱们肯定下BasePukesContainer做为一个扑克牌展现承载容器,须要哪些方法

  1. 能带着扑克牌(子元素)展现
  2. 能批量的增删扑克牌
  3. 扑克牌的支持多种对齐方式、多行展现等

列个图,看了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的位置,找到对应渲染的元素啊。

具体原理以下

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前端工程师(社招)

相关文章
相关标签/搜索