最近學習使用了一款HTML5游戲引擎(青瓷引擎),並用它嘗試做了一個斗地主的游戲,簡單實現了單機對戰和網絡對戰,代碼可已放到github上,在此談談自己如何通過引擎來開發這款游戲的。
(點擊圖片進入游戲體驗)
前文鏈接:
javascript開發HTML5游戲--斗地主(單機模式part1)
javascript開發HTML5游戲--斗地主(單機模式part2)
本文章為第三部分內容,主要AI相關邏輯實現,參考文章斗地主ai設計。主要內容如下:
- 牌型判斷
- 牌型分析
- AI出牌與跟牌
- 出牌流程
- 勝利判斷
- 雜項
一、牌型判斷
斗地主ai設計文章中將牌型分為了11種,我對其中的三帶一、飛機帶翅膀、四帶二這種類型又細分為帶單牌或者帶對兩種,所以一共是14種類型:
單牌、對子、三根、三帶單、三帶對、順子、連對、三順(飛機不帶牌)、飛機帶單、飛機帶對、王炸、炸彈、四帶二、四帶兩對;
如何判斷
主要是用於玩家選中牌是否合法的檢測,根據牌數量和一些邏輯要判斷是比較容易的。比如一張只會是單牌,肯定是對的,兩張牌如果兩張大小一樣是對子,不一樣就是錯誤牌型,這樣一直往后判斷,順子子話就是判斷遞減,因為牌是有序的,所以如果是順子肯定都是每張都比下一張大1,但是還要五張牌以上,基本都是長度加邏輯的判斷,我們很容易得出傳入的一組牌是什么牌型。我把牌型判斷的代碼都寫在了Scirpts/logic/GameRule.js下,簡單用常量列舉出以上幾種牌型,主要看typeJudge這個方法,傳入一組牌,返回判斷結果對象,錯誤牌型返回null,結果對象有三個屬性分別為:
- cardKind:牌型
- val:牌型大小,順子連對之類都是以最大牌為牌型大小,三帶一之類都是以三根的牌大小為牌型大小
- size:記錄這組牌的長度
大小比較
除了炸彈王炸以外,其他牌必須是同牌型而且數量相等才能比較,炸彈可以大過王炸以外的牌型,同是炸彈還是比大小,王炸大任何牌型。這個邏輯就用在跟牌的時候,判斷玩家要出的牌必要大過上家才能出牌。
二、手牌分析
斗地主AI最為復雜就是出牌拆牌問題,如果AI是有什么出什么,那AI很難獲勝,后面牌就零碎的出不完了。斗地主不僅是自己要盡快出完牌,在對手快贏時也要盡量阻止對手贏。在斗地主ai設計中手牌分析一塊給我們理清了思路,剩下的就靠我們自己去用代碼實現。我在游戲中也沒有很完美實現作者所描述的,但也是可以進行游戲了,牌不好的話還是會輸給AI的。一開始看我也是一頭霧水,但是這些邏輯有思路了一步步來都是可以實現的。
按照文章的邏輯,AILogic對象構造的時候將玩家的手牌進行分析(見該類的analyse方法),將各個牌型存進對象的幾個屬性中,跟文章順序有點不同,我分析的順序如圖:

一手牌找出王炸,剩下的再去找出炸彈,然后再去找出三順(飛機不帶牌),以此類推。在AILogic類中,用了八個屬性(都是數組)來存放這些牌型,分析之后我們很容易得到這個玩家的手牌的情況。如果你看過代碼,嘗試在單機游戲發牌完后,在瀏覽器開發者工具的控制台中輸入以下代碼:
var ai = new qc.landlord.AILogic(G.ownPlayer); ai.log();
我拿着左邊圖的手牌,分析會得到右邊的日志信息。會看到打印出以下內容:


