最近在使用Cocos Creator做一款四人斗地主的手机游戏,半成品(仅前端)代码附在最后,仅供参考。游戏中的单机(人机)模式以及游戏过程中的托管都需要出牌算法的设计,因此借这篇博客梳理一下现有的一些思路。
首先,明确“AI出牌算法的目的是能够模拟人打牌时的思考过程”,所以我根据平时自己玩斗地主的游戏经验,将思考过程大致分成了三个部分:“看自己手中的牌”、“看别人打出的牌”和“我该怎么出牌”。
如何让电脑知道自己拿了一手什么牌呢?我这里所说的“知道”并不只是让电脑获取到点数、花色这样的数据,然是在此基础上通过牌型分析生成一套出牌方案。这里,我们需要引进一个“手数”的概念,就是指在不被压的前提下,需要多少次出牌机会才能把手中的牌打光。由于不同的出牌方案,同样的一手牌,可能有好几个不同的手数。
下面这段代码并不是完整代码,是我从项目中截取部分直接复制过来的,写的有些粗糙,为的是能表达大概意思,具体的牌型判断规则可参考四人斗地主。
/* 最后的数字代表此类牌型可能打出的张数
判断牌组的类型:
-1: 不符合逻辑的牌
1: 单牌 ------------------------------------------------------------------------------1
2: 2张相同数字的牌(对子)---------------------------------------------------------------2
3: 3张相同数字的牌 ---------------------------------------------------------------------3
4: 4张相同数字的牌(炸弹)---------------------------------------------------------------4
5: 5张相同数字的牌(炸弹)---------------------------------------------------------------5
6: 6张相同数字的牌(炸弹)---------------------------------------------------------------6
7: 7张相同数字的牌(炸弹)---------------------------------------------------------------7
8: 8张相同数字的牌(炸弹)---------------------------------------------------------------8
9: 2张大王带2张小王(王炸)--------------------------------------------------------------4
10: 5张或5张以上数字连续的牌(顺子)-------------------------------------------------------5~13
11: 3张相同数字的牌带一对(三带二)--------------------------------------------------------5
12: 3个或3个以上连续的对子(姐妹对)-------------------------------------------------------6,8,10,……,26
13: 2个或2个以上连续的3张相同数字的牌(飞机)-----------------------------------------------6,9,12,……,30
14: 2个或2个以上连续的3张相同数字的牌带同样数量的连续的对子(飞机带翅膀)------------------------10,15,20,25,30
*/
// 判断单张
if (choose.length == 1){ // choose 代表被选中的牌,数组型,以下皆是
return 1;
}
// 判断对子
if (choose.length == 2){ // Math.floor(Poker._player1[c_player[i]]/10) 代表player1手中被选中的牌,i 代表被选中的牌中从小到大第i张,从0开始,以下皆是
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[0]]/10) < 14) {
return 2;
}
}
// 判断三张相同的牌
if (choose.length == 3){
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[0]]/10) < 14) {
return 3;
}
}
// 判断四张炸弹
if (choose.length == 4) {
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[0]]/10) < 14) {
return 4;
}
// 判断王炸
if (Math.floor(Poker._player1[c_player[0]]/10) == 14 && Math.floor(Poker._player1[c_player[1]]/10) == 14 && Math.floor(Poker._player1[c_player[2]]/10) == 14 && Math.floor(Poker._player1[c_player[3]]/10) == 14) {
return 9;
}
}
// 判断五张炸弹or三带二
if (choose.length == 5) {
// 判断五张炸弹
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10)) {
return 5;
}
// 判断三带二(对子不能是大小怪)
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10) && Math.floor(Poker._player1[c_player[3]]/10) != 14) {
return 11;
}
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10) && Math.floor(Poker._player1[c_player[0]]/10) != 14) {
return 11;
}
}
// 判断六张炸弹
if (choose.length == 6) {
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10) && Math.floor(Poker._player1[c_player[4]]/10) == Math.floor(Poker._player1[c_player[5]]/10)) {
return 6;
}
}
// 判断七张炸弹
if (choose.length == 7) {
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10) && Math.floor(Poker._player1[c_player[4]]/10) == Math.floor(Poker._player1[c_player[5]]/10) && Math.floor(Poker._player1[c_player[5]]/10) == Math.floor(Poker._player1[c_player[6]]/10)) {
return 7;
}
}
// 判断八张炸弹
if (choose.length == 8) {
if (Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[2]]/10) == Math.floor(Poker._player1[c_player[3]]/10) && Math.floor(Poker._player1[c_player[3]]/10) == Math.floor(Poker._player1[c_player[4]]/10) && Math.floor(Poker._player1[c_player[4]]/10) == Math.floor(Poker._player1[c_player[5]]/10) && Math.floor(Poker._player1[c_player[5]]/10) == Math.floor(Poker._player1[c_player[6]]/10) && Math.floor(Poker._player1[c_player[6]]/10) == Math.floor(Poker._player1[c_player[7]]/10)) {
return 8;
}
}
// 判断顺子
if(choose.length > 4) {
this.result = [];
this.answer = [];
for (var i = 0; i < choose.length - 1; i ++) {
if (Math.floor(Poker._player1[c_player[i]]/10) == Math.floor(Poker._player1[c_player[i + 1]]/10) - 1 && Math.floor(Poker._player1[c_player[i + 1]]/10) < 14) {
this.result[i] = 1;
}
else {
this.result[i] = -1;
}
}
// 生成标准答案参照数组
for (var i = 0; i < choose.length - 1; i ++) {
this.answer[i] = 1;
}
// 比较两数组是否相等
if (this.result.toString() == this.answer.toString()) {
return 10;
}
}
// 判断姐妹对(对子不能有大小怪)
if (choose.length % 2 == 0 && choose.length > 4) {
this.result = [];
this.answer = [];
for (var i = 0; i < choose.length - 2; i ++) {
if (Math.floor(Poker._player1[c_player[i]]/10) == Math.floor(Poker._player1[c_player[i + 2]]/10) - 1 && Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[i + 2]]/10) < 14) {
this.result[i] = 1;
}
else {
this.result[i] = -1;
}
}
// 生成标准答案参照数组
for (var i = 0; i < choose.length - 2; i ++) {
this.answer[i] = 1;
}
// 比较两数组是否相等
if (this.result.toString() == this.answer.toString()) {
return 12;
}
}
// 判断飞机
if (choose.length % 3 == 0 && choose.length > 3) {
this.result = [];
this.answer = [];
for (var i = 0; i < choose.length - 3; i ++) {
if (Math.floor(Poker._player1[c_player[i]]/10) == Math.floor(Poker._player1[c_player[i + 3]]/10) - 1 && Math.floor(Poker._player1[c_player[0]]/10) == Math.floor(Poker._player1[c_player[1]]/10) && Math.floor(Poker._player1[c_player[1]]/10) == Math.floor(Poker._player1[c_player[2]]/10) && Math.floor(Poker._player1[c_player[i + 3]]/10) < 14) {
this.result[i] = 1;
}
else {
this.result[i] = -1;
}
}
// 生成标准答案参照数组
for (var i = 0; i < choose.length - 3; i ++) {
this.answer[i] = 1;
}
// 比较两数组是否相等
if (this.result.toString() == this.answer.toString()) {
return 13;
}
}
// 判断飞机带翅膀(翅膀不能有大小怪)
if (choose.length % 5 == 0 && choose.length > 5) {
this.double = [];
this.treble = [];
// 判断重复元素
for (var i = 0; i < choose.length; i ++) {
var count = 0;
for (var j = i; j < choose.length; j ++) {
if (Math.floor(Poker._player1[c_player[i]]/10) == Math.floor(Poker._player1[c_player[j]]/10)) {
count += 1;
}
}
// 重复3次的元素
if (count == 3) {
this.treble.push(Math.floor(Poker._player1[c_player[i]]/10));
}
// 重复2次的元素
if (count == 2) {
this.double.push(Math.floor(Poker._player1[c_player[i]]/10));
}
}
this.result1 = [];
this.result2 = [];
this.answer1 = [];
this.answer2 = [];
for (var i = 0; i < this.treble.length - 1; i ++) {
if (this.treble[i] == this.treble[i + 1] - 1) {
this.result1[i] = 1;
}
else {
this.result1[i] = -1;
}
}
for (var i = 0; i < this.double.length; i ++) {
if (this.double[i] < 14) {
this.result2[i] = 1;
}
else {
this.result2[i] = -1;
}
}
// 生成标准答案参照数组
for (var i = 0; i < this.treble.length - 1; i ++) {
this.answer1[i] = 1;
}
for (var i = 0; i < this.double.lengt; i ++) {
this.answer2[i] = 1;
}
// 比较两数组是否相等
if (this.result1.toString() == this.answer1.toString() && this.result2.toString() == this.answer2.toString() && this.double.length == this.treble.length * 2) {
return 14;
}
}
根据不同牌型的判断,可以初步产生一些出牌方案,并用刚才提到的手数加以分组区分。
例如:手牌为 AA22334677889999 10 10 10 10 J QQQ 大王 (共25张)
方案一 AA2233+4+6+77+88+9999+10 10 10 10+J+QQQ+大王,手数为10
方案二 AA2233+4+大王+6789 10+789 10 J+99QQQ+10 10,手数为7
等等。
初步方案的生成往往是在拿到牌的第一时间进行的,在叫地主开始之前完成。对于叫地主,目前我的想法是,需要对不同的牌型附加不同的“价值”,比如 五张的炸弹 必定比 四张的炸弹 更有价值,但 四张大小王的王炸 价值最高,当价值累加到一定程度便开始叫分,牌的价值越高,叫得分越高。另外,还需要结合手数,手数少,价值高,叫分高。这里,我还有一个想法,让电脑对拿到底牌后的手数进行预测,如果拿到底牌能让手数明显减少,那么叫高分。至于如何预测,没有任何想法,望大神指点。
我们在第一步中生成的方案是需要不断调整改善的,而改善的依据就是别人打的牌。一般来说,在别人打出牌之后,获取每张牌的点数花色牌型,然后在自己的手牌中寻找合适的牌打出并且确定出牌方案。这样的做法就是典型的“有牌必跟”,显然不符合之前说的“像人一样思考”,因此我认为在获取别人打出的点数花色牌型后,还应该让电脑“看到”这些数据背后隐藏的含义。
我先举些例子,用这些例子说明我所理解的“分析别人的牌”。比如,作为农民的你打出一张Q,地主不要,那么你可以认为地主没有比Q大的牌;比如,作为农民的你看到地主只剩2张牌,那么你一定会尽可能不打对子;再比如,作为农民的你想把牌权交给另一名农民伙伴,你打出了点数很小的对子,但队友却不要,这时你可以判断为你的队友没有对子这一牌型。
那么,如何让电脑也明白这些呢?我的想法是,为除AI以外的三个玩家,分别建立不同的数组,把隐藏含义作为元素加以储存。比如,每当地主打出一次对子,就执行一次 “dizhu[i] ++”,每当农民队友打出一次顺子,就执行一次 “nongmin[j] ++”,i和j分别为牌型对应的数字,当作为农民的AI出牌时,便会选择在 dizhu[] 中值较小的、在 nongmin[] 中值较大的牌型出牌。
然而,随着游戏进度的改变,当“dizhu[i] * 此牌型对应张数”的累加 大于 某一个值时,开始根据剩余牌数进行出牌方案的调整。( “nongmin[]” 同理)
当然,其中还应该有一个贯穿整个游戏的记牌数组,每当别人打出一张牌,对应记牌数组的值就发生变化,并由此调整自己手牌中牌型的价值。
这一步是最后一步,也是最重要的一步,这一步决定了最后的执行结果“出牌”或者“不出”。从我的游戏经验来看,平时我们玩的斗地主游戏,只要一托管,仿佛就将“不出”这个选项抹去了,直到真的没牌出。托管不仅意味着把一手好牌打成烂牌的“神操作”,往往还伴随着队友的无奈甚至指责。
在我看来,“不出”才是斗地主的精髓所在。什么情况下,会选择“不出”呢?首先,是该出牌但没牌出。第一种“没牌出了”,这里面包括没有同样的牌型以及有同样的牌型但没别人大,这些都可以依靠牌型分析解决。第二种“有牌但不愿意出”,这里面包括相同牌型有牌不出以及不同牌型有牌不出(炸弹),至于怎么判定,至今还没有想法。其次,是不该出牌。比如农民队友正在向地主“发起猛攻”,AI就该选择“不出”,等着躺赢。这种情况,我认为应该依靠区分农民和地主,并结合牌的点数大小来判断,应该不难实现。
到这里,我想更多的是为电脑增加策略性,也是团队合作的体现。我的想法是,在不同位置采用不同的游戏策略,这里说的位置指的是相对地主而言的相对位置。地主的上家,主要负责垫牌,因此选择出牌方案时,应该先打出价值高的牌型,尽可能地“消耗”地主,牺牲自己降低地主手牌的总体价值。地主的下家,主要负责接牌,用价值最高的牌型与地主争抢牌权,得到牌权后,用价值最低的牌助攻队友。地主的对家,主要负责转承起合,能出则出。而地主的关键,则是要忍,适当的“不出”可以迷惑对手反而能大大增加胜率。
因此,算法编写方面,可能不同位置需要编写不同的框架,而地主则着重在于 3.1 该不该出牌 的判定。
好了,以上均为个人观点,程序编写也只进行到一半,有不足之处非常欢迎指出。
附:代码