ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于MFC的人机对战五子棋:从GDI绘制到AI博弈算法实践

基于MFC的人机对战五子棋:从GDI绘制到AI博弈算法实践 简介基于MFC的人机对战五子棋项目是一份面向C期末大作业的完整实现适合需要完成类似课题的在校生以及想通过实战掌握MFC框架与博弈算法的开发者。项目覆盖从游戏界面到AI核心的完整链路棋盘绘制、棋子交互、胜负判断并采用极小化极大搜索与启发式评估实现智能电脑棋手同时加入悔棋、计时、难度选择等细节功能。代码按游戏逻辑、界面显示与AI棋手进行面向对象拆分模块职责清晰内部包含核心算法改进与估值函数专用源码便于深入理解评估逻辑。资源包共三十六个文件内含八个源程序文件与八个头文件以及工程配置文件、界面资源文件和五子棋评分说明文档整体压缩后仅一百六十KB。目前已有一百零三人学习可作为课设参考、算法复现与MFC界面编程入门的综合实践样本。1. 引言与项目目标你在期末作业清单上看到“基于MFC的人机对战五子棋”心里大概会犯嘀咕MFC 这种老框架还有人写答案是肯定的——在 Windows 桌面应用课程、C 程序设计和“计算机博弈”实训里MFC 五子棋几乎是检验“会不会把算法落到窗口程序”的标准题目。它不像控制台程序那样只跟 stdin/stdout 打交道你需要处理鼠标消息、GDI 绘制、坐标映射还要在没有现成棋盘组件的情况下自己画出 15x15 网格而人机对战部分则考验算法设计让 AI 在 1 秒内给出有威胁性的落子搜索深度、估值函数都要权衡。这个项目适合两类人一类是刚学完 C 语法、想在 MFC 里做出完整带界面的程序另一类是做课程设计、希望把 AI 算法讲清楚拿高分。本文会从框架选型、棋盘绘制、胜负判定到估值算法和剪枝优化按一条能跑通的路径走一遍把每一步的参数和坑都交代清楚。2. MFC 应用骨架与三关键配置字符集、库模式、消息映射2.1 为什么在 MFC 里做而不是用 Qt 或 Win32MFC 是微软对 Win32 API 的面向对象封装核心价值不在 UI 美观而在于它把窗口过程封装成消息映射表把子控件包装成 CWnd 派生类。对五子棋这个项目来说你需要的东西 MFC 都有一个窗口作为棋盘容器、鼠标点击消息、GDI 位块传输绘制以及 CDialog 或 CView 提供的重绘入口。相比较 Qt 的信号槽和布局系统MFC 代码量大一点但你能更清楚地看到窗口消息从鼠标到 WM_LBUTTONDOWN 再到绘图函数的过程。常见做法是使用“基于对话框”的应用程序类型棋盘直接画在对话框客户区上省去 Document/View 的文档序列化项目结构也更适合期末答辩讲解。2.2 项目向导完成后立刻要改的三个工程设置用 Visual Studio 2022或 2019创建“MFC 应用程序”应用类型选“基于对话框”。向导生成的代码能编译但默认设置有三处跟五子棋项目直接相关需要先调整设置项所处位置推荐值原因字符集项目 → 属性 → 常规 → 字符集使用 Unicode 字符集MFC 类库内部大量使用 TCHAR 宏Unicode 下宽字符串与 Win32 API 的 W 版本匹配避免在 OnDraw 里处理多字节转换MFC 的使用项目 → 属性 → 常规 → MFC 的使用在静态库中使用 MFC生成的 exe 不依赖 mfc140u.dll方便拷贝到其它机器演示代价是 exe 体积增大 1MB 左右附加依赖项链接器 → 输入 → 附加依赖项保持默认确认包含uxtheme.lib对话框界面自绘时可能需要视觉样式支持提示如果编译时遇到fatal error C1189: Please use the /MD switch for _AFXDLL builds说明你选择了“在共享 DLL 中使用 MFC”却把运行库设成了 /MT。把运行库改为“多线程 DLL (/MD)”即可解决。2.3 让棋盘窗口响应鼠标消息映射的三步走MFC 的消息映射不是重写虚函数而是三处配合在对话框类头文件里声明消息处理函数afx_msg void OnLButtonUp(UINT nFlags, CPoint point);在源文件的消息映射表里添加条目ON_WM_LBUTTONUP()实现函数体在函数里把point从设备坐标换算成棋盘行列。下面是一个典型的CMyDlg::OnLButtonUp实现它把点击点映射到 0-14 的数组下标void CMyDlg::OnLButtonUp(UINT nFlags, CPoint point) { // 棋盘左上角坐标为 (BOARD_LEFT, BOARD_TOP)格子边长为 CELL_SIZE // 先判断点击是否落在棋盘边界内避免越界 if (point.x BOARD_LEFT || point.x BOARD_LEFT 14 * CELL_SIZE || point.y BOARD_TOP || point.y BOARD_TOP 14 * CELL_SIZE) { CDialogEx::OnLButtonUp(nFlags, point); return; } // 计算行列像素偏移量除以格子边长四舍五入取最近交叉点 int col (point.x - BOARD_LEFT CELL_SIZE / 2) / CELL_SIZE; int row (point.y - BOARD_TOP CELL_SIZE / 2) / CELL_SIZE; // 锁定非法落子数组下标越界或该位置已有棋子 if (col 0 || col 14 || row 0 || row 14 || m_board[row][col] ! 0) return; // 记录玩家落子然后交给 AI 响应 m_board[row][col] 1; // 1 表示黑子玩家 m_lastMove CPoint(col, row); Invalidate(); // 触发 WM_PAINT重绘棋盘 UpdateWindow(); if (CheckWin(row, col, 1)) { MessageBox(_T(黑棋获胜)); return; } // 让 AI 走一步具体搜索算法见第 5 章 AITurn(); CDialogEx::OnLButtonUp(nFlags, point); }代码说明BOARD_LEFT、BOARD_TOP、CELL_SIZE是常量控制棋盘的像素位置。m_board是 15x15 的二维数组0 表示空位1 表示黑子2 表示白子。四舍五入用 CELL_SIZE / 2实现不必调用round避免浮点误差。Invalidate只是把窗口标记为无效UpdateWindow强制立即重绘这样玩家点击后能立刻看到棋子不用等到消息队列空闲。2.4 输入法干扰与焦点问题对话框程序默认会响应键盘消息。如果玩家在落子前不小心按下空格或回车会触发默认按钮。建议在OnInitDialog里把对话框上可能存在的“确定”“取消”按钮隐藏或者在PreTranslateMessage里拦截回车和空格BOOL CMyDlg::PreTranslateMessage(MSG* pMsg) { // 屏蔽 ESC 和 Enter 导致的对话框关闭 if (pMsg-message WM_KEYDOWN (pMsg-wParam VK_ESCAPE || pMsg-wParam VK_RETURN)) return TRUE; return CDialogEx::PreTranslateMessage(pMsg); }参数说明VK_ESCAPE对应 ESC 键VK_RETURN对应回车。默认情况下对话框会把回车当作 IDOK 处理把 ESC 当作 IDCANCEL 处理这在五子棋里会导致玩家误触关闭窗口提前返回TRUE能拦截掉。3. 棋盘绘制与坐标体系从 GDI 画线到双缓冲位图3.1 为什么直接用 GDI 而不是用控件数组可以用 225 个 Picture 控件拼棋盘但那样不仅拖拽繁琐重合的交叉点根本无法用控件表达。正确做法是在对话框的OnPaint里用 GDI 画线、画圆把整张棋盘用很少的代码画出来还能做到窗口缩放不变形。先定义三个常量const int CELL_SIZE 36; // 格子边长像素 const int BOARD_LEFT 20; // 棋盘左边距 const int BOARD_TOP 20; // 棋盘上边距 const int RADIUS 15; // 棋子半径棋盘是 15 条横线加 15 条竖线交叉点 225 个。绘制时机在OnPaint里完成为了保证窗口大小变化时棋盘不消失要在WM_ERASEBKGND里返回TRUE防止背景闪烁。3.2 双缓冲绘制的具体实现如果直接在OnPaint的CPaintDC上画窗口每次被拖拽或Invalidate都会闪烁。常见做法是先创建一个内存位图把棋盘画到位图上再一次用BitBlt贴到屏幕。代码如下void CMyDlg::OnPaint() { CPaintDC dc(this); // 设备上下文 CRect rc; GetClientRect(rc); CDC memDC; // 内存设备上下文 memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 背景色 memDC.FillSolidRect(rc, RGB(210, 180, 140)); // 画棋盘网格 CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen memDC.SelectObject(pen); for (int i 0; i 15; i) { int x BOARD_LEFT i * CELL_SIZE; int y BOARD_TOP i * CELL_SIZE; memDC.MoveTo(BOARD_LEFT, y); memDC.LineTo(BOARD_LEFT 14 * CELL_SIZE, y); memDC.MoveTo(x, BOARD_TOP); memDC.LineTo(x, BOARD_TOP 14 * CELL_SIZE); } // 画棋子 for (int row 0; row 15; row) { for (int col 0; col 15; col) { if (m_board[row][col] 1) { // 黑子黑色填充稍加高光 memDC.FillSolidRect( BOARD_LEFT col * CELL_SIZE - RADIUS, BOARD_TOP row * CELL_SIZE - RADIUS, RADIUS * 2, RADIUS * 2, RGB(0, 0, 0)); memDC.Ellipse( BOARD_LEFT col * CELL_SIZE - RADIUS, BOARD_TOP row * CELL_SIZE - RADIUS, BOARD_LEFT col * CELL_SIZE RADIUS, BOARD_TOP row * CELL_SIZE RADIUS); } else if (m_board[row][col] 2) { // 白子先画外圈再画内圈形成立体感 memDC.Ellipse( BOARD_LEFT col * CELL_SIZE - RADIUS, BOARD_TOP row * CELL_SIZE - RADIUS, BOARD_LEFT col * CELL_SIZE RADIUS, BOARD_TOP row * CELL_SIZE RADIUS); } } } dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldPen); memDC.SelectObject(pOld); }代码说明CreateCompatibleBitmap创建的内存位图与屏幕 DC 的颜色格式一致BitBlt是内存到屏幕的整块拷贝闪烁只会在位图上发生一次。FillSolidRect用木色背景模拟棋盘。白子的立体感可以通过两次Ellipse画出高光边这里省略了高光细节但答辩时画一个内圆亮点会显得更用心。参数说明CELL_SIZE 36意味着棋盘总宽 14 * 36 504 像素加上左右边距 20对话框客户区宽度至少要 544。如果你的对话框默认宽度不够在资源编辑器里把对话框宽度调到 600 x 620 左右避免右侧和下侧被裁剪。3.3 坐标换算像素点与二维数组的精确关系绘制是把数组下标映射到像素鼠标点击是把像素映射回数组下标。这两个映射必须互逆否则会出现“点击在交叉点旁边却落到另一行”的错觉。映射公式在OnLButtonUp里已经写了核心是col (point.x - BOARD_LEFT CELL_SIZE / 2) / CELL_SIZE。这里有一个容易踩的坑不要把棋盘线画在BOARD_LEFT i * CELL_SIZE上却把格子线之间的间隔当成CELL_SIZE。如果画线起点是BOARD_LEFT第 0 条线横坐标是BOARD_LEFT第 i 条线是BOARD_LEFT i * CELL_SIZE相邻线的像素距离恰好是CELL_SIZE。反过来算行列时点击点落在BOARD_LEFT col * CELL_SIZE附近哪个交叉点最近就看整除前加不加半格。3.4 GDI 资源泄漏检查CPen、CBitmap、CDC都是 RAII 类析构时自动释放 GDI 对象但要注意如果在循环里反复SelectObject却不保存旧对象容易把画刷或画笔画乱。上面代码里保存了pOldPen和pOld在函数末尾恢复这是 MFC 程序的基本素养。Debug 模式下可以用任务管理器监视 GDI 对象数量正常运行时不应持续增长。4. 落子与规则从“堵眼”到五子连珠判定的完整路径4.1 棋子状态与回合管理五子棋的规则很简单15x15 棋盘黑白交替落子先形成五连者胜。代码里用m_board[row][col]存储棋盘状态用m_bPlayerTurn标记当前轮到谁。玩家点击时若m_bPlayerTurn false直接忽略点击AI 落子后把标志位切回true。这个标志位必须与绘图逻辑同步否则会出现玩家点击后棋子已经落下去但 AI 还没走玩家又能再点一次的 bug。用CPoint m_lastMove记录最新一步便于在绘制时用红色小方块标注最后落子位置。标注代码可以加在OnPaint里棋子绘制之后if (m_lastMove.x 0) { int cx BOARD_LEFT m_lastMove.x * CELL_SIZE; int cy BOARD_TOP m_lastMove.y * CELL_SIZE; CBrush markBrush(RGB(255, 0, 0)); memDC.FillRect(CRect(cx - 3, cy - 3, cx 3, cy 3), markBrush); }4.2 八方向连续判断胜负不用移位的写法判断胜负最容易出错的地方在于方向遗漏。五子棋的连续性是 8 个方向的但仔细分析就知道其实只需查 4 条线水平、垂直、左上到右下、右上到左下每条线正反两个方向加起来就是 8 个方向。传统写法是写四个双层循环代码重复度高。简洁做法是定义方向增量数组bool CMyDlg::CheckWin(int row, int col, int player) { // 四个方向的方向向量水平、垂直、斜向下、斜向上 int dx[4] { 1, 0, 1, 1 }; int dy[4] { 0, 1, 1, -1 }; for (int dir 0; dir 4; dir) { int count 1; // 当前棋子自身算一个 // 正方向延伸 for (int step 1; step 5; step) { int nr row dy[dir] * step; int nc col dx[dir] * step; if (nr 0 || nr 14 || nc 0 || nc 14) break; if (m_board[nr][nc] ! player) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int nr row - dy[dir] * step; int nc col - dx[dir] * step; if (nr 0 || nr 14 || nc 0 || nc 14) break; if (m_board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }代码说明dx[4]与dy[4]成对出现分别代表四个方向的“走向”。对于斜向上方向dx1, dy-1正向延伸是行减小、列增大反向延伸是行增大、列减小两个循环互补。count累加的是连续同色棋子的数量不是整个棋盘的总数。你可能注意到超过 5 个也算赢比如六连。如果只想判“恰好五连”需要额外判断两端是否为空或边界但大多数五子棋规则里五连以上也判胜这里不做额外处理。4.3 边界条件与“堵眼”误判在判胜循环里正向和反向延伸各走 4 步但最终连子数可能达到 9因为两边都可以延伸。count 5已经覆盖了五连以上。有一种错误写法是只判断一边方向出现“五连里有空格却判赢”的情况另一种错误是没检查边界直接m_board[nr][nc]导致数组越界程序崩溃或读到脏数据。上面代码在每次取值前都做nr 0 || nr 14等判断这是防越界的标准写法。4.4 平局与棋盘满判断五子棋可能下到棋盘满还没有五连。用int emptyCount记录剩余空位数每次落子后递减落子在已有点上时要拒绝。OnLButtonUp里已经判断了m_board[row][col] ! 0就返回所以不可能覆盖。平局就发生在emptyCount 0且没有五连时。可以在每次落子后检查if (emptyCount 0) { MessageBox(_T(平局)); RestartGame(); }这里的RestartGame负责把m_board清零、m_lastMove置为CPoint(-1, -1)、m_bPlayerTurn置为true并调用Invalidate。5. 人机对战 AI估值函数与 alpha-beta 剪枝搜索5.1 搜索目标设定与工程量评估难点集中在 AI 部分。期末项目要求在 1 秒内给出响应完整 alpha-beta 搜索可以做到但视野不要一次性调到极大极小搜索的完美解——五子棋的状态空间远超国际象棋全盘穷举不现实。常见做法是“启发式搜索 固定深度”结合剪枝做到 2~4 层。如果你只想让 AI 能堵对手的冲四、自己形成活三一层宽度优先的估值函数就够了若要表现得更强再叠加搜索。以下代码基于经典的权值表模式不做搜索树展开逻辑清晰、适合答辩展示。搜索增强方案见 5.5。5.2 落子优先级与攻击-防守评分打分的基本思想遍历所有空位模拟在该位置放黑子或白子然后统计每个方向上形成的棋型长度按威胁程度打分。棋型分为五连、活四、冲四、活三、眠三、活二、眠二等。这里只展示核心打分函数省略辅助函数CountDirection的实现细节int CMyDlg::EvaluatePoint(int row, int col, int player) { if (m_board[row][col] ! 0) return -100000; // 已有棋子极大负分禁止选择 int score 0; // 四个方向分别统计连子长度和开放性 int dirs[4][2] { {1,0}, {0,1}, {1,1}, {1,-1} }; for (int i 0; i 4; i) { int dx dirs[i][0]; int dy dirs[i][1]; int count 1; // 当前模拟棋子 int openEnds 0; // 两头可延伸的端点数 // 正向扫 int r row dy, c col dx; while (r 0 r 15 c 0 c 15 m_board[r][c] player) { count; r dy; c dx; } if (r 0 r 15 c 0 c 15 m_board[r][c] 0) openEnds; // 反向扫 r row - dy; c col - dx; while (r 0 r 15 c 0 c 15 m_board[r][c] player) { count; r - dy; c - dx; } if (r 0 r 15 c 0 c 15 m_board[r][c] 0) openEnds; // 根据连续个数与开放性加权 if (count 5) score 100000; else if (count 4 openEnds 2) score 50000; // 活四 else if (count 4 openEnds 1) score 10000; // 冲四 else if (count 3 openEnds 2) score 5000; // 活三 else if (count 3 openEnds 1) score 1000; // 眠三 else if (count 2 openEnds 2) score 500; // 活二 else if (count 2 openEnds 1) score 100; // 眠二 else if (count 1 openEnds 2) score 50; // 散子 } return score; }代码说明openEnds统计两头是否为空。count 4 openEnds 2表示两头都没被堵下一次落子任意一头都能直接五连这一步必然要堵。权值表的数值关系决定了 AI 的棋风如果你把“活三 5000”调大AI 会更激进地进攻把“冲四 10000”调大AI 更偏重防守。5.3 把评估套用到 AI 落子决策AI 决策分三步先评估所有空位对己方白子的价值再评估对玩家黑子的价值选择两者加权和最大的位置。完整代码void CMyDlg::AITurn() { // 模拟 AI 在当前局面下寻找最优落子 int bestScore -1; CPoint bestMove; for (int row 0; row 15; row) { for (int col 0; col 15; col) { if (m_board[row][col] ! 0) continue; int attackScore EvaluatePoint(row, col, 2); // AI 自己白 int defendScore EvaluatePoint(row, col, 1); // 玩家黑 int totalScore attackScore defendScore * 1.1; // 防守权重略高 if (totalScore bestScore) { bestScore totalScore; bestMove CPoint(col, row); } } } if (bestMove.x 0) { m_board[bestMove.y][bestMove.x] 2; m_lastMove bestMove; Invalidate(); UpdateWindow(); if (CheckWin(bestMove.y, bestMove.x, 2)) MessageBox(_T(白棋获胜)); } }参数说明defendScore * 1.1是防守系数让 AI 在局面接近时优先堵玩家而不是自己盲目进攻。这个系数可以调调到 1.5 时 AI 很“怂”调到 0.8 时 AI 比较激进。有了这个函数人机对战就完整了。5.4 搜索深度不够时的“诱骗”陷阱单纯用 5.2 的局部打分AI 看不到两步之后的威胁。比如何处有跳三、双三组合这些横跨两个空位的棋型单点扫描看不出来容易“顾此失彼”。要在期末答辩里展示更强的 AI就得引入 alpha-beta 剪枝让 AI 往下多想几层int CMyDlg::AlphaBeta(int depth, int alpha, int beta, int player) { if (depth 0) return EvaluateBoard(); // 对整个棋盘做一个静态评估 // 生成候选落子点所有空位的评分降序排列 std::vectorCPoint candidates GenerateCandidates(); if (candidates.empty()) return 0; for (const auto pt : candidates) { m_board[pt.y][pt.x] player; int score -AlphaBeta(depth - 1, -beta, -alpha, 3 - player); m_board[pt.y][pt.x] 0; if (score alpha) { alpha score; // 剪枝 if (alpha beta) break; } } return alpha; }代码说明这是标准 negamax 形式3 - player把 1 变成 2、把 2 变成 1完成换手。alpha是已知最优下界beta是已知最差上界当alpha beta时当前节点对上层已经没有意义直接中断循环。GenerateCandidates用 5.2 的评估函数对空位排序只取前 10~20 个位置能大幅缩小搜索宽度。搜索深度设 4加上剪枝在 15x15 棋盘上能稳定在几百毫秒内完成。5.5 额外优化工作线程与界面不卡顿无论搜索深度多少AI 计算期间界面都会短暂“卡死”。用Sleep和消息泵不是好办法。标准做法是开一个工作线程计算完成后再切换到 UI 线程落子// 在 AI 决策前启用一个工作线程 AfxBeginThread(AIThreadProc, this); UINT AIThreadProc(LPVOID pParam) { CMyDlg* pDlg (CMyDlg*)pParam; pDlg-AITurn(); // 耗时计算 // 注意不能在子线程里直接操作窗口用 PostMessage 回主线程 ::PostMessage(pDlg-GetSafeHwnd(), WM_AI_DONE, 0, 0); return 0; }WM_AI_DONE是自定义消息需要在消息映射里加ON_MESSAGE(WM_AI_DONE, CMyDlg::OnAIDone)对应处理函数。处理函数只负责更新界面和检查胜负。核心要点所有 GDI 绘图和CWnd::Invalidate调用必须在主线程工作线程只做纯计算。6. 提升答辩质量的四个细节悔棋、禁手与双人测试模式到了代码能跑、AI 能应战的阶段项目已经满足基本要求。但要拿高分还可以在功能与稳定性上补四个细节每个都不难却能让答辩老师直接看到你的工程意识。第一个是悔棋。用std::vectorCPoint记录历史落子AI 每次走完后把两步玩家一步、AI 一步压入栈。悔棋按钮弹栈两步同时把m_board对应位置清零并重绘。要注意栈空时不处理否则pop空容器会崩溃。实现不超过 20 行代码。第二个是双人模式开关。在对话框上放一个复选框“双人对战”勾选后OnLButtonUp里的 AI 调用改成切换m_board[row][col]的黑白身份。实际上这个功能只改一个条件分支却能用来自测 AI 强度你左右手互搏看 AI 是否总能防守住也能在答辩时快速演示“玩家1 胜”和“玩家2 胜”两个消息框。第三个是胜负判定的回归测试。CheckWin函数与界面耦合不深可以在项目里加一个测试按钮预置几种棋局来验证横五连、竖五连、斜五连、断连、五子以上。这比在棋盘上手摆快得多也能提前发现坐标方向写反的问题。第四个是部署细节。用静态链接 MFC 编译出的 exe 放到没有安装 Visual Studio 的机器上通常能直接运行。但如果用户机器缺少通用运行库会弹“此项目需要 MFC 库”或 “无法定位程序输入点于 mfc140u.dll”。最稳妥的交付方式是在 Release x64 配置下把“VC 目录”里的运行库打包复制或用“Release 静态库”配置重新编译一次。期末提交一般只要求 exe 和源码能在一台干净虚拟机上跑起来就是加分项。最后留一个持续优化方向把 5.4 的搜索深度从 4 调到 6并配合走法排序再对比相同局面下 AI 的胜率。你会发现 6 层深度的棋力提升明显但单步耗时可能超过 1 秒。如果评测环境是发布版用计时器测量单步耗时超过 800ms 时自动降回 4 层这是一种在“棋力”和“响应时间”之间的经典取舍。本文还有配套的精品资源点击获取
返回列表