多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++五子棋AI实战:EGE图形库与博弈算法实现

C++五子棋AI实战:EGE图形库与博弈算法实现 1. 项目概述从零构建一个带AI的图形化五子棋最近在整理自己的C学习项目库发现很多初学者在掌握了基础语法后常常会陷入“不知道接下来该做什么”的迷茫期。理论学了一堆但面对一个空白的IDE却无从下手。这时一个综合性、有趣味性且能串联起多个知识点的实战项目就显得尤为重要。基于EGE图形库开发一个五子棋人机对战游戏正是这样一个绝佳的练手项目。它不像大型游戏引擎那样复杂但又完整涵盖了图形界面渲染、用户交互、游戏逻辑和核心算法AI这四大模块堪称C从入门到实践的“毕业设计”。这个项目能帮你做什么首先它能让你彻底告别黑框控制台亲手绘制出棋盘、棋子用鼠标点击下棋获得最直观的视觉反馈和成就感。其次你需要设计严谨的游戏规则逻辑比如判断胜负、处理禁手如果你追求专业规则的话。最后也是最具挑战性的部分——实现一个哪怕是最简单的人机对战AI。这个过程会让你深刻理解什么是“搜索”与“评估”这是通往更复杂算法如博弈树、Alpha-Beta剪枝的必经之路。无论你是想巩固C面向对象编程、理解图形库工作原理还是对游戏AI产生兴趣这个项目都能提供一站式的实践场景。2. 核心思路与架构设计2.1 为什么选择EGE图形库在C的图形库生态中EasyX和EGE是面向初学者和教学场景的两大主流选择。EasyX知名度更高但主要活跃于较旧的VC6-VS2013环境。EGEEasy Graphics Engine可以看作是EasyX的一个现代、开源且持续维护的衍生版本。它兼容性更强支持从VC到GCCMinGW的多种编译器并且安装配置极其简单通常只需将头文件和库文件复制到编译器的对应目录即可。对于学习项目而言EGE的API设计直观上手曲线平缓能让我们快速聚焦于游戏逻辑本身而非纠缠于复杂的图形API细节。注意虽然网络上有大量基于EasyX的教程但使用EGE完全可行且其编程思路一脉相承。选择EGE意味着你能获得更好的现代编译器支持和社区资源。2.2 游戏核心模块划分一个结构清晰的五子棋游戏其代码应该像棋盘一样经纬分明。我建议采用模块化的思想将整个项目分解为以下几个核心类每个类职责单一GameBoard棋盘类这是游戏的数据核心。它内部维护一个二维数组例如int board[15][15]用0、1、2分别表示空位、黑棋、白棋。它的职责是纯粹的“状态管理”落子、判断指定位置是否为空、判断是否形成五连珠即胜负判断、以及可能的禁手规则判断。Renderer渲染器类这是游戏的“画家”。它依赖EGE的绘图函数但不应该包含任何游戏逻辑。它的职责是根据GameBoard的当前状态在窗口上绘制出棋盘网格、棋子、以及当前轮到谁下、胜负信息等UI元素。将渲染逻辑独立出来使得未来更换图形库或美化界面变得非常容易。InputHandler输入处理器类这是游戏的“耳朵”。它负责监听EGE的鼠标消息将屏幕坐标转换为棋盘上的行列索引并触发相应的游戏逻辑如玩家落子。它起到了连接用户操作与游戏核心逻辑的桥梁作用。AIPlayerAI玩家类这是游戏的“大脑”。当游戏模式切换到人机对战时这个类将被激活。它接收当前的棋盘状态通过内置的算法进行计算最终返回一个最优的落子位置行列。AI的强弱完全取决于这个类内部算法的设计。GameController游戏控制器类这是游戏的“总指挥”或“主循环”。它负责创建并协调以上所有类的实例。它控制着游戏的主循环处理输入 - 更新棋盘状态 - 判断胜负 - 切换玩家 - 重绘界面。它还管理着游戏模式双人对战 vs 人机对战、当前玩家等全局状态。这种“高内聚、低耦合”的设计使得调试、测试和功能扩展都变得非常方便。例如你想升级AI算法只需修改AIPlayer类的实现其他部分几乎不用动。3. 开发环境搭建与EGE配置3.1 编译器与IDE的选择对于C项目一个顺手的开发环境是成功的一半。Visual Studio Community版本功能强大调试方便是Windows平台下的首选。如果你更喜欢轻量级和跨平台那么VSCode MinGWGCC编译器的组合也非常流行。Visual Studio 2022直接在安装时勾选“使用C的桌面开发”工作负载即可。创建新项目时选择“空项目”。VSCode MinGW需要先安装MinGW-w64并将其bin目录添加到系统环境变量PATH中。然后在VSCode中安装C/C扩展。配置tasks.json用于编译和launch.json用于调试需要一些步骤但这能让你获得一个高度定制化的环境。实操心得对于初学者我强烈建议从Visual Studio开始。它的“一键调试”和直观的项目管理能帮你避开很多环境配置的坑让你更专注于代码本身。等熟悉了整个开发流程后再尝试VSCode等工具也不迟。3.2 EGE图形库的安装与项目配置EGE的安装非常简单以Visual Studio 2022为例下载EGE前往EGE的GitHub发布页或官方站点下载预编译好的开发包通常是一个zip文件如ege-xx.x-msvc.zip。解压与放置解压后你会看到include和lib文件夹。将它们整体复制到你的Visual Studio项目目录下或者放到一个你喜欢的固定位置比如D:\DevLibs\ege。配置项目属性在VS中右键点击你的项目 - “属性”。C/C - 常规 - 附加包含目录添加EGE的include文件夹路径例如$(ProjectDir)ege\include或D:\DevLibs\ege\include。链接器 - 常规 - 附加库目录添加EGE的lib文件夹路径例如$(ProjectDir)ege\lib或D:\DevLibs\ege\lib。链接器 - 输入 - 附加依赖项添加graphics64.lib如果你编译的是64位程序或graphics.lib32位程序。验证安装创建一个简单的.cpp文件包含EGE头文件#include graphics.h并写一个最简单的窗口创建代码编译运行。如果成功弹出一个窗口说明配置成功。// test_ege.cpp #include graphics.h int main() { initgraph(640, 480); // 初始化一个640x480的图形窗口 circle(320, 240, 100); // 在中心画一个圆 getch(); // 等待按键 closegraph(); // 关闭图形窗口 return 0; }4. 核心模块的详细实现4.1 棋盘逻辑类 (GameBoard) 的实现GameBoard类是游戏规则的基石。它的核心是一个二维数组。// GameBoard.h #pragma once #include vector class GameBoard { public: static const int BOARD_SIZE 15; static const int EMPTY 0; static const int BLACK 1; static const int WHITE 2; GameBoard(); // 在(row, col)放置棋子color成功返回true bool placePiece(int row, int col, int color); // 判断位置是否为空 bool isEmpty(int row, int col) const; // 获取指定位置的棋子 int getPiece(int row, int col) const; // 判断落子后是否获胜 bool checkWin(int row, int col, int color) const; // 重置棋盘 void clear(); // 获取当前棋盘状态只读 const std::vectorstd::vectorint getBoardState() const; private: std::vectorstd::vectorint board; // 15x15的棋盘 };checkWin函数是胜负判断的核心。思路是以刚落子的点为中心向四个方向水平、垂直、两个对角线进行扫描统计连续的同色棋子数量。// GameBoard.cpp 中 checkWin 函数的关键部分 bool GameBoard::checkWin(int row, int col, int color) const { // 四个方向向量: 水平(1,0), 垂直(0,1), 主对角线(1,1), 副对角线(1,-1) int dirs[4][2] { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; for (int d 0; d 4; d) { int dx dirs[d][0]; int dy dirs[d][1]; int count 1; // 刚落下的这颗子 // 向正方向搜索 for (int step 1; step 5; step) { int newRow row step * dx; int newCol col step * dy; if (newRow 0 || newRow BOARD_SIZE || newCol 0 || newCol BOARD_SIZE) break; if (board[newRow][newCol] ! color) break; count; } // 向反方向搜索 for (int step 1; step 5; step) { int newRow row - step * dx; int newCol col - step * dy; if (newRow 0 || newRow BOARD_SIZE || newCol 0 || newCol BOARD_SIZE) break; if (board[newRow][newCol] ! color) break; count; } // 如果任意方向连续子数达到5则获胜 if (count 5) { return true; } } return false; }注意事项这里的checkWin在每次落子后调用只检查以新落子点为中心的情况效率很高。但如果你要实现“禁手”规则如黑棋不能下“双三”、“双四”、“长连”判断逻辑会复杂很多需要在placePiece前进行额外的规则校验。4.2 图形渲染类 (Renderer) 的实现Renderer类的任务是把抽象的数据变成屏幕上可见的图像。它需要知道棋盘的大小、每个格子的像素尺寸、窗口偏移量等信息。// Renderer.h #pragma once #include “GameBoard.h” class Renderer { public: Renderer(int windowWidth, int windowHeight); void draw(const GameBoard board); // 绘制整个棋盘和棋子 void drawPiece(int row, int col, int color); // 在指定位置绘制一个棋子 void drawUI(int currentPlayer, bool gameOver, int winner); // 绘制UI信息 // 将屏幕像素坐标转换为棋盘行列索引 bool screenToBoard(int screenX, int screenY, int outRow, int outCol) const; private: int cellSize; // 每个格子的像素大小 int boardOffsetX, boardOffsetY; // 棋盘在窗口中的起始坐标 // ... 其他样式属性如颜色、字体等 };在draw函数中你需要清空背景。用setlinecolor和line函数绘制棋盘的横线和竖线。遍历GameBoard的数组对于非空位置调用drawPiece。黑棋可以用setfillcolor(BLACK)和solidcircle绘制实心圆白棋则先画一个白色实心圆再画一个黑色边框圆来模拟。绘制UI如当前回合提示、胜负信息。坐标转换函数screenToBoard至关重要它决定了鼠标点击是否能准确对应到棋盘交叉点。bool Renderer::screenToBoard(int screenX, int screenY, int outRow, int outCol) const { // 计算点击位置相对于棋盘左上角的坐标 int relativeX screenX - boardOffsetX; int relativeY screenY - boardOffsetY; // 如果点击在棋盘区域外返回false if (relativeX 0 || relativeY 0 || relativeX cellSize * GameBoard::BOARD_SIZE || relativeY cellSize * GameBoard::BOARD_SIZE) { return false; } // 将像素坐标除以格子大小得到近似的行列索引 int approxRow relativeY / cellSize; int approxCol relativeX / cellSize; // 计算点击位置距离最近交叉点的距离 int centerX approxCol * cellSize cellSize / 2; int centerY approxRow * cellSize cellSize / 2; int distance (screenX - (boardOffsetX centerX)) * (screenX - (boardOffsetX centerX)) (screenY - (boardOffsetY centerY)) * (screenY - (boardOffsetY centerY)); // 设置一个容差半径例如格子大小的1/3如果点击在交叉点附近则认为是有效落子 int tolerance cellSize / 3; if (distance tolerance * tolerance) { outRow approxRow; outCol approxCol; // 确保行列在有效范围内 if (outRow 0 outRow GameBoard::BOARD_SIZE outCol 0 outCol GameBoard::BOARD_SIZE) { return true; } } return false; }4.3 输入处理与游戏主循环InputHandler相对简单它通常在游戏主循环中被调用使用EGE的getmessage或peekmessage函数来获取鼠标事件。游戏的主循环通常在GameController或main函数中是整个程序运行的引擎它遵循一个经典的游戏循环模式// 伪代码逻辑 初始化EGE图形窗口 初始化GameBoard, Renderer, InputHandler, AIPlayer等对象 设置当前玩家 黑棋 设置游戏结束标志 false while (程序未退出) { // 1. 处理输入 while (有未处理的消息) { 获取消息 msg; if (msg.message 鼠标左键按下 且 游戏未结束) { InputHandler.转换坐标得到(row, col); if (GameBoard.该位置为空) { GameBoard.放置棋子(row, col, 当前玩家); if (GameBoard.检查胜利) { 游戏结束标志 true; 胜利者 当前玩家 } else { 切换当前玩家 // 黑变白白变黑 } // 重绘 Renderer.绘制(GameBoard); Renderer.绘制UI(...); // 如果是人机对战模式且轮到AI if (模式 人机对战 当前玩家 AI颜色 !游戏结束) { // 2. AI思考并落子 (row_ai, col_ai) AIPlayer.计算最佳落子(GameBoard); GameBoard.放置棋子(row_ai, col_ai, AI颜色); ... // 检查胜利、切换玩家、重绘 } } } if (msg.message 键盘按键 按键 ‘R’) { // 重置游戏 GameBoard.清空(); 重置玩家和结束标志 Renderer.绘制(...); } } // 3. 延时控制循环速度避免CPU占用率100% delay_fps(60); // 例如控制在60帧 } 关闭图形窗口这个循环清晰地展示了“事件驱动”和“状态更新”如何结合。所有游戏逻辑都围绕着用户输入和AI决策触发的状态改变而展开。5. 人机对战AI算法的设计与实现这是项目的精华和难点所在。一个最简单的AI是随机落子但这毫无趣味。我们来实现一个基于“评分表”的贪心AI它虽然不算聪明但已经具备了一定的防守和进攻意识足以让新手玩家感到挑战。5.1 核心思想局面评估与棋型识别AI下棋的本质是在众多可能的位置中选择一个对自己最有利、对对手最不利的位置。我们需要一个函数来量化这个“利”的程度即评估函数。我们不对整个棋盘进行全局评估而是对每一个空位进行独立评估计算如果在此处落子无论是己方还是对方能形成多大的“威胁”。威胁的形态就是五子棋中的各种“棋型”例如连五100000分直接获胜活四10000分下一步就能成五冲四5000分只有一个点能成五活三1000分可以发展成活四眠三200分可以发展成冲四活二100分眠二10分AI的决策过程分为两步评估每个空位模拟己方在此落子计算能形成的所有棋型得到一个“进攻分”再模拟对手在此落子计算对手能形成的棋型得到一个“防守分”因为阻止对手获胜同样重要。该空位的总评分 进攻分 防守分 * 一个权重系数例如0.8表示防守略次于进攻。选择最高分位置遍历所有空位选择总评分最高的位置落子。这就是“贪心”策略只考虑下一步的最优解。5.2 AIPlayer类的实现// AIPlayer.h #pragma once #include “GameBoard.h” #include utility // for std::pair class AIPlayer { public: AIPlayer(int aiColor); // aiColor是AI执子的颜色BLACK或WHITE std::pairint, int getBestMove(const GameBoard board); private: int myColor; int opponentColor; // 评估一个位置对于指定颜色的价值 int evaluatePosition(const GameBoard board, int row, int col, int color) const; // 识别并评分一个方向的棋型辅助函数 int evaluateDirection(const GameBoard board, int row, int col, int dRow, int dCol, int color) const; };getBestMove是主入口std::pairint, int AIPlayer::getBestMove(const GameBoard board) { int bestScore -1; std::pairint, int bestMove {-1, -1}; for (int i 0; i GameBoard::BOARD_SIZE; i) { for (int j 0; j GameBoard::BOARD_SIZE; j) { if (board.isEmpty(i, j)) { // 进攻分我下这里的价值 int attackScore evaluatePosition(board, i, j, myColor); // 防守分对手下这里的威胁我要阻止它 int defendScore evaluatePosition(board, i, j, opponentColor); // 总评分防守重要性系数设为0.7 int totalScore attackScore defendScore * 0.7; if (totalScore bestScore) { bestScore totalScore; bestMove {i, j}; } } } } // 如果所有位置评分都为0开局可以返回一个中心附近的位置 if (bestScore 0) { bestMove {7, 7}; // 棋盘中心 } return bestMove; }evaluatePosition和evaluateDirection函数是实现棋型识别的关键代码较长其核心是模拟落子后向四个方向扫描根据连续同色棋子、空白、边界或异色棋子的排列模式来判断属于哪种棋型。例如判断“活三”的模式可能是“OOO”O代表己方棋子代表空而“冲四”可能是“XOOOO”X代表对手或边界。实操心得编写棋型识别函数是调试AI时最繁琐的部分很容易漏掉某种情况。建议先实现最核心的连五、活四判断让AI能结束游戏和防守必杀棋。然后逐步添加活三、冲四等棋型的识别。可以单独写一个测试函数在特定棋盘布局下打印每个空位的评分来验证你的评估函数是否正确。5.3 AI算法的优化方向上述贪心AI已经可玩但仍有很大提升空间搜索深度贪心算法只看一步。更强的AI需要看多步这就是“博弈树搜索”。MiniMax算法是基础它假设双方都最优下棋递归地向前看N步选择最终局面评估分数最高的走法。Alpha-Beta剪枝这是对MiniMax的巨大优化。在搜索过程中如果发现某条分支明显差于已知的另一条分支就提前停止搜索这条分支能极大减少计算量。启发式搜索棋盘上大部分位置价值很低不需要评估。可以只评估当前所有棋子周围一定范围如2格内的空位这些位置称为“气点”。Zobrist哈希与置换表对于重复出现的棋盘局面直接查表获取评估结果避免重复计算。开局库与残局库使用预定义的开局走法和已知必胜的残局走法。对于学习项目实现一个带简单搜索深度2-4层和Alpha-Beta剪枝的MiniMax算法你的AI就能达到业余中级水平这将是项目一个非常亮眼的成果。6. 项目集成、调试与功能扩展6.1 将所有模块组装起来在main.cpp或GameController中你需要创建所有类的实例并搭建好主循环。确保消息处理、状态更新、画面渲染的流程正确。一个常见的错误是渲染顺序不对导致棋子被网格线盖住或者UI文字没有显示。记住EGE的绘图顺序后绘制的图形会覆盖先绘制的。6.2 调试技巧与常见问题鼠标点击不准这是screenToBoard函数容差设置或坐标计算错误。在调试时可以在鼠标点击处画一个临时红点并打印出计算出的行列号与实际棋盘对比。AI下子太慢如果实现多步搜索递归深度过深会导致计算时间指数级增长。使用Alpha-Beta剪枝并限制搜索深度例如4层。也可以在AI思考时在窗口标题显示“AI Thinking...”给用户一个反馈。内存访问越界在checkWin或棋型识别函数中数组索引[row][col]一定要在访问前检查是否在[0, BOARD_SIZE-1]范围内。游戏逻辑错误比如该切换玩家时没切换胜负判断后游戏没有停止。使用断点调试一步步跟踪程序状态。6.3 功能扩展建议一个基础版本完成后你可以考虑添加以下功能让项目更加丰满游戏模式选择在程序启动时或通过菜单让玩家选择“双人对战”或“人机对战”。AI难度选择实现不同搜索深度或不同评估函数的AI作为简单、中等、困难等难度。悔棋功能在GameBoard类中维护一个落子历史栈std::vectorstd::pairint, int悔棋时弹出栈顶位置并清空该位置棋子。保存与加载棋局将棋盘状态二维数组写入文件或从文件读取。音效与动画EGE支持播放WAV音效。落子时播放“啪”的音效获胜时播放一段音乐。落子时可以加入棋子从上方落下的简单动画。更美观的UI使用EGE的putimage函数加载棋盘背景图片、更精致的棋子图片。用setfont和outtextxy绘制更漂亮的文字提示。7. 项目总结与进阶思考完成这个项目后你收获的不仅仅是一个能运行的五子棋游戏。你实践了面向对象的设计思想将图形、逻辑、输入、AI清晰地分离。你深入理解了事件驱动编程和游戏主循环。你亲手实现了一个具体的评估函数并窥见了博弈树搜索这一广阔领域的大门。这个项目代码量大约在800-1500行是一个非常好的简历作品。在面试中你可以清晰地阐述每个模块的职责分析你所实现AI算法的优缺点例如贪心算法的局限性并讨论如何用MiniMax和Alpha-Beta剪枝进行改进。我个人在实现过程中最深的一点体会是数据和视图的分离至关重要。最初我把绘制棋盘的代码和棋盘数据混在一起导致后来想加一个“预览落子位置”的功能时改动非常痛苦。彻底重构为GameBoard和Renderer后一切变得清晰且易于扩展。另一个教训是关于AI评估函数的调参进攻和防守的权重系数需要大量对弈来调整平衡这个过程本身就像在训练一个简单的模型充满了乐趣。如果你对AI部分意犹未尽可以尝试挑战更复杂的算法比如蒙特卡洛树搜索MCTS它在围棋、象棋等游戏中取得了巨大成功。或者将图形库从EGE迁移到更底层的OpenGL或SDL学习真正的游戏渲染管线。这个五子棋项目完全可以成为你游戏开发与算法学习之路上一块坚实的跳板。
返回列表