當然以后我可以很便利的使用這個來完成一個托管功能,其實也就把玩家也當成一個AI處理。如果你把上面代碼的ownPlayer改成leftPlayer就可以偷偷看到AI的手牌情況啦。
可能會有人有疑問那些三帶一之類的牌型怎么沒了,這里分析其實只是要知道玩家有哪些基本的牌型,合理的分配開,讓AI盡可能快出完。至於類似三帶一、四帶二之類的就稱之為組合牌型,可以在要出牌的時候進行組合,比如在上家出3334這樣的牌的時候,AI一般是先找符合條件的三根,比如有777,再去找哪個合適的單根來帶,找不到單牌,可以考慮去拆對,想想我們玩斗地主的時候也是如此吧。
三、AI出牌與跟牌
出牌
AI出牌,按照文章中的出牌原則來走,總的來說就是對手牌大於2張從小往大出,對手牌小於等2,從大往小出,盡量不出單。通過上面的手牌分析,大概思路是這樣的:比如我們知道手上最小的那張是黑桃3(由於手牌被排序過,最后一張肯定是最小),然后拿着這張牌去找在我們分析的哪個牌型里,找到了比如是一對3,出這個對子;這里我還加了三帶一出法,比如找到是單牌黑桃3,可以在判斷下有沒有三根可以出,有就組成一個三帶一打出去。
跟牌
先給個圖讓大家看看吧:

