一、项目简介
这是一个可以单人游玩的黑白棋小游戏。
采用鼠标左键点击的方式下子。下子之后,处于该点和原本同颜色棋子之间的棋子会转变颜色。本游戏代码设置了可以调整难度的AI(改变内部的difficult参数),可以随自己的喜好调整。
编译环境:visual c++ 6.0
第三方库:Easyx2017
二、运行截图
三、源码解析
首先看游戏的主体部分,也就是其运行逻辑。
void play(void) // 游戏过程 { MOUSEMSG m; int x, y; // 初始化棋子 for(x = 0; x < 8; x++) for(y = 0; y < 8; y++) map[x][y] = 0; map[3][4] = map[4][3] = 'B'; map[3][3] = map[4][4] = 'W'; // 开始游戏 print(); mciSendString("play 音乐\\背景音乐.wma from 0 repeat", NULL, 0, NULL); do { if (Canput('B')) // 如果玩家有下子位置 { while(true) { while(true) { m = GetMouseMsg(); // 获取鼠标消息 if(m.uMsg == WM_LBUTTONDOWN && m.x - 26 < 37 * 8 && m.y - 26 < 37 * 8) // 如果左键点击 break; } x = (m.y - 26) / 37; y = (m.x - 26) / 37; if(judge(x, y, 'B')) // 如果当前位置有效 { draw(x, y, 'B'); // 下子 mciSendString("play 音乐\\下子.wma from 0", NULL, 0, NULL); print(); putimage(37 * y, 37 * x, &img[3]); // 标识下子点 break; } else continue; } if (quit('W')) // 计算机是否失败 break; } if (Canput('W')) // 如果计算机有下子位置 { clock_t start; start = clock(); D('W', 1); // 搜索解法 while (clock() - start < CLOCKS_PER_SEC); draw(X, Y, 'W'); print(); mciSendString("play 音乐\\下子.wma from 0", NULL, 0, NULL); putimage(37 * Y, 37 * X, &img[4]); // 标识下子点 if (quit('B')) // 玩家是否失败 break; } }while (Canput('B') || Canput ('W'));
我们定义了运算用的变量x,y,以及鼠标变量m。MOUSEMSG是Easyx中的结构体,用于保存鼠标消息。
然后初始化棋盘,即在中间的四个位置放上黑白各两颗棋子。map[x][y]是一个二维字符串组,用”B”和”W”分别表示黑棋和白棋。
之后进入Do-while循环,循环条件为两方至少有一方可以下子。
首先玩家(黑)先行动。我们想要达成的目的是,如果我们点击棋盘的一个点,这里允许下子则下子,不能下子则继续检测。
这个结构采取一个双层循环来完成,内层不断调用GetMouseMsg获取鼠标信息(GetMouseMsg是Easyx中的函数,其返回值为之前提到过的MOUSEMSG结构,.x和.y分别表示鼠标点击的横纵坐标位置),如果点击,则确定该点的位置,内层循环结束。判定此次落子是否有效,有效则下子并终止外层循环,无效则返回外层循环的头部。
然后白方下子。这里利用一个动态规划函数算出一个较好的落子点(与难度相关),并在计算开始之前计时。计算结束判定是否经过一秒(CLOCKS_PER_SEC表示一秒钟内CPU运行的时钟周期数),如果不到一秒则延迟到一秒后落子,防止影响人类棋手心态。然后同样执行落子程序以及音乐播放程序。
在整个Play函数外部,前方应该还有一个初始化函数load(),之后还有胜利处理函数。
之后,我们看其中的每一个函数应当如何实现。
这是全局当中声明的函数。
void load(void); // 加载素材 void print(void); // 画棋盘 void draw(int, int, char); // 下当前子 int judge(int, int, char); // 判断当前是否可以落下 bool Canput(char); // 判断是否有棋可吃 bool quit(char); // 判断是否有棋存活 bool ask(void); // 弹出对话框 int D(char, int); // 动态规划 void play(void); // 游戏过程
下面的是全局变量。
const int difficult = 6; // 难度 const int move[8][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {1, -1}, {1, 1}, {-1, 1}}; // 八个方向扩展 char map[8][8]; // 棋盘 IMAGE img[5]; // 保存图片 int black, white; // 双方的棋子数 int X, Y; // 白棋的下子点
加载素材并且初始化变量:
void load(void) // 加载素材 { // 加载图片 loadimage(&img[0], "图片\\空位.bmp"); loadimage(&img[1], "图片\\黑子.bmp"); loadimage(&img[2], "图片\\白子.bmp"); loadimage(&img[3], "图片\\黑子1.bmp"); loadimage(&img[4], "图片\\白子1.bmp"); // 加载音乐 mciSendString("open 音乐\\背景音乐.wma", NULL, 0, NULL); mciSendString("open 音乐\\和局.wma", NULL, 0, NULL); mciSendString("open 音乐\\胜利.wma", NULL, 0, NULL); mciSendString("open 音乐\\失败.wma", NULL, 0, NULL); mciSendString("open 音乐\\下子.wma", NULL, 0, NULL); // 初始化棋盘 initgraph(340, 340); IMAGE qipan; loadimage(&qipan, "图片\\棋盘.bmp"); putimage(0, 0, &qipan); setorigin(26, 26); SetWindowText(GetHWnd(), "黑白棋AI版"); }
loadimage是Easyx库中的函数,用于加载图像。本案例中第一个参数是保存图像的 IMAGE 对象指针,第二个是图像地址。这个函数还可以拉伸图片,或者自动适应IMAGE的大小,具体用法参见Easyx的官方文档。
mciSendString是<mmsystem.h>当中的函数,用来播放多媒体文件的API指令。
initgraph这个函数用于初始化绘图窗口。
putimage用于在当前设备上绘制指定图像。本案例当中代表在(0,0)处绘制棋盘图形。
setorigin也在Easyx当中,用于设置坐标原点。
SetWindowText是Windows API宏,声明在WinUser.h当中,用于设定窗口文本
绘制棋盘:
void print(void) // 画棋盘 { int x, y; black = white = 0; for(x = 0; x < 8; x++) for(y = 0; y < 8; y++) switch(map[x][y]) { case 0: putimage(37 * y, 37 * x, &img[0]); break; case 'B': putimage(37 * y, 37 * x, &img[1]); black++; break; case 'W': putimage(37 * y, 37 * x, &img[2]); white++; break; } }
利用双层循环遍历棋盘,根据map[x][y]中的字符,在对应位置绘制棋子就可以了。
落子:
void draw(int x, int y, char a) // 下当前子 { char b = T(a); // 敌方子 int i, x1, y1, x2, y2; bool sign; for (i = 0; i < 8; i++) { sign = false; x1 = x + move[i][0]; y1 = y + move[i][1]; while (0 <= x1 && x1 < 8 && 0 <= y1 && y1 < 8 && map[x1][y1]) { if(map[x1][y1] == b) sign = true; else { if(sign) { x1 -= move[i][0]; y1 -= move[i][1]; x2 = x + move[i][0]; y2 = y + move[i][1]; while (((x <= x2 && x2 <= x1) || (x1 <= x2 && x2 <= x)) && ((y <= y2 && y2 <= y1) || (y1 <= y2 && y2 <= y))) { map[x2][y2] = a; x2 += move[i][0]; y2 += move[i][1]; } } break; } x1 += move[i][0]; y1 += move[i][1]; } } map[x][y] = a; }
对落子点的八个方向进行检测。如果在该方向上,遇到的第一个棋子与落子颜色不同,并且沿着这条线下去,最后能找到一个和落子颜色相同的棋子,则将这之间的所有棋子改变颜色。
判断当前位置是否可以落子:
int judge(int x, int y, char a) // 判断当前是否可以落下,同draw函数 { if(map[x][y]) // 如果当前不是空的返回0值 return 0; char b = T(a); int i, x1, y1; int n = 0, sign; for (i = 0; i < 8; i++) { sign = 0; x1 = x + move[i][0]; y1 = y + move[i][1]; while (0 <= x1 && x1 < 8 && 0 <= y1 && y1 < 8 && map[x1][y1]) { if(map[x1][y1] == b) sign++; else { n += sign; break; } x1 += move[i][0]; y1 += move[i][1]; } } return n; // 返回可吃棋数 }
和上一个函数差不多的逻辑。
判断是否有棋可吃:
bool Canput(char c) { int x, y; for(x = 0; x < 8; x++) for(y = 0; y < 8; y++) if(judge(x, y, c)) return true; return false; }
遍历棋盘,在每个地方都调用judge函数即可。
判断是否有棋存活
bool quit(char c) { int x, y; bool b = false, w = false; for(x = 0; x < 8; x++) for(y = 0; y < 8; y++) { if(map[x][y] == c) return false; } return true; }
同样是简单的遍历。判断map当中的字符是否全与参数相同即可。
bool ask(void) // 弹出对话框 { HWND wnd = GetHWnd(); int key; char str[50]; ostrstream strout(str, 50); strout <<"黑:" <<black <<" 白:" <<white <<endl; if (black == white) strout <<"世界和平"; else if(black > white) strout <<"恭喜你赢了!"; else strout <<"小样,还想赢我。"; strout <<"\n再来一局吗?" <<ends; if(black == white) key = MessageBox(wnd, str, "和局", MB_YESNO | MB_ICONQUESTION); else if(black > white) key = MessageBox(wnd, str, "黑胜", MB_YESNO | MB_ICONQUESTION); else key = MessageBox(wnd, str, "白胜", MB_YESNO | MB_ICONQUESTION); if(key == IDYES) return true; else return false; }
GetHWnd在Easyx中定义,用于返回绘图窗口句柄。在 Windows 下,句柄是一个窗口的标识,得到句柄后,可以使用 Windows API 中的函数实现对窗口的控制。
ostrstream strout(str,50);,作用是建立输出字符串流对象strout,并使strout与字符数组str关联(通过字符串流将数据输出到字符数组str),流缓冲区大小为50。
MessageBox()函数包含在头文件 windows.h中,它的功能是弹出一个标准的Windows对话框。返回值是一个int型的整数,用于判断用户点击了对话框中的哪一个按钮。
AI决定落子位置:
int D(char c, int step) { // 判断是否结束递归 if (step > difficult) // 约束步数之内 return 0; if (!Canput(c)) { if (Canput(T(c))) return -D(T(c), step); else return 0; } int i, j, max = 0, temp, x, y; bool ans = false; // 建立临时数组 char **t = new char *[8]; for (i = 0; i < 8; i++) t[i] = new char [8]; for (i = 0; i < 8; i++) for (j = 0; j < 8; j++) t[i][j] = map[i][j]; // 搜索解法 for (i = 0; i < 8; i++) for (j = 0; j < 8; j++) if (temp = judge(i, j, c)) { draw(i, j, c); temp -= D(T(c), step + 1); if (temp > max || !ans) { max = temp; x = i; y = j; ans = true; } for (int k = 0; k < 8; k++) for (int l = 0; l < 8; l++) map[k][l] = t[k][l]; } // 撤销空间 for (i = 0; i < 8; i++) delete [] t[i]; delete [] t; // 如果是第一步则标识白棋下子点 if (step == 1) { X = x; Y = y; } return max; // 返回最优解 }
先看函数的输入。Char c代表落子方,step是当前函数递归了几次。要保证这个递归次数不大于difficult的值,以此来决定AI的强度。
#define T(c) ((c == 'B') ? 'W' : 'B'),意思是c为黑或者白,T(c)为对立的白或者黑。这个函数的目的是,也让AI考虑黑方应该如何下棋,以此来一步一步地推断出最佳位置。
搜索解法采取遍历的方法。如果某一点可下,则下该点(不是直接打印在棋盘上),将数据存储到临时变量当中。递归调用指定次数,在这个过程中累计翻转的棋子,如果值超过了之前的最大值,则将落子点更新为该位置。当遍历完成之后,AI就能得出每一点在预计步数之内的最大收益,从而得出最佳落点。
四、完整源码
C语言网提供由在职研发工程师或ACM蓝桥杯竞赛优秀选手录制的视频教程,并配有习题和答疑,点击了解:
一点编程也不会写的:零基础C语言学练课程
解决困扰你多年的C语言疑难杂症特性的C语言进阶课程
从零到写出一个爬虫的Python编程课程
只会语法写不出代码?手把手带你写100个编程真题的编程百练课程
信息学奥赛或C++选手的 必学C++课程
蓝桥杯ACM、信息学奥赛的必学课程:算法竞赛课入门课程
手把手讲解近五年真题的蓝桥杯辅导课程