摘要:原文從零到一,擼一個在線斗地主下篇作者上篇回顧我們說了斗地主游戲的渲染展示部分,最后也講了下中交互的情況,下篇的重點就是游戲邏輯。
原文:從零到一,擼一個在線斗地主(下篇) | AlloyTeam
作者:TAT.vorshen
上篇回顧:我們說了斗地主游戲的渲染展示部分,最后也講了下canvas中交互的情況,下篇的重點就是游戲邏輯。
邏輯主要分成兩塊:流程邏輯和撲克牌對比邏輯。
github地址:https://github.com/vorshen/landlord
流程邏輯 分析這里流程上的邏輯分為兩部分,一個是場景切換,還有一個就是房間頁中游戲進行的流程
先簡單說下場景切換,我們這個斗地主游戲有如下三種場景切換
首頁 -> 大廳頁
大廳頁 -> 房間頁
房間頁 -> 大廳頁
我們這里偷了懶,首頁和大廳頁沒有用canvas,直接上了dom,寫起來也很奔放,沒有用框架。如果游戲想正式一點,千萬不要這樣。看起來首頁和大廳頁邏輯很簡單,那是因為我們漏掉了很多點(時間真的不夠。。)。
用一張圖表現(xiàn)一下,我們漏掉的點:
在我們如此簡化的背景下,如果說還有什么需要注意的,可能就兩點
1、是否提前加載模塊比如當我們進入首頁的時候,要不要把大廳頁和房間頁都初始化完畢?
這里我沒有選擇初始化,一定是真正使用到才會初始化。理由主要就是后面用到再初始化的開銷并不大,可以接受。
如果當遇到,某一個場景很復雜,切換需要較大的開銷,可以考慮提前進行一些初始化的工作。
2、銷毀是真銷毀還是隱藏大廳頁和房間頁存在來回切換的情況,當發(fā)生大廳切換到房間的時候,可以選擇將大廳頁隱藏,也可以選擇將大廳頁銷毀,后面用到再初始化。
這里我們選擇只是將頁面隱藏,也就是說當房間頁第一次展示的時候,需要進行初始化(較大開銷),以后再展示,就是很少的性能開銷了。
大概代碼如下:
/** * 房間展示,主要是生成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中
因為我們頁面簡單而且小,常駐的話對性能影響不大,如果打算常駐的頁面展示率低或者隱藏運行也很占用資源,那還是推薦把真的干掉。
房間中流程 消息驅動首先我們認為在房間中,流程的變化都是事件驅動,具體可以看下圖:
注意:右側如果有箭頭,意味著可能該階段自己切換到該階段(只是該階段主角玩家發(fā)生變化)
在每個階段,前端只能有對應的操作。那么每個階段的切換,事件的發(fā)起者是誰呢?寫代碼的時候,我發(fā)現(xiàn)可以有兩種模式進行階段切換。
1、前端控制以「叫地主階段」->「搶地主階段」為例,首先前端肯定知道游戲的輪轉順序(必須知道,因為布局就得考慮),輪轉順序是逆時針的。
當服務器下發(fā)一條「xx叫地主的消息」后,前端可以知道
接下來要進行搶地主階段了
xx的下一個是yy
那么前端可以主動將狀態(tài)轉為「yy進行搶地主狀態(tài)」。
這個沒有問題,邏輯上也講得通,而且樂觀UI的思想,能讓用戶最快的感知到變化,理論上體驗最佳。甚至!可以節(jié)省與后臺的傳輸,因為后臺只需要下發(fā)「xx叫地主」,都不需要下發(fā)「yy進入到搶地主狀態(tài)」了。
不過情況不是這么簡單……寫代碼過程中發(fā)現(xiàn)了些問題。
「叫地主階段」->「搶地主階段」沒問題,走的通;「搶地主狀態(tài)」->「搶地主狀態(tài)」也沒問題,走得通;「搶地主階段」->「出牌階段」怎么辦?
我們可以在前端將每個玩家叫地主、搶地主的結果記錄下來,然后保證和后端一樣的邏輯,也可以得到這局游戲的地主是誰。但是地主獲得的三張牌呢?這是一定要得從服務器獲得的,出現(xiàn)了沖突,或者說前端無法完整實現(xiàn)的地方。
更明顯的還有「準備階段」->「叫地主階段」,前端完全不知道誰是叫地主的,因為這個可能不按輪轉順序來。
到這里,是不是我們也可以前端+后臺配合的方式?嘗試了一下,并不好,這種組合的形式讓代碼變得難寫,我不推薦這種方式。
不過也不敢保證,也許是我寫法上的問題,如果對這里有建議和想法,可以一起討論。
2、后臺控制所以我最后采用了后端精準控制的方式,一切都是以后臺下發(fā)為準。
我選擇「叫地主」之后,理論上可以將前端狀態(tài)轉到「下個人搶地主」,但是并沒有,我一定得等到后臺狀態(tài)變化的消息才進行轉換。
注意:但是按鈕,還是得提前反饋啊,否則網(wǎng)絡延遲會讓用戶抓狂的。
所以房間邏輯這里,整個流程,是靠后臺消息進行驅動的。代碼大概:
private _addMessageListener() { // 對手進入 this._app.network.addEventListener("Room.PlayerEnterRoom", this._playerEnterRoom); // 對手離開 this._app.network.addEventListener("Room.PlayerLeaveRoom", this._playerLeaveRoom); // 監(jiān)聽玩家準備 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); // 繼續(xù)出牌 this._app.network.addEventListener("Room.LoopPukes", this._loopPukes); // 游戲結束 this._app.network.addEventListener("Room.GameOver", this._gameOver); }
具體代碼在Room.ts中
客戶端同步稍微延伸一下,剛剛說的那種情況,很類似游戲中,客戶端同步的兩種方式:幀同步和狀態(tài)同步
幀同步(行為同步)幀同步的核心就是 不同的客戶端 + 相同的輸入(行為) = 相同的輸出(狀態(tài))
如果能一直保證這個公式成立,那么服務器只需要推下發(fā)行為,無需下發(fā)狀態(tài),行為的開銷肯定遠遠小于狀態(tài),優(yōu)勢在于性能。這一般用于實時性要求高的游戲中,比如格斗類、fps類游戲。
狀態(tài)同步狀態(tài)同步就好理解了,客戶端以服務器下發(fā)的狀態(tài)為準,客戶端就像一個播放器一樣。這種優(yōu)勢在于服務器掌握絕對控制權,一般用于實時性要求不高的游戲中。
與服務器對接游戲和傳統(tǒng)web開發(fā)在網(wǎng)絡上的差距也是很大的,傳統(tǒng)web開發(fā),資源加載完畢后,也就是cgi拉取一些數(shù)據(jù)或者上傳一些數(shù)據(jù)會與后臺對接,總而言之就是前端與后臺的交流并不密切。
但是游戲不一樣,游戲是需要頻繁交換數(shù)據(jù)的,而且必須要有后臺主動推送的能力。斗地主這款游戲算是上行很少的游戲了,理論上其實cgi+長輪詢也能滿足我們的需求,但是現(xiàn)在websocket這么好用,不可能不用啊。
websocketwebsocket這里我們也是裸寫的,沒用開源的庫,也沒寫重連啥的邏輯,如果在線游戲想正規(guī)一點,一定要考慮重連啊。
如果還不了解websocket的同學,可以找介紹看下,很簡單。
但是websocket也有尷尬的地方,主要有兩點:
下行消息一個通道,沒有回調的概念
無法攜帶session
先說1,我們用websocket進行send調用,調用就調用了,沒有回調函數(shù)的概念的。后臺如果想針對我們的請求進行回報,也得走統(tǒng)一的下發(fā)消息,對于前端來說,就是觸發(fā)了onmessage。
這樣肯定是不行的,既然底層不支持,我們就得進行一次封裝,其實核心就是版本號控制一下。
原理如下圖:
我們發(fā)送消息的時候,如果有回調函數(shù),就會記錄一下(自增id標示),然后這個自增id會發(fā)送給后臺。
后臺下發(fā)消息的時候,有兩種,一種是帶著回調id,如果發(fā)現(xiàn)是這種消息,就拿著id去回調函數(shù)池子里面找到對應的函數(shù)執(zhí)行。如果沒有回調id,意味著是單純的推送,對應執(zhí)行。
大概代碼如下,具體代碼在Network.ts中
class Network extends EventDispatcher { // 收到服務器下發(fā)消息 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帶上去,服務器會根據(jù)uid拿到用戶信息。
撲克牌對比邏輯到了斗地主最核心邏輯部分了,那就是撲克牌大小的對比,也是我們使用webassembly的地方。
webassembly對不了解webassembly的同學先簡單介紹一下webassembly,可以理解為:將其他的語言(比如C++,go,java等)寫的代碼,跑在瀏覽器上。其他基礎知識就不在這里提了哈,可以自行查閱。
外界看好wasm的優(yōu)勢在于快!雖然js有v8,但是相比較那些靜態(tài)語言老流氓們,還是有些差距的。目前wasm應用場景最多的應該在于音視頻的解析、字符串操作、大量數(shù)學計算等一些高cpu操作上。
我覺得wasm不僅僅有速度上的優(yōu)勢,還有代碼復用這個被忽視的特性。在游戲上,這個特性幫助會很大。
以我們這個斗地主為例,核心部分是撲克牌對比邏輯。這個邏輯,前端要用把,判斷是否可以出牌的時候,如圖
但是后端不能無腦信任前端的牌吧,后臺也必須得校驗一次。一份邏輯,寫一次總比寫兩次好吧,況且還是一個比較復雜的邏輯。wasm的出現(xiàn)解決了這種場景的痛點,主要也是游戲開發(fā)中,這種情況也比較多,很常見的就是碰撞檢測。
具體一份代碼是怎么用的,我們稍后再說,我們先把撲克牌對比的邏輯用C++寫出來,否則其他都是白搭。
如何對比因為比較簡單,我沒有去網(wǎng)上搜實現(xiàn),自己寫了一套,目前看來應該沒啥問題,是不是最優(yōu)思想不清楚。原理如下
我們先把撲克牌分類一下,如下圖:
對應的枚舉:
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& pukesA, std::vector & 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]); } }
格式化牌和分析牌類型這兩塊,也不復雜,稍微有些細節(jié),感興趣的話可以看,代碼都在puke-compare.h中
js調用c++函數(shù)代碼寫完了,服務器端ok了,我們就得讓前端能跑起來C++的代碼。借助emscripten,其實調用起來也挺方便的,這里沒有時間和篇幅說具體怎么弄的,但可以說的抽象一些。
js調用C++代碼有兩種方向
一種是直接調用C++函數(shù)
還有一種是在js環(huán)境下,new出C++對象,這個不好畫圖,我就不畫了哈
二者的區(qū)別主要也是寫法上的區(qū)別,只調用函數(shù)的方式控制力較弱;new對象的方式,控制能力強,但是如果設計的不好,容易玩壞,而且麻煩些。
注意要考慮垃圾回收,在js側new出來的C++對象,v8可不會幫你垃圾回收,得自己實現(xiàn)一個簡單的引用計數(shù)的垃圾回收(代碼在my_glue_wrapper.cpp中)。所以說,如果選擇new對象的方式,一定要考慮周全。
我們這里相當于兩者結合使用了,畢竟本來就是為了練手,涉及到webidl相關的知識(將C++對象,轉換為js可以理解的對象)。具體代碼在assembly下puke.idl和my_glue_wrapper.cpp中
webassembly這里,本來打算多寫點,但是發(fā)現(xiàn)不好下手,如果寫的詳細,內容會較多。感覺又能開一篇文章了,但最近實在是比較忙,能抽出空寫這兩篇已經(jīng)到極限了……不過現(xiàn)在網(wǎng)上webassembly相關的文章資料已經(jīng)很多了,感興趣的同學可以帶著一起看,應該就很有助于理解了。
思考寫這個游戲期間,因為不同于平時業(yè)務開發(fā),只考慮自己前端的那部分,這次從產品到前端后臺都是一個人,有一些非前端的感觸。
產品流程圖很重要,能提前理清楚一些邏輯坑點,防止無腦擼代碼然后返工。這里吃了不少虧
設計大大們是真的牛皮
聯(lián)調過程保證后端穩(wěn)定性,盡量少改代碼了,后臺重新編譯、重啟的成本高很多
時間關系,沒有弄單元測試,但能準備單元測試,還是要準備,很重要
撲克對比,是否可以引用配置的方式,這樣就可以很好的支持其他撲克模式的對比了
結尾終于到結尾了,感謝閱讀到這里的同學。這個游戲本來是一個無心之作,不過也起到了練手的作用。
兩篇文章更側重于思路和宏觀的一些東西,加上可能一些小坑。斗地主算是一個簡單的游戲,但是我低估了他完成基本閉環(huán)需要的時間,所以很多地方都在趕,如果發(fā)現(xiàn)有寫的不好的、考慮的不好的地方,歡迎斧正~
大家一起交流溝通~
AlloyTeam 歡迎優(yōu)秀的小伙伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)
文章版權歸作者所有,未經(jīng)允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/106224.html
摘要:原文從零到一,擼一個在線斗地主上篇作者背景朋友來深圳玩,若說到在深圳有什么好玩的,那當然是宅在家里斗地主了可是天算不如人算,撲克牌丟了幾張不全大熱天的,誰愿意出去買牌啊。 原文:從零到一,擼一個在線斗地主(上篇) | AlloyTeam作者:TAT.vorshen 背景:朋友來深圳玩,若說到在深圳有什么好玩的,那當然是宅在家里斗地主了!可是天算不如人算,撲克牌丟了幾張不全……大熱天的,...
摘要:由于公司項目轉型,需要創(chuàng)造一個小游戲平臺,需要使用一個比較成熟的前端游戲框架來快速開發(fā)小游戲。僅支持開發(fā)游戲,因為專注,所以高效。早在年的光棍節(jié)前一天晚上,這個游戲就誕生了。原型是一個之前很火的非常魔性的小游戲,叫尋找程序員。 showImg(https://segmentfault.com/img/bVMGY5?w=900&h=500); 寫在前面 實際上我從未想過我會接觸到H5小游...
摘要:小結使用深度優(yōu)先算法,我們能夠檢測性格測試游戲的邏輯正確性,相比以往課堂上的理論,在這里算是一個具體的應用場景吧。其實深度優(yōu)先算法的應用面也很廣,遲早還會再碰面的。 showImg(https://segmentfault.com/img/bVStEU?w=900&h=500); 寫在前面 在開始前想先說一下關于這個課題的感想——能學以致用是一件很快樂的事情。 深度優(yōu)先算法(簡稱DFS...
閱讀 2535·2021-07-26 23:38
閱讀 3435·2019-08-30 13:10
閱讀 2319·2019-08-29 18:33
閱讀 2325·2019-08-29 16:12
閱讀 992·2019-08-29 10:59
閱讀 1802·2019-08-26 17:40
閱讀 773·2019-08-26 11:59
閱讀 816·2019-08-26 11:41