javascript开发HTML5游戏--斗地主(单机模式part3)
最近学习使用了一款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实现网络对战版本的斗地主游戏。