目录
前言
沈阳航空航天大学使用的二打一扑克示例代码来源于由哈尔滨理工大学计算中心梅险老师及其博弈研究组开发维护的二打一程序,更新日期为2015年4月6日,距今10年有余。其在强度上落后于大部分现有程序,但它逻辑清晰,结构明确,便于拓展,适合初学者快速学习和使用。
一、概述
1.1项目背景
二打一扑克牌,就是我们常玩的斗地主。作为计算机博弈项目,它的规则如下:
1)游戏人数:3人。
2)游戏牌数:54张,带大小王,一人17张,三张作为底牌,地主未确定千万家不能看到底牌。
3)出牌规则:
1.发牌:一副牌54张,带大小王,一人17张,三张作为底牌,地主未确定玩家不能看到底牌。
2.叫地主(叫分玩法):叫分按出牌的顺序轮流进行,叫牌时可以选择叫分,或“不叫”。叫分最高者当选地主、最高叫分为本局底分。
3.如果都选择不叫,则本局流局,直接结束并进入下一局游戏。
4.出牌:由地主先出牌,然后按逆时针依次出牌,轮到玩家跟牌时,玩家可以选择“不出”或者出比上一家打的牌。
5.胜负判定:地主打完牌为地主获胜,某一个农民打完牌则为所有农民获胜。
4)牌型:
火箭:即双王(大王和小王),最大的牌
炸弹:四张相同的数值牌(如四个7)
单牌:单个牌(如红桃5)
对牌:数值相同的牌(如梅花4加方块4)
三张牌:数值相同的三张牌(如3个J)
三带一(三带二):数值相同的三张牌+一张单牌或一对牌。例如:333+6,333+99
单顺:五张或更多的连续单牌。不包括2和大小王(如34567、10JQK1)
双顺:三对或更多的连续对牌。不包括2和大小王(如334455、JJQQKK11)
三顺:两个或更多的连续三张牌。不包括2点和双王(如333444,333444555)
飞机带翅膀:三顺+同数量的单牌或对牌(如333444+7+9、333444+66+77)
四带二:四张数值相同的牌+两个单牌或对牌(如5555+3+8、5555+44+77)(不是炸弹)
5)牌型大小:
1.火箭最大,可以打任意其他的牌。
2.炸弹比火箭小,比其他的牌大。都是炸弹时牌牌的分值比大小。
3.除火箭和炸弹外,其他牌必须要牌型相同且张数相同才能比大小。
4.单牌按分值比大小,依次是大王>小王>2>1>K>Q>J>10>9>8>7>6>5>4>3。
5.对牌,三张牌都是按比分值比大小。
6.顺子按最大的一张比大小。
7.飞机带翅膀和四带二按其中的三顺和四张部分比较大小,带的牌不影响大小。
8.注意:以上牌型比较均不分花色。
6)积分规则:
1.基础分(记为N):地主叫的分数,即三人叫分最大的。取值为1,2,3
2.春天:地主所有牌出完,而其余两家一张未出。
3.反春天:农民其中一家先出完牌,而地主只出过一手牌。
4.最终分数=2 × (N+(王炸+炸弹数+春天+反春天) × N)
注意这个最终分数计算方式和平时的加倍不同,更加平缓,主要是为了防止赢的太大。
5)牌的表示:
每张牌有自己对应的编号,在实际运行中,除了王,用牌的编号整除以4就是这张牌的大小(不等于牌值,大部分情况下加三等于牌值)。
例如:25号牌,25/4=6;26号牌,26/4=6;28号牌,28/4=7。
查表发现25,26号牌值相等,都是6+3=9。28号牌牌值是7+3=10。
1.2代码功能概述
本项目的核心是通过对自己的手牌进行评分,进而得到当前情况下应当的叫分或出牌策略。在叫分时,主要查看自己的王、炸弹、大牌数量来决定叫分;在出牌时,先筛选出所有能管上对手的出牌方式,再对剩下的牌进行评分,哪种出牌方式剩下的牌更好,就按照哪种方式出牌。
二、代码结构
2.1.头文件
代码如下:
#include <stdio.h>
#include <string.h>
#include <iostream>
using namespace std;
正常c++,没什么好说的。
2.2.宏定义
代码如下:
#define kPlaMax 500
#define kPlayerName "参赛选手名称"
kPlaMax是指在对手出牌后,我方可能的应对方法的种数。
kPlayerName是队名随便改。
2.3.结构体定义
struct Ddz
{
int iStatus; //引擎状态-1错误,0结束,1开始
char sCommandIn[80]; //通信输入内容
char sCommandOut[80]; //通信输出内容
int iOnHand[21]; //手中牌(所有值初始化为-1)
int iOnTable[162][21]; //以出牌数组(所有值初始化为-2)每行是一手牌,以-1结尾,Pass记为-1
int iToTable[21]; //要出的牌
char sVer[80]; //协议版本号
char sName[80]; //参赛选手称呼
char cDir; //玩家方位编号
char cLandlord; //地主玩家方位编号
char cWinner; //胜利者方位编号
int iBid[3]; //叫牌过程
int iBmax; //当前叫牌数,值域{0,1,2,3}
int iOTmax; //当前出牌手数
int iRoundNow; //当前局次
int iRoundTotal; //和总局数
int iTurnNow; //当前轮次
int iTurnTotal; //总轮数
int iLevelUp; //晋级选手数
int iScoreMax; //转换得分界限
int iVoid; //闲暇并行计算参数
int iLef[3]; //本局底牌
int iLastPassCount; //当前桌面连续PASS数(值域[0,2],初值2,正常出牌取0,一家PASS取1,两家PASS取2)
int iLastTypeCount; //当前桌面牌型张数(值域[0,1108],初值0,iLastPassCount=0时更新值,=1时保留原值,=2时值为0)
int iLastMainPoint; //当前桌面主牌点数(值域[0,15],初值-1,iLastPassCount=0时更新值,,=1时保留原值,=2时值为-1)
int iPlaArr[kPlaMax][21]; //己方多种出牌可行解集(各出牌解由牌编号升序组成-1间隔,-2收尾)
int iPlaCount; //己方多种出牌可行解数量(值域[0,kPlaMax-1])
int iPlaOnHand[21]; //己方模拟出牌后手牌编码
};
整个代码都是基于这个结构体运行。
对于这些结构体成员,重要的有以下几个:
成员变量名 | 数据类型 | 含义 |
---|---|---|
sCommandIn[80] | char | 通信输入内容,即从外部接收到的用于控制游戏流程、传递信息等的指令或消息。 |
sCommandOut[80] | char | 通信输出内容,是程序向外部发送的反馈信息、出牌指令等内容。 |
iOnHand[21] | int | 玩家手中持有的牌,所有值初始化为 -1,非 -1 的值代表具体的牌编号。 |
iOnTable[162][21] | int | 桌面上已出的牌数组,所有值初始化为 -2,每行代表一手牌,以 -1 结尾,若玩家选择 Pass 则记为 -1。 |
iLastPassCount | int | 当前桌面连续出现玩家选择“PASS”(不出牌)的次数。正常出牌时置为 0,一家 PASS 则为 1,两家 PASS 为 2。 |
iLastTypeCount | int | 当前桌面上所出的牌型对应的牌的数量。当 iLastPassCount 为 0(正常出牌)时更新该值,为 1 时保留原值,为 2 时值为 0。 |
iLastMainPoint | int | 当前桌面上所出牌型的主牌点数。当 iLastPassCount 为 0 时更新该值,为 1 时保留原值,为 2 时值为 -1。 |
iPlaArr | int[kPlaMax][21] | 己方多种出牌的可行解集,各出牌解由牌编号升序排列,用 -1 间隔,以 -2 收尾。 |
iPlaCount | int | 己方多种出牌可行解的数量。 |
iPlaOnHand | int[21] | 己方模拟出牌后剩余手牌的编码。 |
iToTable[21] | int | 玩家当前要出的牌。 |
三、函数说明
3.1 函数分类概述
代码中的函数主要分为以下几类:
- 牌型判断函数:用于判断一组牌是否符合特定的牌型,如火箭、炸弹、单张等。
- 出牌帮助函数:根据当前游戏局势和玩家手牌,提供可行的出牌组合建议。
- 消息处理函数:负责处理游戏过程中的输入输出消息,包括读取消息、分析消息和发送响应消息。
- 其他辅助函数:提供一些通用的功能,如计算牌的数量、分析牌型和数量等。
3.2 具体函数说明
3.2.1 牌型判断函数
牌型判断函数共有11种,这里挑几种具体介绍。
- IsType0Pass
- 功能:判断一组牌是否为过牌(无牌)。
- 参数:int iCs[],待判断的牌数组。
- 返回值:int,1 表示是过牌,0 表示不是。
- 实现逻辑:调用CountCards()函数判断牌的数量,若数量是0则返回1,否则返回0。
//B00-START判断出牌类型是否为弃权
//最后修订者:梅险,最后修订时间:15-02-10
int IsType0Pass(int iCs[])
{
int iCount;
iCount = CountCards(iCs);
if(iCount==0)
return 1;
return 0;
}
- IsType2Bomb
- 功能:判断一组牌是否为炸弹。
- 参数:int iCs[],待判断的牌数组。
- 返回值:int,1 表示是火箭,0 表示不是。
- 实现逻辑:先判断牌是不是4张,不是就返回0。然后判断是否满足:iCs数组4号是-1(似乎还是在看是否是四张牌);第0张牌不是-1;第0、1、2、3号牌都相等。
//B02-START判断出牌类型是否为炸弹
//最后修订者:夏侯有杰&梅险,最后修订时间:15-03-10
//修订内容及目的:防止牌是空的
int IsType2Bomb(int iCs[])
{
if(4 != CountCards(iCs))
return 0;
if((iCs[4]==-1) && ( iCs[0]/4!= -1 && iCs[0]/4==iCs[1]/4 && iCs[0]/4==iCs[2]/4 && iCs[0]/4==iCs[3]/4 ))
return 1;
return 0;
}
- IsType10LinkThreeSingle
- 功能:判断一组牌是否为三顺带单(飞机带翅膀)。
- 参数:int iCs[],待判断的牌数组。
- 返回值:int,1 表示是三顺带单,0 表示不是。
- 实现逻辑:先判断牌是不是小于8张或者不是4的倍数,有一个满足就返回0。然后对牌进行排序,排序的依据是bymuch,即把相同的牌多的放在前面,并且考虑是否有炸弹。这里的目的是将牌型整理为三顺在前,单牌在后的形式。统计三顺的数量并且按照这个数量iNum将前iNum*3个牌定为三顺,调用IsType9LinkThree确定他们是否是三顺。如果是返回1。
//B1001-START判断三顺带单,返回1是,0不是
//最后修订者:夏侯有杰,最后修订时间:15-03-10
//修订内容及目的:防止44455556的出现时排序后顺序乱
int IsType10LinkThreeSingle(int iCs[])
{
int iCount = CountCards(iCs);
int iTempArr[18];
int i,n ,m , j , iNum , iTemp ; //iNum记录有多少个 3+1
if(iCount < 8 || iCount % 4 != 0)
return 0;
memset(iTempArr,-1,18*4); //初始化iTempArr,值都为-1
iNum = iCount/4;
SortByMuch(iCs); //排序
//判断是不是有炸弹
n = 1,m = 0;
while (n)
{
for(i = m ; i < m+4;i++)
iTempArr[i] = iCs[i];
//判断iTempArr是不是炸弹,不是则跳出
if(0 == IsType2Bomb(iTempArr))
{
n = 0;
}
else
{
//如果是的话,那么这个炸弹应该是一个顺子加一个单牌,单排就应该放在iCs的后面
iTemp = iCs[m];
for(j = m+1 ; j < iCount ;j++)
{
iCs[j-1] = iCs[j];
iCs[j] = iTemp;
iTemp = iCs[j];
}
m = m+3;
}
memset(iTempArr,-1,18*4);
}
//将三顺赋值给iTempArr
for(i = 0 ; i < 3*iNum;i++)
{
iTempArr[i] = iCs[i];
}
//判断iTempArr是不是三顺
if(1 == IsType9LinkThree(iTempArr))
{
//将iTempArr传回iCs 防止55544465这样的牌型出现
for(i = 0 ; i < 3*iNum;i++)
{
iCs[i] = iTempArr[i];
}
return 1;
}
return 0;
}
其他牌型判断函数:略。
3.2.2 出牌帮助函数
出牌帮助函数的作用是在得知对手出牌后,调用这些函数判断自己有几种可以管上对手牌的牌。
- Help0Pass
- 功能:在过牌时提供出牌帮助,调用其他出牌帮助函数。
- 参数:struct Ddz * pDdz,指向 Ddz 结构体的指针。
- 返回值:无。
- 实现逻辑:调用其他函数。
//H00-START从iOnHand中查询弃权牌型到iPlaArr,目前双顺到三顺带牌不能首出
//最后修订者:梅险,最后修订时间:15-02-17 12:00
void Help0Pass(struct Ddz * pDdz)
{
Help1Rocket(pDdz);
Help2Bomb(pDdz);
Help3Single(pDdz);
Help4Double(pDdz);
Help5Three(pDdz);
Help6ThreeOne(pDdz);
Help6ThreeDouble(pDdz);
Help7LinkSingle(pDdz);
// Help8LinkDouble(pDdz);
// Help9LinkThree(pDdz);
// Help10LinkThreeSingle(pDdz);
// Help10LinkThreeDouble(pDdz);
Help11FourSingle(pDdz);
Help11FourDouble(pDdz);
}
- Help2Bomb
- 功能:查看自己的手牌里是否有能管上对方刚出的牌的炸弹。
- 参数:struct Ddz * pDdz,指向 Ddz 结构体的指针。
- 返回值:无。
- 实现逻辑:先将自己的手牌按照大小排序。判断自己的手牌是否小于四张,或者对手出牌是否是102王炸,有一个是则返回。判断对手出牌是否为204炸弹,如果是则从第4张开始厉遍所有牌,若这张牌和它前面三张牌值相等则认为有炸弹,同时判断是否比对手刚出的炸弹大。如果对手没出炸弹,则去掉判断是否比对手刚出的炸弹大即可。
void Help2Bomb(struct Ddz * pDdz)
{
int i,iCount;
SortById(pDdz->iOnHand);
iCount=CountCards(pDdz->iOnHand);
if (102==pDdz->iLastTypeCount || iCount<4)
return;
if(204==pDdz->iLastTypeCount)
{
for(i=3;pDdz->iOnHand[i]>=0;i++)
if(pDdz->iPlaCount+1<kPlaMax
&& pDdz->iOnHand[i]/4==pDdz->iOnHand[i-1]/4
&& pDdz->iOnHand[i-1]/4==pDdz->iOnHand[i-2]/4
&& pDdz->iOnHand[i-2]/4==pDdz->iOnHand[i-3]/4
&& pDdz->iOnHand[i]/4 > pDdz->iLastMainPoint)
{
pDdz->iPlaArr[pDdz->iPlaCount][0]=pDdz->iOnHand[i-3];
pDdz->iPlaArr[pDdz->iPlaCount][1]=pDdz->iOnHand[i-2];
pDdz->iPlaArr[pDdz->iPlaCount][2]=pDdz->iOnHand[i-1];
pDdz->iPlaArr[pDdz->iPlaCount][3]=pDdz->iOnHand[i];
pDdz->iPlaArr[pDdz->iPlaCount][4]=-1;
pDdz->iPlaCount++;
}
}
else
for(i=3;pDdz->iOnHand[i]>=0;i++)
if(pDdz->iPlaCount+1<kPlaMax
&& pDdz->iOnHand[i]/4==pDdz->iOnHand[i-1]/4
&& pDdz->iOnHand[i-1]/4==pDdz->iOnHand[i-2]/4
&& pDdz->iOnHand[i-2]/4==pDdz->iOnHand[i-3]/4)
{
pDdz->iPlaArr[pDdz->iPlaCount][0]=pDdz->iOnHand[i-3];
pDdz->iPlaArr[pDdz->iPlaCount][1]=pDdz->iOnHand[i-2];
pDdz->iPlaArr[pDdz->iPlaCount][2]=pDdz->iOnHand[i-1];
pDdz->iPlaArr[pDdz->iPlaCount][3]=pDdz->iOnHand[i];
pDdz->iPlaArr[pDdz->iPlaCount][4]=-1;
pDdz->iPlaCount++;
}
}
- Help7LinkSingle
- 功能:查看自己的手牌里是否有能管上对方刚出的牌的单顺。
- 参数:struct Ddz * pDdz,指向 Ddz 结构体的指针。
- 返回值:无。
- 实现逻辑:首先去掉编号除以四大于12的牌,这样的牌是2或王,不能组成顺子。然后将重复的牌改为-1,再经过两次排序,一次从小到大一次从大到小,这样就可以把所有-1调到后面去。然后检查上一轮对手出牌了还是pass了,若是pass那么检查从长度5到长度12的所有顺子,反之只检查和对手出牌对应的长度的顺子。
void Help7LinkSingle(struct Ddz * pDdz)
{ int i,j,k,iLength,iTemp,iCount,iBiaoji,iFlag;
int iCopyCards[21];
iCount=CountCards(pDdz->iOnHand);
iLength=pDdz->iLastTypeCount-700;
SortById(pDdz->iOnHand);
for(i=0;i<21;i++)
iCopyCards[i]=pDdz->iOnHand[i];
for(i=0;iCopyCards[i]>=0;i++)
if(iCopyCards[i]/4>=12)
iCopyCards[i]=-1;
for(i=1;iCopyCards[i]>=0;i++)
if(iCopyCards[i]/4==iCopyCards[i-1]/4)
iCopyCards[i-1]=-1;
for(i=0;i<iCount;i++)
for(j=i+1;j<iCount;j++)
if(iCopyCards[i]<iCopyCards[j])
{
iTemp=iCopyCards[i];
iCopyCards[i]=iCopyCards[j];
iCopyCards[j]=iTemp;
}
for(i=0;iCopyCards[i]>=0;i++)
for(j=i+1;iCopyCards[j]>=0;j++)
if(iCopyCards[i]>iCopyCards[j])
{
iTemp=iCopyCards[i];
iCopyCards[i]=iCopyCards[j];
iCopyCards[j]=iTemp;
}
//去除重复的牌并从小到大排序
if(pDdz->iLastTypeCount==0)
{
for(iLength=5;iLength<=12;iLength++)
{
for(i=0;iCopyCards[i+iLength-1]>=0;i++)
{
iBiaoji=0;
iFlag=0;
if(iCopyCards[i]/4>pDdz->iLastMainPoint)
{
for(j=i;j<iLength-1+i;j++)
{
if(iCopyCards[j]/4!=iCopyCards[j+1]/4-1)
{
iBiaoji=1;
break;
}
}
}
else
continue;
if(pDdz->iPlaCount+1<kPlaMax
&& iBiaoji==0)
{
for(k=i;k<iLength+i;k++)
{
pDdz->iPlaArr[pDdz->iPlaCount][iFlag]=iCopyCards[k];
iFlag++;
if(k==iLength-1+i)
pDdz->iPlaArr[pDdz->iPlaCount][iFlag++]=-1;
}
pDdz->iPlaCount++;
}
}
}
}
else
{
for(i=0;iCopyCards[i+iLength-1]>=0;i++)
{
iBiaoji=0;
iFlag=0;
if(iCopyCards[i]/4>pDdz->iLastMainPoint)
{
for(j=i;j<iLength-1+i;j++)
{
if(iCopyCards[j]/4!=iCopyCards[j+1]/4-1)
{
iBiaoji=1;
break;
}
}
}
else
continue;
if(pDdz->iPlaCount+1<kPlaMax
&& iBiaoji==0)
{
for(k=i;k<iLength+i;k++)
{
pDdz->iPlaArr[pDdz->iPlaCount][iFlag]=iCopyCards[k];
iFlag++;
if(k==iLength-1+i)
pDdz->iPlaArr[pDdz->iPlaCount][iFlag++]=-1;
}
pDdz->iPlaCount++;
}
}
}
}
其他帮助函数:略
3.2.3 消息处理函数
在程序运行时,对弈平台会向程序发送信息,这和一个人用键盘输入别无二致。
发送的信息分为:
字符 | 代表意思 | 对应处理函数 | 输入举例 | 备注 |
---|---|---|---|---|
DOU | 版本信息 | GetDou | DOUDIZHUVER 1.0 | 不用管 |
INF | 轮局信息 | GetInf | INFO 1,4,1,9,9,3150 | 不用管 |
DEA | 牌套信息 | GetDea | DEAL A10,16,33,38,48,22,0,14,49,17,28,30,50,31,13,8,37 | 系统给你发牌,A是你的位置,后面是你的牌 |
BID | 叫牌过程 | GetBid | BID WHAT 或 BID A3 | 第一种问你叫几分,第二种告诉你别人叫几分 |
LEF | 底牌信息 | GetLef | LEFTOVER A51,3,9 | A是地主名,后面是地主多的三张牌 |
PLA | 出牌过程 | GetPla | PLAY WHAT 或 PLAY A8,9,10,0,3 | 第一种问你出什么,第二种告诉你别人出什么 |
GAM | 胜负信息 | GetGam | GAMEOVER C | C赢了 |
EXI | 强制退出 | exit(0) | EXIT | 不用管 |
- InputMsg
- 功能:从标准输入读取消息并存储到 Ddz 结构体的 sCommandIn 成员中。
- AnalyzeMsg
- 功能:分析输入的消息,并调用相应的处理函数。
其他消息处理函数:略
3.2.4 其他辅助函数
- CountCards
- 功能:计算一组牌的数量。
- 参数:int iCards[],牌数组。
- 返回值:int,牌的数量。
- AnalyzeTypeCount
- 功能:分析一组牌的牌型和数量,并返回相应的标识值。
- 参数:int iCards[],牌数组。
- 返回值:int,牌型和数量的标识值。
- UpdateHisPla
- 功能:更新对手出牌信息,根据接收到的命令判断对手是 PASS 还是正常出牌,更新出牌记录、出牌状态和牌型信息。
- 参数:struct Ddz* pDdz,指向 Ddz 结构体的指针,包含游戏的当前状态信息。
- 返回值:无
- SortById
- 功能:对牌数组按照牌的 ID 进行升序排序,方便后续对牌型的判断和处理。
- 参数:int iCards[],需要排序的牌数组。
- 返回值:无
- SortByMuch
- 功能:将牌按照相同牌的数量进行排序,相同数量多的牌排在前面。
- 参数:int iCards[],需要排序的牌数组。
- 返回值:无
其他辅助函数:略
3.2.5 牌值计算相关函数
这是本程序不出bug的情况下牌力强弱的关键。
- CalBid
- 功能:查看自己的手牌,给自己手牌评分,根据评分高低返回叫分值。
- 参数:struct Ddz * pDdz,指向 Ddz 结构体的指针。
- 返回值:叫分分值。
- 实现逻辑:历遍17张牌,有王炸+10,有炸弹+6,每有一张2+2,有一张单独的王+3。评分大于8叫三分,大于6叫2分,大于3叫1分。
有三种情况,上家是地主,下家是地主,自己是地主。我们假设自己是地主进行讲解,其他情况殊途同归。
- different_select
- 功能:判断地主情况,调用不同情况对应的函数,假设我们直接进入自己是地主的函数。
- my_landowner_come_method
- 功能:调用函数判断该出什么牌。
- 返回值:空
- 参数:struct Ddz * pDdz,指向 Ddz 结构体的指针。
- 具体实现:首先调用HelpPla,计算所有可能出牌类型,存入pDdz->iPlaArr[],pDdz->iPlaCount。然后历遍数组,假设已经出了第i组牌,然后 调用CalCardsValue2函数,计算这么出以后剩下的牌的评分,并拿它和最大的比,最终得到最大的,并将iToTable改为最大的。
- CalCardsValue2
这是本文最重要的也是最后一个函数,它代表着如何计算剩下的一手牌“好不好”,这是直接影响程序“牌力”的关键。- 功能:给剩余的牌评分。
- 返回值:double 牌的评分
- 参数:int iPlaOnHand[],假设的出完牌后的手牌。
- 具体实现:具体实现并不难,首先初始化二维数组Pai【】【】统计每一个分值的牌有几张,这里的分值是真正的牌值,例如3就是3,K就是13,A当14记。统计牌的数量Car_count,每有一张牌-3分。然后对Pai数组进行统计,规则如下:
手牌情况 | 计算规则 |
---|---|
无手牌 | dSum 加 10000 分 |
有手牌 | 初始 dSum 为 100 分,每有1张手牌 dSum 减 3 分 |
A 的数量 | - 1 张:dSum 加 4 分 - 2 张:dSum 加 2 分 - 3 张:dSum 加 1 分 |
2 的数量 | - 1 张:dSum 加 3 分 - 2 张:dSum 加 1 分 - 3 张:dSum 加 0.7 分 |
王的数量 | - 1 张:dSum 加 15 分 - 2 张:dSum 加 25 分 |
手牌数量 > 4 | 计算顺子和炸弹: - 炸弹(四张相同牌):Zd 计数,dSum 加 Zd * 11 分 - 顺子: - 5 张顺子:加 5 分 - 6 张顺子:加 3 分 - 7 张顺子:加 3 分 - 减去顺子剩下的单牌数量(Dz1) * 4 |
手牌数量 > 5 | 计算连对: - 连对: - 3 连对:加 10 分 - 4 连对:加 6 分 - 5 连对:加 6 分 减去顺子剩下的单牌数量(Dz1) * 4 - 根据 Dz1 和 Dz2 的大小比较选择 dSum 的值: - Dz1 < Dz2:dSum 取 dSum1 的值 - Dz1 > Dz2:dSum 取 dSum2 的值 - Dz1 == Dz2:取 dSum1 和 dSum2 中较大的值赋给 dSum |
手牌数量 = 4 | - 有炸弹:dSum 乘以 20 倍 - 有三张相同牌(AAA):dSum 乘以 15 倍 - 有对子:dSum 乘以 10 倍 - 无上述牌型: - 小牌(小于 10)数量 >= 2:根据单牌牌面乘以 15 加分 - 小牌数量 < 2:根据单牌牌面与 14 的差值乘以 12 加分 |
手牌数量 = 3 | - 有三张相同牌(AAA):dSum 乘以 3 倍 - 有对子:dSum 乘以 2 倍 - 无上述牌型: - 小牌数量为 2 或 3:根据小牌单牌加分 10 * (L - 1) 分 - 小牌数量不为 2 或 3:根据大牌单牌加分 5 * (L - 1) 分 |
手牌数量 = 2 | - 有对子:dSum 乘以 3 倍 - 无对子:根据单牌牌面乘以 dSum 加分 |
手牌数量 < 2 | dSum 乘以 1000 倍 |
这里分数越高就越有可能在出牌后得到当前情况,也就是说,某种牌型使评分变高得多,它就更可能留下。
double CalCardsValue2(int iPlaOnHand[])
{
double dSum = 100, dSum1 = 0, dSum2 = 0, dSum3 = 0;
int i;
int x;
int j;
int Pai[3][14] = { 0 }; //处理后的牌
int Zd = 0, Dz1 = 0, Dz2 = 0; //Zd:炸弹的数量,Dz1:统计后的单牌的数量,Dz2:为统计后的双牌的后剩余单牌的数量
int Car_count = 0; //手中剩余牌的数量
for (i = 0; i < 14; i++) //初始赋值
{
Pai[0][i] = i + 3;
}
for (i = 0; iPlaOnHand[i] >= 0; i++) //统计手中的牌(存入数组)
{
dSum = dSum - 3; //每多一张牌分数减 3
x = iPlaOnHand[i] / 4 + 3;
Pai[1][x]++;
Car_count++;
}
//如果手中没牌,为最大估分,加10000
if (Car_count == 0)
{
dSum = dSum + 10000;
}
// 手中剩余牌的话
else {
if (Pai[1][11] > 0) //A:每次加2;
{
if (Pai[1][11] == 1)
{
dSum = dSum + 4;
}
else if (Pai[1][11] == 2)
{
dSum = dSum + 2;
}
else if (Pai[1][11] == 3)
{
dSum = dSum + 1;
}
}
if (Pai[1][12] > 0)
{
if (Pai[1][12] == 1) //2:每次加3
{
dSum = dSum + 3;
}
else if (Pai[1][12] == 2)
{
dSum = dSum + 1;
}
else if (Pai[1][12] == 3)
{
dSum = dSum + 0.7;
}
}
if (Pai[1][13] > 0) //王:每次加5分
{
if (Pai[1][13] == 1)
{
dSum = dSum + 15; //一个王
}
else if (Pai[1][13] == 2)
{
dSum = dSum + 25; //两个王
}
}
if (Car_count > 4) //手中的牌大 于五张,可以构成单顺
{
for (i = 0; i < 13; i++) //分类估分前的准备
{
if (Pai[1][i] == 4) //炸弹的数量(满足四张牌)
{
Zd++;
}
if (i < 12) //只对小于A的牌分析
{
Pai[2][i] = Pai[1][i] - 1; //除去构成单顺的可能 Paid[2][i]为独牌的情况
if (Pai[2][i] == 1)
{
Dz1++; //单独的牌
}
}
}
dSum = dSum + Zd * 11; //估分加上炸弹
dSum1 = dSum2 = dSum3 = dSum;
for (i = 0; i < 12; i++) //只用考虑 3~A
{
if (i < 8) //但顺最少5张牌
{
if (Pai[1][i] > 0 && Pai[1][i + 1] > 0 && Pai[1][i + 2] > 0 && Pai[1][i + 3] && Pai[1][i + 4] > 0) //单顺估值
{
dSum1 = dSum1 + 5; // 至少构成五顺 +5;
if (i < 12 && Pai[1][i + 5]>0) //构成六顺 加3;
{
dSum1 = dSum1 + 3;
if (i < 12 && Pai[1][i + 6]>0) //构成七顺 再加 3;
{
dSum1 = dSum1 + 3;
}
}
}
}
}
dSum1 = dSum1 - Dz1 * 4; //单顺的得分
if (Car_count > 5) //构成双顺后的单牌统计
{
for (i = 0; i < 12; i++)
{
Pai[2][i] = Pai[1][i] - 2;
if (Pai[2][i] == 1)
{
Dz2++;
}
}
if (Pai[1][i] > 1 && Pai[1][i + 1] > 1 && Pai[1][i + 2] > 1) //3AA
{
dSum2 = dSum2 + 10;
if (Pai[1][i + 3] > 1) //4AA
{
dSum2 = dSum2 + 6;
if (Pai[1][i + 4] > 1) //5AA 目前只考虑到最大5连顺的情况
{
dSum2 = dSum2 + 6;
}
}
}
dSum2 = dSum2 - Dz2 * 4;
if (Dz1 < Dz2) //剩余牌最少为优
{
dSum = dSum1;
}
else if (Dz1 > Dz2)
{
dSum = dSum2;
}
else //如果剩余牌的数量一样,估分最好的为优
{
if (dSum1 > dSum2)
{
dSum = dSum1;
}
else
{
dSum = dSum2;
}
}
}
else //如果不能构成双顺
{
dSum = dSum1;
}
}
else
if (Car_count == 4) //手中的牌数少于5张牌时,为决定牌局胜负的关键,因此格外注意
{
int L1 = 0; //L1为小牌的数量
int sum4 = 0;
int sum3 = 0;
int sum2 = 0;
for (i = 0; i < 14; i++)
{
if (Pai[1][i] == 4) //如果最后四张牌是炸弹
{
sum4++;
}
else if (Pai[1][i] == 3) //如果最后三张牌是AAA型
{
sum3++;
}
else if (Pai[1][i] == 2) //如果最后四张牌中含有双
{
sum2++;
}
}
if (sum4 > 0) //如果有炸弹,估份最高
{
dSum = dSum * 20;
}
else if (sum3 > 0) //如果有AAA,估分次高
{
dSum = dSum * 15;
}
else if (sum2 > 0) //如果含双,估分第三高
{
dSum = dSum * 10;
}
else { //以上情况都不存在的话
for (i = 0; i < 14; i++)
{
if (i < 11) //小于10(小牌)
{
L1++;
}
}
if (L1 >= 2) // 小牌多(建议先出小牌)三张或两张
{
for (i = 0; i < 14; i++)
{
if (Pai[1][i] == 1)
dSum = dSum + i * 15;
}
}
else {
for (i = 10; i < 14; i++)
{
if (Pai[1][i] == 1)
{
dSum = dSum + (14 - i) * 12;
}
}
}
}
}
else if (Car_count == 3) //剩余三张牌
{
int sum3 = 0;
int sum2 = 0;
int L = 0;
for (i = 0; i < 14; i++)
{
if (Pai[1][i] == 3)
{
sum3++;
}
else if (Pai[1][i] == 2)
{
sum2++;
}
}
if (sum3 > 0)
{
dSum = dSum * 3;
}
else if (sum2 > 0)
{
dSum = dSum * 2;
}
else { //三张独牌
for (i = 0; i < 10; i++)
{
L++;
}
if (L == 3 || L == 2) //三张小牌,从小到大出
{
for (i = 0; i < 10; i++)
{
if (Pai[1][i] == 1) //(小牌估分高)
{
dSum = dSum + 10 * (L - 1);
break;
}
}
}
else { //从大往小出 (大牌估分低)
for (i = 13; i >= 0; i--)
{
if (Pai[1][i] == 1)
{
dSum = dSum + 5 * (L - 1);
}
}
}
}
}
else if (Car_count == 2)
{
for (i = 0; i < 14; i++)
{
if (Pai[1][i] == 2)
{
dSum = dSum * 3;
}
else { //最大的先出(估分低)
for (j = 13; j >= 0; j--)
{
if (Pai[1][i] == 1)
{
dSum = dSum + dSum * i;
}
}
}
}
}
else
{
dSum = dSum * 1000;
}
}
return dSum;
}
四、总结
4.1 代码修改意见
以下仅代表本文作者个人观点:
CalCardsValue2等评分函数是修改的关键。
在作者看来,这些函数有以下一些值得修改的点:
- 1.将手牌数改为“出牌手数”或增加出牌手数这一考虑空间。例如:前几天某同学给我提供了这样的牌型:
这幅牌显然应当将连对拆成两个顺子出,甚至容易走出春天,但按照本函数的分析过程,在手牌大于4时会找出最长的顺子(6-A)其他的全按照单牌处理。手牌大于5时会找出连对,其余全按照单牌处理,这两个方案比较,来判断这副牌是否“好”,这显然欠妥当。这副牌的“手数”只要四次,因此它比较好,这是应当考虑的。 - 2.增加牌的“呼应”
判断牌的好坏时只考虑到单牌数量,没有考虑单牌之间是否有呼应。另外,对子也要有所考虑,例如手里憋着对三一定不是好的选择。 - 3.考虑其他玩家剩余牌数
假如对手只剩一张牌,那无论如何也不能“送走”他。队友只剩一张尽量“送走”他。 - 4.考虑其他特殊情况
很早之前给同学讲二打一博弈时,就有同学说"那我写的很细不就行了吗?"现在想想并不无道理,写出很多特殊情况起码可以拿下很多“必胜”局。 - 5.叫分函数修改
现有的叫分函数及其简单,只考虑大牌数量,这是不行的,我建议还是考虑“手数”“呼应”为好,起码也要考虑顺子、连对等牌型的数量。
4.2 未来的展望
强化学习。
我们可以看到,分值计算函数里,+4,*3等操作显然只是人脑随意想出来的整数,并没有精心设计,因此我认为通过强化学习的思路可以让这些参数更加的精确。
今年寒假的训练营里,老师给我们演示了一个多年前的由本校学长写的亚马逊棋程序,它可以实现自我对弈。它的关键参数有6个,这个程序可以随意地修改这些关键参数,然后将他们分组比赛,决出123名并记录,使用者观察这些记录就能判断出哪种参数最厉害。不得不佩服写这个代码的学长在多年前就有了早期的强化学习思想。现在,在拥有本文的示例代码、DQN等成熟的算法以及AI编程助手等强大工具的加持下,我认为做出一个优化示例里的参数的强化学习模型并不难。
还有判断对手手牌的学习模型,这可能对我们的帮助异常大,可以看到,我们的示例代码里根本没有α-β剪枝算法等常规博弈算法,只是对每一步本身进行分析。这是因为二打一等牌类项目属于非完备信息博弈,我不知道对手的牌是什么,自然无法想象对手下一步怎么做,当我们可以预测对手手牌时,我们就可以为程序添加各种博弈算法,这或许作用非常大。
最后是更加远大的想法:一个基本脱离示例代码的强化学习模型。这里有一篇重庆理工大学硕士论文,里面将牌理解为这样一个矩阵,不由让人眼前一亮:
这避免了17 * 4再加上一些特征的超大矩阵出现,又可以准确的识别牌的强度,似乎还可以向卷积神经网络方向发展(毕竟那些1很像一个个线条)。
4.3 作者的碎碎念
啊啊啊啊啊终于写完了,现在是凌晨1:52,这是我除了交作业以外第一次用markdown写文章。好吧,其实交作业那次我是AI生成的,所以这是我第一次手打md格式,有格式问题望大家海涵。
希望对大家有帮助。
我的理解很粗浅,还是借助了豆包去阅读代码,可能有错误,欢迎大家发现了指正。
2025/5/5
五、参考文献
【1】王鑫。斗地主博弈智能体的深度强化学习方法研究 [D]. 重庆理工大学,2024.