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

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

上篇回顧:我們說了鬥地主遊戲的渲染展示部分,最後也講了下canvas中交互的情況,下篇的重點就是遊戲邏輯

邏輯主要分成兩塊:流程邏輯和撲克牌對比邏輯。

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

17張牌,你能秒我?

流程邏輯

分析

這裏流程上的邏輯分爲兩部分,一個是場景切換,還有一個就是房間頁中游戲進行的流程

先簡單說下場景切換,我們這個鬥地主遊戲有如下三種場景切換

  1. 首頁 -> 大廳頁
  2. 大廳頁 -> 房間頁
  3. 房間頁 -> 大廳頁

我們這裏偷了懶,首頁和大廳頁沒有用canvas,直接上了dom,寫起來也很奔放,沒有用框架。如果遊戲想正式一點,千萬不要這樣。看起來首頁和大廳頁邏輯很簡單,那是因爲我們漏掉了很多點(時間真的不夠。。)。

用一張圖表現一下,我們漏掉的點:
demo和正式遊戲的差異

在我們如此簡化的背景下,如果說還有什麼需要注意的,可能就兩點

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叫地主的消息」後,前端可以知道

  1. 接下來要進行搶地主階段了
  2. 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也有尷尬的地方,主要有兩點:

  1. 下行消息一個通道,沒有回調的概念
  2. 無法攜帶session

先說1,我們用websocket進行send調用,調用就調用了,沒有回調函數的概念的。後臺如果想針對我們的請求進行回報,也得走統一的下發消息,對於前端來說,就是觸發了onmessage。

這樣肯定是不行的,既然底層不支持,我們就得進行一次封裝,其實核心就是版本號控制一下。

原理如下圖:
websocket實現回調

我們發送消息的時候,如果有回調函數,就會記錄一下(自增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張中的牌誰大
  • 連對,無論連了幾對,比的是最大的那對中的牌誰大
  • 炸彈,其實比單張
  • 其他的就不羅列了,其實都是這樣

那麼我們就可以這樣

  1. 格式化傳入的pukes
  2. 得到pukes的類型 和 這個類型下,能代表最大的那張牌
  3. 除了炸彈,如果類型對不上,認爲比不過
  4. 類型一樣,比核心牌

代碼如下,具體代碼在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 歡迎優秀的小夥伴加入。
簡歷投遞: [email protected]
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)

clipboard.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章