每當一個玩家打出牌后,我們都要把出的牌型記錄下來,還有最后是哪個玩家出的牌,這些都是AI用於判斷出牌的信息,也就是在AILogic.follow方法的三個參數:
- 當前牌面最大牌
- 當前最大牌出牌的玩家是否是地主
- 當前出牌的玩家剩余手牌的數量
跟牌就是出跟上家出牌一樣牌型的牌,把傳進方法的牌型,用switch判斷對號入座,這里有14種牌型,就可以分為14個case塊,剩下的就是一塊一塊按照跟牌的原則去完善就ok了。像王炸這樣直接返回null了,這樣就是這個不出。當然炸彈算是特殊情況,一般是不出的,我們可以判斷AI在無牌可跟情況下,給出一些特殊情況觸發出炸彈,比如自己只剩兩手牌,當手上就一個炸彈一個順子的時候,相信要是我們自己玩的話肯定就很爽的炸下去,然后贏了,雖然存在被炸的風險。這里我就不多貼代碼了,有興趣可以到我的github上看看,這部分寫的比較雜亂。
四、出牌流程
在完成牌型判斷和AI出牌跟牌算法后,我們就可以繼續完善整個出牌的流程。繼搶地主流程完成之后,會通知地主開始出牌,所謂出牌也個輪換的過程,跟搶地主是類似的。在Scripts/ui/landlordUI.js中寫了個playCard方法來實現玩家出牌,傳入的是一個player,這里看下這段代碼吧:
1 /** 2 * 輪換出牌 3 * @param {Player} player 玩家 4 */
5 LandlordUI.prototype.playCard = function (player){ 6 var self = this; 7 if(player.isAI){ 8 console.info(player.name + '出牌中'); 9 var ai = new qc.landlord.AILogic(player); 10 //ai.info();
11 //根據下家是否是AI判斷他的出牌區
12 var area = player.nextPlayer.isAI ? self.rightCards : self.leftCards; 13 for (var i = 0; i < area.children.length; i++) {//清空
14 area.children[i].destroy(); 15 } 16 //AI出牌
17 self.game.timer.add(1000, function(){ 18 var result = null; 19 if(!self.roundWinner || self.roundWinner.name == player.name){//如果本輪出牌贏牌是自己:出牌
20 self.cleanAllPlayArea(); 21 result = ai.play(window.playUI.currentLandlord.cardList.length); 22 } else { //跟牌
23 result = ai.follow(self.winCard, self.roundWinner.isLandlord, self.roundWinner.cardList.length); 24 } 25 if(result){ 26 for (i = 0; i < result.cardList.length ; i ++) {//將牌顯示到出牌區域上
27 var c = self.game.add.clone(self.cardPrefab, area); 28 c.getScript('qc.engine.CardUI').show(result.cardList[i], false); 29 c.interactive = false; 30 for (var j = 0; j < player.cardList.length; j ++) {//刪除手牌信息
31 if(player.cardList[j].val === result.cardList[i].val 32 && player.cardList[j].type === result.cardList[i].type){ 33 player.cardList.splice(j, 1); 34 break; 35 } 36 } 37 } 38 if(result.cardKind === G.gameRule.BOMB || result.cardKind === G.gameRule.KING_BOMB){//出炸彈翻倍
39 var rate = parseInt(window.playUI.ratePanel.text); 40 window.playUI.ratePanel.text = (rate * 2) + ''; 41 } 42 self.roundWinner = player; 43 delete result.cardList; 44 self.winCard = result; 45 window.playUI.reDraw(); 46 } else { 47 self.game.add.clone(self.msgPrefab, area); 48 } 49 if(player.cardList.length === 0){ 50 self.judgeWinner(player); 51 return; 52 } 53 //繼續下家出牌
54 self.playCard(player.nextPlayer); 55 }); 56 } 57 else { 58 console.info('該你出了'); 59 if(self.getReadyCardsKind()){ 60 self.playBtn.state = qc.UIState.NORMAL; 61 } else { 62 self.playBtn.state = qc.UIState.DISABLED; 63 } 64 self.playBtn.visible = true; 65 self.warnBtn.visible = true; 66 self.cleanPlayArea(); 67 if(!self.roundWinner || self.roundWinner.name == player.name){//如果本輪出牌贏牌是自己:出牌,不顯示不出按鈕
68 self.winCard = null; 69 } else { 70 self.noCardBtn.visible = true; 71 } 72 self.promptTimes = 0; 73 //准備要提示的牌
74 var ai = new qc.landlord.AILogic(G.ownPlayer); 75 self.promptList = ai.prompt(self.winCard); 76 } 77 }
簡單解釋下
- 根據傳入的是否是AI玩家,是分析AI玩家的牌並根據邏輯打牌,這里是每次出牌都重新分析,不是就顯示玩家操作出牌的按鈕;
- 根據當前最大牌的出牌者是否是自己(我給每個玩家都取名,作為標識,根據名字判斷)來確定是出牌還是跟牌,因為AI出牌與跟牌調用不同的方法,玩家在出牌時不會顯示【不出】按鈕,第一輪出牌是沒有最大牌的,直接由地主出牌;
- 每當玩家有出牌都將牌顯示到自己的出牌區域上,不出牌要在出牌區上顯示“不出”,同時要扣除手牌,扣除后如果手牌數為0,就算結束了,可以進入勝利判定;
- 每次出完牌后,由於每個玩家player都有下一家的引用,再次調用playCard(player.nextPlayer)就可以進入下一家出牌,這里為了讓AI會有一個間隔出牌效果,添加了個計時器,隔1秒后再進入出牌;
- 任何玩家出牌如果是炸彈(含王炸)將倍數翻倍。
出牌流程就是這樣子一直輪換,直到有一家把牌出完,這樣就可以進入最后的勝利判定。
五、勝利判定
勝利判定也是做一些操作,在一局游戲結束后,要做的事情邏輯實現並不復雜,我這里就歸納下代碼中做了些什么:
- 顯示沒出完牌玩家剩下的手牌,單機模式下就是顯示兩個AI玩家剩下的手牌
- 計算分數:底分*倍數,地主還需要再翻一倍
- 根據玩家勝負顯示“你贏了”或者“你輸了”
- 顯示【開始】按鈕
這里提下分數的保存問題,引擎為我們也提供了個便利的緩存方法。在游戲對象game下可以獲取到可以storage(點擊看文檔)對象,該對象可以幫我們將信息存到瀏覽器的緩存中,用的是我們熟知的key/value形式,簡單易用。在Player類中可以看到以下代碼:
Object.defineProperties(Player.prototype, { score: { get: function(){ return this._score; }, set: function(v){ this._score = v; var storage = G.game.storage; storage.set(this.name, v); storage.save(); } } });
這樣就可以很便利的保存分數,大家也可以在進入單機模式的按鈕時間中發現用改方法提取緩存中的分數信息,當然找不到就給玩家默認500的分數。
六、雜項
寫到這里整個單機模式的斗地主就算是完成了,小弟第一次做游戲,也是第一次發博客,不足之處各位讀者多包涵。最后說兩個游戲中增加體驗的內容:
拖動選牌
一般我在玩紙牌類的游戲的話,很喜歡這種拖動的方式,刷的過去一排牌選上來了,我在游戲中也添加了這個功能,具體怎么實現的,繼續往下看吧。
青瓷引擎為我們提供了一種面向組件式編程(點擊看文檔),我們可以將一個腳本掛載在任意的一個游戲節點下,在整個游戲開發中很多地方都用到了這種方式。
我編寫了一個CardlistUI.js掛載在玩家手牌的父節點上,根據拖放的開始和結束坐標,計算中間有哪些紙牌,是的話就選中上來。這里值得一提的是,我們需要把掛載腳本的節點的屬性
Interactive打上勾,否則該節點只是個普通節點是無法進行拖放、點擊等操作的,如圖:

