尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC五子棋AI实现:从CDialog界面到估值函数与Alpha-Beta剪枝

MFC五子棋AI实现:从CDialog界面到估值函数与Alpha-Beta剪枝 简介这是一个面向C期末项目实战的完整MFC人机对战五子棋源码包适合有一定C或Windows桌面开发基础的学生用于课程设计、毕业设计参考或算法学习。资源基于MFC框架实现完整五子棋游戏涵盖棋盘绘制、棋子落子、胜负判断、人机对战核心算法并包含悔棋、计时、难度选择等细节功能。压缩包共36个文件主体为8个cpp和8个h源码文件另有工程配置类文件sln、vcproj、vcxproj、资源文件rc、aps及辅助说明文档整体约160KB结构直观。通过源码可重点学习Minimax搜索与启发式评估函数的设计思路也可借鉴MFC下界面与逻辑分离的面向对象实现方式。目前已有103人学习对于需要快速上手MFC游戏开发或理解简单人工智能算法的读者具有不错的参考价值。1. 从 MFC 对话框到五子棋 AI这份期末大作业到底拆了什么一个用 CDialog 搭出来的五子棋程序真正难住初学者的往往不是“下棋”而是 MFC 的消息映射和窗口绘制。这份资源里能看到 denglu.cpp、SettingDlg.cpp、五子棋Dlg.cpp、估值函数.cpp、核心算法改进.cpp 等文件已经覆盖了“登录-设置-对弈-AI 搜索”的完整闭环。如果你正在做 C 课程设计或者想把“基于 MFC 的人机对战五子棋”作为棋类 AI 的入门练习这套代码可以直接提供一个可编译的骨架。它的 AI 部分并不神秘一个估值函数加一个带剪枝的极大极小搜索工作量最大的是如何在 CDialog 里把棋盘画稳定、把胜负判断写对。下面顺着这些文件逐一展开先从界面层说起。2. MFC 界面层CDialog 骨架、棋盘绘制与双缓冲消除闪烁2.1 从工程文件看 MFC 程序的组织方式打开工程压缩包第一眼会被一堆 .dsp、.vcproj、.sln、.ncb 文件淹没。其实对一个期末大作业来说需要关注的源文件非常集中。先列一张表把文件角色理清楚文件职责五子棋Dlg.cpp / .h主对话框棋盘显示、鼠标落子、按钮消息处理Fivezq.cpp / .h棋局状态维护与 AI 调用入口denglu.cpp / .h登录对话框进入主界面前的简单身份验证SettingDlg.cpp / .h难度、先手、棋盘参数设置pos.h棋盘坐标封装Point 结构体估值函数.cpp棋型识别与局面评分核心算法改进.cppAlpha-Beta 剪枝、候选点生成的改进实现MFC 程序的组织方式和控制台程序完全不同。它没有 main 函数作为唯一入口而是通过类内部的BEGIN_MESSAGE_MAP把 Windows 消息映射到成员函数。这个工程里主对话框是CFivezqDlg典型的消息映射如下BEGIN_MESSAGE_MAP(CFivezqDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_BN_CLICKED(IDC_BTN_RESTART, CFivezqDlg::OnBtnRestart) ON_BN_CLICKED(IDC_BTN_UNDO, CFivezqDlg::OnBtnUndo) END_MESSAGE_MAP()ON_WM_PAINT把窗口重绘消息派发给OnPaintON_WM_LBUTTONDOWN处理鼠标左键按下ON_BN_CLICKED对应按钮点击。理解这张表再看五子棋Dlg.cpp就不会被大量函数吓到凡是出现在消息映射里的函数才是这个对话框真正干活的地方。2.2 棋盘坐标体系与落子响应棋盘通常定义成 15×15 的二维数组数组下标是逻辑坐标窗口客户区坐标是像素坐标。两者转换是这类棋类程序最容易出错的位置。常见做法是在OnInitDialog里计算棋盘左上角起点m_ptStart和格子边长m_nCell然后通过鼠标消息换算void CFivezqDlg::OnLButtonDown(UINT nFlags, CPoint point) { // point 是客户区鼠标坐标m_nCell / 2 用于四舍五入到最近格子 int row (point.y - m_ptStart.y m_nCell / 2) / m_nCell; int col (point.x - m_ptStart.x m_nCell / 2) / m_nCell; if (row 0 || row N || col 0 || col N) return; if (m_board[row][col] ! EMPTY) return; m_board[row][col] PLAYER; if (CheckWin(row, col, PLAYER)) { m_turn TURN_END; } else { AIPlay(); } Invalidate(FALSE); // 只重绘客户区避免闪烁 CDialogEx::OnLButtonDown(nFlags, point); }坐标换算是这里的关键用加上 m_nCell / 2再整除意味着鼠标点在格子左边缘 30% 的位置也会落到当前格用户体验比直接整除更宽松。数组边界检查必须放在访问m_board之前否则调试时收到“下标越界”的崩溃信息会很难定位尤其 AI 落子函数内部也依赖同一套坐标换算。2.3 双缓冲绘图避免棋局刷新时的闪烁MFC 的OnPaint如果直接在屏幕 DC 上画线画圆棋盘刷新时会出现明显闪烁因为底层的 WM_ERASEBKGND 会先擦掉背景然后程序才慢慢绘制。五子棋棋盘有 15×15 条网格和若干棋子不停橡皮擦式刷新会让眼睛很难受。工程里的OnPaint采用双缓冲解决void CFivezqDlg::OnPaint() { CPaintDC dc(this); // 窗口 DC CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, m_rcClient.Width(), m_rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); DrawBoard(memDC); // 画背景与网格 for (int i 0; i N; i) for (int j 0; j N; j) if (m_board[i][j] ! EMPTY) DrawPiece(memDC, i, j, m_board[i][j]); // 一次性把内存 DC 内容拷贝到屏幕 dc.BitBlt(0, 0, m_rcClient.Width(), m_rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); bmp.DeleteObject(); }CreateCompatibleDC创建一块与屏幕格式一致的内存画布CreateCompatibleBitmap为它分配像素存储。所有绘制操作都先画到内存 DC最后用BitBlt整体刷新到窗口。Invalidate(FALSE)里的FALSE也很重要它告诉系统不要擦除背景否则双缓冲效果会被背景擦除动作破坏。我在调试时遇到过一个问题棋盘画出来了但棋子边缘有残影原因正是OnEraseBkgnd返回了真导致每次Invalidate都先刷掉整块客户区。3. 五子棋 AI 算法估值函数、Minimax 与 Alpha-Beta 剪枝3.1 棋型识别与估值函数设计电脑棋力高低首先取决于能不能“看懂”棋盘上的棋型。五子棋中连五、活四、冲四、活三、眠三这些棋型的重要性完全不同。如果给每种棋型一个分数AI 就能在搜索时比较不同落子的好坏。这里有一个被很多初学者忽略的点棋型分值不是等比增长的而是跳跃式增长。原因很简单活四下一步必然成五所以活四的分值应当接近连五冲四虽然也有机会成五但会被对手堵住一边所以明显低于活四。常见分值表如下棋型参考分值连五1000000活四100000冲四50000活三10000眠三1000活二100眠二10工程里的估值函数.cpp做的事就是把黑白双方在四个方向上的棋型统计出来再按这个分值表累加。关键代码如下int EvaluateDirection(const int board[15][15], int x, int y, int dx, int dy, int color) { int cnt 0; // 连续棋子数 int block 0; // 被堵住的端点数 // 正方向统计 for (int step 1; ; step) { int nx x dx * step; int ny y dy * step; if (nx 0 || nx 15 || ny 0 || ny 15) { block; break; } if (board[nx][ny] color) cnt; else { if (board[nx][ny] ! EMPTY) block; break; } } // 反方向统计 for (int step 1; ; step) { int nx x - dx * step; int ny y - dy * step; if (nx 0 || nx 15 || ny 0 || ny 15) { block; break; } if (board[nx][ny] color) cnt; else { if (board[nx][ny] ! EMPTY) block; break; } } return ScoreByForm(cnt, block); }ScoreByForm根据cnt和block查表连续 5 个返回 1000000cnt 4 block 0是活四cnt 4 block 1是冲四cnt 3 block 0是活三。这个函数实现的准确度直接决定 AI 会不会“睁眼瞎”。我在测试中就遇到过电脑不挡活三的情况最后定位到因为ScoreByForm没有处理cnt 4 block 2的死四把死四也当成冲四给了高分。3.2 极大极小搜索让电脑往后想 4 步五子棋没有吃子所以搜索树的评估完全依赖估值函数。极大极小搜索的思路是电脑每一步都假设自己走最优、对手也走最优然后比较双方后续局面的结果。这里要注意一个实现误区不是让搜索函数返回“哪一步最好”而是让递归函数返回“当前局面的最好分数”在调用处再挑选对应落点。裁剪简化后的核心结构如下int Minimax(int color, int depth, int alpha, int beta) { if (depth 0) return EvaluateBoard(); vectorPoint cand GenerateCandidates(); if (color AI) { int best -INF; for (const Point p : cand) { board[p.x][p.y] AI; int v Minimax(PLAYER, depth - 1, alpha, beta); board[p.x][p.y] EMPTY; // 还原 best max(best, v); alpha max(alpha, v); if (beta alpha) break; // 剪枝 } return best; } else { int best INF; for (const Point p : cand) { board[p.x][p.y] PLAYER; int v Minimax(AI, depth - 1, alpha, beta); board[p.x][p.y] EMPTY; best min(best, v); beta min(beta, v); if (beta alpha) break; } return best; } }depth表示剩余搜索层数不是总步数。AI 层取max玩家层取min这种交替正好模拟双方对抗。每次递归前落子、递归后还原是搜索类代码的标准姿势。如果漏了“还原”那一步后面的候选点会带着上一层的错误棋盘状态参与评估结果完全失控。GenerateCandidates也不是把 225 个空位全放进去那样深度 4 的搜索量级是 200 的 4 次方任何期末电脑都扛不住。3.3 Alpha-Beta 剪枝与走法排序核心算法改进.cpp里的核心改进就是对候选点做裁剪和排序。Alpha-Beta 剪枝本身写出来很简单难在它极度依赖节点的访问顺序如果先搜索了很好的落点那么后续大量分支可以迅速被剪掉如果先搜索烂点剪枝效果就大打折扣。vectorPoint GenerateCandidates(int radius) { vectorPoint pts; bool used[15][15] { false }; for (int i 0; i 15; i) for (int j 0; j 15; j) if (board[i][j] ! EMPTY) { for (int dx -radius; dx radius; dx) for (int dy -radius; dy radius; dy) { int nx i dx, ny j dy; if (nx 0 nx 15 ny 0 ny 15 board[nx][ny] EMPTY !used[nx][ny]) { used[nx][ny] true; pts.push_back({nx, ny}); } } } sort(pts.begin(), pts.end(), [](const Point a, const Point b) { return EstimateScore(a) EstimateScore(b); }); return pts; }radius就是候选点生成范围。落子只可能发生在已有棋子附近距离超过radius的空位在战术上几乎不可能是当前最优步。我一般把简单难度设成radius 1, depth 2中等难度设成radius 2, depth 4困难难度设成radius 2, depth 6但深度 6 在纯单线程下会有肉眼可见的卡顿适合让用户在主界面“等待电脑思考”的延迟中接受。4. 人机对战流程登录窗口、难度控制、胜负判断与悔棋4.1 登录框与主窗口的跳转MFC 工程里出现denglu.cpp和SettingDlg.cpp意味着程序不是直接进入棋盘而是先走一个流程。以常见的课程设计套路来说登录对话框负责验证用户名密码设置对话框负责选择难度和先后手最后进入主对弈窗口。在CWinApp::InitInstance中这段跳转一般写成BOOL CMyApp::InitInstance() { // 登录 CDengluDlg loginDlg; if (loginDlg.DoModal() ! IDOK) return FALSE; // 难度设置 CSettingDlg settingDlg; if (settingDlg.DoModal() ! IDOK) return FALSE; // 主棋盘 CFivezqDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return TRUE; }DoModal是模态对话框的关键它内部启动消息循环直到对话框退出前后面的代码不会继续执行。很多 MFC 初学者以为DoModal返回后窗口就没了其实返回值就是对话框确定或取消的结果。这个流程的优点是模块间耦合度低登录、设置、对弈各自独立只要设置对话框把自己选择的难度写入一个公共变量主对话框启动时读取即可。4.2 回合控制与落子流程人机对战的回合控制最怕出现“玩家落子后电脑还没走完玩家又点了一颗”的重入问题。项目里用一个简单的枚举状态机解决enum Turn { TURN_PLAYER, TURN_AI, TURN_END } m_turn;鼠标落子的处理函数开头就检查状态void CFivezqDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_turn ! TURN_PLAYER) return; // 坐标换算、落子、胜负判断... m_turn TURN_AI; AIThink(); // 这里会执行搜索阻塞几百毫秒到几秒 m_turn TURN_PLAYER; }AIThink内部调用搜索由于 MFC 消息循环是单线程的搜索期间鼠标点击被系统排队不会真的丢失但也不会再次触发OnLButtonDown因为m_turn已经变成TURN_AI。如果 AI 搜索时间太长界面会“卡住”但这在期末项目里可以接受。如果想让界面不卡就要把AIThink放到工作线程那又是另一套线程同步问题不建议在这个阶段引入。4.3 胜负判断的实现细节胜负判断是“写得快但容易错”的典型。它要检查从落子位置出发四个方向水平、垂直、两条对角线上连续同色棋子是否达到 5 个。正确做法是每个方向双向延伸统计bool CFivezqDlg::CheckWin(int row, int col, int color) { int dir[4][2] { {1,0}, {0,1}, {1,1}, {1,-1} }; for (int k 0; k 4; k) { int cnt 1; for (int s 1; ; s) { int r row dir[k][0] * s; int c col dir[k][1] * s; if (r 0 || r N || c 0 || c N) break; if (board[r][c] ! color) break; cnt; } for (int s 1; ; s) { int r row - dir[k][0] * s; int c col - dir[k][1] * s; if (r 0 || r N || c 0 || c N) break; if (board[r][c] ! color) break; cnt; } if (cnt 5) return true; } return false; }这段代码看起来简单坑全在边界。dir里四个方向覆盖了所有需要检查的直线但双向统计时容易忘记反方向导致只数了半边。另一个常见错误是先用board[r][c]再判断r是否越界数组访问越界会读到野值表现出来就是“明明横着只有 4 个棋电脑却判定赢了”。我一般会在调试时故意放一个坐标打印确认每次CheckWin的row和col都在 0 到 14 之间。4.4 悔棋与栈结构悔棋功能通常依赖一个落子历史栈。人机对战时玩家悔棋需要同时撤销两步电脑的最后一步和玩家的最后一步否则轮次会对不上。栈可以用vectorPoint实现因为只需要只能在尾部操作void CFivezqDlg::OnBtnUndo() { if (m_history.size() 2) return; Point aiStep m_history.back(); m_history.pop_back(); Point playerStep m_history.back(); m_history.pop_back(); board[aiStep.x][aiStep.y] EMPTY; board[playerStep.x][playerStep.y] EMPTY; m_turn TURN_PLAYER; Invalidate(FALSE); }这个函数的逻辑前提是每一步落子都被记录并且玩家落子后电脑一定会跟着落子。如果棋盘最后一手是玩家落子且 AI 尚未回应悔棋只弹出一个点就会打乱状态。我在实现时会在AIThink最后才把电脑落点压入历史栈避免出现半悔棋状态。难度选择与 AI 搜索参数可以直接联动难度搜索深度候选点半径预期响应时间简单21小于 50ms中等42约 300ms困难62数秒SettingDlg拿到用户选择后通过m_ai.SetLevel(...)传给搜索类。不要把难度处理散落在棋盘类里否则后面想加“深度 7”或“以攻为主”的策略时会到处改代码。5. 编译与排错实战从 VC6 到 VS2022 的 MFC 工程迁移5.1 “此项目需要 MFC 库”的成因与处置用 Visual Studio 打开这套源码时可能先遇到“此项目需要 MFC 库”的提示。这个错误的本意不是缺文件而是当前工程没有把 “使用 MFC” 选项打开。右键项目 - 属性 - 常规 - MFC 使用改成“在共享 DLL 中使用 MFC”。如果是通过 .dsp 或旧版 .vcproj 打开VS 会询问是否进行格式转换确认转换后第一件事就是检查这个属性。另一个相关联的问题是运行新编译的 exe 时提示缺少mfc140u.dll这是因为目标机器没有安装对应版本的 Visual C Redistributable。开发机安装完整 VS部署机需要单独装运行库。网上常见的错误信息error: microsoft visual c 14.0 or greater is required多出现在 Python 扩展安装场景但这套 MFC 项目如果使用 VS2015 之后的工具集也需要确认本机有 14.0 以上版本的 C 运行库不能只装 .NET 运行时。5.2 常见编译错误与修复老工程从 VC6 迁移到 VS2022第一个撞墙的通常是字符串类型。VC6 时代默认使用多字节字符集而 VS2022 默认使用 Unicode于是代码里的字符串字面量无法自动转换报出C2664一系错误。修复不是全局关闭 Unicode而是把字符串统一包上_T宏// 错误写法 MessageBox(游戏结束); // 正确写法 MessageBox(_T(游戏结束));_T在 Unicode 工程下变成L游戏结束在多字节工程下仍是普通字符串一段代码两边通吃。如果项目里大量使用char*和CString混用还需要关注CStringA与CStringW的差异。另一个高发问题是GetVersionEx等旧 API 被标记弃用报C4996。在课程设计层面直接在文件顶部加#define _CRT_SECURE_NO_WARNINGS #define _WINSOCK_DEPRECATED_NO_WARNINGS能绕开大多数“安全警告”但要注意这治标不治本。真正的越界访问不会因为屏蔽警告而消失该用std::vector的地方不要用纯数组。5.3 用 TRACE 和断点定位 AI 崩溃AI 搜索最典型的问题是递归深度太大导致堆栈溢出或者估值函数访问了越界下标。遇到崩溃时先在Minimax入口加一行输出TRACE(_T(depth%d, color%d, candidate%d\n), depth, color, cand.size());TRACE 的内容会显示在 VS 的“输出”窗口比断点更容易观察搜索轨迹。如果输出窗口显示depth先增大后急剧减小说明递归没有正确回溯多半是落子后没有还原棋盘。如果候选点数量陡然变成 0检查GenerateCandidates的边界条件。我在调试中还遇到过EvaluateBoard返回超大值导致alpha溢出表现为 AI 疯狂在一个方向连下而不防守原因是估值函数把双方棋型完全独立计分没有考虑“若对方下一步连五己方的冲四其实没有意义”这层攻防关系。用 TRACE 打印出当前局面的 AI 分和玩家分很快能看出分数失衡。6. 一个实用的调参技巧让估值函数只重算受影响区域6.1 增量评估代替全盘扫描深度 4 的搜索已经能走出像样的棋但评分函数如果每次都扫描整个 15×15 棋盘搜索耗时里会有七成浪费在场面上并没有变化的位置。实际项目中常见的做法是增量评估每次落子后只重新统计该点周围 9×9 范围内的棋型变化。因为一盘五子棋的胜负手只会发生在落子附近远离落子点的棋型不会因为在远处加一颗子就改变成五连或冲四。实现时不需要为整个棋盘维护极其复杂的数据结构只要把EvaluateDirection限定在一个数据块内int EvaluateRegionDelta(int x, int y, int deltaColor) { int diff 0; for (int dx -4; dx 4; dx) for (int dy -4; dy 4; dy) { int nx x dx; int ny y dy; if (nx 0 || nx 15 || ny 0 || ny 15) continue; // 只累加该点四个方向的棋型分变化 diff DirectionScoreDiff(nx, ny, deltaColor); } return diff; }每次落子后把board[old]的评分扣掉加上board[new]的评分得到新的总局面分。这样搜索过程中不再需要遍历 225 个格子搜索深度不变的情况下速度提升仍然明显。注意DirectionScoreDiff必须同时处理四个方向否则斜线上的活三会被漏掉。6.2 把攻防分数拆开来看调试 AI 棋力时最容易忽略的是攻击分和防守分的权重关系。很多五子棋 AI 把某一方的评分直接当作当前局面的分但这样 AI 会只顾自己冲四不管对手已经连四。更好的做法是让EvaluateBoard返回两个值本方可形成的最高威胁以及对方本方可形成的最高威胁。例如候选点进攻评分为 50000形成冲四防守评分为 100000阻挡对方活四那么实际搜索时就应当把防守分乘以一个略大于 1 的系数让 AI 在“自己能赢”和“对方快赢”之间做出更合理的判断。调参时先把两个分值分别打印出来如果 AI 总是不防守就检查是不是防守分支的beta更新有误而不是急着改分值表。这样调整之后AI 在残局中会明显更“粘人”不再出现连续一两手都不理对方杀气的情况。本文还有配套的精品资源点击获取
返回列表