简介:基于MFC框架开发的五子棋人机对战与人人对战项目,面向学习C++ Windows桌面应用和入门游戏AI的开发者。压缩包共37个文件,约3.55MB,包含7个头文件、6个cpp源文件、工程文件(dsp/sln/dsw)、MFC资源脚本(rc/rc2/ico/bmp),以及编译生成的exe、obj等中间文件,便于对照分析与二次开发。项目内置简易AI算法(Minimax/Alpha-Beta剪枝),可实现人机与人人两种模式,并提供比赛结果记录功能;在VC6.0环境下可完成创建、编译与调试,是理解MFC消息映射、文档视图结构、博弈树搜索及数据持久化的完整实战案例。目前已有195人学习/下载,适合需要进阶Windows界面程序设计与算法落地的开发者参考。
1. 从 MFC 五子棋人机这个工程包说起:它值得你花时间吗
打开网上搜索“MFC五子棋人机”,你会看到大量以mfc.rar这类命名存在的课程设计、毕业设计资源包。说句实话,这个标题听起来像是某个年代久远的压缩包,但它背后代表的是一个非常经典的 MFC 入门到进阶项目:用对话框或视图窗口绘制棋盘,鼠标落子,再加上一个人机对战 AI。很多人的第一份 MFC 实战代码就是它,也因此被“工程打不开、编译报错、AI 傻到不会堵你”劝退过。这篇文章不评价某个特定压缩包的质量,而是把这一类工程背后的实现路径拆开——从打开.dsp/.vcxproj工程文件,到棋盘绘制、落子交互、AI 评分算法、胜负判定,每一环都给出可以照着写的代码和参数说明。适合正在学 MFC 的初学者,也适合要交课程设计、想把老旧代码跑起来的从业者。
2. 把这套工程跑起来:工程结构、编译环境与第一条命令
2.1 压缩包里的东西通常长什么样,怎么认出主干
不管是哪个版本的五子棋 MFC 工程,解压后你最需要先找的是工程文件。老一点的有.dsp和.dsw(VC 6.0 时代),新一点的有.vcxproj和.sln(Visual Studio 2010 以后)。然后是对应的.cpp、.h、.rc资源文件。.rc文件决定了窗口上有什么按钮、菜单、对话框布局,很多编译错误恰恰是因为.rc文件里的资源 ID 和代码里#define的 ID 对不上。
拿到压缩包后,我的习惯是先别急着双击工程文件,而是打开资源视图看一下主对话框或主窗口的资源 ID。五子棋项目的主窗口通常是对话框(IDD_MAIN_DIALOG),也可能是CView单文档视图。这一步决定了你后面代码在哪里画棋盘。对话框程序常见用CDialogEx,它没有现成的OnDraw,一般是用自绘控件或者在OnPaint里画;视图程序则是重写OnDraw。
2.2 高版本 VS 打开老工程的三个必改项
现在拿一个 VC 6.0 年代的五子棋源码,用 Visual Studio 2019/2022 打开,几乎不可能一路点“确定”就编译通过。第一个必改项是字符集。老工程默认是多字节字符集,新 VS 默认是 Unicode,这会导致TCHAR相关代码疯狂报错。打开项目属性 -> 常规 -> 字符集,改成“使用多字节字符集”,问题往往少一半。
第二个必改项是 SDK 版本和目标平台版本。老工程的 Windows SDK 版本在项目属性里经常是 8.1,新机器上如果没有装对应的 SDK,会直接报“SDK 未找到”。把它改成你机器上有的版本,比如 10.0.22621.0。目标平台版本同理,选本地已安装的。
第三个必改项是平台工具集。VC 6.0 时代的工程文件里根本没有这个选项,VS 打开后需要手动选择Visual Studio 2019 (v142)或Visual Studio 2022 (v143)之类。这步不操作,直接编译会报fatal error C1083: 无法打开预编译头文件。所以我的顺序是:确定资源 ID -> 改字符集 -> 改平台工具集和 SDK 版本 -> 编译 -> 逐个消错误,而不是一上来就点“本地 Windows 调试器”等着看结果。
3. 棋盘绘制与鼠标落子:从坐标换算到重绘通知
3.1 画一个能下的棋盘,你要处理的是逻辑坐标和窗口大小的关系
五子棋棋盘固定在 15×15 或 19×19 格。画棋盘的正确姿势是让逻辑坐标和像素坐标解耦。一个常见的做法是定义起点m_startX、m_startY,每格间距m_cellSize,棋盘尺寸 = (格子数 - 1) × 格距。代码里不写死像素值,而是按窗口大小动态计算格距,这样窗口拉伸时棋盘居中。
// BoardView.cpp - 棋盘几何参数初始化(示例) #define BOARD_SIZE 15 m_cellSize = min(rect.Width() / (BOARD_SIZE + 2), rect.Height() / (BOARD_SIZE + 2)); m_startX = (rect.Width() - (BOARD_SIZE - 1) * m_cellSize) / 2; m_startY = (rect.Height() - (BOARD_SIZE - 1) * m_cellSize) / 2;这里rect是客户区矩形,+2是为了给棋盘四周留白,避免棋子贴到窗口边缘。m_cellSize取宽高两个方向的较小值,保证 15×15 的棋盘在任意窗口形状下都能完整绘制。参数选择上,19×19 棋盘+2也可以继续用,但格距会比 15 路的更小,容易造成鼠标点击误判,建议 19 路时用+3。
画线用LineTo循环画 15 条横线和 15 条竖线,画棋子用Ellipse。棋子的半径通常取m_cellSize * 0.42,留出 0.08 的空间作为棋子之间的视觉间隙,否则棋子边缘会互相挤住看上去像粘在一起。落子记录放在一个二维数组int board[15][15]里,0 表示空,1 表示黑子,2 表示白子。
3.2 OnLButtonDown 里的点击判定:距离阈值和边界检查这么写
鼠标点击的判定是整个交互里最容易出现“点不准”体验的地方。OnLButtonDown拿到的是像素坐标point,要做两件事:第一,判断点是否落在棋盘范围内;第二,把像素坐标转换成最近的格子索引。转换公式是col = round((point.x - m_startX) / m_cellSize)。这里必须用round做四舍五入而不是int强转,因为强转会直接把小数截断,导致你点在第 8 格和第 9 格中间偏右时被分配到第 8 格。
// BoardView.cpp - 鼠标落子与有效性检查 void CBoardView::OnLButtonDown(UINT nFlags, CPoint point) { int col = (point.x - m_startX + m_cellSize / 2) / m_cellSize; int row = (point.y - m_startY + m_cellSize / 2) / m_cellSize; // +m_cellSize/2 是为了实现四舍五入,等价于 round() if (col < 0 || col >= BOARD_SIZE || row < 0 || row >= BOARD_SIZE) return; // 点击位置在棋盘外 if (m_board[row][col] != 0) return; // 该位置已有棋子,忽略本次点击 m_board[row][col] = m_currentPlayer; // 记录当前玩家落子 // 把数组的内容同步到窗口 InvalidateRect(NULL, FALSE); // 触发重绘,FALSE 表示保留背景减少闪烁 // 重绘后,窗口的 OnPaint 会重新画棋盘和所有棋子 }InvalidateRect(NULL, FALSE)是这里的关键。FALSE参数告诉系统重绘时不擦除背景,配合OnEraseBkgnd里直接返回TRUE不重画背景,能有效降低棋盘闪烁。落子之后如果你直接调RedrawWindow会闪得厉害,因为背景被先擦成白色再重画。这个细节在你棋盘格数多、电脑分辨率高的时候特别明显。
3.3 禁止窗口拖动和固定大小:三个地方配合才不别扭
搜索热词里很多人在问“MFC禁止拖动窗口大小”和“MFC状态栏怎么显示”,这两个点正好是五子棋体验的加分项。棋盘窗口如果允许用户随意拉伸,格子间距会变化,整体观感不专业。常见做法是重写OnGetMinMaxInfo把最小最大尺寸设成同一个值:
void CBoardDlg::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { // 把窗口的宽高锁死在 800x600(示例值) lpMMI->ptMinTrackSize.x = 800; lpMMI->ptMinTrackSize.y = 600; lpMMI->ptMaxTrackSize.x = 800; lpMMI->ptMaxTrackSize.y = 600; }这个方案比去掉WS_THICKFRAME窗口样式要好,因为去掉厚边框虽然不能拖了,但窗口在任务栏右键菜单里“最大化”仍然可点,状态会不对。OnGetMinMaxInfo把两个方向都锁死,用户拖不动也最大化不了,视觉上更干净。
状态栏显示则是另一个常用技巧。对话框程序里没有现成的状态栏,常见做法是CStatusBar控件在OnInitDialog里创建,然后调用SetPaneText更新文本:显示当前谁走、第几步、AI 是否在思考。类视图应用程序则可以直接用框架自带的状态栏。
4. 人机 AI 怎么设计:权值评分是性价比最高的选择
4.1 为什么课程设计和简单实战项目里,少用博弈树搜索
很多人一提到五子棋 AI 就想到极小化极大搜索加 Alpha-Beta 剪枝,这个方向本身没错,但放在 MFC 框架下有几个现实问题。第一,MFC 是典型的界面线程模型,如果你在消息响应函数里直接跑一层 8 层深的博弈树,窗口会直接卡死,鼠标挪不动,像程序崩溃了一样。第二,五子棋的搜索空间远大于三子棋,没有好的启发式剪枝和评估函数,搜索个几百毫秒只能覆盖很浅的深度,下出来的人工智障。
常见做法是采用权值评分法。核心思想是:遍历棋盘上每一个空点,分别在横、竖、两条斜线四个方向统计“如果在这里落一颗棋子,会形成什么样的棋型”,比如活四、冲四、活三、眠三、活二。每种棋型赋予一个分数,进攻和防守统一用同一套分值表,AI 选分数最高的点落子。这方案实现简单、计算量小,在 15×15 棋盘上的响应时间可以做到 10 毫秒以内,效果对得起工程项目的定位。
4.2 棋型打分表:一套实用的权值参数
这里直接给一套我常用的分值,适合初级到中级水平的对战。如果电脑玩家和人对下,这套参数给人的感觉是“有压迫感但不会无敌”:
| 棋型 | 分值 | 说明 |
|---|---|---|
| 连五 | 100000 | 已经赢了,最高优先级 |
| 活四 | 10000 | 两端都开放,对手无法阻挡 |
| 冲四 | 5000 | 一端被堵,但只有一处能挡 |
| 活三 | 2000 | 两端开放的三连 |
| 眠三 | 500 | 一端被堵的三连 |
| 活二 | 200 | 两端开放的二连 |
| 眠二 | 50 | 一端被堵的二连 |
这套表的核心思路是“宁可不攻,也要先堵”。如果只看进攻分数,AI 容易只顾自己冲四,漏掉对手已经形成的活三。所以代码实现时我会对每个空点分别用黑子视角和白子视角各打一次分,取两个分数的最大值作为该点的最终权重。也就是说,这个点不管是自己走能赢,还是对方走能赢,都会进入 AI 的视野,这就是“攻防一体”。
4.3 落子评分函数:四方向统计的标准姿势
// AI.cpp - 对某个空位置进行评分(核心片段) int EvaluatePoint(int board[][15], int row, int col, int player) { int maxScore = 0; // directions[4][2] 表示四方向: 水平, 垂直, 左上到右下, 右上到左下 int dirs[4][2] = { {1,0}, {0,1}, {1,1}, {1,-1} }; for (int i = 0; i < 4; i++) { int count = 1; // 当前点本身算一颗 int block = 0; // 被封堵的端点数,0/1/2 int empty = 0; // 紧邻的空位数 // 正方向延伸 int nr = row + dirs[i][0], nc = col + dirs[i][1]; while (nr >= 0 && nr < 15 && nc >= 0 && nc < 15 && board[nr][nc] == player) { count++; nr += dirs[i][0]; nc += dirs[i][1]; } // 遇到边界或非己方棋子时,判断是否被堵死 if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15) block++; else if (board[nr][nc] != 0) block++; // 反方向延伸(对称逻辑,省略中间代码) // 略:向负方向同样统计 count 和 block // 根据 count 和 block 查表得分数,累加到 maxScore int score = ScoreTable(count, block, empty); maxScore += score; // 四方向分数累加,形成该点综合评分 } return maxScore; }这段代码的逻辑说明:dirs四个方向互相对称,正方向和反方向分别延伸,统计出连子数量count和封堵端点数block。四方向分数累加而不是取最大值,好处是棋盘中央的点天然更容易形成多方向配合(比如横着是活三、竖着是活二),综合实力更强,不会只盯着单条线走。ScoreTable就是前面那张表映射成函数:count == 5直接返回 100000,count == 4时看block == 0返回 10000,block == 1返回 5000,以此类推。如果要调 AI 棋力,调这张表的数值最直接。比如把活三从 2000 改成 4000,AI 会变得激进,优先自己成形而不是堵对手。
4.4 AI 选点循环:先按空点遍历,再做随机加权
AI 最终的执行入口是一个类似GetBestMove的函数。遍历所有空位,用EvaluatePoint打分。这里有一个常常被忽略的细节:如果出现多个同分最高点,每次都选第一个,那么 AI 的落子会非常“死板”,同一盘棋的走法永远一样。处理方法是把分数排序后,对前几名做小范围随机,或者在高分区内加一个小的随机扰动值。
// AI.cpp - 选点循环(简化版) CPoint GetBestMove(int board[][15], int aiPlayer) { int bestScore = -1; std::vector<CPoint> candidates; for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (board[r][c] != 0) continue; // 分别从己方和对方两个视角打分,取更大的那个 int score = max(EvaluatePoint(board, r, c, aiPlayer), EvaluatePoint(board, r, c, 3 - aiPlayer)); if (score > bestScore) { bestScore = score; candidates.clear(); candidates.push_back(CPoint(c, r)); } else if (score == bestScore) { candidates.push_back(CPoint(c, r)); } } } // 从这里随机选一个同分点,避免每次走法过于固定 int idx = rand() % (int)candidates.size(); return candidates[idx]; }3 - aiPlayer的写法很典型:玩家是 1,AI 是 2,3 - 1 = 2拿到 AI 自己的分,3 - 2 = 1拿到玩家的分。取 max 表示这个位置无论是进攻还是防守价值高,AI 都会优先占住。这个设计对应了实战中常见的“电脑只会进攻不会防守”的缺陷。整个循环时间复杂度 O(n²×16),15×15 棋盘上 225 个点,单次评分最多统计 4 方向 × 各延伸 14 步,计算量在毫秒级别,完全不需要开线程去异步计算,直接在按钮响应里同步调也不会卡界面。
5. 避坑与排查:MFC 五子棋最常见的 5 类问题
5.1 一编译就报错,C2027/C2065 这类语法错误满天飞
现象:解压别人的工程包,用 VS2019 打开,按 F7 编译,屏幕刷出一大堆error C2027: 使用了未定义类型或者C2065: 未声明的标识符。
原因:这类旧工程里大量依赖#include "stdafx.h",但高版本 VS 的预编译头机制对stdafx.h的支持有变化。还有一部分原因是代码里用了afxwin.h等 MFC 头文件,但工程建成了普通 Win32 控制台程序,没有勾选“使用 MFC”选项。
解决:右键项目 -> 属性 -> 常规 -> “MFC 的使用”改成“在共享 DLL 中使用 MFC”。如果你是新建的工程导入源码,注意创建工程时向导里就得选 MFC 应用,或者通过项目 -> 更改项目类型把项目变成 MFC 项。预处理器的WIN32_LEAN_AND_MEAN如果定义着且报 Windows 头文件错误,把这一项从预处理器定义里删掉。
5.2 点击棋盘没反应,OnLButtonDown 根本没进
现象:窗口画出来了、棋盘的格子也画出来了,但鼠标点上去棋子不出现。
原因:最常见的是把代码写在视图CView里,但视图类没有正确设置CS_HREDRAW/CS_VREDRAW以外的窗口样式,或者把OnLButtonDown重写成了对话框类里的消息函数。另一种情况比较隐蔽:棋盘用CStatic控件绘制,鼠标消息被控件截获,父窗口的OnLButtonDown根本收不到。
解决:在CStatic上做棋盘时,给控件设置SS_NOTIFY样式,并重写控件的OnLButtonDown而不是对话框的。对话框类自己画棋盘则没有这个问题。我一般用PreTranslateMessage做分发,把棋盘控件范围内的消息手动转到棋盘逻辑函数里处理。
5.3 棋盘闪烁严重,鼠标拖动窗口边缘时尤其明显
现象:每次落子后窗口闪白一下,像屏幕刷新跟不上。
原因:InvalidateRect(NULL, TRUE)会把背景擦成窗口背景色,再重画整个棋盘和棋子,双重绘制之间屏幕先亮后暗。这是 GDI 双击缓冲没做好的典型症状。
解决:把InvalidateRect的第二个参数改成FALSE,并重写OnEraseBkgnd:
BOOL CBoardView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉 MFC 背景不需要擦除,闪烁直接消失 }这个改法有个前提:你的OnPaint里画的内容必须覆盖整个客户区,否则会残留上一次的图形。画棋盘和棋子的代码会枚举所有坐标,天然覆盖全部,所以这里安全。如果你的窗口里有其他按钮或静态文本,它们所属的子控件自己管理重绘,不受影响。
5.4 AI 下棋位置经常和玩家选点重合或者落子后立刻又消失
现象:AI 走了一步棋,界面上的确画了一颗棋子,但紧接着又消失或者变成了另一个位置的棋子。
原因:AI 落子的棋盘数组和界面绘制的棋盘数组不是同一个。有一种典型的诡异问题是:AI 在一个函数里修改了局部数组副本,界面读的却是全局数组。这种情况常见于把棋盘数组定义为头文件里的static int board[15][15],多个.cpp各包含一份副本,互相不影响。
解决:棋盘数组只在一个.cpp里定义,其他文件用extern声明:
// BoardData.h extern int g_board[15][15]; // BoardData.cpp int g_board[15][15] = { 0 };5.5 判断胜负的算法把活四判成赢了,导致游戏提前结束
现象:玩家下了四颗连子,但中间有空位,并不是真正的五连,程序却弹窗“你赢了”。
原因:胜负判定函数只统计了连续同色棋子数量,没有检查两端是否为空。四子的活四和冲四都被当作胜利条件。
解决:判定时不仅要数字,还要检查两端。连续五颗同色棋子中,只要存在一种连续五子的排列且两侧不考虑对方棋子阻碍(即真正的count == 5)才算赢。正确写法是:从一个点出发,每四个方向统计连续同色棋子的数量,必须是精确等于 5 而不是大于等于 5,因为在 15×15 的棋盘上,count > 5意味着六连以上,此时反而要小心数组越界,一般判赢没问题,但要先做边界检查。更稳的方案是先判断当前位置落子后,四条直线上是否有任意一条形成连续五个同色,没有就把count > 5的情况交给后续的逻辑。
6. 再进一步:用三种方式验证你的 MFC 五子棋 AI 到底强不强
这个项目走到这一步,你已经有了完整的棋盘交互和一套权值评分 AI。接下来最值得做的事是验证 AI 是否“可调可控”。我常用的三种验证方式分别是:AI 自对弈、评分热力图、历史对局重演。
AI 自对弈是最简单的验收手段。写一个按钮驱动函数,让 AI 执黑和执白各走一盘,把落子序列存进文件。看序列里是否出现 AI 连续很多步都在被动防守,如果是,说明评分表里防守分权重不足。常见的调整手段:把活三的分值从 2000 提到 2500,或把冲四从 5000 提到 5500,让 AI 在判断“自己冲”和“堵对手”之间更加平衡。
评分热力图是调试 AI 是否“瞎走”的最好工具。在棋盘绘制代码里增加一种调试模式:遍历所有空位,调用EvaluatePoint计算出分数,把分数按区间映射到不同程度的高亮色。分数越高的位置颜色越红,空位是白色。这样你一眼就能看出 AI 的注意力集中在哪。如果你发现热力图呈现“哪里都是红色”,说明评分表区分度不够,棋型分值应该拉开差距;如果热力图只集中在一个狭长区域,可能是评分的方向统计有 bug,某个方向没有统计到。
历史对局重演功能虽然不复杂,但对“复盘 AI 每一步的选择”非常有用。把每步的落子坐标追加到一个数组文件,比如game_log.txt,然后加一个“回放”按钮,定时器每隔 300 毫秒把当前步数加 1 并重绘。在回放模式下,把数组对应的坐标画一个半透明标记,就能看到 AI 的思考路径。300 毫秒是人眼舒适的速度,改成 80 毫秒可以快速浏览全盘。为什么要这个功能?因为五子棋的胜负很多时候在 20 步内就决定了,回放能看到 AI 是在哪一步“脑子抽了”,而不是最后一步输了才去找原因。
我的个人习惯是每次调整完评分表,都先让 AI 执黑跟自己对弈五盘,只看两步棋之间是不是有明显的“打自己脸”的走法——比如上一手刚刚堵了对手的活二,下一手立刻在十几格之外下了一个无关的点。这种不连贯往往不是评分表的问题,而是方向统计时某个方向被漏了。这种问题用热力图一眼就能定位。三个工具配合下来,这个 MFC 五子棋工程的 AI 水平可以稳定在一个“能跟人认真下一会儿,但不会让人觉得绝望”的强度。做这方向最大的收获不是这个棋有多强,而是你完全掌控了从界面事件到业务算法到数据验证的这条链路;写过一遍,后面做任何带策略逻辑的 MFC 桌面应用,骨架都在脑子里。希望帮到你。
本文还有配套的精品资源,点击获取