鼠標右鍵點擊出牌
在PC上玩斗地主的時候呢,經常選完牌直接右擊就出牌啦,不用再去找那個【出牌】按鈕點着出,確實也是個不錯的體驗。
首先在瀏覽器上右擊就會出現右擊菜單,得先屏蔽掉它,引擎在這事上好像還沒有支持,自己找了份代碼,如下:
1 /** 屏蔽瀏覽器右擊菜單*/ 2 if (window.Event) 3 window.document.captureEvents(Event.MOUSEUP); 4 function nocontextmenu(event){ 5 event.cancelBubble = true; 6 event.returnValue = false; 7 return false; 8 } 9 function norightclick(event){ 10 if (window.Event){ 11 if (event.which == 2 || event.which == 3) 12 return false; 13 } 14 else 15 if (event.button == 2 || event.button == 3){ 16 event.cancelBubble = true; 17 event.returnValue = false; 18 return false; 19 } 20 } 21 window.document.oncontextmenu = nocontextmenu; // for IE5+ 22 window.document.onmousedown = norightclick; // for all others 23 /** 屏蔽瀏覽器右擊菜單 end*/
在右鍵點擊事件上,輸入交互(點擊看文檔)還是有不錯的支持,這里利用出牌按鈕是否顯示判斷已經輪到自己出牌了,代碼如下:
1 var self = this, input = self.game.input; 2 this.addListener(input.onPointerDown, function(id, x, y){ 3 input = self.game.input; 4 var pointer = input.getPointer(id); 5 if (pointer.isMouse) { 6 if (pointer.id === qc.Mouse.BUTTON_RIGHT) { 7 if(self.playBtn.visible){ 8 self.playEvent(); 9 } 10 } 11 } 12 }, self);
出牌提示
一開始我用AI出牌/跟牌算法來做提示,發現很不好用,因為AI跟牌並不是任何情況都會出牌的,點着提示不跳出提示牌愣在那也是很不合理的。我之前也玩過qq的斗地主,跟牌提示的 話不僅僅是提示一種牌,而是將所有符合條件的牌依次顯示,比如當前最大是張K,我手上有張2和一對A,沒有大小王,點一下【提示】2被選上來,再點一下第一張A被選上來,原來的2 又回去了,再次點擊又變回2了。提示是根據匹配度來的,並不考慮現在需不需要出或者會不會拆牌的問題,比如單根,就從能大過上家的單牌從小到大開始找,然后再去對子里從小到大 找,然后是三根,然后是拆炸彈,最后是出炸彈,沒有了之后從頭開始,如果玩家沒有任何牌可以出,直接幫做【不出】操作。
在上面出牌的代碼中,輪到玩家的代碼最后有一段這樣的代碼:
self.promptTimes = 0; //准備要提示的牌 var ai = new qc.landlord.AILogic(G.ownPlayer); self.promptList = ai.prompt(self.winCard);
這個也是依賴於AI手牌分析的,具體方法各位應該看代碼會更清楚些。每次輪到玩家就將這個提示牌的數組promptList保存起來,每次玩家點擊【提示】,先用promptList的長度對 promptTimes取模,用這個結果去找promptList中的元素,完了之后要把promptTimes 加 1。把對應的牌選出來。這樣就達到循環提示出牌的效果,跟牌依然是根據牌型對號入座,一 個個去寫,出牌的話我采用了比較偷懶的做法,直接從小到大提示,比如有牌是這樣228885,先提示一張5,然后888,然后22,再點又一張5了,如此循環。
總結
小弟之前都是做Java web開發的,前端也會做,對於就是js還算是比較熟悉的。畢業一年多吧,也不算工作經驗豐富,一開始要做游戲還真沒什么概念,把界面布局做好后,想着這個AI設計也是很迷茫的,不過網上很多大神寫的文章都給了我很多思路,跟着他們講解的思路走,一步步來,把復雜的分成一小塊一小塊的完成了,最后問題總會解決的。做這個斗地主單機版了花了一周左右吧,這其中除了引擎提供了很好的支持外,青瓷引擎中的文檔也給了諸多幫助。學習新的東西,我都是把內容都過一遍,大概知道能做啥,有個印象,有demo的話也去看看,在用的時候就知道我大概什么事情,再去翻文檔,或者去請教別人,用多了自然也就熟悉了。雖然說最后游戲是可以玩了,代碼還是很雜亂的,存在許多不合理的,當然也有不少的bug,圖片都是網絡上找的,整體游戲界面也比較粗糙,自己能力還是需要許多提升的,這個游戲可以優化的地方還有很多。
單機模式的游戲就和大家分享到這里,后面我還會跟大家分享結合socket.io實現網絡對戰版本的斗地主游戲。


