一、项目简介

这是一个可以单人游玩的黑白棋小游戏。

采用鼠标左键点击的方式下子。下子之后,处于该点和原本同颜色棋子之间的棋子会转变颜色。本游戏代码设置了可以调整难度的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语言完整源码

点赞(0)

C语言网提供由在职研发工程师或ACM蓝桥杯竞赛优秀选手录制的视频教程,并配有习题和答疑,点击了解:

一点编程也不会写的:零基础C语言学练课程

解决困扰你多年的C语言疑难杂症特性的C语言进阶课程

从零到写出一个爬虫的Python编程课程

只会语法写不出代码?手把手带你写100个编程真题的编程百练课程

信息学奥赛或C++选手的 必学C++课程

蓝桥杯ACM、信息学奥赛的必学课程:算法竞赛课入门课程

手把手讲解近五年真题的蓝桥杯辅导课程

Dotcpp在线编译      (登录可减少运行等待时间)