
简介这是一份面向高校C课程期末大作业场景的完整项目源码基于MFC框架实现人机对战五子棋适合正在学习C面向对象编程、Windows桌面开发与基础AI搜索算法的学生参考与二次开发。压缩包共36个文件约160KB以cpp与h源码文件为主辅以vcproj、sln、vcxproj等工程配置文件和rc资源脚本另含ReadMe说明与评分文档可直接用Visual Studio打开编译运行。项目围绕游戏界面绘制、棋盘落子与胜负判断、极小化极大搜索与启发式估值函数、悔棋计时与难度选择等模块展开并将游戏逻辑、界面与电脑棋手分离为不同类体现清晰的面向对象设计思路。已有103人学习下载读者可借此获得一份结构完整、可运行可调试的课程设计参考方案理解MFC消息机制与AI走棋评估的实现要点并在此基础上改进算法或扩展功能。1. 从一份 C 期末大作业说起MFC 人机对战五子棋到底要做什么很多人搜 C 期末大作业翻到的都是控制台版贪吃蛇、图书管理系统这类东西交上去老师看一眼就给个及格。真正能拉开差距的是带图形界面、带算法对抗、能当场演示的完整项目。基于 MFC 的人机对战五子棋就是这样一个典型它同时踩中了 C 面向对象、Windows 消息机制、GUI 绘图、博弈搜索算法四个考点工作量够大演示效果够直观代码量又控制在一个人两三周能啃下来的范围内。这个项目的核心目标很明确用 MFC 搭一个 15×15 的棋盘窗口玩家鼠标点击落子电脑在合理时间内算出应对位置并落子任意一方连成五子即判胜负。听起来简单但真正动手你会发现难点根本不在画棋盘而在于怎么让电脑“看起来聪明”——它不能太强让你永远赢不了也不能太弱一步就被堵死。这中间的搜索深度、评估函数权重、候选点剪枝策略才是决定这个作业能不能拿高分的关键。适合谁做学过 C 基础语法、了解类和对象、但没怎么碰过 Windows 编程的本科生。如果你连指针和数组都还发怵建议先把 C 基础打牢再回来。下面从工程搭建开始一步步把这个东西跑起来。2. 用 Visual Studio 搭出 MFC 五子棋工程骨架2.1 创建 MFC 项目与窗口初始化打开 Visual Studio2019 或 2022 都行新建项目搜索“MFC”选择“MFC 应用”项目名填 Gobang。向导里应用程序类型选“单文档”项目样式选“MFC 标准”其余保持默认。生成后你会得到一整套文档/视图框架但五子棋其实用不了这么重我一般会把大部分自动生成的菜单和工具栏删掉只保留主框架窗口和视图类。关键操作在视图类的OnDraw函数里。MFC 的绘图全部走 CDC设备上下文棋盘、棋子、提示信息都在这画。先在视图类头文件里加几个成员变量// GobangView.h 视图类成员变量 const int BOARD_SIZE 15; // 15路棋盘 const int CELL_SIZE 40; // 每格像素 int m_board[BOARD_SIZE][BOARD_SIZE]; // 0空 1黑(玩家) 2白(电脑) bool m_bGameOver; // 游戏结束标志 CPoint m_lastMove; // 最后落子位置用于高亮m_board用二维 int 数组存棋盘状态比用 vector 更直观也方便调试时直接看内存。CELL_SIZE设 40 像素15 格就是 600×600 的绘图区加上边距窗口大概 640×680在 1080P 屏幕上刚好。初始化在视图类构造函数里做CGobangView::CGobangView() noexcept { memset(m_board, 0, sizeof(m_board)); // 全部置空 m_bGameOver false; m_lastMove CPoint(-1, -1); }memset把整个数组清零比双重循环快且不容易写错。注意这里不能用 {0}之外的花哨写法MFC 项目里保持朴素最稳。2.2 棋盘绘制与鼠标落子响应绘图逻辑写在OnDraw里。先画木色背景再画 15 条横线和 15 条竖线最后遍历m_board把已有棋子画上去。void CGobangView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); // 木色背景 pDC-FillSolidRect(rect, RGB(222, 184, 135)); CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen pDC-SelectObject(pen); // 画网格线 for (int i 0; i BOARD_SIZE; i) { int pos CELL_SIZE / 2 i * CELL_SIZE; pDC-MoveTo(CELL_SIZE / 2, pos); pDC-LineTo(CELL_SIZE / 2 (BOARD_SIZE - 1) * CELL_SIZE, pos); pDC-MoveTo(pos, CELL_SIZE / 2); pDC-LineTo(pos, CELL_SIZE / 2 (BOARD_SIZE - 1) * CELL_SIZE); } // 画棋子 for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] 0) continue; int x CELL_SIZE / 2 c * CELL_SIZE; int y CELL_SIZE / 2 r * CELL_SIZE; CBrush brush(m_board[r][c] 1 ? RGB(0,0,0) : RGB(255,255,255)); CBrush* pOldBrush pDC-SelectObject(brush); pDC-Ellipse(x - 16, y - 16, x 16, y 16); pDC-SelectObject(pOldBrush); } } pDC-SelectObject(pOldPen); }坐标换算关系要记牢棋盘第 c 列第 r 行的交叉点屏幕坐标是(CELL_SIZE/2 c*CELL_SIZE, CELL_SIZE/2 r*CELL_SIZE)。这个偏移量CELL_SIZE/2是为了让最边缘的线不贴窗口边。鼠标响应用OnLButtonDownvoid CGobangView::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bGameOver) return; // 屏幕坐标反算行列 int c (point.x - CELL_SIZE / 2 CELL_SIZE / 2) / CELL_SIZE; int r (point.y - CELL_SIZE / 2 CELL_SIZE / 2) / CELL_SIZE; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) return; if (m_board[r][c] ! 0) return; // 已有子 m_board[r][c] 1; // 玩家黑子 m_lastMove CPoint(c, r); if (CheckWin(r, c, 1)) { m_bGameOver true; MessageBox(_T(你赢了)); } else { ComputerMove(); // 电脑走 } Invalidate(); // 触发重绘 CView::OnLButtonDown(nFlags, point); }反算行列时那个 CELL_SIZE/2再除的写法本质是四舍五入到最近的交叉点。如果你直接整除点在线中间会偏半格手感很差。Invalidate()是 MFC 里触发重绘的标准做法别去手动调OnDraw。提示MFC 项目里字符串统一用_T()宏包裹否则 Unicode 字符集下会报错。这是新手第一个翻车点。3. 人机对战的核心评估函数与极小极大搜索怎么落地3.1 棋型评分表的设计与权重调参电脑要“聪明”本质是给每个空位打分选最高分的落子。打分依据是这个位置放子后能形成什么棋型。五子棋常见棋型按威胁从高到低连五、活四、冲四、活三、眠三、活二、眠二。我一般用一张评分表棋型说明分值连五五子相连100000活四两端开放的四连10000冲四一端被封的四连1000活三两端开放的三连1000眠三一端被封的三连100活二两端开放的二连100眠二一端被封的二连10注意活三和冲四都给 1000因为实战中一个活三的威胁约等于一个冲四都需要立即应对。这张表不是死的你可以根据电脑强弱调整。想让电脑更凶把进攻方分值乘 1.2想让电脑更稳把防守方分值乘 1.5。评估函数的核心是扫描四个方向横、竖、左斜、右斜统计每个方向的连续同色子数和两端封堵情况// 评估某个位置放 color 后的得分 int CGobangView::EvaluatePoint(int r, int c, int color) { int totalScore 0; int dirs[4][2] {{0,1},{1,0},{1,1},{1,-1}}; for (int d 0; d 4; d) { int count 1; // 包含当前子 int block 0; // 被封端数 int empty 0; // 中间空位数用于跳活三判断 // 正方向扫描 for (int step 1; step 4; step) { int nr r dirs[d][0] * step; int nc c dirs[d][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { block; break; } if (m_board[nr][nc] color) count; else if (m_board[nr][nc] 0) { empty; break; } else { block; break; } } // 负方向扫描同理略 // ... totalScore GetShapeScore(count, block, empty); } return totalScore; }GetShapeScore根据 count、block、empty 三个参数查表返回分值。这里有个细节如果 count 达到 5直接返回连五分不用再看 block。跳活三如 X_XX的判断靠 empty 参数中间隔一个空位但两端开放威胁同样很大分值给活三的 0.8 倍。3.2 极小极大搜索与 Alpha-Beta 剪枝实现光有评估函数不够电脑还要预判玩家下一步。极小极大搜索的思路是电脑走一步假设玩家走最优应对再假设电脑走最优如此递归若干层选一个最坏情况下最好的走法。朴素极小极大在 15×15 棋盘上搜索深度 4 就会爆炸必须加 Alpha-Beta 剪枝。核心思想是如果某个分支已经确定比已知最差选择还差就不用继续搜了。// alpha: 当前MAX层已知最好值, beta: MIN层已知最好值 int CGobangView::Minimax(int depth, int alpha, int beta, bool isMax) { if (depth 0) return EvaluateBoard(); // 到达深度返回全局评估 // 获取候选点只考虑已有棋子周围2格内的空位 vectorCPoint candidates GetCandidates(); if (candidates.empty()) return EvaluateBoard(); if (isMax) { // 电脑回合最大化 int best -INF; for (auto p : candidates) { m_board[p.y][p.x] 2; int val Minimax(depth - 1, alpha, beta, false); m_board[p.y][p.x] 0; best max(best, val); alpha max(alpha, best); if (beta alpha) break; // 剪枝 } return best; } else { // 玩家回合最小化 int best INF; for (auto p : candidates) { m_board[p.y][p.x] 1; int val Minimax(depth - 1, alpha, beta, true); m_board[p.y][p.x] 0; best min(best, val); beta min(beta, best); if (beta alpha) break; // 剪枝 } return best; } }GetCandidates是关键优化不要遍历全部 225 个空位只取已有棋子周围 2 格内的空位。开局阶段候选点可能只有十几个中盘也就三四十个搜索空间直接降一个数量级。搜索深度建议设 4 层电脑-玩家-电脑-玩家。深度 2 电脑太短视深度 6 在普通笔记本上要等好几秒体验差。深度 4 配合好的评估函数已经能下出像样的棋。注意递归里修改m_board后一定要还原否则棋盘状态会乱。这是搜索类算法最常见的 bug调试时如果发现电脑“看到”的棋盘和实际不符先查这里。4. 胜负判定、状态显示与工程收尾4.1 五子连珠判定的四个方向扫描胜负判定比想象中简单从最后落子点出发四个方向各数一遍同色连续子任意方向达到 5 就赢bool CGobangView::CheckWin(int r, int c, int color) { int dirs[4][2] {{0,1},{1,0},{1,1},{1,-1}}; for (int d 0; d 4; d) { int count 1; // 正方向 for (int step 1; step 5; step) { int nr r dirs[d][0]*step, nc c dirs[d][1]*step; if (nr0||nrBOARD_SIZE||nc0||ncBOARD_SIZE) break; if (m_board[nr][nc] ! color) break; count; } // 负方向 for (int step 1; step 5; step) { int nr r - dirs[d][0]*step, nc c - dirs[d][1]*step; if (nr0||nrBOARD_SIZE||nc0||ncBOARD_SIZE) break; if (m_board[nr][nc] ! color) break; count; } if (count 5) return true; } return false; }只从最后落子点判断不用全盘扫描效率高且逻辑清晰。注意正负方向各扫 4 步就够因为加上当前子最多 9 个超过 5 就返回。4.2 状态栏显示当前回合与提示信息MFC 单文档程序自带状态栏但默认只显示“就绪”。要显示“轮到你了”“电脑思考中”这类信息需要拿到状态栏指针并设置窗格文本。在MainFrm.cpp里状态栏的窗格在static UINT indicators[]数组里定义。默认有三个窗格ID_SEPARATOR、ID_INDICATOR_CAPS、ID_INDICATOR_NUM。我一般把第二个改成自定义 ID// MainFrm.cpp static UINT indicators[] { ID_SEPARATOR, // 主提示区 ID_INDICATOR_TURN, // 自定义当前回合 ID_INDICATOR_CAPS, };然后在resource.h里加#define ID_INDICATOR_TURN 59142选一个没占用的 ID。更新状态栏文本// 在视图类里获取状态栏并设置文本 CMainFrame* pFrame (CMainFrame*)AfxGetMainWnd(); if (pFrame) { pFrame-m_wndStatusBar.SetPaneText(1, _T(轮到你了)); }SetPaneText第一个参数是窗格索引0 是主提示区1 就是你自定义的那个。电脑思考时设成“电脑思考中…”结束后改回“轮到你了”。这个细节虽小但演示时老师一眼就能看到交互反馈印象分直接拉满。提示状态栏窗格宽度可以在SetPaneInfo里调整默认太窄会显示不全。设成 120 像素左右比较合适。5. 避坑与排查MFC 五子棋最容易翻车的五个地方5.1 点击落子位置偏移半格现象鼠标点在交叉点上棋子却画在格子中间。 原因坐标反算时没有做四舍五入直接整除导致向下取整。 解决反算公式改成(point.x - CELL_SIZE/2 CELL_SIZE/2) / CELL_SIZE本质是加半个格子再除。更严谨的写法是用round函数但整数运算里加偏移量就够了。5.2 电脑思考时界面卡死现象点击落子后窗口无响应几秒后才恢复。 原因搜索算法在 UI 线程里同步执行阻塞了消息循环。 解决简单做法是在搜索前调SetPaneText显示“思考中”并强制刷新让用户知道程序没死。进阶做法是用工作线程跑搜索但 MFC 多线程涉及跨线程更新 UI新手容易踩更多坑。期末作业级别同步搜索加提示就够了深度控制在 4 层以内一般不超过 1 秒。5.3 评估函数导致电脑“自杀”现象电脑明明可以堵住玩家的活四却跑去下无关位置。 原因评估函数只算了进攻分没算防守分。电脑只看到自己下那里能形成活三没看到玩家下一步就连五了。 解决评估时同时计算“我方下这里的得分”和“对方下这里的得分”最终分值取两者加权。防守权重设 1.2 到 1.5让电脑优先堵。具体做法是在EvaluatePoint里分别用 color1 和 color2 算两次取max(attack, defense * 1.3)。5.4 递归搜索后棋盘状态未还原现象电脑走了几步后棋盘上出现莫名其妙的白子或者玩家落子被覆盖。 原因Minimax 递归里修改了m_board但没有在返回前还原。 解决每次m_board[p.y][p.x] 2之后递归调用完必须紧跟m_board[p.y][p.x] 0。建议用 RAII 思路封装一个临时落子类构造时落子、析构时还原避免手动遗漏。期末作业里手动还原也行但一定要成对出现。5.5 Unicode 字符集下字符串报错现象编译时报“不能将 const char* 转换为 LPCTSTR”。 原因VS 默认用 Unicode 字符集而代码里写了你赢了这种窄字符串。 解决所有字符串字面量用_T(...)包裹或者项目属性里把字符集改成“多字节字符集”。推荐前者因为多字节字符集在新版 VS 里已经标记为弃用。另外MessageBox的参数也要用_T()。6. 让电脑更强一点候选点排序与迭代加深的实战技巧前面那套 Minimax 加 Alpha-Beta 已经能跑出一个及格线以上的电脑但如果你想让它在班级演示里明显比别人强一截有两个改动性价比最高。第一个是候选点排序。Alpha-Beta 剪枝的效率极度依赖搜索顺序——如果先搜到好棋后面大量分支会被直接剪掉。做法是在GetCandidates返回后对每个候选点用EvaluatePoint快速打一个分按分值从高到低排序再进递归。这一步额外开销很小但剪枝效率能提升好几倍同样深度 4 的搜索时间可能从 800ms 降到 200ms。// 候选点排序按启发式分值降序 sort(candidates.begin(), candidates.end(), [](const CPoint a, const CPoint b) { int sa EvaluatePoint(a.y, a.x, isMax ? 2 : 1); int sb EvaluatePoint(b.y, b.x, isMax ? 2 : 1); return sa sb; });第二个是迭代加深。不要固定搜 4 层而是从 2 层开始搜逐步加深每层记录最佳走法。如果某一层搜索超时比如超过 1 秒就用上一层的结果。这样开局阶段棋盘空、候选点多可能只搜到 3 层就超时中盘候选点少能搜到 5 层甚至 6 层。配合一个简单的计时器DWORD startTime GetTickCount(); // 在递归里检查 if (GetTickCount() - startTime 1000) throw TimeoutException();用异常做超时中断比较粗暴但有效捕获后回退到上一层结果。注意异常不要跨线程抛这里单线程没问题。还有一个容易被忽略的点开局第一步。如果玩家先手落天元电脑最好固定下在相邻位置不要浪费时间去搜。我一般写死如果棋盘上只有一颗子电脑直接下在它旁边一格。这既省时间也避免搜索算法在空旷棋盘上给出奇怪选择。最后说一个验证电脑水平的方法自己跟自己下。把电脑的进攻权重调高当“激进版”防守权重调高当“保守版”让两个版本对弈十局。如果激进版胜率明显高说明你的评估函数偏防守如果保守版总赢说明进攻分给太高了。这个自对弈调参法比拍脑袋改分值靠谱得多。我踩过最深的坑是早期版本没做候选点限制深度设了 6结果点一下鼠标要等十几秒演示时老师站在旁边看着进度条转圈那场面相当尴尬。后来把候选点限制在已有棋子周围 2 格、深度降到 4、加上排序剪枝响应时间稳定在 300ms 以内棋力反而因为搜索更聚焦而提升了。做这类作业响应速度比绝对棋力重要得多毕竟没人愿意等一个思考半分钟的对手。希望帮到你。本文还有配套的精品资源点击获取