Visual Studio五子棋开发:从界面绘制到AI博弈算法详解

发布时间:2026/9/8 12:39:15
Visual Studio五子棋开发:从界面绘制到AI博弈算法详解 简介面向 Visual Studio 游戏开发初学者的完整五子棋项目源码使用 C 与 EasyX 图形库实现涵盖人机对战和人人对战两种模式适合有一定 C 基础、想通过实战项目弄懂窗口事件驱动、棋盘状态管理和基础 AI 算法的学习者。压缩包共 49 个文件约 107MB包含 5 个 cpp 源文件、配套头文件、VS 工程文件以及用于界面绘制的 jpg/png 图片、背景音乐和音效 wav 素材另有调试缓存和存档记录。已有 2652 人学习浏览。项目完整度高人机模式以穷举为基础并涉及 Minimax 与 Alpha-Beta 剪枝的进阶思路同时实现落子合法性判断、五连胜负检测、悔棋、重新开始、保存/加载棋局等功能素材齐全、结构清晰便于直接打开研究和二次开发也可作为课程设计或毕业设计的参考。1. 项目整体设计与思路拆解1.1 功能模块划分与选择理由用 Visual Studio 做五子棋很多同学一上来就想把界面、逻辑、AI 全部写在一个文件里结果到后面逻辑乱成一团加一个悔棋功能都要翻半天代码。我的建议是先花半小时画一个模块划分草图把功能拆成三层界面层、逻辑层、AI 层。界面层负责画棋盘、画棋子、接收鼠标点击逻辑层维护一个 15×15 的二维数组记录每个位置的棋子状态0 空、1 黑子、2 白子同时负责落子合法性检查和胜负判断AI 层只做一件事——根据当前棋盘数组返回一个落子坐标。这个分层的好处是AI 算法写错了你只需要在逻辑层和数据层里调试不需要管界面刷新反过来界面卡了也不会牵连到 AI 逻辑。我见过一个典型的反面案例同学把“判断胜负”写在了绘图函数里每次重绘都跑一遍五子连珠判断结果棋盘刷新一卡一卡的。后来我帮他把判断逻辑抽出来放到每次落子之后单独调用问题立刻解决。这说明“职责分离”不是代码洁癖而是实实在在的调试效率。1.2 技术选型为什么用 C 和 GDI 绘制五子棋这个项目用控制台也能做但说实话效果大打折扣。既然标题是“Visual Studio 实现”我默认你在 Windows 环境下用 VS 开发推荐用 C 结合 MFC 对话框框架或者纯 Win32 窗口程序来做界面绘图直接用 GDIGraphics Device Interface不需要引入额外的图形库。GDI 的优点是 Windows 系统自带API 稳定画直线、画圆、填充颜色这些基础操作足够用学习成本低。你不需要精通 GDI只需要掌握几个关键函数CDialog或CWnd的OnPaint函数里用CPaintDC获得设备上下文MoveTo/LineTo画线Ellipse画椭圆棋子FillSolidRect填充背景。如果你用的是 VS2022新建项目时选择“MFC 应用程序”在向导里选“基于对话框”就可以快速得到一个带窗体的工程骨架。可能有同学问为什么不用 Qt 或者 EasyXQt 功能强但对于一个课程设计级别的五子棋来说配置和信号槽的学习成本偏高EasyX 更偏向于简单绘图但它在 VS 里需要额外安装而且它的事件循环和 MFC 差异较大。综合考虑MFC GDI 是最贴近 Visual Studio 原生环境、最不容易在环境配置上卡住的组合。如果你完全不会 MFC用 Win32 API 手写窗口也可以核心思路完全一样注册窗口类、处理WM_PAINT和WM_LBUTTONDOWN消息。2. 棋盘绘制与落子交互实现2.1 棋盘坐标与格子尺寸设计标准五子棋棋盘是 15×15 条交叉线不是 15×15 个格子。也就是说纵向 15 条线、横向 15 条线棋子落在线的交叉点上。设计坐标时我建议把棋盘左上角第一个交叉点定为原点设定格子边长为 40 像素棋盘边缘留 30 像素的边距。这样整个棋盘的像素宽度就是总宽度 边距 × 2 格子边长 × 14 30 × 2 40 × 14 620 像素窗口大小可以设置成 640×680 左右多出来的空间用于显示提示信息。实现时定义一个CPoint结构体数组或者直接用二维数组的索引来算像素坐标像素坐标 X 边距 列索引 × 格子边长 像素坐标 Y 边距 行索引 × 格子边长反过来鼠标点击后要得到棋盘行列索引就要做一次反向换算行索引 (点击Y - 边距 格子边长 / 2) / 格子边长 列索引 (点击X - 边距 格子边长 / 2) / 格子边长这里的加半个格子边长是为了四舍五入让鼠标点在两个交叉点中间时自动归到较近的那个交叉点。得到索引后一定要先判断是否在 0~14 范围内否则数组越界是必然的事。我用表格整理一下核心公式。操作计算公式说明坐标转索引row (y - margin halfCell) / cellSize四舍五入到最近交叉点索引转坐标x margin col * cellSize用于绘制棋子的圆心位置合法性判断0 row 15 0 col 15越界直接 return占用判断board[row][col] 00 表示空位这些公式看起来简单但实际编码时很容易犯错尤其是边缘点击时会出现负索引或者索引 15所以判断条件一定要写严谨。2.2 落子与界面刷新流程落子流程的本质是鼠标点击 → 坐标转换 → 合法性判断 → 更新二维数组 → 触发界面重绘 → 切换玩家。在 MFC 对话框程序中我一般重写OnLButtonDown消息处理函数代码逻辑大概是void CMyGobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 坐标转换为棋盘索引 int col (point.x - m_margin m_cellSize / 2) / m_cellSize; int row (point.y - m_margin m_cellSize / 2) / m_cellSize; if (row 0 || row 15 || col 0 || col 15) return; // 2. 检查该位置是否已有棋子 if (m_board[row][col] ! 0) return; // 3. 当前轮到玩家时落子标记为黑子 if (m_currentTurn TURN_PLAYER) { m_board[row][col] BLACK_STONE; m_currentTurn TURN_AI; // 4. 判断玩家是否胜利 if (CheckWin(row, col, BLACK_STONE)) { // 游戏结束处理胜利逻辑 } else { // 5. 触发重绘然后让 AI 思考落子 InvalidateRect(NULL, TRUE); AI_ThinkAndMove(); } } CDialogEx::OnLButtonDown(nFlags, point); }这里有一个很重要的细节InvalidateRect只是“标记窗口需要重绘”不会立刻重绘。真正的绘制动作发生在OnPaint里。所以你在别的地方先改了棋盘数组再调用InvalidateRectOnPaint执行时会读取最新数组画出来的就是最新状态。我见过有人试图在点击事件里直接调用绘制函数结果窗口一最小化再恢复画面就乱了因为WM_PAINT消息到来时重绘的还是旧内容。正确做法是所有画面都从棋盘数组推导OnPaint里只做绘制不做逻辑修改。2.3 棋子的绘制细节棋子不要直接画一个大圆那样看起来又扁又平。一个简单的提升方案是用双色填充先画一个深色底圆再在里面偏移几个像素画一个浅色小圆模拟高光。GDI 的Ellipse只能画等宽边框的圆想要渐变效果可以用CreateSolidBrush创建画刷配合SelectObject使用画完记得把旧对象恢复回来。还有一个细节是棋子的半径一般取格子边长的 40% 比较合适比如格子边长 40棋子半径就是 16 像素留出足够的间距让棋盘线可见。3. 核心胜负判断逻辑3.1 赢棋判定的四个方向五子棋的胜负判断是整个项目最核心、也最容易出 bug 的地方。判断逻辑不复杂从当前落子位置出发沿着四个方向水平、垂直、主对角线、副对角线分别向两端延伸统计相同颜色棋子的连续数量。如果任意一个方向连续同色棋子数达到 5 个当前方获胜。这里我推荐一个简洁的实现方式用一个方向数组把四个方向统一处理。方向可以用(dx, dy)表示四个方向分别是水平(0, -1) 和 (0, 1) 垂直(-1, 0) 和 (1, 0) 主对角线(-1, -1) 和 (1, 1) 副对角线(-1, 1) 和 (1, -1)以水平方向为例判断连续黑子数量的思路是从当前点出发向左一直数遇到非黑子或者越界就停再向右一直数遇到非黑子或者越界就停两边的数量加起来加上当前点自身就是水平方向连续黑子的总数。代码可以这样写bool CheckWin(int row, int col, int stoneType) { int directions[4][2] { {0, 1}, // 水平 {1, 0}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; for (int i 0; i 4; i) { int count 1; // 当前棋子 // 正方向延伸 for (int step 1; ; step) { int newRow row directions[i][0] * step; int newCol col directions[i][1] * step; if (newRow 0 || newRow 15 || newCol 0 || newCol 15) break; if (m_board[newRow][newCol] ! stoneType) break; count; } // 反方向延伸 for (int step 1; ; step) { int newRow row - directions[i][0] * step; int newCol col - directions[i][1] * step; if (newRow 0 || newRow 15 || newCol 0 || newCol 15) break; if (m_board[newRow][newCol] ! stoneType) break; count; } if (count 5) return true; } return false; }这段代码的优点是只扫描当前落点附近的棋子时间复杂度 O(1)不需要全盘扫描。很多新手会写成“遍历整个棋盘判断”虽然也能工作但每次落子都多了一个 225 格的全扫描属于无谓的开销。另外注意方向数组的设计把四个方向的正反两边统一处理代码量可以少很多。3.2 平局与游戏状态管理胜负判断正确之后还需要处理游戏状态。我习惯用一个枚举类型管理当前对局状态enum GameState { STATE_PLAYING, // 对局中 STATE_PLAYER_WIN, // 玩家胜 STATE_AI_WIN, // AI 胜 STATE_DRAW // 平局 };平局的判断最简单棋盘数组全部非空也就是没有任何空位此时没有产生五连就认为是平局。实际项目中平局很少见因为 15×15 棋盘 225 个位置要完全下满概率很低但判断逻辑不能省否则棋盘满了程序还在等待落子用户就会觉得卡死了。游戏状态管理的另一个作用是控制输入。对局结束后鼠标点击就不应该再落子否则用户可能在胜负已分的情况下继续乱点。我通常在OnLButtonDown开头检查m_gameState ! STATE_PLAYING直接 return。菜单栏可以加一个“重新开始”按钮把棋盘数组清零、状态改回STATE_PLAYING、重绘窗口这样一个完整的对局循环就闭环了。4. 人机对战AI从评分到搜索4.1 最简单的权重评分法人机对战是这个项目最吸引人、也最有技术含量的部分。AI 棋力的高低取决于评估函数和搜索深度。我先说最简单但很有效的办法权重评分法也叫贪心评分法。基本思路是遍历棋盘上所有空位对每个空位分别计算“如果 AI 落在这里AI 能获得多少分”和“如果玩家落在这里玩家能获得多少分”两者相加作为这个位置的最终得分AI 选择得分最高的位置落子。这样 AI 既会进攻选自己得分高的位置也会防守选对手得分高的位置只是防守和进攻的权重需要调优。分数怎么算比较通用的做法是以当前空位为中心在四个方向上分别截取一个长度为 5 的窗口分析窗口内棋子的排列模式。模式越接近五连分数越高。模式典型含义建议分值五连已经获胜100000活四两头都通必胜50000冲四X_ 等一头被堵对方必须堵5000活三能形成活四2000眠三X__ 等只能形成冲四500活二能形成活三100其他不明显0这里的分值不是绝对的你可以根据实际效果调整。我自己的经验是五连和活四的分值要拉开数量级否则 AI 可能在能赢的时候不去下制胜手反而去堵一个无关紧要的眠二。实现时定义一个分数表用四个方向的五元组枚举来查表。评分法的优点是代码量少、运行快、不用递归搜索适合作为第一个版本先跑通整个对局流程。缺点是 AI 只看眼前一步遇到“需要连续几步才能做杀”的局面会显得比较“短视”。我的建议是先实现评分法把整个项目打通再考虑要不要升级到搜索算法。4.2 极小化极大搜索与α-β剪枝进阶如果你想让人机对战的 AI 更聪明下一步就是在评分法的基础上加入搜索。搜索的核心思路是AI 不仅要看自己落子后的局面还要预判玩家会怎么应对双方轮流推演几层最后再根据终局局面评分选择最优路线。这就是经典的极小化极大Minimax算法。AI 层叫“极大层”它要从所有候选落点中选择评分最高的方案玩家层叫“极小层”它会假设玩家选择对 AI 最不利的落点。两层交替递归到设定的深度后用评估函数计算棋盘总分逐层回溯。α-β 剪枝则是这个算法的优化在搜索过程中维护一个当前最优值如果发现某条分支不可能比已知结果更好就直接剪掉不再继续深入。实际编码中单纯的剪枝还不够因为 15×15 棋盘空位太多即使剪枝到第四层计算量依然很大。一个常见的优化是只搜索“已有棋子附近的空位”比如只考虑距离任何已有棋子两格以内的空位候选点数量可以从两百多个降到大几十个搜索速度大幅提升。我给一个简化版的伪代码参考int Minimax(int depth, int alpha, int beta, bool isAITurn) { if (depth 0) return EvaluateBoard(); // 对整个棋盘打分 if (isAITurn) { int best -INF; vectorPoint candidates GetCandidateMoves(); for (Point p : candidates) { m_board[p.row][p.col] AI_STONE; best max(best, Minimax(depth - 1, alpha, beta, false)); m_board[p.row][p.col] EMPTY; alpha max(alpha, best); if (beta alpha) break; // 剪枝 } return best; } else { int best INF; vectorPoint candidates GetCandidateMoves(); for (Point p : candidates) { m_board[p.row][p.col] PLAYER_STONE; best min(best, Minimax(depth - 1, alpha, beta, true)); m_board[p.row][p.col] EMPTY; beta min(beta, best); if (beta alpha) break; // 剪枝 } return best; } }搜索深度我建议初次设置为 2 到 4 层。深度太浅比如 1AI 等于只看一步跟纯评分法差不多深度太深比如 6 层以上在普通 PC 上会明显卡顿用户体验很差。等你把主体功能跑通了再考虑用后台线程计算 AI 落子避免搜索期间窗口无响应。4.3 UI线程卡顿的避免这里要特别提醒一个问题如果你在OnLButtonDown里直接调用深度搜索搜索时间可能达到几百毫秒甚至几秒窗口会进入“未响应”状态系统会弹窗询问“是否关闭程序”非常影响体验。我的建议是第一版尽量用 2 层深度配合候选点裁剪把单次搜索时间控制在 100 毫秒以内如果一定要用更深搜索可以用std::thread开一个后台线程执行 AI 计算计算完成后通过PostMessage通知主线程刷新界面。线程之间共享棋盘数组要加锁或者利用PostMessage传递结果坐标保证数据安全。5. 常见问题与避坑指南5.1 环境与工程配置的坑在 VS 里新建 MFC 项目时如果安装 VS 时没有勾选“使用 C 的桌面开发”工作负载向导里是找不到 MFC 模板的。解决办法是在 Visual Studio Installer 里勾选“使用 C 的桌面开发”并同时勾选“适用于最新 v143 生成工具的 C MFCx86 和 x64”安装完成后重启 VS 就有了。字符集问题也很常见。VS 新建 MFC 项目默认使用 Unicode 字符集如果你在代码里直接写SetWindowText(对局开始)非 Unicode 的字符串常量会报类型不匹配的编译错误。我建议项目属性里保持 Unicode 不变代码中所有字符串都使用_T()宏包裹比如_T(对局开始)。如果实在不想处理可以在项目属性 → 常规 → 字符集里改成“使用多字节字符集”但要意识到这不是推荐做法。还有一个小坑是画线模糊。如果你把窗口设置成可缩放窗体变大变小后棋盘线可能出现模糊或者坐标错位。解决办法是重绘时每次都用最新的客户区大小重新计算偏移而不是用固定的边距值。5.2 逻辑与交互的坑收集整理一下我在写这个项目过程中踩过和帮别人解决过的问题做成一张速查表症状可能原因解决方案点击棋盘某个点棋子出现在旁边位置坐标换算公式少了半格偏移换算时加上cellSize / 2再整除棋子叠在已有棋子上没判断board[row][col] ! 0落子前强制检查非空直接 return五连了但没提示胜利检查方向只查了一个方向必须遍历四个方向窗口最小化再恢复棋子消失或乱画OnPaint里没有基于数组重绘保证OnPaint完整重绘整个棋盘AI 落子很慢窗口卡死在 UI 线程里跑深度搜索降低搜索深度或把搜索放到后台线程点“重新开始”后棋盘没清空忘记把数组置零并触发重绘重置数组 InvalidateRect编译报错找不到CDialogEx项目没启用 MFC 支持或头文件缺失确认工程类型是 MFC 应用程序并 includepch.h这些坑每个都是真实出现过的尤其是坐标换算和重绘问题几乎每个第一次写棋类程序的同学都会踩一遍。我建议你在动手前先在纸上把坐标公式推一遍再把棋盘数组这个核心数据结构定清楚后续写起来就会顺畅很多。5.3 评估与调试技巧写 AI 评分法时怎么验证 AI 的棋力是否正常一个技巧是给 AI 加上日志输出——每次 AI 落子时把它对每个候选点的进攻分、防守分输出到调试窗口或者临时文件你会发现 AI 跑偏的原因往往是分值配置不合理。比如 AI 总是只顾进攻不防守说明进攻分的权重远高于防守分如果 AI 总是畏手畏脚乱堵说明防守分权重过高。我还习惯加一个“指定 AI 先手/后手”的功能。AI 先手时开局会走天元正中央后手时第一手应该下在玩家落子的附近。这两个行为验证通过说明 AI 的基础逻辑基本正常。6. 扩展思路让项目从“能用”到“出彩”做完基础的人机对战后项目已经可以交了但如果你想让作品更有含金量这几个扩展方向可以挑一两个做进去悔棋功能用一个栈保存历史落子坐标和回合信息每次悔棋弹出栈顶几步并回退棋盘数组。这个功能实操性强在答辩时也容易讲清楚。提示功能调用 AI 搜索函数在当前局面下算一个推荐落点高亮显示相当于给玩家的“助手”。保存与复盘对局过程中把所有落子顺序记录到文件支持回放。这比截图演示有说服力得多。对战模式选择在菜单里增加“人人对战”模式AI 层不启用玩家双方轮流落子。更复杂的 AI 策略引入“冲四必须堵”“活三必须堵”等强制应对规则作为剪枝条件提升 AI 防守的精准度。7. 我的实操心得最后说一点个人体会。这类棋类项目真正花时间的不是界面也不是胜负判断而是 AI 的参数调优和边界情况处理。我第一次写的时候评分函数看起来没问题但 AI 就是时不时下出“自杀式”的昏招后来一步步打印候选点和分值才发现是模式统计的代码把“中间隔一子的连子”也算成了活三。这种逻辑错误靠眼睛看很难发现最好的办法就是写完立刻写一批针对性的测试用例比如“棋盘上只有一个活三时AI 的下一步应该堵在哪”把每个棋型的行为都验证一遍再开始调整体验。如果让我给出一个最关键的开发顺序我会建议先用最原始的随机落子 AI 把这个项目从点击到胜利跑通再用评分法替换随机 AI最后再考虑是否升级到搜索算法。每一步都是在前一步可运行的基础上迭代的这样排查问题最快也最能保证你的项目始终处于“能跑”的状态。本文还有配套的精品资源点击获取