C语言——五子棋、井字棋人机对“战”
针对 “【C语言实现五子棋、三子棋人机对战,包含电脑人工智能对战(可攻可守)】(非标题党)” 的详细介绍
前言
本文会对这篇文章进行分解详细介绍:【C语言实现五子棋、三子棋人机对战,包含电脑人工智能对战(可攻可守)】(非标题党) 内容有点繁杂,大家可以按照目录,依据个人需要查阅内容。
一、整体思路game( )
其实整体思路可以直接参照game函数,已经很清楚地罗列了每一步: 对于随机数生成器不太熟悉的伙伴们,可以自行查阅srand()、time()、rand()库函数,srand()、rand()函数通常是一起使用的,srand()就是用来设置随机数种子的函数,rand()就是用来产生随机数的函数。time()函数使得以现在的系统时间作为随机数的种子来产生随机数 之所以把srand()函数放这,而不是和rand()函数放一起,是担心程序运行过快,产生的随机数都是同一个。 srand((unsigned int)time(NULL)); int z = rand() % k
void game()
{
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);
if (ret != 'C')
break;
}
switch (ret)
{
case 'O':
printf("玩家赢了\n");
break;
case 'X':
printf("电脑赢了\n");
break;
case 'Q':
printf("平局\n");
break;
}
}
二、函数实现
1.棋盘初始化board_init( )
首先将棋盘初始化为空字符,给我们的棋子留下可落子坐标,其中棋盘大小可通过更改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] = ' ';
}
}
}
2.打印棋盘board_display( )
假设棋盘大小为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]);
if (j < col - 1)
{
printf("|");
}
}
printf(" %d", k);
k++;
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;
for (z = 1; z < COL + 1; z++)
{
printf(" %d ", z);
}
printf("\n");
}
最后一行的列数,发现可以单独输出算作一行,所以就单独写在了最后。
3.判断游戏输赢board_wolf( )
这里我们要首先知道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;
for (i = 0; i < row; i++)
{
for (j = 0; j < col-(RULE-1); j++)
{
if (board[i][j] != ' ' && board[i][j] == board[i][j + 1])
{
flag++;
if (flag == RULE - 1)
return board[i][j];
continue;
}
else
flag = 0;
}
}
②列,扫描整个棋盘有棋子的地方,判断每个棋子向下方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状board[ i ][ j ]。 防止数组的非法访问,最后4行(RULE-1)不考虑。
for (i = 0; i < col; i++)
{
for (j = 0; j < row-(RULE-1); j++)
{
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遍历不一样哦!!!
for (i = 0; i < row-(RULE-1); i++)
{
for (j = 0; j < col-(RULE-1); j++)
{
if (board[i][j] != ' ' && board[i][j] == board[i + 1][j + 1])
{
flag++;
if (flag == RULE - 1)
return board[i][j];
else
i++;
}
else
flag = 0;
}
}
④向左下斜,扫描整个棋盘有棋子的地方,判断每个棋子向左下斜方向有没有同形状的棋子,如果有且满足5子连成,那么返回当前坐标的棋子形状。 防止数组的非法访问,最左的四列和最后的四行不考虑 注意:斜向左下方向是行坐标-1,列坐标+1遍历!!!
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
int ret = isfull(board, ROW, COL);
if (ret == 1)
{
return 'Q';
}
return 'C';
4.判断棋盘是否已满isfull( )
遍历整个棋盘,如果有空字符说明棋盘没满,返回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++)
{
if (board[i][j] == ' ')
return 0;
}
}
return 1;
}
5.玩家下棋PlayerMove( )
扫描整个棋盘,将为空字符的坐标作为可落子位置。虽然玩家输入的坐标是从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 (1)
{
while (getchar() != '\n');
scanf("%d,%d", &i, &j);
if (i >= 1 && i <= row && j >= 1 && j <= col)
{
if (board[i - 1][j - 1] == ' ')
{
board[i - 1][j - 1] = 'O';
break;
}
else
{
printf("位置已被占用,请重新输入:>");
}
}
else
{
printf("坐标非法,请重新输入:>");
}
}
}
6.电脑下棋ComputerMove( )
此函数没什么好讲的,只有两行,重点在于是它里面嵌套的函数。当时封装了这个函数是为了和玩家下棋PlayerMove( )的函数对称。
void ComputerMove(char board[ROW][COL], int row, int col)
{
computer_calc(board, ROW, COL);
}
7.电脑智能下棋系统(包含计分)computer_calc( )
此函数来自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;
for (int y = -1; y <= 0; y++)
{
for (int x = -1; x <= 1; x++)
{
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)
{
if (board[curRow][curCol] == 'O')
playernum++;
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] == '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:
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;
}
}
}
}
}
8.电脑智能下棋computer_think( )
它是来自电脑智能下棋系统(包含计分)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;
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));
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)
{
int z = rand() % k;
board[index_row[z]][index_col[z]] = 'X';
}
else
{
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; 然后将代码间的写了注释,基本恨不得每句都介绍了,希望对大家有所帮助。最后还是强调一下,那个给每种棋型设置分数部分,大家肯定是可以测试出更好的,不一定非要按照我拟定的分数来。
|