原文:從零到一,擼一個在線鬥地主(上篇) | AlloyTeam
作者:TAT.vorshen
上篇回顧:我們說了鬥地主遊戲的渲染展示部分,最後也講了下canvas中交互的情況,下篇的重點就是遊戲邏輯。
邏輯主要分成兩塊:流程邏輯和撲克牌對比邏輯。
github地址:https://github.com/vorshen/landlord
流程邏輯
分析
這裏流程上的邏輯分爲兩部分,一個是場景切換,還有一個就是房間頁中游戲進行的流程
先簡單說下場景切換,我們這個鬥地主遊戲有如下三種場景切換
- 首頁 -> 大廳頁
- 大廳頁 -> 房間頁
- 房間頁 -> 大廳頁
我們這裏偷了懶,首頁和大廳頁沒有用canvas,直接上了dom,寫起來也很奔放,沒有用框架。如果遊戲想正式一點,千萬不要這樣。看起來首頁和大廳頁邏輯很簡單,那是因爲我們漏掉了很多點(時間真的不夠。。)。
用一張圖表現一下,我們漏掉的點:
在我們如此簡化的背景下,如果說還有什麼需要注意的,可能就兩點
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叫地主的消息」後,前端可以知道
- 接下來要進行搶地主階段了
- 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也有尷尬的地方,主要有兩點:
- 下行消息一個通道,沒有回調的概念
- 無法攜帶session
先說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的同學先簡單介紹一下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張中的牌誰大
- 連對,無論連了幾對,比的是最大的那對中的牌誰大
- 炸彈,其實比單張
- 其他的就不羅列了,其實都是這樣
那麼我們就可以這樣
- 格式化傳入的pukes
- 得到pukes的類型 和 這個類型下,能代表最大的那張牌
- 除了炸彈,如果類型對不上,認爲比不過
- 類型一樣,比核心牌
代碼如下,具體代碼在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前端工程師(社招)