ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于MFC的人机对战五子棋:从工程搭建到AI估值与Alpha-Beta剪枝

基于MFC的人机对战五子棋:从工程搭建到AI估值与Alpha-Beta剪枝 简介这是一份面向高校C课程学习者与Windows桌面开发入门者的期末大作业参考方案围绕MFC框架实现人机对战五子棋帮助读者理解面向对象设计、界面开发与博弈算法的结合方式。压缩包共36个文件约160KB以cpp与h源码为主体配合vcproj、sln、vcxproj等工程配置以及rc资源脚本、doc说明文档和ReadMe.txt覆盖从界面到算法的完整工程结构。已有103人学习下载。项目将游戏逻辑、用户界面与电脑棋手拆分为独立类核心涉及极小化极大搜索与启发式评估函数并包含悔棋、计时、难度选择等细节功能还附有评分与算法改进相关文档便于读者对照源码理解估值函数设计、胜负判断与调试思路适合作为课程设计模板或MFC与AI算法综合练习的起点。1. 从 MFC 对话框到人机对战一份能跑起来的五子棋大作业长什么样期末周临近C 大作业选题里「基于 MFC 的人机对战五子棋」几乎是每年都会被翻出来的经典款。它看起来简单——棋盘、落子、判胜但真正动手才会发现MFC 的对话框工程、GDI 绘图、消息映射、AI 估值函数这几块拼在一起坑比想象中多。很多人卡在第一步新建完 MFC 对话框工程面对一堆自动生成的代码不知道从哪下手也有人棋盘画出来了鼠标点击却对不上格子更常见的是 AI 只会堵眼前一步被玩家一个双三直接带走。这篇笔记面向两类人一是第一次接触 MFC、需要交一份能演示能讲清原理的大作业的同学二是写过 C 但没碰过 Windows 桌面绘图、想借五子棋把消息机制和 GDI 摸一遍的开发者。我会按「工程骨架 → 棋盘绘制 → 鼠标交互 → 胜负判定 → 人机 AI → 调试避坑」的顺序把每一步的可复现代码和参数讲清楚。整套方案用纯 MFC 标准 C不依赖第三方库Visual Studio 2019/2022 社区版直接能编译适合作为课程设计或自学练手项目。2. 工程骨架与棋盘数据结构先把地基打对2.1 为什么选对话框工程而不是单文档MFC 提供三种典型工程单文档SDI、多文档MDI、基于对话框Dialog-based。五子棋这种「一个固定窗口 一块绘图区 几个按钮」的形态对话框工程最省事。单文档会带一整套文档/视图/框架结构对一个大作业来说属于过度设计反而增加讲解负担。新建工程时选「MFC 应用」应用程序类型选「基于对话框」项目名建议用英文如GobangMFC避免中文路径导致资源编译器偶发报错。生成后你会看到GobangMFCDlg.cpp和对应的.h主对话框资源在GobangMFC.rc里ID 默认是IDD_GOBANGMFC_DIALOG。我一般会把棋盘逻辑和界面逻辑分开新建一个GobangLogic.h/.cpp放纯算法对话框类只负责绘图和消息转发。这样 AI 部分可以单独写测试不用每次启动界面。2.2 棋盘用一维数组还是二维数组棋盘是 15×15 的标准五子棋规格。存储上二维数组int board[15][15]最直观但一维数组int board[225]在遍历和估值时缓存更友好索引换算idx y * 15 x。对大作业来说两者都行我倾向一维因为后面 AI 打分要频繁扫描整盘一维写起来更紧凑。// GobangLogic.h #pragma once const int BOARD_SIZE 15; // 15x15 标准棋盘 const int EMPTY 0; // 空位 const int PLAYER 1; // 玩家棋子 const int AI 2; // 电脑棋子 class GobangLogic { public: GobangLogic(); void reset(); // 清空棋盘 bool place(int x, int y, int who); // 落子返回是否合法 int get(int x, int y) const; // 读取某点 bool isWin(int x, int y, int who) const; // 判断某点落子后是否连五 bool isFull() const; // 棋盘是否已满 private: int m_board[BOARD_SIZE * BOARD_SIZE]; int m_count; // 已落子数 };// GobangLogic.cpp #include pch.h #include GobangLogic.h GobangLogic::GobangLogic() { reset(); } void GobangLogic::reset() { for (int i 0; i BOARD_SIZE * BOARD_SIZE; i) m_board[i] EMPTY; m_count 0; } bool GobangLogic::place(int x, int y, int who) { if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) return false; int idx y * BOARD_SIZE x; if (m_board[idx] ! EMPTY) return false; // 已有子拒绝 m_board[idx] who; m_count; return true; } int GobangLogic::get(int x, int y) const { return m_board[y * BOARD_SIZE x]; } bool GobangLogic::isFull() const { return m_count BOARD_SIZE * BOARD_SIZE; }place里做了边界检查和占位检查这是后面鼠标点击防重复落子的第一道闸。isWin单独实现逻辑是以刚落下的点为中心沿横、竖、两条对角线四个方向各数连续同色棋子任一方向达到 5 即胜。bool GobangLogic::isWin(int x, int y, int who) const { // 四个方向横、竖、主对角、副对角 const int dx[4] {1, 0, 1, 1}; const int dy[4] {0, 1, 1, -1}; for (int d 0; d 4; d) { int cnt 1; // 正方向延伸 for (int s 1; s 5; s) { int nx x dx[d] * s, ny y dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (m_board[ny * BOARD_SIZE nx] ! who) break; cnt; } // 反方向延伸 for (int s 1; s 5; s) { int nx x - dx[d] * s, ny y - dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (m_board[ny * BOARD_SIZE nx] ! who) break; cnt; } if (cnt 5) return true; } return false; }参数说明dx/dy数组把四个方向编码成向量避免写四段重复代码cnt从 1 开始是因为当前点本身算一个。注意副对角方向dy取 -1这是最容易写反的地方写反了会导致斜向连五判不出来。3. 用 GDI 把棋盘画到对话框上坐标换算与双缓冲3.1 绘图入口选 OnPaint 还是 OnEraseBkgndMFC 对话框默认会擦背景如果只在OnPaint里画棋盘窗口重绘时会闪。正确做法是响应WM_ERASEBKGND直接返回 TRUE 阻止擦除然后在OnPaint里用双缓冲一次性贴图。双缓冲的意思是先画到内存 DC再BitBlt到屏幕 DC这样不会看到逐笔绘制的过程。先在对话框类里加成员CDC m_memDC; CBitmap m_memBmp;以及棋盘几何参数。// 在 GobangMFCDlg.h 的类声明里加 private: GobangLogic m_logic; int m_cellSize; // 每格像素 int m_margin; // 棋盘边距 int m_originX; // 棋盘左上角屏幕坐标 int m_originY; bool m_gameOver; void drawBoard(CDC* pDC); void boardToScreen(int bx, int by, int sx, int sy); bool screenToBoard(int sx, int sy, int bx, int by);3.2 坐标换算屏幕像素和棋盘格子的双向映射棋盘 15 条线、14 个格子落子点在交叉线上。设每格cellSize 36像素边距margin 30则棋盘宽高都是14 * 36 504像素加上两侧边距总宽504 60 564。原点m_originX marginm_originY margin。void CGobangMFCDlg::boardToScreen(int bx, int by, int sx, int sy) { sx m_originX bx * m_cellSize; sy m_originY by * m_cellSize; } bool CGobangMFCDlg::screenToBoard(int sx, int sy, int bx, int by) { // 四舍五入到最近的交叉点 bx (sx - m_originX m_cellSize / 2) / m_cellSize; by (sy - m_originY m_cellSize / 2) / m_cellSize; if (bx 0 || bx BOARD_SIZE || by 0 || by BOARD_SIZE) return false; // 距离交叉点太远则视为无效点击 int cx, cy; boardToScreen(bx, by, cx, cy); if (abs(sx - cx) m_cellSize / 2 || abs(sy - cy) m_cellSize / 2) return false; return true; }screenToBoard里的四舍五入是关键鼠标点不可能精确落在交叉点上用 cellSize/2再整除等价于就近取整。后面那个距离判断是防止玩家点到格子正中间时被误判到某个交叉点提升手感。3.3 双缓冲绘制棋盘和棋子void CGobangMFCDlg::drawBoard(CDC* pDC) { CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 背景 memDC.FillSolidRect(rc, RGB(238, 203, 140)); // 画 15 条横线和竖线 CPen pen(PS_SOLID, 1, RGB(80, 40, 0)); CPen* pOldPen memDC.SelectObject(pen); for (int i 0; i BOARD_SIZE; i) { int sx, sy; boardToScreen(0, i, sx, sy); memDC.MoveTo(sx, sy); boardToScreen(BOARD_SIZE - 1, i, sx, sy); memDC.LineTo(sx, sy); boardToScreen(i, 0, sx, sy); memDC.MoveTo(sx, sy); boardToScreen(i, BOARD_SIZE - 1, sx, sy); memDC.LineTo(sx, sy); } memDC.SelectObject(pOldPen); // 画棋子 for (int y 0; y BOARD_SIZE; y) { for (int x 0; x BOARD_SIZE; x) { int v m_logic.get(x, y); if (v EMPTY) continue; int sx, sy; boardToScreen(x, y, sx, sy); int r m_cellSize / 2 - 3; CBrush brush(v PLAYER ? RGB(20, 20, 20) : RGB(240, 240, 240)); CBrush* pOldBrush memDC.SelectObject(brush); memDC.Ellipse(sx - r, sy - r, sx r, sy r); memDC.SelectObject(pOldBrush); } } // 一次性贴到屏幕 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }逻辑说明CreateCompatibleDC创建内存设备上下文CreateCompatibleBitmap创建与屏幕兼容的位图所有绘制先在内存里完成最后BitBlt一次拷贝。参数上棋子半径取cellSize/2 - 3留 3 像素间隙避免相邻棋子粘连。玩家黑子用RGB(20,20,20)AI 白子用RGB(240,240,240)白子要加一圈深色边框才看得清可以在Ellipse前先画一个稍大的深色圆。在OnPaint里调用drawBoard并加上OnEraseBkgnd返回 TRUEBOOL CGobangMFCDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 阻止默认擦背景消除闪烁 }提示如果发现窗口最小化再恢复后棋盘消失多半是没在OnPaint里重绘或者内存 DC 没随窗口尺寸重建。把双缓冲对象做成局部变量、每次OnPaint重新创建能规避大部分这类问题。4. 鼠标落子与胜负判定消息映射怎么接4.1 用类向导绑定 WM_LBUTTONDOWN在对话框资源上右键「添加事件处理程序」消息类型选WM_LBUTTONDOWN类列表选对话框类函数名默认OnLButtonDown。生成后在里面写落子逻辑。void CGobangMFCDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) { CDialogEx::OnLButtonDown(nFlags, point); return; } int bx, by; if (!screenToBoard(point.x, point.y, bx, by)) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (!m_logic.place(bx, by, PLAYER)) { // 该点已有子 CDialogEx::OnLButtonDown(nFlags, point); return; } Invalidate(); // 触发重绘 if (m_logic.isWin(bx, by, PLAYER)) { m_gameOver true; MessageBox(_T(你赢了), _T(结果), MB_OK); return; } if (m_logic.isFull()) { m_gameOver true; MessageBox(_T(平局), _T(结果), MB_OK); return; } // 轮到 AI aiMove(); Invalidate(); CDialogEx::OnLButtonDown(nFlags, point); }逻辑说明先判m_gameOver防止结束后继续落子screenToBoard把像素坐标转成棋盘坐标失败直接返回place返回 false 说明该点已有子也返回。落子成功后Invalidate触发OnPaint重绘。玩家赢或平局要立刻置m_gameOver否则 AI 还会继续走。参数上Invalidate()默认擦背景配合前面的OnEraseBkgnd返回 TRUE 就不会闪。如果想让重绘更精确可以传InvalidateRect只刷新棋盘区域但大作业里全刷足够。4.2 胜负判定的边界连五还是长连标准五子棋无禁手里连成 5 个或以上都算赢。isWin里cnt 5就是这个规则。如果老师要求实现有禁手的专业规则那要额外处理「三三禁手」「四四禁手」「长连禁手」复杂度陡增大作业一般不做。这里明确用无禁手规则答辩时能说清就行。一个容易忽略的点判定必须针对「刚落下的那颗子」而不是全盘扫描。全盘扫描每次 O(225×4×5)虽然也不慢但针对落子点判定是 O(4×5)更干净也避免把之前已经连五但没判的情况重复触发。4.3 重开一局和状态复位加一个「重新开始」按钮ID 设为IDC_BTN_RESTART双击生成OnBnClickedBtnRestartvoid CGobangMFCDlg::OnBnClickedBtnRestart() { m_logic.reset(); m_gameOver false; Invalidate(); }reset把棋盘清空、计数归零m_gameOver复位然后重绘。注意如果 AI 是异步线程跑的这里要加锁或标志位但本方案 AI 是同步调用不存在竞态。5. 人机对战 AI估值函数与极小极大搜索怎么落地5.1 先想清楚 AI 要什么水平大作业的 AI 不需要打到职业段位但也不能只会堵一步。合理的定位是能识别活三、冲四、活四这些基本棋型会做简单的攻防权衡搜索深度 2 到 4 层。常见做法是「棋型打分 极小极大 Alpha-Beta 剪枝」这套组合在 15×15 上跑起来毫秒级答辩时也讲得清楚。如果只做一层贪心——对每个空位算一个分选最高分落子——实现最简单但会被「双三」这种需要两步才能形成的威胁骗过。加一层搜索考虑对手回应就能明显改善。5.2 棋型打分表怎么定打分的基本单位是「一条线上某个空位如果落子能形成什么棋型」。常见棋型分值棋型说明建议分值成五已有四子落子即五连100000活四两端开放的四连10000冲四一端被封的四连1000活三两端开放的三连1000眠三一端被封的三连100活二两端开放的二连100眠二一端被封的二连10这些分值不是绝对的核心是让「成五 活四 冲四 ≈ 活三 眠三 ≈ 活二」。冲四和活三同分是有意的冲四下一步能成五活三下一步能成活四威胁等级接近AI 需要根据局面权衡。// 评估在 (x,y) 落 who 子后该点四个方向的棋型总分 int GobangLogic::evaluatePoint(int x, int y, int who) const { const int dx[4] {1, 0, 1, 1}; const int dy[4] {0, 1, 1, -1}; int total 0; for (int d 0; d 4; d) { int count 1; // 当前子 int block 0; // 被封端数 int empty 0; // 空位数用于识别跳活三 // 正方向 for (int s 1; s 5; s) { int nx x dx[d] * s, ny y dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) { block; break; } int v m_board[ny * BOARD_SIZE nx]; if (v who) count; else if (v EMPTY) { empty; break; } else { block; break; } } // 反方向同理 for (int s 1; s 5; s) { int nx x - dx[d] * s, ny y - dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) { block; break; } int v m_board[ny * BOARD_SIZE nx]; if (v who) count; else if (v EMPTY) { empty; break; } else { block; break; } } total shapeScore(count, block, empty); } return total; }shapeScore根据count连子数、block被封端数、empty是否跳空返回对应分值。这里简化了跳活三的识别用empty标记实际写的时候可以按count和block组合查表。5.3 极小极大 Alpha-Beta 剪枝搜索框架AI 走一步玩家走一步交替到指定深度叶子节点用全盘估值。Alpha-Beta 剪枝在搜索过程中维护alpha当前最大下界和beta当前最小上界当alpha beta时剪掉后续分支。int CGobangMFCDlg::minimax(int depth, int alpha, int beta, bool isAI) { if (depth 0) return evaluateBoard(); // 候选点只考虑已有棋子周围 2 格内的空位减少分支 std::vectorCPoint moves genMoves(); if (moves.empty()) return evaluateBoard(); if (isAI) { int best INT_MIN; for (auto p : moves) { m_logic.place(p.x, p.y, AI); int val minimax(depth - 1, alpha, beta, false); m_logic.undo(p.x, p.y); // 撤销 best max(best, val); alpha max(alpha, best); if (alpha beta) break; // 剪枝 } return best; } else { int best INT_MAX; for (auto p : moves) { m_logic.place(p.x, p.y, PLAYER); int val minimax(depth - 1, alpha, beta, true); m_logic.undo(p.x, p.y); best min(best, val); beta min(beta, best); if (alpha beta) break; } return best; } }参数说明depth是剩余搜索层数大作业取 2 到 4 足够alpha/beta初始传INT_MIN/INT_MAXisAI标记当前该谁走。genMoves只生成已有棋子周围 2 格内的空位这是把分支从 225 降到几十的关键优化否则深度 4 会卡顿。undo需要在GobangLogic里补一个撤销方法把该点置回 EMPTY 并--m_count。aiMove就是遍历候选点对每个点试落 AI 子调用minimax取最高分对应的点void CGobangMFCDlg::aiMove() { std::vectorCPoint moves genMoves(); if (moves.empty()) return; int bestVal INT_MIN; CPoint bestMove moves[0]; for (auto p : moves) { m_logic.place(p.x, p.y, AI); int val minimax(3, INT_MIN, INT_MAX, false); m_logic.undo(p.x, p.y); if (val bestVal) { bestVal val; bestMove p; } } m_logic.place(bestMove.x, bestMove.y, AI); if (m_logic.isWin(bestMove.x, bestMove.y, AI)) { m_gameOver true; MessageBox(_T(电脑赢了), _T(结果), MB_OK); } }注意genMoves如果返回空棋盘全空AI 第一步要特殊处理直接下天元7,7。否则空棋盘上没有任何「已有棋子周围」的点AI 会无子可下。6. 避坑与排查那些让大作业翻车的细节6.1 现象编译报错「无法打开包括文件 pch.h」原因MFC 工程默认开启预编译头新建的.cpp如果没在第一行#include pch.h或者工程属性里预编译头设置不一致就会报这个。解决所有新建的.cpp第一行都加#include pch.h如果某个文件不想用预编译头在文件属性里把「预编译头」设为「不使用预编译头」。6.2 现象鼠标点击落子位置偏移一格原因screenToBoard里没做四舍五入直接用整除导致点击交叉点右下方时被算到下一个格子。解决整除前加m_cellSize / 2即(sx - m_originX m_cellSize/2) / m_cellSize。另外确认m_originX和m_cellSize在OnInitDialog里已经初始化别用未初始化的值。6.3 现象AI 思考时界面卡死原因minimax在 UI 线程同步执行深度太大或候选点太多时阻塞消息循环。解决深度控制在 4 以内genMoves限制候选点数量比如按估值排序取前 12 个如果非要更深把 AI 放到工作线程用PostMessage回传结果但大作业不建议引入线程复杂度。6.4 现象斜向连五判不出来原因isWin里副对角方向的dy写成了1而不是-1或者dx/dy数组顺序和循环里的方向对不上。解决四个方向固定为(1,0)横、(0,1)竖、(1,1)主对角、(1,-1)副对角写完后手动在纸上画一遍验证。这是血泪经验斜向判定错一次能查半天。6.5 现象窗口拉伸后棋盘错位或棋子画到外面原因棋盘几何参数在OnInitDialog里按初始窗口尺寸算死了窗口拉伸后没重算。解决把m_cellSize和m_originX/Y的计算放到OnSize里根据当前客户区尺寸动态算保证棋盘居中。或者干脆在对话框属性里把「边框」设为「对话框外框」并禁用最大化从源头避免拉伸。7. 让 AI 更聪明的两个进阶技巧候选点排序与置换表前面那套 AI 能应付大作业但如果你想让它在答辩时多拿几分有两个改动性价比很高。第一个是候选点排序。genMoves现在返回的顺序是遍历顺序Alpha-Beta 剪枝的效果高度依赖先搜到好棋。做法是对每个候选点先算一个「快速估值」——分别算 AI 落这里的得分和玩家落这里的得分取两者较大值作为排序键降序排列。这样 AI 自己的好点和必须堵的玩家好点都排在前面剪枝能砍掉更多分支。实测在深度 4 下排序后搜索节点数能降一半以上。std::vectorCPoint CGobangMFCDlg::genMoves() { std::vectorstd::pairint, CPoint scored; for (int y 0; y BOARD_SIZE; y) { for (int x 0; x BOARD_SIZE; x) { if (m_logic.get(x, y) ! EMPTY) continue; if (!hasNeighbor(x, y, 2)) continue; // 周围 2 格内有子才考虑 int sAI m_logic.evaluatePoint(x, y, AI); int sPlayer m_logic.evaluatePoint(x, y, PLAYER); scored.push_back({ max(sAI, sPlayer), CPoint(x, y) }); } } std::sort(scored.begin(), scored.end(), [](auto a, auto b) { return a.first b.first; }); std::vectorCPoint result; for (int i 0; i (int)scored.size() i 12; i) result.push_back(scored[i].second); // 只取前 12 个 return result; }hasNeighbor判断某点周围radius格内是否有棋子没有就跳过这是把候选从 225 降到几十的第一层过滤。max(sAI, sPlayer)让攻防点都靠前。取前 12 个是分支上限太大搜索慢太小可能漏掉关键点12 是个经验值。第二个是置换表。同一局面可能通过不同落子顺序到达用哈希表缓存「局面 → 估值」下次遇到直接查表。局面哈希可以用 Zobrist 哈希给每个「位置 颜色」组合预生成一个随机数局面哈希就是所有已落子对应随机数的异或。落子时异或一次撤销时再异或一次天然可逆。// Zobrist 表初始化程序启动时执行一次 static UINT64 zobrist[BOARD_SIZE][BOARD_SIZE][3]; static UINT64 g_hash 0; void initZobrist() { srand((unsigned)time(nullptr)); for (int y 0; y BOARD_SIZE; y) for (int x 0; x BOARD_SIZE; x) for (int c 0; c 3; c) zobrist[y][x][c] ((UINT64)rand() 32) | rand(); }落子时g_hash ^ zobrist[y][x][who]撤销时再异或同一个值即可还原。置换表用std::unordered_mapUINT64, int存估值配合深度信息判断是否可用。大作业里如果嫌麻烦只做候选点排序就能有明显提升置换表属于锦上添花。最后说个验证方法写一个selfPlay函数让 AI 自己跟自己下跑 100 局看有没有出现「明明能赢却去堵无关位置」的蠢棋。我一般会打印每步的估值和候选点排序肉眼扫几局就能发现估值函数的漏洞。这套东西调通之后你会发现 MFC 那层只是壳真正有意思的是估值和搜索的权衡——这也是我做完这个作业后最深的体会界面能抄AI 的手感只能自己一局一局喂出来。希望帮到你。本文还有配套的精品资源点击获取
返回列表