针对 “【C语言实现五子棋、三子棋人机对战,包含电脑人工智能对战(可攻可守)】(非标题党)” 的详细介绍
本文会对这篇文章进行分解详细介绍:【C语言实现五子棋、三子棋人机对战,包含电脑人工智能对战(可攻可守)】(非标题党)
内容有点繁杂,大家可以按照目录,依据个人需要查阅内容。
其实整体思路可以直接参照game函数,已经很清楚地罗列了每一步:
对于随机数生成器不太熟悉的伙伴们,可以自行查阅srand()、time()、rand()库函数,srand()、rand()函数通常是一起使用的,srand()就是用来设置随机数种子的函数,rand()就是用来产生随机数的函数。time()函数使得以现在的系统时间作为随机数的种子来产生随机数 之所以把srand()函数放这,而不是和rand()函数放一起,是担心程序运行过快,产生的随机数都是同一个。
srand((unsigned int)time(NULL));
int z = rand() % k
//游戏实现
void game()
{
//设置随机数的生成器,这一步是与computer_think()中的int z = rand() % k;对应
srand((unsigned int)time(NULL));
//存储数据 - 二维数组
char board[ROW][COL];
//初始化棋盘 - 初始化空格
board_init(board, ROW, COL);
//打印一下棋盘
board_display(board, ROW, COL);
char ret = 0;
while (1)
{
//玩家下棋
PlayerMove(board, ROW, COL);
//清屏
system("cls");
board_display(board, ROW, COL);
//判断游戏输赢
ret = board_wolf(board, ROW, COL);
if (ret != 'C')
break;
//电脑下棋
printf("对方正在下棋......\n");
Sleep(1000);//让电脑假装思考
ComputerMove(board, ROW, COL);
//清屏
system("cls");
board_display(board, ROW, COL);
//判断游戏输赢
ret = board_wolf(board, ROW, COL);
//C是游戏继续,在判断平局isfull函数中出现
//此行就是说ret不是游戏继续的情况,就结束循环
if (ret != 'C')
break;
}
switch (ret)
{
//正好是玩家棋子的字符
case 'O':
printf("玩家赢了\n");
break;
//正好是电脑棋子的字符
case 'X':
printf("电脑赢了\n");
break;
case 'Q':
printf("平局\n");
break;
}
}
首先将棋盘初始化为空字符,给我们的棋子留下可落子坐标,其中棋盘大小可通过更改game.h头文件中宏定义的ROW(行),COL(列)来更改棋盘的大小。实现:将二维数组board初始化为空字符。
void board_init(char board[ROW][COL], int row, int col)
{
int i = 0;
int j = 0;
for (i = 0; i < row; i++)
{
for (j = 0; j < col; j++)
{
board[i][j] = ' ';
}
}
}
假设棋盘大小为10×10。棋盘是由一行( %c |)循环10次,一行(———|)循环10次,这两行×10组成。
不清楚我在说什么就看下图:
注意:每输入一行“ %c | %c | %c | %c | … | %c ”就在末尾添加上数字,方便玩家使用时看横坐标。 对应代码已在下方
void board_display(char board[ROW][COL], int row, int col)
{
int i = 0;
int k = 1;
for (i = 0; i < row; i++)
{
int j = 0;
for (j = 0; j < col; j++)
{
//同上面介绍一样,一个空字符 + 棋子 + 一个空字符
printf(" %c ", board[i][j]);
//忽略最后一组" %c |"的 "|"
if (j < col - 1)
{
printf("|");
}
}
//给最右侧一列加上行数,方面玩家输入坐标
//每输入一行"( %c |) × 10"就在末尾加上一个数字
printf(" %d", k);
k++;
//每输出一行都要加上\n回车
printf("\n");
if (i < row - 1)
{
int j = 0;
for (j = 0; j < col; j++)
{
//输出三个短横一个竖杠的组合---|,且忽略最后最后一组的 |
printf("———");
if (j < col - 1)
{
printf("|");
}
}
printf("\n");
}
}
//给最后一行加上列数,方便玩家输入坐标
int z = 0;
//从1~10
for (z = 1; z < COL + 1; z++)
{
printf(" %d ", z);
}
printf("\n");
}
最后一行的列数,发现可以单独输出算作一行,所以就单独写在了最后。
这里我们要首先知道RULE是game.h头文件中宏定义的棋盘规则,表示几子连成算赢。此处我们假设RULE为5,即五子连成算赢。(当然如原文所说的,可以更改,但不要超过5,代码未写死)
本函数实现:分别将棋盘判断5次(一个棋子的行,列,斜右下,斜左下和是否满棋【平局】)
①行,扫描整个棋盘中有棋子的地方,判断每个棋子向右方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状board[ i ][ j ]。 但我们发现,从第7列往后数,即使形状都相同,也不可能成5,且在程序运行中这一行
if (board[i][j] != ’ ’ && board[i][j] == board[i][j + 1]) ,"j+1"可能都超过棋盘范围了,我们还在访问,这就会造成数组的非法访问。
所以这里还将代码更改为了:for (j = 0; j < col-(RULE-1); j++),如果是五子棋这里应该是可以直接写成col-4,就是忽略最后四列,但是如果要写成三子棋的,这里就应该是col-2,所以这里直接没有把代码写死,写成col-(RULE-1)这样管你几子棋都适用。 友情提示:不要把RULE设为0,1之类这种非人类哦!
//判断游戏输赢
char board_wolf(char board[ROW][COL],int row, int col)
{
int i = 0;//行
int j = 0;//列
int flag = 0;
//行RULE个棋子连成
for (i = 0; i < row; i++)
{
//避免数组非法访问,所以写成col-(RULE-1)忽略掉那些不可能成RULE个棋子连成的位置
for (j = 0; j < col-(RULE-1); j++)
{
//仅判断坐标位置有棋子的点,j+1表明是向右横向方向判断
if (board[i][j] != ' ' && board[i][j] == board[i][j + 1])
{
//如果有同类型棋子则累加
flag++;
//向右数足够4个棋子(RULE-1)加上自己所在坐标位置的一个棋子,总共5子连成。
if (flag == RULE - 1)
//返回当前坐标的棋子形状(字符)
return board[i][j];
continue;
}
//发现未满足5子连成,将flag及时清0
else
flag = 0;
}
}
②列,扫描整个棋盘有棋子的地方,判断每个棋子向下方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状board[ i ][ j ]。 防止数组的非法访问,最后4行(RULE-1)不考虑。
//列RULE个棋子连成
for (i = 0; i < col; i++)
{
//最后四行不考虑,为了代码的通用性,所以写成row-(RULE-1)
for (j = 0; j < row-(RULE-1); j++)
{
//仅判断坐标位置有棋子的点,j+1表明是向下方向判断
if (board[j][i] != ' ' && board[j][i] == board[j + 1][i])
{
flag++;
if (flag == RULE - 1)
return board[j][i];
continue;
}
else
flag = 0;
}
}
代码中注释同①中的差不多,不多赘述。
③向右下斜,扫描整个棋盘有棋子的地方,判断每个棋子向右下斜方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状。 防止数组的非法访问,最后四行和四列不考虑
唯一注意的是:斜向右下方向是行和列坐标同时+1遍历,和①中只需列+1遍历、②中只需行+1遍历不一样哦!!!
//左上角为起点,向右下斜着RULE个棋子连成
for (i = 0; i < row-(RULE-1); i++)
{
for (j = 0; j < col-(RULE-1); j++)
{
//i+1,j+1保证方向是斜右下
if (board[i][j] != ' ' && board[i][j] == board[i + 1][j + 1])
{
flag++;
if (flag == RULE - 1)
return board[i][j];
//在扫描某一行中的每一列的棋子斜右下方向,也就是行和列同时+1遍历,所以此处添加行自+1
else
i++;
}
else
flag = 0;
}
}
④向左下斜,扫描整个棋盘有棋子的地方,判断每个棋子向左下斜方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状。 防止数组的非法访问,最左的四列和最后的四行不考虑
注意:斜向左下方向是行坐标-1,列坐标+1遍历!!!
//右上角为起点,向左下斜着RULE个棋子连成
//注意:这里是列从9开始依次递减,RULE-1忽略0~4列
for (j = col - 1; j >= RULE - 1; j--)
{
//同理,忽略最后四行
for (i = 0; i < row - (RULE - 1); i++)
{
//向左下斜是行坐标依次递增,列坐标依次递减
if (board[i][j] != ' ' && board[i][j] == board[i + 1][j - 1])
{
flag++;
if (flag == RULE - 1)
return board[i][j];
//列依次递减
else
j--;
}
else
flag = 0;
}
}
⑤平局,如果棋盘满了返回Q,没满则需要继续,所以返回C
//平局
//如果棋盘满了返回1,不满返回0.
int ret = isfull(board, ROW, COL);
if (ret == 1)
{
return 'Q';
}
//继续
return 'C';
遍历整个棋盘,如果有空字符说明棋盘没满,返回0,否则说明棋盘满了,返回1
//判断棋盘是否已满
int isfull(char board[ROW][COL], int row, int col)
{
int i = 0;
int j = 0;
for (i = 0; i < row; i++)
{
for (j = 0; j < col; j++)
{
//如果有空字符,则说明棋盘没满,返回0
if (board[i][j] == ' ')
return 0;
}
}
//否则说明棋盘满了,返回1
return 1;
}
扫描整个棋盘,将为空字符的坐标作为可落子位置。虽然玩家输入的坐标是从1开始,但是电脑是按照0开始的,所以玩家输入的行列坐标-1即为真实棋盘坐标。
补充一个内容:while (getchar() != ‘\n’); 如果没有这行代码,测试时输入坐标1 2或者1/2等非(1,2)这种格式的,会产生卡死或者死循环的情况,
原因: scanf遇到与要求输入类型不相符的字符时,scanf会直接跳过,既不接收也不清除,而是存放在缓存区中,当下次调用时,程序会直接从缓存区读取非法字符,造成死循环。
解决: 此处利用getchar( )依次一个一个的接受字符,只要不是\n就一直接收,否则会返回EOF。
具体解释,下面代码中注释已经很详细了。
void PlayerMove(char board[ROW][COL], int row, int col)
{
int i = 0;
int j = 0;
//提示语,可要可不要
printf("*注意:输入格式为 行,列 (例如:1,2) 祝您游戏愉快! \n");
printf("请下棋:>");
//如果输入坐标非法或被占用,while循环使得可以重新输入
while (1)
{
//解决scanf非法输入字符造成的死循环
while (getchar() != '\n');
scanf("%d,%d", &i, &j);
//防止数组越界访问,输入的行列坐标都应该小于棋盘大小
//为了方便玩家,所以将行列的首坐标设为从1开始
if (i >= 1 && i <= row && j >= 1 && j <= col)
{
//玩家输入行列坐标-1哦!
//如果该坐标为空字符,则可下棋
if (board[i - 1][j - 1] == ' ')
{
board[i - 1][j - 1] = 'O';
break;
}
//否则说明该坐标有字符,已经被占用了
else
{
printf("位置已被占用,请重新输入:>");
}
}
//如果输入坐标不存在,即为坐标非法
else
{
printf("坐标非法,请重新输入:>");
}
}
}
此函数没什么好讲的,只有两行,重点在于是它里面嵌套的函数。当时封装了这个函数是为了和玩家下棋PlayerMove( )的函数对称。
void ComputerMove(char board[ROW][COL], int row, int col)
{
//电脑智能下棋系统(包含计分)
computer_calc(board, ROW, COL);
}
此函数来自computer.c文件中,有点长,不急咱分解来一步一步地看。
主体思路简述:扫描整个棋盘,对每个空棋格进行上下,左右,斜左上,斜右下这八个方向 进行扫描,并累计玩家棋子个数,电脑棋子的个数,以及两端空棋格的个数。对我们下的棋子和这些棋子拼在一起会形成的棋局(活二、眠二、活三、眠三、活四、冲四、连五)设置分数,将所有分数累加到一起,作为该坐标的分数,将该分数保存到一个与棋盘大小一模一样的二维数组calc_score[ROW][COL]里,且与棋盘的坐标一一对应。
没明白没关系,继续看:
①如下,playernum,computernum,emptynum分别为扫描到该空棋格周围的玩家棋子个数、电脑棋子个数、两端空子个数。并且创建一个保存我们即将要累加的分数的二维数组calc_score[ROW][COL],且该二维数组应该与棋盘大小一模一样,坐标一一对应,因为该数组保存的是分数,是int型,所以我们将其初始化为0而非空字符。初始化这里使用了库函数memset( )。
看了还没明白是吧,没事上图,咱就是说一定弄清楚:
//电脑智能下棋系统(包含计分)
void computer_calc(char board[ROW][COL], int row, int col)
{
int playernum = 0; //人 - 连续棋子的个数
int computernum = 0; //机 - 连续棋子的个数
int emptynum = 0; //两端空子的个数
//创建一个计分数组
int calc_score[ROW][COL];
//对计分数组清零
memset(calc_score, 0, sizeof(calc_score));
int r = 0;
int c = 0;
②把棋盘中所有空棋格周围进行扫描,但是我们发现【(-1,-1),(1,1)】【(0,-1),(0,1)】【(1,-1),(-1,1)】【(1,0),(-1,0)】这四个方向与对应反方向互为一组,所以我们只需要扫描一个方向时,直接反向再扫描它的反方向,就得到一组了。 所以我们只需要先确定出这四个方向。
for (r = 0; r < row; r++)
{
for (c = 0; c < col; c++)
{
//不考虑非空点
if (board[r][c] != ' ')
continue;
//八个方向进行扫描
//先对(-1,-1),(0,-1),(1,-1),(1,0)这四个方向进行扫描
for (int y = -1; y <= 0; y++)
{
for (int x = -1; x <= 1; x++)
{
//排除棋子原坐标和(-1,0)坐标
if (y == 0 && x != 1)
continue;
③玩家棋子 四个方向和他们的反方向计数。 且最多只记录以该空棋格为原点,向这八个方向延伸依次扫描5个元素。
注意!!!很重要看这里!!! 我们是把玩家棋子周围计数和电脑棋子周围计数分别来统计的!这里是只统计玩家棋子周围的玩家棋子 扫描时遇到电脑棋子不做累计!后面会对电脑棋子周围的电脑棋子进行计数的。也就是说只计数该棋子周围同类型棋子的数量!!!
通过这种方式的计数,所以我们就可以得到7种棋型了(活二、眠二、活三、眠三、活四、冲四、连五),明确的说是:电脑要下的这颗棋的位置如果被玩家下了会造成怎样的棋型。 什么?你不认识这几个棋型?好…
//每个方向重置一次,准确说是每两个方向为一组的方向重置一次。
playernum = 0;
computernum = 0;
emptynum = 0;
//玩家棋子周围计数
int i = 0;
for (i = 1; i < RULE; i++)
{
//正方向(即为我们确定好的四个方向)扫描
int curRow = r + i * x;
int curCol = c + i * y;
//避免数组的非法访问,不能超出棋盘的范围
if (curRow >= 0 && curRow < row && curCol >= 0 && curCol < col)
{
//如果扫描到"o"即为玩家棋子
if (board[curRow][curCol] == 'O')
playernum++;
//如果记录到空字符,则记录空端数emptynum
else if (board[curRow][curCol] == ' ')
{
emptynum++;
//只要碰见空端就可以退出循环了,我们只考虑两端这两个端!
break;
}
//如果为"x"字符,也退出循环,因为这里我们只记录玩家棋子数
else
break;
}
}
//反向周围计数(这部分和上面这四个方向的判断方式一模一样,不多赘述)
for (i = 1; i < RULE; i++)
{
//反向扫描
int curRow = r - i * x;
int curCol = c - i * y;
//避免数组的非法访问,不能超出棋盘的范围
if (curRow >= 0 && curRow < row && curCol >= 0 && curCol < col)
{
if (board[curRow][curCol] == 'O')
playernum++;
else if (board[curRow][curCol] == ' ')
{
emptynum++;
break;
}
else
break;
}
}
④玩家棋子计分(权值): 既然已经分析好了棋型,那么现在就要为这些棋型设置分数。其实说简单点就是判断该空棋格落子的必要性,分数越大,这个位置落子的必要性越大。 我们将上面讲的这7种,外加一种普通棋型(就是周围没有棋子可,落子后不形成任何一种棋型)根据紧急程度来设置分数。比如某一组方向playernum=4,电脑如果不下这,玩家一旦下这里后凑齐5个就赢了,所以这个位子超级紧急,就可以把分数设得较高。 这个分数大家可以自己测试调节,我的仅作参考。
//玩家棋子计分(权值)
switch (playernum)
{
case 1://活二(落子后可形成,后面同理)
//形成活二危险性不大,可以把分数设置小一点
calc_score[r][c] += 10;
break;
case 2:
if (emptynum == 1)//眠三
{
calc_score[r][c] += 30;
break;
}
else if (emptynum == 2)//活三
{
calc_score[r][c] += 40;
break;
}
case 3:
if (emptynum == 1)//冲四
{
calc_score[r][c] += 60;
break;
}
else if (emptynum == 2)//活四
{
calc_score[r][c] += 2000;
break;
}
case 4://连五
//只要玩家下在这就连成5子赢了,电脑觉得这里十分紧急,把分数设置极大,去堵玩家的棋,不让玩家连成5子
calc_score[r][c] += 10100;
break;
}
⑤电脑棋子 周围计数:
对于电脑棋子周围计数不多介绍,和上面玩家棋子周围计数简直不要一模一样。唯一需要区别的是在构成棋型时,玩家棋子周围计数是: 电脑要下的这颗棋的位置如果被玩家下了会造成怎样的棋型。而这里电脑棋子周围计数是: 电脑要下的这颗棋的位置如果被电脑自己下了会造成怎样的棋型。 这两种对后面设置分数上会产生不同的影响。比如电脑会形成的连五,和玩家会形成的连五哪个更重要, 当然是电脑的连五咯,电脑说:我再下一步就赢了,当然要下。
也就是说对于某一种棋型,电脑是该攻还是该守,都源于你设置的分数可以注意看看下面代码中的注释
//电脑棋子周围计数,该部分与前面计量玩家棋子一模一样
emptynum = 0;
for (i = 1; i < RULE; i++)
{
int curRow = r + i * x;
int curCol = c + i * y;
if (curRow >= 0 && curRow < row && curCol >= 0 && curCol < col)
{
if (board[curRow][curCol] == 'X')
computernum++;
else if (board[curRow][curCol] == ' ')
{
emptynum++;
break;
}
else
break;
}
}
//反向周围计数
for (i = 1; i < RULE; i++)
{
int curRow = r - i * x;
int curCol = c - i * y;
if (curRow >= 0 && curRow < row && curCol >= 0 && curCol < col)
{
if (board[curRow][curCol] == 'X')
computernum++;
else if (board[curRow][curCol] == ' ')
{
emptynum++;
break;
}
else
break;
}
}
//电脑棋子计分
switch (computernum)
{
//这个空棋格周围没有自己人,形成不了任何棋型,即为普通棋型,很不重要,所以设置分数很低
case 0:
calc_score[r][c] += 5;
break;
case 1://活二
calc_score[r][c] += 10;
break;
case 2:
if (emptynum == 1)//眠三
{
calc_score[r][c] += 25;
break;
}
else if (emptynum == 2)//活三
{
calc_score[r][c] += 50;
break;
}
case 3:
if (emptynum == 1)//冲四
{
calc_score[r][c] += 55;
break;
}
else if (emptynum == 2)//活四
{
//电脑说:只要我下这里就可以形成活四,但是比起玩家棋子会形成活四来说,此时防守比攻击重要,保守起见,应该去堵玩家(守)。
//所以这里的活四没有玩家棋子设置分数的活四分数高。
calc_score[r][c] += 100;
break;
}
case 4://连五
//电脑说:我只要下在这,就可以形成五子连棋,只要落子就要赢了,一定下这里!所以把分数设为最高
calc_score[r][c] += 20000;
break;
}
}
}
}
}
它是来自电脑智能下棋系统(包含计分)computer_calc( )函数中嵌套的一个函数。
思路:先找出二维数组int calc_score[ROW][COL]保存的分数中选出最大分数,将它的坐标作为最后电脑落子的位置。
出现问题:但是如果有多个相同的最大分数呢?这时候该如何落子?为了解决这个问题,我们再创建一个二维数组或者两个一维数组,此处我写的是两个一维数组,一个保存行标,一个保存列标, (相当于就是把它当成一个散装的二维数组)如果出现多个相同最大分数那么就往后接着添加。最后电脑落子时就从这些记录下来的几组行列坐标中随机选择一个落子。
如果最大分数坐标只有一个,那么就直接选择这两个一维数组的首元素,分别作为行列标board[index_row[k]][index_col[k]] ,此时的k值也必定是0,所以直接索引到k。
//电脑智能下棋
void computer_think(int calc_score[ROW][COL],char board[ROW][COL],int row,int col)
{
int maxscore = 0;//最高分数
//分别保存相等max的行,列,分数
//此处也可以直接创建一个二维数组
int index_row[100] = { 0 }; //保存最高分数所对应的行标
int index_col[100] = { 0 }; //保存最高分数所对应的列标
int i = 0;
int j = 0;
int k = 0;
for (i = 0; i < row; i++)
{
for (j = 0; j < col; j++)
{
if (board[i][j] == ' ')
{
//不断更新最大分数
if (calc_score[i][j] > maxscore)
{
//清空保存的行列坐标
memset(index_row, 0, sizeof(index_row));
memset(index_col, 0, sizeof(index_col));
//保证每一次向后保存行列坐标都是从索引0开始,向后依次累加
k = 0;
maxscore = calc_score[i][j];
//实时更新保存最大分数坐标
index_row[k] = i;
index_col[k] = j;
}
//如果最大分数已存在
else if (calc_score[i][j] == maxscore)
{
//向后面继续保存行列坐标
k++;
index_row[k] = i;
index_col[k] = j;
}
}
}
}
//如果最大分数有多个坐标
if (k > 0)
{
//设置随机值,随机值来自index_row,index_col的索引
//假如他们总共保存了3组行列坐标,那么z就是在[0,1,2]这三个索引中随机选一个。
int z = rand() % k;
//随机的行标,和随机的列标作为最终在棋盘board上落子的坐标
board[index_row[z]][index_col[z]] = 'X';
}
//如果最大份数只有一个坐标
else
{
//和board[index_row[0]][index_col[k]]一个意思
//也就是说最终只有一个独有的最大分数,没有多个,所以index_row和index_col分别只保存了一个行坐标和一个列坐标。就下这就可以了。
board[index_row[k]][index_col[k]] = 'X';
}
}
注意:清空保存的行列坐标这一步的作用是:如果遇到我已经保存了很多个相同的目前遇到的最大值坐标,接着后面我又找到更大的分数,这时index_row和index_col里保存的行列坐标就不是最大值了,所以要即时清零,保证里面保存的一定是最大分数的行列坐标
memset(index_row, 0, sizeof(index_row));
memset(index_col, 0, sizeof(index_col));
上面代码中注释写得很详细。
在写这篇详细介绍的同时,我也发现了一些漏洞,本篇改动地地方在:①判断游戏输赢board_wolf( ) ②电脑智能下棋系统(包含计分)computer_calc( )中对八个方向进行扫描//八个方向进行扫描,改动了为了,if (y == 0 && x != 1) continue; 然后将代码间的写了注释,基本恨不得每句都介绍了,希望对大家有所帮助。最后还是强调一下,那个给每种棋型设置分数部分,大家肯定是可以测试出更好的,不一定非要按照我拟定的分数来。