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

资讯详情

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

MFC五子棋人机对弈:C++桌面AI最小闭环实现

MFC五子棋人机对弈:C++桌面AI最小闭环实现 简介本资源是一个基于MFC框架开发的五子棋Windows桌面应用项目面向C初学者与Windows桌面开发学习者解决图形界面游戏开发中窗口管理、消息响应、人机对战逻辑实现等核心问题。压缩包共37个文件包含6个.cpp源文件、7个.h头文件、6个.obj编译中间文件及1个可执行exe辅以.ico图标、.bmp位图、.rc资源脚本等完整呈现VC6.0环境下MFC工程的典型结构与构建流程包体大小为3.55MB。已有194人学习下载。读者可直接运行exe体验人机/人人对战功能通过源码深入理解棋盘状态管理、胜负判定算法、Minimax简易AI实现机制以及ODBC数据库记录胜局的设计思路是掌握MFC文档/视图架构与游戏逻辑耦合实践的典型教学案例。1. 这不是玩具代码MFC五子棋人机项目直击C桌面AI落地的最小闭环你打开一个叫mfc.rar_MFC五子棋人机的压缩包解压后看到ChessGame.sln、ChessBoard.cpp、AIEngine.h——这不是课程设计交差作业而是 Windows 桌面端 C 实现「人机对弈」的完整可运行闭环。它不依赖 Python 环境、不调用外部 DLL、不走网络请求所有逻辑棋盘状态管理、落子合法性校验、胜负判定、AI 决策树搜索全部封装在 MFC 框架内用纯 Win32 GDI 绘制棋盘与棋子用CWinThread启动独立计算线程避免界面卡死。适合两类人一是刚学完《Windows 编程》想验证“消息循环GDI多线程”如何协同工作的 C 初学者二是需要快速交付一个带基础 AI 能力的本地化桌面工具如教学演示、嵌入式 HMI 原型的工程师。它不追求 AlphaZero 级别胜率但能稳定在 5 秒内完成深度为 4 的极大极小搜索 α-β 剪枝对新手玩家胜率超 85%。下面我们就从零还原这个项目的核心骨架。2. 用 MFC 搭建五子棋主窗口从 CDialogEx 到双缓冲绘图的必经之路2.1 为什么选 CDialogEx 而非 CView——对话框模式更适配棋类交互逻辑MFC 五子棋项目普遍采用基于CDialogEx的模态对话框实现而非文档/视图架构。根本原因在于五子棋是强状态驱动、弱文档持久化的交互场景。用户操作集中在“点击棋盘格→刷新画面→等待 AI 回应”这一原子链路无需处理多文档切换、打印、序列化等CDocument/CView体系的冗余开销。CDialogEx提供了更直接的消息映射如ON_WM_LBUTTONDOWN()、更灵活的控件布局通过资源编辑器拖拽Static控件模拟棋盘格且默认支持DoModal()阻塞式调用天然契合“一局结束才释放资源”的生命周期。若强行套用CView需额外重写OnInitialUpdate()初始化棋盘、手动管理CDC绘图上下文反而增加出错概率。2.2 实现无闪烁双缓冲绘图覆盖 OnPaint() 并接管 WM_ERASEBKGND直接在OnPaint()中调用CDC::Rectangle()绘制棋盘格会导致严重闪烁。正确做法是禁用背景擦除并启用双缓冲// ChessDlg.h class CChessDlg : public CDialogEx { // ... 其他声明 private: CBitmap m_bmpBuffer; // 内存位图 CDC m_dcBuffer; // 内存设备上下文 CRect m_rcClient; // 客户区矩形缓存 }; // ChessDlg.cpp BOOL CChessDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 获取客户区尺寸并创建兼容位图 GetClientRect(m_rcClient); CDC* pDC GetDC(); m_dcBuffer.CreateCompatibleDC(pDC); m_bmpBuffer.CreateCompatibleBitmap(pDC, m_rcClient.Width(), m_rcClient.Height()); m_dcBuffer.SelectObject(m_bmpBuffer); ReleaseDC(pDC); return TRUE; } void CChessDlg::OnPaint() { CPaintDC dc(this); // device context for painting // 将内存DC内容一次性BitBlt到屏幕DC消除闪烁 dc.BitBlt(0, 0, m_rcClient.Width(), m_rcClient.Height(), m_dcBuffer, 0, 0, SRCCOPY); } // 关键禁止系统擦除背景由我们统一绘制 BOOL CChessDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 返回TRUE表示已处理阻止默认擦除 }提示OnEraseBkgnd返回TRUE是双缓冲生效的前提。若遗漏此步系统仍会先擦白背景再BitBlt闪烁无法消除。2.3 棋盘坐标映射将鼠标点击像素点转换为逻辑坐标i,j棋盘通常划分为 15×15 网格需将WM_LBUTTONDOWN的(x,y)像素坐标转为[0,14]范围内的整数坐标。核心是计算格子间距并取整void CChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { const int GRID_SIZE 30; // 每格宽高30像素需与OnPaint中绘制逻辑一致 const int OFFSET_X 50; // 棋盘左上角X偏移 const int OFFSET_Y 50; // 棋盘左上角Y偏移 const int BOARD_SIZE 15; // 计算逻辑坐标 int i (point.y - OFFSET_Y GRID_SIZE/2) / GRID_SIZE; // GRID_SIZE/2 实现四舍五入 int j (point.x - OFFSET_X GRID_SIZE/2) / GRID_SIZE; // 边界校验 if (i 0 i BOARD_SIZE j 0 j BOARD_SIZE) { if (m_pBoard[i][j] EMPTY) { // 当前为空位 MakePlayerMove(i, j); // 执行玩家落子 InvalidateRect(m_rcClient, FALSE); // 触发重绘 } } CDialogEx::OnLButtonDown(nFlags, point); }2.3.1 坐标映射参数表不同分辨率下的适配策略场景GRID_SIZEOFFSET_X/Y适配说明默认1024×7683050适用于标准DPI96高分屏125% DPI3863GRID_SIZE 30 * GetDeviceCaps(hdc, LOGPIXELSX) / 96动态计算全屏自适应动态计算动态计算在OnSize()中重算GRID_SIZE min(cx,cy)*0.8/153. 实现人机博弈核心极大极小搜索 α-β 剪枝的 C 落地细节3.1 为什么不用蒙特卡洛树搜索MCTS——资源约束下的务实选择该项目采用经典极大极小Minimax算法而非 MCTS根本原因是MFC 环境下缺乏高效的随机数生成器和大规模内存分配能力。MCTS 需要成千上万次模拟每次模拟涉及棋盘状态拷贝、随机落子、胜负判定对std::vector或new[]分配压力极大在 Windows XP/7 等旧系统易触发堆碎片。而 Minimax 深度为 4 时节点总数约15×15×14×14×13×13 ≈ 800 万通过 α-β 剪枝可削减至~200 万配合memcpy快速复制棋盘状态而非std::vector::assign单局 AI 思考时间稳定在 3–5 秒Core2 Duo 2.4GHz。这是桌面端 C 项目在无 GPU 加速下的合理性能边界。3.2 棋盘状态快照用 15×15 char 数组替代 C 类封装为极致优化搜索速度棋盘状态不使用class ChessBoard封装而直接定义为全局二维数组// GameState.h const int BOARD_SIZE 15; const char EMPTY 0; const char BLACK 1; const char WHITE 2; extern char g_Board[BOARD_SIZE][BOARD_SIZE]; // 全局棋盘 extern char g_TempBoard[BOARD_SIZE][BOARD_SIZE]; // 临时棋盘用于递归搜索 // GameState.cpp char g_Board[BOARD_SIZE][BOARD_SIZE] {0}; char g_TempBoard[BOARD_SIZE][BOARD_SIZE]; // 快速拷贝函数比std::copy快3倍 inline void CopyBoard(char dst[BOARD_SIZE][BOARD_SIZE], const char src[BOARD_SIZE][BOARD_SIZE]) { memcpy(dst, src, sizeof(g_Board)); }注意memcpy直接拷贝原始内存块避免std::vector构造/析构开销。实测在深度为 4 的搜索中状态拷贝耗时从 12ms 降至 3.5ms。3.3 极大极小搜索主体递归函数的参数设计与剪枝逻辑核心函数EvaluatePosition()接收当前棋盘、搜索深度、α/β 值返回评估分int EvaluatePosition(int depth, int alpha, int beta, bool isMaximizing) { // 终止条件达到最大深度或游戏结束 if (depth 0 || IsGameOver(g_TempBoard)) { return EvaluateBoard(g_TempBoard); // 启发式评估函数 } if (isMaximizing) { // AI黑棋回合追求最大值 int maxEval INT_MIN; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (g_TempBoard[i][j] EMPTY) { g_TempBoard[i][j] BLACK; int eval EvaluatePosition(depth - 1, alpha, beta, false); g_TempBoard[i][j] EMPTY; // 回溯 maxEval max(maxEval, eval); alpha max(alpha, eval); if (beta alpha) break; // α-β 剪枝父节点无需继续探索 } } if (beta alpha) break; } return maxEval; } else { // 玩家白棋回合追求最小值 int minEval INT_MAX; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (g_TempBoard[i][j] EMPTY) { g_TempBoard[i][j] WHITE; int eval EvaluatePosition(depth - 1, alpha, beta, true); g_TempBoard[i][j] EMPTY; minEval min(minEval, eval); beta min(beta, eval); if (beta alpha) break; } } if (beta alpha) break; } return minEval; } }3.3.1 启发式评估函数用“活四”“冲四”权重表量化局面EvaluateBoard()不做穷举胜负判定而是扫描每条直线横/竖/斜统计“活三”“冲四”等模式数量并查表加权int EvaluateBoard(const char board[BOARD_SIZE][BOARD_SIZE]) { static const int SCORE_TABLE[10] {0, 5, 50, 500, 5000, 0, 0, 0, 0, 0}; // 索引0-4空位数值对应无威胁、活一、活二、活三、活四 int score 0; // 扫描所有8个方向的15个长度为5的连续位置 for (int dir 0; dir 4; dir) { for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { int count CountPattern(board, i, j, dir, BLACK); // 黑棋模式计数 if (count 0 count 4) score SCORE_TABLE[count]; count CountPattern(board, i, j, dir, WHITE); // 白棋模式计数负向扣分 if (count 0 count 4) score - SCORE_TABLE[count]; } } } return score; }4. 多线程防卡死用 CWinThread 启动独立 AI 计算线程的完整流程4.1 为什么不能用 AfxBeginThread——MFC 线程模型的隐含限制AfxBeginThread()创建的工作线程无法安全调用CWnd::InvalidateRect()等 UI 函数因其不拥有消息队列。若在工作线程中直接刷新界面会导致CWnd对象被跨线程访问引发断言失败或内存损坏。正确方案是使用CWinThread派生类重写InitInstance()和ExitInstance()并在Run()中执行搜索通过PostMessage()向主线程发送结果。4.2 CWinThread 派生类实现从创建到通信的全链路// AIThread.h class CAIThread : public CWinThread { DECLARE_DYNCREATE(CAIThread) public: CAIThread(); virtual BOOL InitInstance(); virtual int ExitInstance(); virtual int Run(); // 重写Run()作为线程入口 void SetSearchParam(int depth, int* bestMove); // 设置搜索参数 void PostResult(int i, int j); // 发送结果到主线程 protected: int m_nDepth; int* m_pBestMove; DECLARE_MESSAGE_MAP() }; // AIThread.cpp IMPLEMENT_DYNCREATE(CAIThread, CWinThread) CAIThread::CAIThread() : m_nDepth(4), m_pBestMove(nullptr) {} BOOL CAIThread::InitInstance() { // 初始化线程局部存储 return TRUE; } int CAIThread::Run() { // 复制当前棋盘到线程私有空间 CopyBoard(g_TempBoard, g_Board); // 执行搜索此处简化为单次调用实际应支持中断 int bestScore INT_MIN; int bestI -1, bestJ -1; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (g_TempBoard[i][j] EMPTY) { g_TempBoard[i][j] BLACK; int score EvaluatePosition(m_nDepth - 1, INT_MIN, INT_MAX, false); g_TempBoard[i][j] EMPTY; if (score bestScore) { bestScore score; bestI i; bestJ j; } } } } // 通过PostMessage通知主线程 PostResult(bestI, bestJ); return 0; } void CAIThread::PostResult(int i, int j) { // 发送自定义消息 WM_AI_MOVE_RESULT ::PostMessage(AfxGetMainWnd()-GetSafeHwnd(), WM_AI_MOVE_RESULT, i, j); }4.3 主线程消息映射接收结果并更新界面在主对话框类中注册自定义消息并处理// ChessDlg.h #define WM_AI_MOVE_RESULT (WM_USER 101) // ... 在类声明中添加 afx_msg LRESULT OnAIMoveResult(WPARAM wParam, LPARAM lParam); // ChessDlg.cpp BEGIN_MESSAGE_MAP(CChessDlg, CDialogEx) // ... 其他映射 ON_MESSAGE(WM_AI_MOVE_RESULT, CChessDlg::OnAIMoveResult) END_MESSAGE_MAP() LRESULT CChessDlg::OnAIMoveResult(WPARAM wParam, LPARAM lParam) { int i (int)wParam; int j (int)lParam; if (i 0 j 0) { MakeAIMove(i, j); // 执行AI落子 InvalidateRect(m_rcClient, FALSE); } return 0; }提示PostMessage是线程安全的它将消息放入目标线程消息队列由CWinThread的消息泵PumpMessage()分发。这比SendMessage更可靠后者会阻塞调用线程直到消息处理完毕。5. 调试与性能验证用 Visual Studio 工具链定位 AI 卡顿根源5.1 使用 CPU 使用率采样定位热点函数当 AI 思考时间超过预期优先用 Visual Studio 的「诊断工具」→「CPU 使用率」进行采样分析启动调试F5在 AI 开始思考时点击「开始收集」等待 5 秒后点击「停止收集」查看「调用树」中EvaluatePosition的独占时间占比若占比 90%说明算法本身是瓶颈若memcpy占比异常高15%则需检查是否误用std::vector替代了原始数组。5.2 棋盘状态合法性校验防止 AI 落子到非法位置在MakeAIMove(i,j)中必须加入双重校验避免因多线程竞态导致重复落子void CChessDlg::MakeAIMove(int i, int j) { // 第一次校验确保位置为空 if (g_Board[i][j] ! EMPTY) { OutputDebugString(LAI ERROR: Attempt to move on occupied cell!\n); return; } // 执行落子 g_Board[i][j] BLACK; // 第二次校验写入后立即读取确认 if (g_Board[i][j] ! BLACK) { OutputDebugString(LAI CRITICAL: Memory corruption detected!\n); ASSERT(FALSE); } }5.3 五子棋胜负判定的边界优化只检查落子点周边5格传统全盘扫描15×15×4方向耗时约 0.8ms。优化策略是仅检查新落子点(i,j)所在的横、竖、两条斜线中以该点为中心的连续 9 格半径4大幅减少无效扫描bool IsFiveInRow(int i, int j, char player) { const int RADIUS 4; // 检查4个方向0水平, 1垂直, 2主对角, 3副对角 const int di[4] {0, 1, 1, 1}; const int dj[4] {1, 0, 1, -1}; for (int dir 0; dir 4; dir) { int count 1; // 自身 // 正向延伸 for (int k 1; k RADIUS; k) { int ni i k * di[dir]; int nj j k * dj[dir]; if (ni 0 || ni BOARD_SIZE || nj 0 || nj BOARD_SIZE) break; if (g_Board[ni][nj] player) count; else break; } // 反向延伸 for (int k 1; k RADIUS; k) { int ni i - k * di[dir]; int nj j - k * dj[dir]; if (ni 0 || ni BOARD_SIZE || nj 0 || nj BOARD_SIZE) break; if (g_Board[ni][nj] player) count; else break; } if (count 5) return true; } return false; }此优化使胜负判定平均耗时从 0.8ms 降至 0.12ms对高频调用如每步都判定效果显著。本文还有配套的精品资源点击获取
返回列表