Visual Studio五子棋开发:从界面绘制到AI博弈算法详解
2026/9/8 12:39:13 网站建设 项目流程

简介:面向 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 窗口程序来做界面,绘图直接用 GDI(Graphics Device Interface),不需要引入额外的图形库。

GDI 的优点是 Windows 系统自带,API 稳定,画直线、画圆、填充颜色这些基础操作足够用,学习成本低。你不需要精通 GDI,只需要掌握几个关键函数:CDialogCWndOnPaint函数里用CPaintDC获得设备上下文,MoveTo/LineTo画线,Ellipse画椭圆(棋子),FillSolidRect填充背景。如果你用的是 VS2022,新建项目时选择“MFC 应用程序”,在向导里选“基于对话框”,就可以快速得到一个带窗体的工程骨架。

可能有同学问,为什么不用 Qt 或者 EasyX?Qt 功能强,但对于一个课程设计级别的五子棋来说,配置和信号槽的学习成本偏高;EasyX 更偏向于简单绘图,但它在 VS 里需要额外安装,而且它的事件循环和 MFC 差异较大。综合考虑,MFC + GDI 是最贴近 Visual Studio 原生环境、最不容易在环境配置上卡住的组合。如果你完全不会 MFC,用 Win32 API 手写窗口也可以,核心思路完全一样:注册窗口类、处理WM_PAINTWM_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; vector<Point> 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; vector<Point> 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 层。深度太浅(比如 1)AI 等于只看一步,跟纯评分法差不多;深度太深(比如 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++ MFC(x86 和 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,最后再考虑是否升级到搜索算法。每一步都是在前一步可运行的基础上迭代的,这样排查问题最快,也最能保证你的项目始终处于“能跑”的状态。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询