1. 项目概述与核心价值
最近在整理旧项目时,翻出了一个基于EasyX图形库用C++写的黑白棋AI。这个项目虽然不算复杂,但麻雀虽小五脏俱全,它完整地串联了游戏逻辑、图形界面、AI算法这三个核心模块,对于想从控制台“黑框框”转向图形化编程,或者想入门博弈树搜索算法的C++开发者来说,是一个非常不错的练手项目。黑白棋(也叫翻转棋)规则简单,但策略空间不小,很适合作为AI算法的“试金石”。通过这个项目,你不仅能学会如何使用EasyX这个轻量级的图形库来绘制棋盘、棋子并处理鼠标交互,更能深入理解如何将经典的极大极小值搜索(Minimax)和阿尔法-贝塔剪枝(Alpha-Beta Pruning)算法应用到一个具体的游戏场景中,并亲眼看到你的AI在图形界面上与你对弈。这比单纯在控制台输出一堆坐标和字符要有成就感得多。
很多人觉得图形化编程和AI算法是两座大山,其实用EasyX这座“小桥”就能轻松跨过第一座。EasyX的API设计非常贴近Windows原生的GDI,但又做了大量简化,你几乎可以用画图板的思维来理解它。而AI部分,我们不需要用到复杂的深度学习,经典的博弈树搜索算法在黑白棋这个规模的游戏上已经能表现出相当不错的智能。这个项目的核心价值就在于“实战”:把抽象的算法逻辑,通过一个看得见、摸得着的图形化游戏呈现出来,让学习过程变得直观且有趣。无论你是想巩固C++面向对象编程,还是为游戏开发或算法学习打基础,这个项目都能提供一条清晰的路径。
2. 环境搭建与EasyX图形库初探
2.1 开发环境配置要点
工欲善其事,必先利其器。这个项目推荐使用Visual Studio作为开发环境,因为它与EasyX的集成最为简单无缝。当然,如果你偏爱VSCode或CLion,配合MinGW编译器也能使用EasyX,但需要手动配置库文件和链接参数,步骤会稍显繁琐。对于初学者,我强烈建议从Visual Studio开始,它能帮你避开很多环境上的坑,把精力集中在代码逻辑本身。
Visual Studio的版本选择上,VS 2019或VS 2022社区版都是免费且功能完备的。安装时,记得勾选“使用C++的桌面开发”工作负载,这会自动安装必要的C++编译器和基础库。接下来就是安装EasyX图形库。你需要去EasyX的官网下载安装包,注意根据你的Visual Studio版本和系统架构(x86或x64)选择对应的安装程序。运行安装程序后,它会自动检测已安装的VS版本并将头文件和库文件部署到正确的位置。安装完成后,新建一个空项目,在源代码文件中直接#include <graphics.h>并写一个简单的初始化窗口代码,如果能成功弹出一个窗口,说明环境就配置成功了。
注意:如果你的项目需要使用
char类型(例如处理中文字符),在包含graphics.h之前,建议先#define _CRT_SECURE_NO_WARNINGS来禁用一些微软认为不安全的函数警告,或者使用更安全的函数版本。另外,EasyX的绘图操作默认是在一个“绘图窗口”上进行的,这个窗口本身也是一个消息循环,理解这一点对后续处理用户交互很重要。
2.2 EasyX核心绘图与交互机制解析
EasyX的核心功能可以概括为“绘图”和“交互”。绘图方面,它提供了一系列极其直观的函数,比如circle()画圆、rectangle()画矩形、fillcircle()画实心圆、outtextxy()在指定位置输出文字。对于我们的黑白棋项目,棋盘就是一系列直线(line())构成的网格,棋子就是填充了黑色或白色的圆。
更关键的是交互处理。EasyX通过ExMessage结构体和peekmessage()、getmessage()等函数来处理鼠标和键盘消息。这里有一个非常重要的细节:消息处理模式。EasyX默认是“批处理”模式,你需要在一个循环里不断peekmessage()来获取消息队列中的消息。对于游戏来说,我们通常采用以下模式:
ExMessage msg; while (true) { // 1. 处理所有累积的输入消息 while (peekmessage(&msg, EX_MOUSE | EX_KEY)) { switch (msg.message) { case WM_LBUTTONDOWN: // 处理鼠标左键点击,将屏幕坐标(msg.x, msg.y)转换为棋盘坐标 int row = (msg.y - BOARD_TOP) / CELL_SIZE; int col = (msg.x - BOARD_LEFT) / CELL_SIZE; if (isValidMove(row, col)) { // 执行落子逻辑 } break; case WM_KEYDOWN: if (msg.vkcode == VK_ESCAPE) exit(0); // 按ESC退出 break; } } // 2. 游戏逻辑更新(例如AI思考) if (currentPlayer == AI_PLAYER && !gameOver) { AIPlacePiece(); currentPlayer = HUMAN_PLAYER; } // 3. 图形渲染 cleardevice(); // 清屏 DrawBoard(); DrawPieces(); // ... 绘制其他UI,如分数、当前玩家提示等 // 4. 短暂延迟,控制帧率,避免CPU占用率100% Sleep(10); }这个循环结构是游戏的主循环,它清晰地分离了输入、更新、渲染三个过程,是游戏编程的基本范式。理解并实现好这个主循环,项目就成功了一半。
3. 游戏逻辑与数据结构设计
3.1 棋盘状态的核心表示法
如何表示一个8x8的黑白棋棋盘?最直观的方法是使用一个二维数组,比如int board[8][8]。我们可以用0表示空位,1表示黑子,2表示白子(或者用枚举类型enum Piece { EMPTY, BLACK, WHITE }更清晰)。这种表示法简单明了,访问和修改任何位置的状态都是O(1)的时间复杂度。
但是,对于AI算法,特别是需要进行大量局面评估和快速走子生成时,我们可能需要更高效的表示方法。一种在黑白棋AI中常用的优化是使用位棋盘(Bitboard)。即用两个64位的无符号整数(unsigned long long),一个表示黑子的位置,一个表示白子的位置。每一位对应棋盘上的一个格子。这种表示法的优势在于,很多棋盘操作(如判断某方向是否有连续棋子、快速计算可落子位置)可以利用位运算(与、或、异或、移位)在常数时间内完成,速度极快。不过,位棋盘的实现和理解门槛稍高。对于我们的教学和实战项目,使用二维数组已经完全足够,且更易于理解和调试。我建议初学者先用二维数组实现所有功能,待项目完成后,如果对性能有进一步追求,再考虑将其重构为位棋盘,这将是一个很好的进阶练习。
3.2 游戏规则的关键算法实现
黑白棋的核心规则是“夹吃”:当一方在空位落子后,如果在横、竖、斜八个方向的任意一条线上,该子的两端都是己方的棋子,则中间被“夹住”的所有对方棋子都会翻转为己方颜色。实现这个flipPieces函数是整个游戏逻辑的难点。
我的实现思路是,以落子点为中心,向八个方向(使用两个数组dx[8] = {-1, -1, 0, 1, 1, 1, 0, -1}和dy[8] = {0, 1, 1, 1, 0, -1, -1, -1}表示方向)进行探索。对于每个方向:
- 从落子点的下一个格子开始,沿着该方向移动。
- 如果遇到对方棋子,继续前进。
- 如果遇到己方棋子,则说明从落子点到这个己方棋子之间的所有对方棋子都需要翻转。此时,沿着原路返回,将经过的格子全部翻转为己方颜色。
- 如果遇到空位或棋盘边界,则这个方向无效。
这里有一个极易出错的细节:必须在确认该方向最终遇到己方棋子后,才能执行翻转。不能一边探索一边翻转,因为可能探索到一半发现是死路(比如最后是空位),此时前面的翻转操作就是错误的。我的做法是,在探索每个方向时,先用一个临时列表记录下沿途遇到的对方棋子的坐标,直到遇到己方棋子,才将列表里的所有坐标进行翻转;如果遇到空位或边界,则清空列表,继续下一个方向的判断。
void Game::makeMove(int row, int col, Piece player) { if (board[row][col] != EMPTY) return; // 位置已有子,无效 if (!isValidMove(row, col, player)) return; // 不是合法落子点,无效 board[row][col] = player; // 放置己方棋子 vector<pair<int, int>> piecesToFlip; // 八个方向探索 int dx[] = {-1, -1, 0, 1, 1, 1, 0, -1}; int dy[] = {0, 1, 1, 1, 0, -1, -1, -1}; Piece opponent = (player == BLACK) ? WHITE : BLACK; for (int i = 0; i < 8; i++) { int x = row + dx[i]; int y = col + dy[i]; vector<pair<int, int>> tempFlipped; // 临时记录该方向可能被翻转的棋子 while (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[x][y] == opponent) { tempFlipped.push_back({x, y}); x += dx[i]; y += dy[i]; } // 检查探索结束的原因:如果停在己方棋子上,则翻转临时记录的所有棋子 if (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[x][y] == player) { piecesToFlip.insert(piecesToFlip.end(), tempFlipped.begin(), tempFlipped.end()); } // 其他情况(出界或为空),tempFlipped被丢弃 } // 执行翻转 for (auto& pos : piecesToFlip) { board[pos.first][pos.second] = player; } }isValidMove函数的逻辑与上述翻转逻辑的前半部分类似,只是它不需要执行翻转,只需要判断在某个方向上是否存在可翻转的对手棋子序列即可。一个合法的落子点,必须至少在一个方向上满足“夹吃”条件。
4. AI引擎:极大极小搜索与阿尔法-贝塔剪枝
4.1 评估函数的设计艺术
AI要下棋,首先得能判断一个棋盘局面是对自己“好”还是“坏”,这个判断标准就是评估函数。一个粗糙的评估函数可以只计算双方棋子数量的差值。但这远远不够,因为黑白棋的前中期,占角、占边、避免危险位置(如星位)比单纯追求子数更重要。
一个相对完善的评估函数应该是加权组合多种因素:
- 子力差:最简单的基础,
(我的棋子数 - 对手棋子数)。 - 行动力:当前玩家可落子的位置数量。行动力越大,意味着选择越多,通常局面越主动。计算行动力本身也有开销,可以缓存结果。
- 棋盘位置价值:给棋盘上的每个格子赋予一个静态价值。例如,四个角的价值最高(比如+100),因为角上的棋子永远不会被翻转。紧邻角的“C位”和“X位”价值为负(比如-50),因为早期占据这些位置很容易让对方得角。边上的格子价值为正,中心格子价值稍低。我们可以预先定义一个8x8的权重矩阵。
- 稳定子:那些在任何情况下都不会被翻转的棋子(通常是角及其延伸出的满行/满列)。识别稳定子算法较复杂,在搜索深度不深时,可以暂不实现。
一个简单的评估函数示例:
int Evaluator::evaluate(const Board& board, Piece player) { Piece opponent = (player == BLACK) ? WHITE : BLACK; int score = 0; // 1. 子力差 (基础分) score += (countPieces(board, player) - countPieces(board, opponent)) * 10; // 2. 位置价值 for (int i = 0; i < BOARD_SIZE; i++) { for (int j = 0; j < BOARD_SIZE; j++) { if (board[i][j] == player) { score += POSITION_WEIGHT[i][j]; } else if (board[i][j] == opponent) { score -= POSITION_WEIGHT[i][j]; } } } // 3. 行动力 (当前玩家的合法步数) int myMobility = generateMoves(board, player).size(); int oppMobility = generateMoves(board, opponent).size(); score += (myMobility - oppMobility) * 5; return score; }权重的具体数值(如10, 5)需要通过对弈测试来调整。前期可以更看重位置和行动力,后期(比如最后20步)可以更看重子力差。评估函数的设计是黑白棋AI的“灵魂”,也是调优空间最大的部分。
4.2 极大极小搜索算法的核心框架
有了评估函数,AI就可以通过向前看几步,模拟后续可能的发展,来选择当前最优的一步。这就是极大极小搜索。其核心思想是:假设对手也是最优的。在AI的回合(MAX层),它选择对自己评估分数最高的走法;在模拟对手的回合(MIN层),它假设对手会选择对AI评估分数最低(即对对手最有利)的走法。
算法通过递归实现:
// 函数返回在当前棋盘状态下,对于给定玩家,进行深度搜索后得到的最佳分数。 int Minimax::search(Board& board, int depth, Piece currentPlayer) { if (depth == 0 || gameIsOver(board)) { return evaluator.evaluate(board, aiPlayer); // 叶子节点,返回评估值 } vector<Move> moves = generateMoves(board, currentPlayer); if (moves.empty()) { // 如果当前玩家无棋可下,则跳过 return search(board, depth - 1, getOpponent(currentPlayer)); } int bestValue; if (currentPlayer == aiPlayer) { // MAX层 bestValue = INT_MIN; for (Move& move : moves) { board.makeMove(move.row, move.col, currentPlayer); // 尝试走一步 int value = search(board, depth - 1, getOpponent(currentPlayer)); board.undoMove(move.row, move.col, currentPlayer); // 撤销这一步(回溯) if (value > bestValue) { bestValue = value; } } return bestValue; } else { // MIN层 bestValue = INT_MAX; for (Move& move : moves) { board.makeMove(move.row, move.col, currentPlayer); int value = search(board, depth - 1, getOpponent(currentPlayer)); board.undoMove(move.row, move.col, currentPlayer); if (value < bestValue) { bestValue = value; } } return bestValue; } }为了记录最佳着法,我们通常会在顶层调用(depth为初始最大深度,currentPlayer为AI)时,不仅返回分数,也记录下产生这个分数的第一步移动。这就需要稍微修改一下函数签名,或者使用一个全局/成员变量来记录。
4.3 阿尔法-贝塔剪枝的性能飞跃
极大极小搜索需要遍历整个游戏树,节点数随着深度呈指数级增长。深度为4时可能还行,深度到6、7时等待时间就难以接受了。阿尔法-贝塔剪枝就是为了解决这个问题,它能在不改变搜索结果的前提下,剪掉大量不必要的分支。
其原理是引入两个值:alpha和beta。
alpha:表示MAX玩家至少能保证的分数(下界)。在MAX层,如果发现某个分支的返回值v >= beta,那么MIN父节点就不会选择这个分支(因为MIN希望分数小),这个MAX节点的其他分支就不用搜了(beta剪枝)。beta:表示MAX玩家至多能得到的分数(上界)。在MIN层,如果发现某个分支的返回值v <= alpha,那么MAX父节点就不会选择这个分支(因为MAX希望分数大),这个MIN节点的其他分支就不用搜了(alpha剪枝)。
代码改造如下:
int AlphaBeta::search(Board& board, int depth, int alpha, int beta, Piece currentPlayer) { if (depth == 0 || gameIsOver(board)) { return evaluator.evaluate(board, aiPlayer); } vector<Move> moves = generateMoves(board, currentPlayer); if (moves.empty()) { return search(board, depth - 1, alpha, beta, getOpponent(currentPlayer)); } // 对走法进行排序能极大提升剪枝效率!好的走法(如高价值位置)先搜索。 orderMoves(moves, board, currentPlayer); if (currentPlayer == aiPlayer) { // MAX层 int value = INT_MIN; for (Move& move : moves) { board.makeMove(move.row, move.col, currentPlayer); int childValue = search(board, depth - 1, alpha, beta, getOpponent(currentPlayer)); board.undoMove(move.row, move.col, currentPlayer); if (childValue > value) { value = childValue; if (depth == maxDepth) { // 记录根节点的最佳着法 bestMove = move; } } alpha = max(alpha, value); if (value >= beta) { break; // beta剪枝 } } return value; } else { // MIN层 int value = INT_MAX; for (Move& move : moves) { board.makeMove(move.row, move.col, currentPlayer); int childValue = search(board, depth - 1, alpha, beta, getOpponent(currentPlayer)); board.undoMove(move.row, move.col, currentPlayer); value = min(value, childValue); beta = min(beta, value); if (value <= alpha) { break; // alpha剪枝 } } return value; } }走法排序是阿尔法-贝塔剪枝高效的关键。如果我们总是先搜索看起来最好的走法(比如吃角、吃边),那么就更可能触发剪枝条件。一个简单的排序方法是根据落子后能翻转的棋子数量,或者根据落子位置的位置价值进行排序。
5. 图形界面与AI的整合实战
5.1 主循环与多线程思考
将AI集成到图形化程序中,最大的挑战是不能让AI的思考阻塞主消息循环。如果在主循环中直接调用AISearch(),在AI思考的几秒甚至更长时间里,窗口会失去响应,用户无法进行任何操作。
解决方案是使用多线程。我们可以创建一个单独的线程来运行AI的思考算法。主线程(UI线程)负责渲染和响应用户输入。当轮到AI时,主线程启动一个工作线程进行搜索,搜索完成后,工作线程通过某种方式(例如设置一个标志位、发送消息、使用回调函数)将结果(最佳落子位置)通知给主线程,主线程再执行落子并更新界面。
一个简单的实现模式:
// 全局或类成员变量 std::atomic<bool> aiThinking(false); std::pair<int, int> aiBestMove; std::thread aiThread; // 在主游戏循环中 if (currentPlayer == AI_PLAYER && !gameOver && !aiThinking) { aiThinking = true; // 启动AI线程,传入当前棋盘状态的拷贝 aiThread = std::thread([this]() { aiBestMove = aiEngine.findBestMove(this->board, AI_DEPTH); aiThinking = false; // 思考完成 }); aiThread.detach(); // 分离线程,让其独立运行 } // 在渲染或更新逻辑中检查AI是否思考完毕 if (!aiThinking && currentPlayer == AI_PLAYER) { // 确保线程已结束(这里detach了,所以主要靠标志位) // 执行AI的落子 makeMove(aiBestMove.first, aiBestMove.second, AI_PLAYER); currentPlayer = HUMAN_PLAYER; // 可以在这里加入一个延迟,让玩家看清AI的落子 }注意:多线程编程需要小心数据竞争。确保AI线程读取的棋盘状态是稳定的(可以在启动线程时拷贝一份),并且AI线程在写入
aiBestMove和aiThinking标志时,主线程读取这些变量是安全的。使用std::atomic布尔类型或互斥锁(std::mutex)进行同步。
5.2 界面美化与用户体验优化
基础的棋盘棋子绘制完成后,我们可以添加很多细节来提升用户体验:
- 高亮合法落子点:在玩家回合,用半透明浅色圆点或高亮边框标出所有可以落子的位置。这需要调用
generateMoves函数获取列表并绘制。 - 落子动画:落子时,可以让棋子从小变大逐渐绘制出来,或者添加一个简单的音效(EasyX支持
mciSendString播放WAV文件)。翻转棋子时,可以逐帧绘制颜色渐变过程。 - 游戏信息显示:在棋盘旁或下方,用
outtextxy或settextstyle配合RECT结构绘制文本框,显示当前回合、双方棋子数、剩余空格、AI搜索深度等信息。 - 悔棋功能:维护一个游戏历史状态栈。每次落子(包括AI)前,将当前棋盘状态压栈。悔棋时,弹出栈顶状态并恢复。注意栈的大小管理。
- AI难度选择:提供下拉菜单或按钮,让用户选择AI的搜索深度。深度越大,AI越强,但思考时间也越长。可以将深度设置保存在配置文件中。
实现一个简单的落子点高亮示例:
void Game::drawHints() { if (currentPlayer != HUMAN_PLAYER) return; vector<Move> moves = generateMoves(board, HUMAN_PLAYER); setfillcolor(YELLOW); setlinecolor(BLACK); for (const Move& m : moves) { int x = BOARD_LEFT + m.col * CELL_SIZE + CELL_SIZE / 2; int y = BOARD_TOP + m.row * CELL_SIZE + CELL_SIZE / 2; // 画一个半透明的黄色小圆点作为提示 setfillcolor(EGERGB(255, 255, 0, 128)); // ARGB,128表示半透明 solidcircle(x, y, CELL_SIZE / 6); } }6. 性能调优与进阶探索
6.1 搜索效率的极致优化
当搜索深度增加,或者你想实现更强大的AI时,单纯的阿尔法-贝塔剪枝可能还不够。以下是一些进阶优化策略:
- 置换表:这是一个缓存,用于存储已经搜索过的局面对应的搜索结果(分数、最佳走法、搜索深度等)。当再次遇到相同的局面时,可以直接从表中读取结果,避免重复搜索。实现置换表需要解决棋盘局面的哈希问题(如使用Zobrist哈希),以及表项的替换策略(如始终替换、深度优先替换等)。
- 开局库:对于前十几步,直接使用人类大师对局或强AI计算好的最优走法,无需搜索。可以预置一个开局库文件,AI在开局阶段查表即可。
- 迭代加深:不直接设定一个固定深度,而是从深度1开始搜索,然后深度2,深度3... 直到用完分配的时间。这样既能保证在时间耗尽时有一个可用的结果(即使没搜到最深),也能利用浅层搜索的信息为深层搜索的走法排序提供更好的依据。
- 更精细的评估函数:加入更多高级特征,如“稳定子”识别、“前沿子”(与空位相邻的棋子)数量(少为好)、“奇偶性”策略等。这些特征的计算需要平衡准确性和速度。
6.2 常见问题与调试技巧实录
在开发过程中,你肯定会遇到各种问题。以下是我踩过的一些坑和解决方法:
AI走法明显愚蠢,甚至自杀:
- 检查点:首先确认
isValidMove函数逻辑完全正确。用打印日志的方式,在AI选择落子前,输出所有合法走法及其评估分数,看AI是否选择了分数低的。 - 评估函数问题:检查评估函数中,是否错误地将“对AI有利”的分数算成了负值。记住,在极大极小框架下,评估函数永远是从当前正在思考的AI玩家的视角打分。
- 搜索深度:深度为1时,AI就是“贪心”的,只看一步。深度太浅会导致AI看不到后续的翻转,从而做出短视决策。尝试增加深度。
- 检查点:首先确认
图形界面闪烁严重:
- 原因:这是因为在
while循环中不断cleardevice()清屏重绘,屏幕在清空和绘制之间快速切换。 - 解决方案:双缓冲。EasyX支持简单的双缓冲。在初始化窗口后,调用
BeginBatchDraw(),然后在主循环的渲染部分结束后调用FlushBatchDraw()。这样所有的绘图指令会先在一个内存画布上执行,完成后一次性更新到屏幕,消除闪烁。
initgraph(640, 480); BeginBatchDraw(); // 开启批量绘图 while (true) { // ... 处理消息、逻辑更新 ... cleardevice(); // ... 所有绘图操作 ... FlushBatchDraw(); // 批量绘制 Sleep(10); }- 原因:这是因为在
AI思考时间过长,程序“卡死”:
- 确认是否使用了多线程。如果没有,UI线程必然被阻塞。
- 检查剪枝和走法排序:低效的走法顺序会严重降低阿尔法-贝塔剪枝的效率。确保你的
orderMoves函数有效,例如优先搜索角点、边点、翻转数多的点。 - 限制搜索时间:实现迭代加深,并在每次深度增加前检查是否超时。可以设置一个最大思考时间(如5秒),时间一到,立即返回当前已找到的最佳着法。
悔棋后状态异常:
- 确保状态栈的完整性:每次落子前,保存的是落子前的完整状态(包括棋盘、当前玩家、步数等)。悔棋时,要恢复所有相关状态,而不仅仅是棋盘。
- 注意AI线程:如果玩家在AI思考时点击悔棋,需要能够中断AI的搜索。这可以通过在AI搜索函数中定期检查一个“停止标志”来实现,当悔棋按钮被按下时,设置这个标志。
这个项目从零到一的实现过程,本身就是一次对C++面向对象设计、算法应用和图形编程的综合性锻炼。当你看到自己编写的AI在棋盘上与你斗智斗勇,甚至能战胜浅层的自己时,那种成就感是无可替代的。你可以尝试为它添加更酷的功能,比如联网对战、不同风格的AI(激进型、保守型)、或者用更高级的算法(如蒙特卡洛树搜索)来改造它,这将会是另一个有趣的故事了。