原文: 從零到一,擼一個在線鬥地主(下篇) | AlloyTeam
做者:TAT.vorshen
上篇回顧:咱們說了鬥地主遊戲的渲染展現部分,最後也講了下canvas中交互的狀況,下篇的重點就是遊戲邏輯。前端
邏輯主要分紅兩塊:流程邏輯和撲克牌對比邏輯。java
github地址:https://github.com/vorshen/landlordc++
這裏流程上的邏輯分爲兩部分,一個是場景切換,還有一個就是房間頁中游戲進行的流程git
先簡單說下場景切換,咱們這個鬥地主遊戲有以下三種場景切換github
咱們這裏偷了懶,首頁和大廳頁沒有用canvas,直接上了dom,寫起來也很奔放,沒有用框架。若是遊戲想正式一點,千萬不要這樣。看起來首頁和大廳頁邏輯很簡單,那是由於咱們漏掉了不少點(時間真的不夠。。)。web
用一張圖表現一下,咱們漏掉的點:canvas
在咱們如此簡化的背景下,若是說還有什麼須要注意的,可能就兩點後端
好比當咱們進入首頁的時候,要不要把大廳頁和房間頁都初始化完畢?瀏覽器
這裏我沒有選擇初始化,必定是真正使用到纔會初始化。理由主要就是後面用到再初始化的開銷並不大,能夠接受。服務器
若是當遇到,某一個場景很複雜,切換須要較大的開銷,能夠考慮提早進行一些初始化的工做。
大廳頁和房間頁存在來回切換的狀況,當發生大廳切換到房間的時候,能夠選擇將大廳頁隱藏,也能夠選擇將大廳頁銷燬,後面用到再初始化。
這裏咱們選擇只是將頁面隱藏,也就是說當房間頁第一次展現的時候,須要進行初始化(較大開銷),之後再展現,就是不多的性能開銷了。
大概代碼以下:
/** * 房間展現,主要是生成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中
由於咱們頁面簡單並且小,常駐的話對性能影響不大,若是打算常駐的頁面展現率低或者隱藏運行也很佔用資源,那仍是推薦把真的幹掉。
首先咱們認爲在房間中,流程的變化都是事件驅動,具體能夠看下圖:
注意:右側若是有箭頭,意味着可能該階段本身切換到該階段(只是該階段主角玩家發生變化)
在每一個階段,前端只能有對應的操做。那麼每一個階段的切換,事件的發起者是誰呢?寫代碼的時候,我發現能夠有兩種模式進行階段切換。
以「叫地主階段」->「搶地主階段」爲例,首先前端確定知道遊戲的輪轉順序(必須知道,由於佈局就得考慮),輪轉順序是逆時針的。
當服務器下發一條「xx叫地主的消息」後,前端能夠知道
那麼前端能夠主動將狀態轉爲「yy進行搶地主狀態」。
這個沒有問題,邏輯上也講得通,並且樂觀UI的思想,能讓用戶最快的感知到變化,理論上體驗最佳。甚至!能夠節省與後臺的傳輸,由於後臺只須要下發「xx叫地主」,都不須要下發「yy進入到搶地主狀態」了。
不過狀況不是這麼簡單……寫代碼過程當中發現了些問題。
「叫地主階段」->「搶地主階段」沒問題,走的通;「搶地主狀態」->「搶地主狀態」也沒問題,走得通;「搶地主階段」->「出牌階段」怎麼辦?
咱們能夠在前端將每一個玩家叫地主、搶地主的結果記錄下來,而後保證和後端同樣的邏輯,也能夠獲得這局遊戲的地主是誰。可是地主得到的三張牌呢?這是必定要得從服務器得到的,出現了衝突,或者說前端沒法完整實現的地方。
更明顯的還有「準備階段」->「叫地主階段」,前端徹底不知道誰是叫地主的,由於這個可能不按輪轉順序來。
到這裏,是否是咱們也能夠前端+後臺配合的方式?嘗試了一下,並很差,這種組合的形式讓代碼變得難寫,我不推薦這種方式。
不過也不敢保證,也許是我寫法上的問題,若是對這裏有建議和想法,能夠一塊兒討論。
因此我最後採用了後端精準控制的方式,一切都是之後臺下發爲準。
我選擇「叫地主」以後,理論上能夠將前端狀態轉到「下我的搶地主」,可是並無,我必定得等到後臺狀態變化的消息才進行轉換。
注意:可是按鈕,仍是得提早反饋啊,不然網絡延遲會讓用戶抓狂的。
因此房間邏輯這裏,整個流程,是靠後臺消息進行驅動的。代碼大概:
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也有尷尬的地方,主要有兩點:
先說1,咱們用websocket進行send調用,調用就調用了,沒有回調函數的概念的。後臺若是想針對咱們的請求進行回報,也得走統一的下發消息,對於前端來講,就是觸發了onmessage。
這樣確定是不行的,既然底層不支持,咱們就得進行一次封裝,其實核心就是版本號控制一下。
原理以下圖:
咱們發送消息的時候,若是有回調函數,就會記錄一下(自增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,能夠理解爲:將其餘的語言(好比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
由於類型多,看起來同類型對比複雜,其實並非,由於同類型對比,核心比的是某一單張牌。
那麼咱們就能夠這樣
代碼以下,具體代碼在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中
代碼寫完了,服務器端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 歡迎優秀的小夥伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)