ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++期末大作业:基于MFC的人机对战五子棋完整实现指南

2026/10/4 3:01:03 拓冰建站 浏览量
C++期末大作业:基于MFC的人机对战五子棋完整实现指南 简介这是一份面向高校C课程期末大作业场景的完整项目源码基于MFC框架实现人机对战五子棋适合正在学习C面向对象编程、Windows桌面开发或需要提交课程设计的学生参考。资源包共36个文件约160KB以cpp与h源码文件为主辅以vcproj、sln、vcxproj等工程配置以及rc资源脚本、doc说明文档和user用户配置覆盖界面搭建、算法实现与工程编译的完整链路。项目围绕游戏界面绘制、极小化极大搜索与启发式评估函数、人机对战逻辑、悔棋计时与难度选择等模块展开并配有评分文档与核心算法改进说明便于理解估值函数与搜索策略的落地方式。目前已有103人学习下载可作为课程设计参考模板帮助读者快速理清MFC工程结构、掌握AI棋手设计思路并完成调试排错。1. 从一份期末大作业说起MFC 人机对战五子棋到底要交什么很多人第一次看到「C期末大作业-基于MFC的人机对战五子棋项目完整实现-2025」这个标题脑子里冒出来的第一个问题是这到底是一个能跑起来的程序还是一份只能看的报告答案很直接——它是一份必须能在 Visual Studio 里编译、运行、落子、判胜、并且电脑会还手的完整 Windows 桌面程序。MFC 负责窗口、消息循环、绘图和控件C 负责棋盘数据结构、胜负判定和 AI 搜索五子棋负责把这两条线拧成一个能演示的作品。适合谁适合正在被期末大作业卡住、或者想用 MFC 把 C 基础串起来的人。它不要求你会深度学习但要求你能看懂消息映射、CDC 绘图和极小化极大搜索。下面按「先跑通界面、再补规则、最后加 AI」的顺序拆开讲每一步都能直接抄进工程里。2. 用 MFC 搭出可落子的棋盘从工程创建到鼠标消息映射2.1 为什么选 MFC 而不是 Qt 或控制台期末大作业的评分点通常集中在「有没有界面、能不能交互、代码结构清不清楚」。控制台五子棋虽然逻辑简单但演示效果差老师一眼就看出你没碰 Windows 编程。Qt 当然更现代但配置环境、装库、写 CMake 对只学过 C 基础的人来说是额外负担。MFC 是 Visual Studio 自带的工作负载新建项目就能用消息映射机制虽然老派但胜在资料多、报错少、和 C 课程衔接紧。常见做法是选「MFC 应用程序」→「单文档」或「基于对话框」我一般推荐基于对话框棋盘绘制区域就是一个静态框或整个客户区按钮「重新开始」「悔棋」「切换先手」直接拖控件省掉文档/视图架构的理解成本。选单文档也不是不行但你要额外处理OnDraw、OnLButtonDown和视图类的关系。基于对话框的话主窗口类就是CChessDlg所有逻辑塞进去也不乱。下面以基于对话框为例给出关键步骤。2.2 创建工程与棋盘坐标映射打开 Visual Studio新建 MFC 应用程序命名GobangMFC应用程序类型选「基于对话框」取消「关于」框大作业不需要。在资源视图里把主对话框拉大一点比如 800×600删掉默认的静态文本和确定取消按钮留一个「重新开始」按钮ID 改为IDC_BTN_RESTART。棋盘不是直接画在对话框上的而是先定义一个逻辑坐标到像素坐标的映射。假设棋盘 15×15格子边长 40 像素左上角留 30 像素边距// ChessDlg.h 里加成员 const int BOARD_SIZE 15; // 15 路棋盘 const int CELL_SIZE 40; // 每格像素 const int MARGIN 30; // 棋盘边距 int m_board[BOARD_SIZE][BOARD_SIZE]; // 0 空 1 黑 2 白 bool m_bBlackTurn; // 当前是否黑方人走// ChessDlg.cpp 的 OnInitDialog 里初始化 for (int i 0; i BOARD_SIZE; i) for (int j 0; j BOARD_SIZE; j) m_board[i][j] 0; m_bBlackTurn true;逻辑说明m_board用二维数组存棋子状态0 表示空1 表示黑2 表示白。m_bBlackTurn控制轮到谁。参数说明BOARD_SIZE改成 19 就是 19 路棋盘但 AI 搜索量会指数上升期末作业 15 路足够。CELL_SIZE和MARGIN决定窗口里棋盘的位置改完要同步调整对话框大小否则棋盘会被裁掉。2.3 鼠标点击落子与消息映射在对话框类里添加WM_LBUTTONDOWN消息处理。类向导里选CChessDlg消息WM_LBUTTONDOWN生成OnLButtonDown。核心是把鼠标像素坐标转成棋盘行列void CChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 判断是否在棋盘范围内 int col (point.x - MARGIN CELL_SIZE / 2) / CELL_SIZE; int row (point.y - MARGIN CELL_SIZE / 2) / CELL_SIZE; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; // 2. 该位置已有棋子则忽略 if (m_board[row][col] ! 0) return; // 3. 人走黑棋落子 if (m_bBlackTurn) { m_board[row][col] 1; m_bBlackTurn false; Invalidate(); // 触发重绘 if (CheckWin(row, col, 1)) // 判胜 { MessageBox(_T(黑方获胜)); return; } // 4. 电脑走白棋 ComputerMove(); Invalidate(); if (CheckWin(m_lastRow, m_lastCol, 2)) MessageBox(_T(白方获胜)); m_bBlackTurn true; } CDialogEx::OnLButtonDown(nFlags, point); }逻辑说明(point.x - MARGIN CELL_SIZE/2) / CELL_SIZE是四舍五入取整保证点格子中心附近都能落子。落子后立刻Invalidate()让OnPaint重画。CheckWin判断当前落子是否形成五连。参数说明m_lastRow、m_lastCol是电脑落子后记录的位置用于判胜。注意Invalidate()不要传TRUE否则背景闪烁严重默认FALSE只重绘无效区域。2.4 用 OnPaint 画棋盘和棋子在OnPaint里用CPen和CBrush画线、画圆。不要用CDC::Rectangle画棋子圆角不好看用Ellipsevoid CChessDlg::OnPaint() { CPaintDC dc(this); CPen pen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen dc.SelectObject(pen); // 画网格线 for (int i 0; i BOARD_SIZE; i) { dc.MoveTo(MARGIN, MARGIN i * CELL_SIZE); dc.LineTo(MARGIN (BOARD_SIZE - 1) * CELL_SIZE, MARGIN i * CELL_SIZE); dc.MoveTo(MARGIN i * CELL_SIZE, MARGIN); dc.LineTo(MARGIN i * CELL_SIZE, MARGIN (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; CBrush brush(m_board[r][c] 1 ? RGB(0, 0, 0) : RGB(255, 255, 255)); CBrush* pOldBrush dc.SelectObject(brush); int x MARGIN c * CELL_SIZE; int y MARGIN r * CELL_SIZE; int rad CELL_SIZE / 2 - 3; dc.Ellipse(x - rad, y - rad, x rad, y rad); dc.SelectObject(pOldBrush); } } dc.SelectObject(pOldPen); }逻辑说明先画 15 条横线和 15 条竖线再遍历m_board画棋子。黑棋用黑色画刷白棋用白色画刷白棋最好加一圈黑边否则在白色背景上看不清。参数说明rad CELL_SIZE/2 - 3让棋子比格子略小留出间隙。如果窗口缩放OnPaint里的坐标不会自动缩放需要自己处理OnSize期末作业固定窗口大小即可。3. 胜负判定与禁手五子连珠的四个方向扫描怎么写才不出错3.1 四方向扫描的通用函数五子棋判胜的本质是以刚落子的点为中心向四个方向延伸看同色棋子是否达到 5 个。四个方向是水平、垂直、主对角线、副对角线。不要写四段重复代码用一个方向数组bool CChessDlg::CheckWin(int row, int col, int color) { // 四个方向右、下、右下、左下 const int dr[4] { 0, 1, 1, 1 }; const int dc[4] { 1, 0, 1, -1 }; for (int d 0; d 4; d) { int count 1; // 当前棋子本身算一个 // 正方向延伸 for (int step 1; step 5; step) { int r row dr[d] * step; int c col dc[d] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (m_board[r][c] ! color) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int r row - dr[d] * step; int c col - dc[d] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (m_board[r][c] ! color) break; count; } if (count 5) return true; } return false; }逻辑说明dr、dc分别表示行和列的变化量。方向 0 是水平向右方向 1 是垂直向下方向 2 是右下方向 3 是左下。每个方向先向正方向数再向反方向数两边加起来count 5就赢。参数说明color传 1 判黑传 2 判白。注意边界检查必须在取m_board之前否则数组越界会直接崩溃这是血泪经验。3.2 为什么不用遍历整个棋盘有人图省事每次落子后全盘扫描所有横、竖、斜线。15×15 的棋盘横线 15 条、竖线 15 条、对角线 29×2 条每次判胜要扫几百个格子。虽然期末作业性能要求不高但全盘扫描容易写出边界 bug而且电脑 AI 搜索时会频繁调用判胜全盘扫描会拖慢搜索速度。以落子点为中心的四方向扫描最多检查 4×832 个格子快一个数量级代码也更短。常见做法是只传刚落子的坐标不要传整个棋盘。3.3 禁手要不要做标准五子棋有禁手规则黑棋不能走双三、双四、长连。期末大作业里禁手属于加分项不是必做项。如果你时间紧先不做禁手老师一般不会扣分。如果要做至少实现「长连禁手」黑棋连成 6 个及以上算输。实现方式是在CheckWin里对黑棋单独判断count 5而不是count 5超过 5 个返回 false 并提示禁手。双三、双四需要额外扫描活三、冲四代码量翻倍建议放到最后一章做进阶。提示判胜函数一定要在人和电脑落子后都调用并且传入正确的颜色。我见过有人电脑落子后忘了判胜结果电脑赢了不弹窗演示时当场翻车。4. 人机对战 AI极小化极大 评估函数的最小可用版本4.1 为什么选极小化极大而不是神经网络期末大作业的 AI 不需要打败职业棋手只需要「看起来会下棋」。极小化极大Minimax配合一个简单的棋型评估函数就能达到「堵活三、冲四、偶尔做双三」的水平。神经网络方案需要训练数据、框架依赖和大量调参对课程作业来说性价比极低。常见做法是搜索深度 2 到 4 层评估函数只数活三、冲四、活二加上位置权重。这样电脑在普通笔记本上 1 秒内能出招演示效果足够。4.2 评估函数的棋型打分评估函数的核心是给每种棋型打分。我一般用下面这套分值简单但有效棋型说明分值五连已经连成 5 个100000活四两边都能成五10000冲四一边能成五1000活三两边都能成四1000眠三一边能成四100活二两边都能成三100眠二一边能成三10实现时不需要精确识别所有棋型可以简化对每个空位假设放上己方棋子用CheckWin类似的扫描统计连子数再根据两端是否被堵给分。下面是一个简化版评估// 评估某个空位对 color 的价值 int CChessDlg::EvaluatePoint(int row, int col, int color) { int score 0; const int dr[4] { 0, 1, 1, 1 }; const int dc[4] { 1, 0, 1, -1 }; for (int d 0; d 4; d) { int count 1; int block 0; // 被堵端数 // 正方向 for (int step 1; step 5; step) { int r row dr[d] * step; int c col dc[d] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) { block; break; } if (m_board[r][c] color) count; else if (m_board[r][c] 0) break; else { block; break; } } // 反方向同理 for (int step 1; step 5; step) { int r row - dr[d] * step; int c col - dc[d] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) { block; break; } if (m_board[r][c] color) count; else if (m_board[r][c] 0) break; else { block; break; } } // 根据连子数和被堵端数打分 if (count 5) score 100000; else if (count 4 block 0) score 10000; else if (count 4 block 1) score 1000; else if (count 3 block 0) score 1000; else if (count 3 block 1) score 100; else if (count 2 block 0) score 100; else if (count 2 block 1) score 10; } return score; }逻辑说明对每个方向统计以(row, col)为中心、假设放上color后能连成多少个以及两端是否被对方棋子或边界堵住。block为 0 表示两边都空为 1 表示一边被堵。参数说明分值表可以根据实测调整活三和冲四都给 1000 是为了让电脑优先堵对方的活三同时自己冲四。注意这个函数只评估单个点不是全盘评估所以搜索时要把所有空位都算一遍。4.3 电脑选点的完整流程电脑走棋分两步先看自己能不能直接赢再看对方有没有必赢点要堵最后才用评估函数选最优。这个顺序能避免「电脑明明能赢却去堵人」的尴尬void CChessDlg::ComputerMove() { int bestRow -1, bestCol -1; int bestScore -1; // 1. 遍历所有空位 for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] ! 0) continue; // 2. 进攻分假设自己下这里 int myScore EvaluatePoint(r, c, 2); // 3. 防守分假设对方下这里 int oppScore EvaluatePoint(r, c, 1); // 4. 综合分进攻权重略高于防守 int total myScore * 2 oppScore; if (total bestScore) { bestScore total; bestRow r; bestCol c; } } } if (bestRow ! -1) { m_board[bestRow][bestCol] 2; m_lastRow bestRow; m_lastCol bestCol; } }逻辑说明myScore * 2 oppScore让电脑在同等条件下优先自己成五同时也会堵对方的活三。如果myScore达到 100000自己能赢total会远大于任何防守分电脑直接取胜。参数说明进攻权重 2 可以改成 1.5 或 3权重越高电脑越激进。m_lastRow、m_lastCol用于后续判胜。这个版本没有递归搜索但已经能应付大部分课堂演示。如果要更强把EvaluatePoint换成 Minimax 递归深度 2 即可。注意EvaluatePoint里不要修改m_board它只是假设性评估。我见过有人在评估函数里真的落子再撤销结果忘记撤销导致棋盘错乱这种 bug 很难查。5. 避坑与排查MFC 五子棋最容易翻车的五个地方5.1 现象点击棋盘没反应或者落子位置偏移原因OnLButtonDown的坐标转换公式写错或者对话框客户区坐标和屏幕坐标混淆。MFC 的OnLButtonDown传进来的point是客户区坐标不需要ScreenToClient。如果棋盘画在子控件上比如Picture Control那OnLButtonDown要绑到子控件类不是对话框。解决在OnLButtonDown开头加TRACE(_T(x%d y%d\n), point.x, point.y);运行后看输出窗口的坐标。如果坐标对但落子偏检查MARGIN和CELL_SIZE是否和OnPaint里一致。常见错误是OnPaint用了MARGIN30OnLButtonDown里写了MARGIN20。5.2 现象棋盘闪烁严重落子后整个窗口重画原因Invalidate()默认参数是TRUE会先擦背景再重画导致闪烁。或者OnPaint里用了CPaintDC之外还调了Invalidate。解决Invalidate(FALSE)只标记重绘区域不擦背景。如果还闪在OnPaint里用双缓冲创建内存 DC 和位图先画到内存再BitBlt到屏幕。双缓冲代码大约 20 行网上模板很多期末作业加上能明显提升演示效果。5.3 现象电脑落子后不判胜或者判胜颜色传错原因ComputerMove里记录了m_lastRow、m_lastCol但调用CheckWin时传了 1黑而不是 2白。或者电脑落子后直接return没走判胜逻辑。解决把判胜逻辑封装成AfterMove(row, col, color)人和电脑都调同一个函数。颜色参数不要硬编码用m_board[row][col]取当前棋子颜色。这样改完任何一方落子后判胜都不会漏。5.4 现象AI 第一步下在天元第二步开始乱下原因评估函数只看了单点连子没有考虑全局。空棋盘时所有点得分一样bestScore初始为 -1第一个点就被选中。后续因为进攻权重太高电脑只顾自己连子不堵人。解决空棋盘时让电脑下天元7,7或随机附近。进攻权重从 2 降到 1.2 左右让防守分更有存在感。如果还乱在EvaluatePoint里给靠近棋盘中心的点加位置分比如score (7 - abs(r-7) - abs(c-7))。5.5 现象Release 模式下 AI 变慢或崩溃Debug 正常原因Debug 模式下数组越界可能不崩Release 下直接访问非法内存。或者EvaluatePoint里的循环边界写错step 5写成step 5导致多读一格。解决所有数组访问前必须检查r 0 r BOARD_SIZE c 0 c BOARD_SIZE。用ASSERT在 Debug 下断言Release 下用if兜底。另外Release 模式下TRACE不输出调试信息改用OutputDebugString或写日志文件。6. 让 AI 再强一点用两层 Minimax 替换单点评估单点评估的电脑只会「看一步」遇到「活三冲四」的组合拳就挡不住。想让演示效果再上一个台阶把ComputerMove里的选点逻辑换成两层 Minimax电脑走一步假设人走一步取最坏情况下的最好结果。代码量增加不多但棋力提升明显。// 两层搜索电脑走一步人回应一步 int CChessDlg::Minimax(int depth, bool isMax, int outRow, int outCol) { // 如果已经有一方赢了返回极值 // 这里简化depth0 时返回全盘评估 if (depth 0) return EvaluateBoard(); int bestScore isMax ? -1000000 : 1000000; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] ! 0) continue; // 只考虑已有棋子附近的空位减少搜索量 if (!HasNeighbor(r, c, 2)) continue; m_board[r][c] isMax ? 2 : 1; int score Minimax(depth - 1, !isMax, outRow, outCol); m_board[r][c] 0; // 撤销 if (isMax score bestScore) { bestScore score; if (depth 2) { outRow r; outCol c; } } else if (!isMax score bestScore) { bestScore score; } } } return bestScore; }逻辑说明isMaxtrue表示轮到电脑电脑选分数最高的isMaxfalse表示轮到人人选分数最低的对电脑最不利。HasNeighbor检查该空位周围 2 格内有没有棋子没有就跳过这样能把 225 个空位压缩到几十个搜索速度提升十倍以上。EvaluateBoard是全盘评估可以简单地把所有己方棋子的EvaluatePoint加起来减去对方的分。参数说明depth2表示电脑走一步、人走一步搜索量大约 40×401600 个节点普通电脑毫秒级出招。depth4会到百万级需要加 Alpha-Beta 剪枝否则演示时会卡住。HasNeighbor的距离参数 2 是经验值改成 1 会漏掉跳活三改成 3 搜索量翻倍。我自己的习惯是先保证单点评估版本能跑通、能演示再花半小时加两层搜索。如果时间只够做一个单点评估加进攻权重 2 已经能赢大部分没写过 AI 的同学。最后提醒一句大作业答辩时老师常问「你的 AI 为什么下这里」你要能指着评估函数说出「因为这里活三得分 1000冲四得分 1000加起来比堵对方高」。把评估表打印出来放在手边比背代码管用。希望帮到你。本文还有配套的精品资源点击获取