從零到一,擼一個在線鬥地主(上篇)

原文: 從零到一,擼一個在線鬥地主(上篇) | AlloyTeam
做者:TAT.vorshen

背景:朋友來深圳玩,若說到在深圳有什麼好玩的,那固然是宅在家裏鬥地主了!但是天算不如人算,撲克牌丟了幾張不全……大熱天的,誰願意出去買牌啊。不過問題不大,做爲移動互聯網時代的程序猿,固然是擼一個手機在線鬥地主來代替實體牌了。html

github地址:https://github.com/vorshen/landlord前端

閱讀前注意:
本文分爲上下兩篇,本篇講準備工做以及前端一些佈局相關的知識;下一篇講webassembly實現核心邏輯和server端相關。c++

因爲源碼在github上所有都有,因此文章更偏向于思路的講解。git

業餘時間有限,遊戲樣式醜= =,有些細節也沒打磨,敬請諒解。不過仍是達到了閉環,線下開黑娛樂應該沒有問題。github

17張牌,你能秒我?

遊戲大概樣式
遊戲大概樣式web

準備

技術選型與準備

typescript + canvas + webassembly + c++(server)
首先確定是Web的,人齊有個局域網server端啓動,而後QQ、微信、瀏覽器訪問,直接就開幹了啊。既然是Web的,那必須是typescript啊,我以爲寫過ts的,這輩子應該不會再想寫js了吧……算法

鬥地主做爲一個元素很少、沒炫酷場景的遊戲,其實dom徹底能夠吃得住。可是作個Web遊戲,不用個canvas做爲舞臺,總感受哪裏不對勁。因此最終咱們仍是用canvas來渲染。這裏咱們就沒有用成熟的渲染引擎了,鍛鍊鍛鍊本身。typescript

既然做爲練手做品,總要折騰點,webassembly做爲目前很火的技術,咱們固然要嘗試一下啦,因此遊戲的一些核心邏輯採用了webassembly實現,這裏會在下一篇詳細講解。canvas

編碼前

既然是本身從零到一,產品設計開發都得是本身,咱們先簡單梳理一下游戲的流程。咱們這個鬥地主不一樣於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前端工程師(社招)

clipboard.png

相關文章
相關標籤/搜索