原文: 從零到一,擼一個在線鬥地主(上篇) | AlloyTeam
做者:TAT.vorshen
背景:朋友來深圳玩,若說到在深圳有什麼好玩的,那固然是宅在家裏鬥地主了!但是天算不如人算,撲克牌丟了幾張不全……大熱天的,誰願意出去買牌啊。不過問題不大,做爲移動互聯網時代的程序猿,固然是擼一個手機在線鬥地主來代替實體牌了。html
github地址:https://github.com/vorshen/landlord前端
閱讀前注意:
本文分爲上下兩篇,本篇講準備工做以及前端一些佈局相關的知識;下一篇講webassembly實現核心邏輯和server端相關。c++
因爲源碼在github上所有都有,因此文章更偏向于思路的講解。git
業餘時間有限,遊戲樣式醜= =,有些細節也沒打磨,敬請諒解。不過仍是達到了閉環,線下開黑娛樂應該沒有問題。github
遊戲大概樣式web
typescript + canvas + webassembly + c++(server)
首先確定是Web的,人齊有個局域網server端啓動,而後QQ、微信、瀏覽器訪問,直接就開幹了啊。既然是Web的,那必須是typescript啊,我以爲寫過ts的,這輩子應該不會再想寫js了吧……算法
鬥地主做爲一個元素很少、沒炫酷場景的遊戲,其實dom徹底能夠吃得住。可是作個Web遊戲,不用個canvas做爲舞臺,總感受哪裏不對勁。因此最終咱們仍是用canvas來渲染。這裏咱們就沒有用成熟的渲染引擎了,鍛鍊鍛鍊本身。typescript
既然做爲練手做品,總要折騰點,webassembly做爲目前很火的技術,咱們固然要嘗試一下啦,因此遊戲的一些核心邏輯採用了webassembly實現,這裏會在下一篇詳細講解。canvas
既然是本身從零到一,產品設計開發都得是本身,咱們先簡單梳理一下游戲的流程。咱們這個鬥地主不一樣於QQ鬥地主,QQ鬥地主是隨機進入房間,沒法開黑。而咱們追求的是一塊兒玩,因此遊戲房間的概念是一大不一樣。瀏覽器
簡單列了一下咱們遊戲的流程:
傳統的鬥地主邏輯以下:
雖然這裏貼出來了,但本身真正開始寫的時候,壓根沒梳理,就是一把梭,上來就擼碼。結果發現了很多邏輯上的衝突點和細節點,鬥地主看起來是一個小遊戲,不過邏輯還蠻複雜的,再加上在線非單機,徹底低估了遊戲的複雜度,一把辛酸淚……
設計沒啥好說的,從網上找了幾個圖就看成基本的元素了(難看就難看了……沒辦法)
下面就正式開始了
首先鬥地主這個遊戲是橫屏的,這個蛋疼了,由於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鬥地主的房間頁:
咱們大體劃分一下模塊,如圖所示:
不考慮細節的狀況下仍是比較簡單的,能夠看出,主要就是六大區域:
咱們這就不考慮出牌特效啥的了(找幾個基礎的素材就要了我命了),若是用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的實現,很輕鬆,可是,咱們使用canvas渲染,該如何針對不一樣屏幕尺寸進行適配呢?
這裏有兩種大的考慮方向:
衆所周知咱們用原生canvas接口,繪製元素,都是用絕對定位的形式,不支持flex。看了下業界一些遊戲渲染引擎,alloyrender、erget、easelJS也都是用x,y座標控制顯示對象的位置。
個人理解是既然你採用canvas了,天然是會出現頻繁重繪,彈性佈局更偏向於靜止的頁面場景,對於遊戲上需求不大,不必花大功夫吃力不討好。不過咱們這個鬥地主是一個偏頁面靜止的遊戲,感興趣的同窗能夠嘗試嘗試,針對上面那五個模塊用固定大小+百分比的方式來實現一下彈性佈局。因爲時間和篇幅關係,這裏就不貼效果圖和代碼了。
這種方式的優點是能夠把屏幕使用率拉滿,也不會有變形;
劣勢就是太麻煩了,光是這五個區域的佈局還好,可是還涉及到區域裏面細節的時候,實在是hold不住了,因此我最終也沒有采用這種方式。若是有那些簡單的佈局場景,仍是能夠試試。
看名字就知道是採用「縮放」來抹平不一樣屏幕尺寸的差別了。怎麼縮放,也是有不少種方案,我羅列兩個我以爲比較好的,應該也是用的比較多的
二者的原理以下所示:
兩者的針對的場景也不太相同
「所有展現+黑邊」:全部內容都必須展現出來,黑邊能夠用大背景掩蓋住
「核心展現+無黑邊」:整個舞臺能夠很大,用戶只須要聚焦核心區域
綜上所述,咱們確定要採用的是第一種方式了
整個頁面不是很複雜,爲了練手,咱們也沒有用業界成熟的渲染引擎。可是總不能用canvas原生的寫法,因此首先咱們封裝了幾個基礎的組件
以上是此次遊戲中須要用到的渲染相關的基類,咱們具體的展現對象(撲克牌),或者容器(手牌)都是繼承它們,再進行一些擴充。具體的代碼github上都能看到。
下面用張圖表示一下整個項目中組件狀況
這裏假設咱們要正式開發一個遊戲,藉助渲染引擎,意味着不須要考慮base部分了。那麼大概流程是以下的。
流程基本上就是如此。
這裏咱們用頁面上最重要的一個組件爲例,講一下
BasePukesContainer是很是重要的一個組件,如其名,它是負責撲克牌展現的。玩家的手牌(HandPukes)、玩家出的牌(DesktopPukes)都是繼承於它,因此BasePukesContainer抽象就很重要了
首先,咱們肯定下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的位置,找到對應渲染的元素啊。
具體原理以下
(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前端工程師(社招)