MFC五子棋人机对战实战:从工程配置到AI权值评分全解析
简介基于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和.dswVC 6.0 时代新一点的有.vcxproj和.slnVisual 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直接返回 100000count 4时看block 0返回 10000block 1返回 5000以此类推。如果要调 AI 棋力调这张表的数值最直接。比如把活三从 2000 改成 4000AI 会变得激进优先自己成形而不是堵对手。4.4 AI 选点循环先按空点遍历再做随机加权AI 最终的执行入口是一个类似GetBestMove的函数。遍历所有空位用EvaluatePoint打分。这里有一个常常被忽略的细节如果出现多个同分最高点每次都选第一个那么 AI 的落子会非常“死板”同一盘棋的走法永远一样。处理方法是把分数排序后对前几名做小范围随机或者在高分区内加一个小的随机扰动值。// AI.cpp - 选点循环简化版 CPoint GetBestMove(int board[][15], int aiPlayer) { int bestScore -1; std::vectorCPoint 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的写法很典型玩家是 1AI 是 23 - 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并重写OnEraseBkgndBOOL 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 桌面应用骨架都在脑子里。希望帮到你。本文还有配套的精品资源点击获取