简介:这是一份面向C++初学者与课程设计需求者的MFC实战项目源码,围绕Windows平台下的人机对战五子棋展开,适合想通过完整案例理解图形界面开发、事件处理与基础AI算法的学习者。压缩包共41个文件,约1.94MB,以h头文件与cpp源文件为核心,配合sln、vcproj等工程配置,另有doc课程设计报告、bmp与ico界面素材及rc资源脚本,结构完整可直接编译运行。项目覆盖玩家信息录入、15×15棋盘绘制、落子与输赢判断、悔棋、错误提示等模块,并给出随机落子到Minimax、Alpha-Beta剪枝的AI实现思路,是理解MFC文档视图与CDC绘图机制的典型范例。目前已有185人学习下载,可帮助读者快速掌握五子棋核心逻辑与MFC工程组织方式,并在此基础上扩展更智能的对弈策略。
1. 从一份 MFC 五子棋源码说起:100013227 能跑出什么
很多人第一次接触 Windows 桌面开发,都是从 MFC 开始的。但真正让人卡住的不是语法,而是「一个能跑起来的完整工程长什么样」。这份编号 100013227 的资源,就是一套基于 MFC 实现的人机对战五子棋游戏,包含machinegobang源码目录、src源码、程序设计课程设计报告、LICENSE 和 README。它不是那种只贴几段核心代码的片段,而是一个能编译、能落子、能判断输赢、能悔棋的完整对话框工程。
如果你正在做 C++ 课程设计,或者想找一个体量适中、逻辑闭环的 MFC 练手项目,这份资源的价值在于:它把「棋盘绘制、鼠标落子、AI 走子、胜负判定、悔棋、错误提示」这几件事串成了一条线。下面我按实际拆包和复现的顺序,把这份资源讲透。
2. 工程结构与 MFC 对话框骨架:先搞清楚代码怎么组织
拿到一个 MFC 工程,最忌讳上来就改代码。先看目录结构,判断它是单文档、多文档还是对话框程序,这决定了后面所有消息映射的写法。
2.1 从 machinegobang 目录看工程类型
这份资源的源码主体在machinegobang和src两个目录里。常见的 MFC 五子棋工程会采用**基于对话框(Dialog-based)**的结构,原因是棋盘本身就是一个固定尺寸的绘制区域,不需要文档/视图那套框架。判断方法很简单:看有没有CWinApp::InitInstance()里出现dlg.DoModal()。
一个典型的对话框工程文件构成如下:
| 文件 | 作用 |
|---|---|
machinegobang.cpp | 应用类实现,程序入口InitInstance |
machinegobangDlg.cpp | 主对话框类,承载棋盘绘制与交互 |
machinegobangDlg.h | 对话框类声明,成员变量与消息函数 |
resource.h | 资源 ID 定义 |
*.rc | 对话框、菜单、图标等资源脚本 |
stdafx.cpp | 预编译头,老版本 VS 常见 |
提示:如果工程里出现
pch.h而不是stdafx.h,说明它是在 VS2017 之后创建的;两者只是预编译头命名不同,不影响逻辑。
2.2 消息映射:OnPaint 与 OnLButtonDown 是两条主线
MFC 对话框程序的核心是消息映射表。五子棋这个场景里,真正干活的消息只有两个:WM_PAINT负责画棋盘和棋子,WM_LBUTTONDOWN负责把鼠标点击换算成棋盘坐标。
// machinegobangDlg.h 中的消息声明 protected: afx_msg void OnPaint(); // 绘制棋盘与棋子 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); // 鼠标左键落子 DECLARE_MESSAGE_MAP()// machinegobangDlg.cpp 中的消息映射 BEGIN_MESSAGE_MAP(CMachinegobangDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() END_MESSAGE_MAP()逻辑说明:ON_WM_PAINT()把系统重绘请求绑定到OnPaint,窗口被遮挡、最小化恢复时都会触发;ON_WM_LBUTTONDOWN()把鼠标左键按下绑定到落子函数。参数point是客户区坐标,单位是像素,需要除以格子间距才能得到行列号。
参数说明:nFlags表示按键状态(是否同时按了 Ctrl、Shift),五子棋里一般用不到;point.x、point.y是相对窗口左上角的坐标。常见做法是设一个m_nCellSize(比如 30 像素)和m_nMargin(棋盘边距),用(point.x - m_nMargin + m_nCellSize/2) / m_nCellSize做四舍五入取整。
2.3 棋盘状态用二维数组存,别用容器
五子棋棋盘是 15×15,状态只有「空、黑、白」三种。最省事也最不容易翻车的做法是开一个定长二维数组:
// 0=空 1=黑子(玩家) 2=白子(AI) int m_board[15][15]; // 初始化 void CMachinegobangDlg::InitBoard() { memset(m_board, 0, sizeof(m_board)); // 全部置空 m_bGameOver = FALSE; // 重置游戏结束标志 m_nStepCount = 0; // 步数清零,用于悔棋 }逻辑说明:memset把整块内存按字节置 0,对 int 数组来说等价于全部赋 0,比双重循环快且不易写错。m_bGameOver控制游戏结束后是否还允许落子,m_nStepCount是悔棋功能的计数基础。
参数说明:数组维度写死 15 是五子棋标准盘面,如果要做 19 路,改维度同时要改绘制循环和胜负判断的边界。常见做法是把 15 定义成宏#define BOARD_SIZE 15,避免到处改数字。
3. 棋盘绘制与落子交互:OnPaint 里到底画了什么
棋盘画得好不好,直接决定这个项目看起来是「课程作业」还是「能拿得出手」。这一章把绘制和交互拆开讲,都是可以直接抄进自己工程的写法。
3.1 用 CDC 画网格线:双缓冲避免闪烁
直接在OnPaint里用CPaintDC画线,窗口一动就会闪。血泪经验是加一层内存 DC 做双缓冲。
void CMachinegobangDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); // 内存DC双缓冲,避免重绘闪烁 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); memDC.SelectObject(&bmp); memDC.FillSolidRect(&rect, RGB(238, 203, 140)); // 木色背景 // 画15x15网格 CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); memDC.SelectObject(&pen); for (int i = 0; i < BOARD_SIZE; i++) { int pos = m_nMargin + i * m_nCellSize; memDC.MoveTo(m_nMargin, pos); memDC.LineTo(m_nMargin + (BOARD_SIZE - 1) * m_nCellSize, pos); memDC.MoveTo(pos, m_nMargin); memDC.LineTo(pos, m_nMargin + (BOARD_SIZE - 1) * m_nCellSize); } DrawPieces(&memDC); // 画所有已落棋子 dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }逻辑说明:先在内存 DC 上完成全部绘制,最后一次性BitBlt到屏幕,这样中间过程不可见,不会闪。FillSolidRect铺底色,循环画横竖线,DrawPieces单独抽出来遍历m_board画圆。
参数说明:m_nMargin是棋盘左上角留白,一般取m_nCellSize的一半到一倍;m_nCellSize是格子边长,30 像素在 15 路盘上总宽约 450 像素,窗口尺寸要相应设置。RGB(238,203,140)是常见的木色,想换风格改这三个值即可。
3.2 鼠标坐标换算成行列:边界判断不能省
落子最容易翻车的地方是坐标换算后越界,或者点到格子外还硬落。
void CMachinegobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bGameOver) return; // 游戏已结束,忽略点击 int col = (point.x - m_nMargin + m_nCellSize / 2) / m_nCellSize; int row = (point.y - m_nMargin + m_nCellSize / 2) / m_nCellSize; // 越界或已有子,直接返回 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; if (m_board[row][col] != 0) return; m_board[row][col] = 1; // 玩家落黑子 m_nStepCount++; Invalidate(); // 触发重绘 if (CheckWin(row, col, 1)) { m_bGameOver = TRUE; MessageBox(_T("你赢了")); return; } AiMove(); // 轮到AI }逻辑说明:+ m_nCellSize/2是为了让点击落在格子中心附近也能正确取整,否则必须精确点到交叉点。越界和重复落子两个判断必须放在写数组之前,否则会写坏内存。Invalidate()触发WM_PAINT,棋盘才会更新。
参数说明:row是行(y 方向),col是列(x 方向),别写反。m_board[row][col] = 1里 1 代表玩家,2 代表 AI,这个约定要和胜负判断、绘制函数保持一致,否则会出现「画出来是白子但逻辑当黑子算」的玄学 bug。
3.3 胜负判断:四个方向扫一遍就够
判断输赢不需要遍历全盘,只需要以刚落的子为中心,向四个方向数连续同色子。
BOOL CMachinegobangDlg::CheckWin(int row, int col, int player) { // 四个方向:横、竖、主对角、副对角 int dir[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { int count = 1; // 正方向 for (int k = 1; k < 5; k++) { int r = row + dir[d][0]*k, c = col + dir[d][1]*k; if (r<0||r>=BOARD_SIZE||c<0||c>=BOARD_SIZE||m_board[r][c]!=player) break; count++; } // 反方向 for (int k = 1; k < 5; k++) { int r = row - dir[d][0]*k, c = col - dir[d][1]*k; if (r<0||r>=BOARD_SIZE||c<0||c>=BOARD_SIZE||m_board[r][c]!=player) break; count++; } if (count >= 5) return TRUE; } return FALSE; }逻辑说明:以落子点为中心,每个方向向两侧延伸,遇到边界或异色就停,累计数量达到 5 即获胜。这种写法比全盘扫描快得多,也更容易保证正确性。
参数说明:dir数组四个方向分别是水平、垂直、主对角线、副对角线,{1,-1}表示行增列减。player传 1 判玩家、传 2 判 AI,复用同一个函数。注意count初始为 1,代表落子本身。
4. 人机对战 AI 与悔棋:从随机落子到评分选点
AI 是这类课程设计里最能拉开差距的部分。资源里提到随机法和 Minimax,实际工程中更常见的是「评分表 + 局部搜索」,实现难度和效果比较平衡。
4.1 先做能跑的随机 AI,再谈优化
如果只是想让程序跑通,随机落子是最低成本的方案,但体验很差。常见做法是先写一个「能落子且不越界」的随机版本,把主流程跑通,再替换成评分算法。
void CMachinegobangDlg::AiMove() { // 收集所有空位 std::vector<CPoint> empty; for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) if (m_board[r][c] == 0) empty.push_back(CPoint(c, r)); if (empty.empty()) return; int idx = rand() % empty.size(); // 随机选一个空位 int row = empty[idx].y, col = empty[idx].x; m_board[row][col] = 2; m_nStepCount++; Invalidate(); if (CheckWin(row, col, 2)) { m_bGameOver = TRUE; MessageBox(_T("AI赢了")); } }逻辑说明:先把所有空位收集到vector,再随机取一个,避免随机坐标反复撞到已占位置导致死循环。落子后同样要重绘和判胜。
参数说明:CPoint(c, r)里 x 存列、y 存行,和前面坐标换算保持一致。rand()需要srand((unsigned)time(NULL))初始化,否则每次运行落子序列一样。
4.2 评分选点:给每个空位打分再取最大
想让 AI 有点「脑子」,核心思路是:对每个空位,分别评估「AI 下这里能形成多少连子」和「玩家下这里能形成多少连子」,两者加权求和,取最高分。
// 评估某点在某方向上的连子潜力,简化版 int CMachinegobangDlg::EvaluatePoint(int row, int col, int player) { int score = 0; int dir[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { int cnt = 1; for (int k = 1; k < 5; k++) { int r = row + dir[d][0]*k, c = col + dir[d][1]*k; if (r<0||r>=BOARD_SIZE||c<0||c>=BOARD_SIZE) break; if (m_board[r][c] == player) cnt++; else if (m_board[r][c] == 0) break; // 空位可延伸但不再计同色 else break; // 对方子阻断 } if (cnt >= 5) score += 10000; else if (cnt == 4) score += 1000; else if (cnt == 3) score += 100; else if (cnt == 2) score += 10; } return score; }逻辑说明:对四个方向分别统计同色连子数,按连子长度给不同权重。五连给最高分,四连次之,以此类推。AI 选点时,对每个空位算EvaluatePoint(row,col,2)*1.2 + EvaluatePoint(row,col,1),前者是进攻,后者是防守,系数 1.2 让 AI 略偏进攻。
参数说明:权重值(10000/1000/100/10)是经验值,可以调。系数 1.2 越大 AI 越激进,越小越保守。这个评分函数是简化版,没有考虑「两头堵」和「活四冲四」的差别,但作为课程设计足够用。
4.3 悔棋:用栈保存每一步
悔棋的本质是「撤销最近一步玩家 + 一步 AI」。用一个栈记录落子顺序,悔棋时弹出并清空对应格子。
// 落子时压栈 struct Move { int row, col, player; }; std::vector<Move> m_history; // 悔棋:撤销两步(AI一步 + 玩家一步) void CMachinegobangDlg::Undo() { if (m_history.size() < 2) return; for (int i = 0; i < 2; i++) { Move m = m_history.back(); m_history.pop_back(); m_board[m.row][m.col] = 0; // 清空该位置 } m_nStepCount -= 2; m_bGameOver = FALSE; // 悔棋后恢复可落子 Invalidate(); }逻辑说明:m_history按落子顺序存储,悔棋时连续弹出两步,把棋盘对应位置清空。m_bGameOver要重置,否则玩家赢了之后悔棋会卡在结束状态。
参数说明:Move结构体记录行列和落子方,方便扩展「只悔一步」或「悔到指定步」。m_history.size() < 2的判断防止空栈弹出崩溃,这是最常见的翻车点。
5. 编译与运行避坑:MFC 工程最容易翻车的五个地方
MFC 工程的坑大多不在业务逻辑,而在环境和配置。下面五条是我实际复现这类工程时最常遇到的。
5.1 现象:编译报stdafx.h找不到
原因:工程是老版本 VS 创建的,用的是stdafx.h预编译头,而新版本 VS 默认生成pch.h,或者根本没启用预编译头。
解决:在项目属性 → C/C++ → 预编译头里,把「预编译头文件」改成stdafx.h,或者把源码里的#include "stdafx.h"统一替换成#include "pch.h"。两者选其一,不要混用。
5.2 现象:提示「无法打开包括文件 afxwin.h」
原因:安装 Visual Studio 时没有勾选「使用 C++ 的桌面开发」里的 MFC 组件,MFC 头文件和库根本没装。
解决:打开 Visual Studio Installer → 修改 → 单个组件,搜索「MFC」,勾选「适用于最新 v143 生成工具的 C++ MFC」。装完重启 VS,afxwin.h就能找到了。
5.3 现象:程序能编译但运行时报「无法启动此程序,因为计算机中丢失 mfc140.dll」
原因:工程用了 MFC 动态库(共享 DLL),但目标机器没装对应运行库。
解决:项目属性 → 高级 → 「MFC 的使用」改成「在静态库中使用 MFC」。这样会把 MFC 代码静态链接进 exe,不依赖外部 dll,代价是文件变大。课程设计交作业建议用静态库,避免答辩机器上跑不起来。
5.4 现象:棋盘画出来了但点击没反应
原因:对话框没有收到WM_LBUTTONDOWN,常见于棋盘是用CStatic子控件画的,鼠标消息被子控件截走了。
解决:要么把棋盘的绘制和点击都放在主对话框的OnPaint和OnLButtonDown里(推荐),要么给子控件派生类并处理它自己的鼠标消息。检查消息映射表里ON_WM_LBUTTONDOWN()是否真的加在了主对话框类里。
5.5 现象:AI 落子后偶尔「卡死」或重复落同一位置
原因:随机 AI 用rand()生成坐标后没有检查该位置是否已有子,反复撞到已占位置导致循环。
解决:改成先收集所有空位再随机选,如 4.1 的写法。另外确认srand只调用一次,放在InitInstance或对话框初始化里,不要每次落子都调。
6. 把这份源码用出价值:改造方向与验证习惯
这份 100013227 资源最大的价值不是「能跑」,而是它提供了一个结构清晰的起点,方便你往上加东西。我一般会先做三件事验证它是否真的可用:第一,用静态库方式编译一遍,确认不依赖运行库;第二,故意在已有棋子位置点击,看是否有错误提示而不是崩溃;第三,连续悔棋到空盘,确认不会越界。
改造方向上,最值得动手的是把 4.2 的评分函数升级成带「活四/冲四/活三」区分的版本。做法是在EvaluatePoint里不仅数连子长度,还判断两端是否被堵。比如同样是四连,两端都空是「活四」,几乎必胜;一端被堵是「冲四」,威胁小很多。加了这个区分,AI 的棋力会有肉眼可见的提升。
另一个实用技巧是给 AI 加一层「必胜/必堵」的快速判断:落子前先扫一遍,如果 AI 自己能五连就直接下,如果玩家下一步能五连就堵住,这两个判断放在评分之前,能避免评分函数权重没调好时 AI「见死不救」。代码上就是在AiMove开头加两个FindWinPoint(player)调用,逻辑和CheckWin类似,只是把「判断是否赢」改成「找出能赢的点」。
从那以后我每次拿到这类 MFC 课程设计源码,都会先强制走一遍「静态库编译 → 边界点击 → 连续悔棋」这三步,确认工程是活的再动业务代码。希望这份拆解帮到你,少走几个配置上的弯路。
本文还有配套的精品资源,点击获取