MFC五子棋人机对战:界面、AI与避坑指南

发布时间:2026/10/10 0:16:21
MFC五子棋人机对战:界面、AI与避坑指南
简介这份资源是面向C初学者与课程设计需求者的MFC人机对战五子棋完整项目编号100013227适合作为Windows图形界面编程与游戏算法入门的实战参考。压缩包共41个文件、约1.94MB以8个h头文件与8个cpp源文件为核心配合sln、vcproj、dsw等工程文件可直接在Visual Studio中打开编译另含exe可执行程序、bmp与ico界面素材、rc资源脚本以及doc课程设计报告和md说明文档便于理解整体架构。项目覆盖玩家信息录入、15×15棋盘绘制、人机对弈AI、输赢判断、悔棋与错误提示等模块读者可据此掌握CDC绘图、对话框控件、二维数组状态管理与Minimax或Alpha-Beta剪枝思路。目前已有186人学习适合需要完整课设方案、排错参考与算法实现细节的开发者。1. 基于MFC的人机对战五子棋从界面到AI落子的完整实现路径用VS新建一个MFC对话框工程拖一个15×15的棋盘控件再写一个能挡住活三的AI——这套东西听起来像是课设作业但真正动手做过的都知道坑远比想象中多。棋盘坐标和屏幕像素的映射、GDI双缓冲防闪烁、极小极大搜索的深度控制、评估函数的权重调参每一步都有翻车的可能。这篇笔记面向两类人一是正在做MFC课程设计、需要一份能跑通的五子棋人机对战方案的在校生二是想用MFC练手Windows桌面开发、顺便复习博弈树搜索的工程师。我会把界面搭建、棋盘绘制、胜负判定、AI决策这四块拆开讲代码可以直接抄参数可以照着调踩过的坑也会标出来。2. 棋盘绘制与交互GDI双缓冲、坐标映射和鼠标响应2.1 为什么必须用双缓冲不用会怎样MFC的对话框默认在OnPaint里直接往屏幕DC上画每次重绘都会先擦背景再画线条和棋子。棋盘有15×15条线、几十个棋子如果每次鼠标移动都触发重绘屏幕会明显闪烁。这不是玄学是GDI的绘制顺序决定的——背景擦除和前景绘制之间存在时间差人眼能感知到。解决办法是内存DC先在内存里创建一块和客户区等大的位图把所有绘制操作写到这块位图上最后一次性BitBlt到屏幕DC。这样用户看到的永远是完整画面不会出现中间态。// 在对话框类中添加成员变量 CDC m_memDC; // 内存DC CBitmap m_memBmp; // 内存位图 CBitmap* m_pOldBmp; // 旧位图指针 // 初始化内存DC在OnInitDialog中调用 void CChessDlg::InitMemDC() { CClientDC dc(this); m_memDC.CreateCompatibleDC(dc); m_memBmp.CreateCompatibleBitmap(dc, BOARD_PX, BOARD_PX); m_pOldBmp m_memDC.SelectObject(m_memBmp); } // OnPaint中改为从内存DC拷贝 void CChessDlg::OnPaint() { CPaintDC dc(this); DrawBoard(m_memDC); // 所有绘制画到内存DC dc.BitBlt(0, 0, BOARD_PX, BOARD_PX, m_memDC, 0, 0, SRCCOPY); }BOARD_PX是棋盘区域的总像素宽度一般取15 * GRID_SIZEGRID_SIZE是格子间距常用40像素。CreateCompatibleBitmap必须用屏幕DC做参数不能用内存DC否则位图颜色深度会不对画出来是黑白的。2.2 像素坐标和棋盘坐标的映射鼠标点击拿到的是像素坐标(point.x, point.y)需要转成棋盘索引(row, col)。映射公式很简单但边界处理容易出错// 像素坐标转棋盘坐标返回false表示点击在棋盘外 bool CChessDlg::PixelToBoard(CPoint pt, int row, int col) { int margin GRID_SIZE / 2; // 棋盘边距 int x pt.x - margin; int y pt.y - margin; if (x 0 || y 0) return false; col (x GRID_SIZE / 2) / GRID_SIZE; // 四舍五入到最近交叉点 row (y GRID_SIZE / 2) / GRID_SIZE; if (row 0 || row 15 || col 0 || col 15) return false; return true; }这里加GRID_SIZE / 2再做整除效果是四舍五入——用户点到交叉点附近就算选中不用精确到像素。margin是棋盘左上角第一个交叉点的偏移通常取GRID_SIZE / 2让棋盘居中。2.3 鼠标消息处理和落子逻辑在OnLButtonDown里处理落子。注意要判断当前是否轮到人类、该位置是否已有棋子、游戏是否已结束void CChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bGameOver || m_bAIThinking) return; // 游戏结束或AI思考中 if (m_nCurrentPlayer ! PLAYER_HUMAN) return; int row, col; if (!PixelToBoard(point, row, col)) return; if (m_board[row][col] ! EMPTY) return; PlaceStone(row, col, PLAYER_HUMAN); if (CheckWin(row, col, PLAYER_HUMAN)) { m_bGameOver TRUE; MessageBox(_T(你赢了)); return; } // 切换为AI回合 m_nCurrentPlayer PLAYER_AI; m_bAIThinking TRUE; Invalidate(); // 触发重绘显示人类棋子 // 用定时器延迟AI落子避免界面卡死 SetTimer(TIMER_AI_MOVE, 100, nullptr); }m_bAIThinking这个标志位很关键。AI搜索如果直接在OnLButtonDown里同步执行界面会卡住不响应用户体验很差。用SetTimer延迟100毫秒再触发AI计算给界面一个刷新机会。如果搜索深度较大比如6层以上还需要把AI放到工作线程里这个后面讲。3. 人机对战AI极小极大搜索、评估函数和剪枝优化3.1 极小极大搜索在五子棋里怎么落地五子棋是零和博弈适合用极小极大Minimax搜索。核心思想是AI在自己的回合选让评估分最高的走法在对手回合选让评估分最低的走法假设对手也会最优决策。递归展开到指定深度叶子节点用评估函数打分再逐层回传。// 极小极大搜索depth为剩余搜索深度 int CChessDlg::Minimax(int depth, bool isMax, int alpha, int beta) { // 检查是否已有胜者 if (m_aiWin) return WIN_SCORE - (MAX_DEPTH - depth); if (m_humanWin) return -WIN_SCORE (MAX_DEPTH - depth); if (depth 0) return EvaluateBoard(); // 到达叶子返回评估分 if (isMax) { int best INT_MIN; for (int r 0; r 15; r) { for (int c 0; c 15; c) { if (m_board[r][c] ! EMPTY) continue; if (!HasNeighbor(r, c, 2)) continue; // 剪掉远处空位 m_board[r][c] PLAYER_AI; int val Minimax(depth - 1, false, alpha, beta); m_board[r][c] EMPTY; best max(best, val); alpha max(alpha, best); if (beta alpha) return best; // Alpha-Beta剪枝 } } return best; } else { int best INT_MAX; for (int r 0; r 15; r) { for (int c 0; c 15; c) { if (m_board[r][c] ! EMPTY) continue; if (!HasNeighbor(r, c, 2)) continue; m_board[r][c] PLAYER_HUMAN; int val Minimax(depth - 1, true, alpha, beta); m_board[r][c] EMPTY; best min(best, val); beta min(beta, best); if (beta alpha) return best; } } return best; } }HasNeighbor(r, c, 2)判断该空位周围2格内是否有棋子。没有的话直接跳过——五子棋的落子只会在已有棋子附近产生意义搜索整个15×15棋盘是浪费。这个剪枝能把分支因子从225降到20~40效果非常明显。WIN_SCORE取一个足够大的数比如10000000。减去(MAX_DEPTH - depth)是为了让AI优先选步数少的胜利路径——同样赢棋早一步赢更好。3.2 评估函数怎么给一个局面打分评估函数是AI强弱的核心。五子棋的评估通常基于棋型连五、活四、冲四、活三、眠三、活二、眠二。每种棋型给不同权重扫描所有行、列、两个对角线方向统计双方棋型得分AI得分减去人类得分就是局面分。// 棋型分值表可根据实测调整 const int SCORE_FIVE 1000000; // 连五 const int SCORE_LIVE_FOUR 100000; // 活四 const int SCORE_RUSH_FOUR 10000; // 冲四 const int SCORE_LIVE_THREE 8000; // 活三 const int SCORE_SLEEP_THREE1000; // 眠三 const int SCORE_LIVE_TWO 800; // 活二 const int SCORE_SLEEP_TWO 100; // 眠二 int CChessDlg::EvaluateBoard() { int aiScore 0, humanScore 0; // 四个方向横、竖、主对角线、副对角线 int dx[] {1, 0, 1, 1}; int dy[] {0, 1, 1, -1}; for (int r 0; r 15; r) { for (int c 0; c 15; c) { if (m_board[r][c] EMPTY) continue; int player m_board[r][c]; for (int d 0; d 4; d) { // 只统计以该点为起点的连续棋型避免重复 int pr r - dx[d], pc c - dy[d]; if (pr 0 pr 15 pc 0 pc 15 m_board[pr][pc] player) continue; int count 0; int tr r, tc c; while (tr 0 tr 15 tc 0 tc 15 m_board[tr][tc] player) { count; tr dx[d]; tc dy[d]; } int score GetPatternScore(count, r, c, dx[d], dy[d], player); if (player PLAYER_AI) aiScore score; else humanScore score; } } } return aiScore - humanScore; }GetPatternScore根据连续棋子数和两端是否被封堵来判断棋型。比如连续4个且两端都空是活四一端被封是冲四。这段逻辑比较长核心是查两端的状态。参数调优的经验活三的分值要足够高让AI必须防守但冲四的分值要高于活三因为冲四下一步就成五。实际调试时经常出现AI只顾进攻不防守的情况多半是防守方棋型分值给低了。3.3 Alpha-Beta剪枝和搜索深度控制上面代码里已经嵌了Alpha-Beta剪枝。原理是如果当前分支已经确定比之前找到的最优解差就没必要继续搜索。alpha是Max层当前能找到的最好值beta是Min层当前能找到的最好值当beta alpha时剪枝。搜索深度建议开局阶段棋子数6深度4足够因为局面开阔深搜收益低中局阶段棋子数6~20深度6这是AI表现最关键的阶段残局阶段棋子数20深度8甚至更深但要注意性能如果深度6以上导致界面卡顿两个方案一是把AI放到AfxBeginThread创建的工作线程里通过自定义消息通知主线程更新界面二是加迭代加深——先搜深度2再搜深度4逐层加深配合时间限制。// 工作线程入口 UINT CChessDlg::AIThreadProc(LPVOID pParam) { CChessDlg* pDlg (CChessDlg*)pParam; // 执行AI搜索 pDlg-m_aiMove pDlg-FindBestMove(); // 通知主线程 ::PostMessage(pDlg-m_hWnd, WM_AI_MOVE_DONE, 0, 0); return 0; }自定义消息WM_AI_MOVE_DONE在主线程里处理落子和重绘。注意工作线程里不要直接操作MFC控件所有UI更新都通过消息回主线程做。4. 避坑与排查MFC五子棋开发中最容易翻车的5个地方4.1 棋盘闪烁严重鼠标一动就闪现象鼠标在棋盘上移动棋盘线条和棋子不停闪烁眼睛受不了。原因OnPaint里直接往屏幕DC绘制背景擦除和前景绘制之间有间隙。另外如果用了Invalidate()没带FALSE参数会触发OnEraseBkGnd擦背景加剧闪烁。解决一是加双缓冲见2.1节二是Invalidate(FALSE)避免擦背景三是重写OnEraseBkGnd直接返回TRUE。BOOL CChessDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 背景已在内存DC中绘制不需要擦除 }4.2 AI落子后界面卡死几秒现象人类落子后界面无响应过几秒AI才落子。原因AI搜索在UI线程同步执行深度6时遍历几十万个节点耗时可能超过2秒。解决把AI搜索放到工作线程通过PostMessage通知主线程。或者降低搜索深度到4配合更好的评估函数来弥补棋力。实测深度4加优化评估函数已经能打败大部分业余玩家。4.3 评估函数导致AI只进攻不防守现象AI疯狂进攻人类形成活三也不管直接被连五。原因进攻棋型分值给太高防守棋型分值给太低。或者评估函数只统计AI得分没减去人类得分。解决确保EvaluateBoard返回的是aiScore - humanScore。另外活三的防守分值要单独加权——当人类有活三时AI必须堵这个可以在搜索前加一层规则判断如果人类有活四或活三优先考虑堵截点。4.4 坐标映射偏差点击位置和落子位置对不上现象点交叉点附近棋子落在隔壁位置。原因margin计算错误或者整除时没有做四舍五入。解决确认margin GRID_SIZE / 2映射时加GRID_SIZE / 2再整除。可以在OnLButtonDown里加临时调试代码把点击的像素坐标和转换后的棋盘坐标用TRACE输出对比看偏差。4.5 胜负判定漏判斜向五连现象横向和竖向五连能判赢斜向五连没反应。原因CheckWin只检查了四个方向中的两个或者对角线方向的边界判断写错了。解决CheckWin必须检查四个方向横、竖、主对角线左上到右下、副对角线右上到左下。每个方向从当前落子点向两端延伸计数总数≥5即胜。bool CChessDlg::CheckWin(int row, int col, int player) { int dx[] {1, 0, 1, 1}; int dy[] {0, 1, 1, -1}; for (int d 0; d 4; d) { int count 1; // 正方向延伸 for (int i 1; i 5; i) { int r row dx[d] * i, c col dy[d] * i; if (r 0 || r 15 || c 0 || c 15) break; if (m_board[r][c] ! player) break; count; } // 反方向延伸 for (int i 1; i 5; i) { int r row - dx[d] * i, c col - dy[d] * i; if (r 0 || r 15 || c 0 || c 15) break; if (m_board[r][c] ! player) break; count; } if (count 5) return true; } return false; }5. 进阶技巧用迭代加深和置换表把AI棋力再提一档前面讲的极小极大加Alpha-Beta深度6已经能应付大部分场景。但如果想让AI更强有两个方向值得投入迭代加深和置换表。迭代加深的思路是不直接搜深度6而是先搜深度2拿到一个最佳走法再用这个走法作为深度4搜索的首选分支依次类推到深度6。这样做的好处是浅层搜索的结果可以给深层搜索提供走法排序依据——先搜好走法Alpha-Beta剪枝效率更高。实测同样深度6迭代加深能减少30%~50%的节点访问量。// 迭代加深框架 int CChessDlg::IterativeDeepening(int maxDepth) { int bestMove -1; for (int d 2; d maxDepth; d 2) { int score Minimax(d, true, INT_MIN, INT_MAX); if (m_bTimeUp) break; // 超时则用上一层结果 bestMove m_bestMoveThisRound; } return bestMove; }置换表是另一个利器。五子棋搜索中不同走法顺序可能到达相同局面用哈希表缓存已搜索过的局面及其分值下次遇到直接查表。MFC里可以用CMap或者自己写一个简单的哈希表。键值用Zobrist哈希——对每个棋位和玩家组合预生成随机数局面哈希就是所有已落子的随机数异或。// Zobrist哈希初始化 void CChessDlg::InitZobrist() { srand(12345); for (int r 0; r 15; r) for (int c 0; c 15; c) for (int p 0; p 2; p) m_zobrist[r][c][p] ((UINT64)rand() 32) | rand(); } // 落子/悔子时更新哈希 UINT64 CChessDlg::UpdateHash(UINT64 hash, int r, int c, int player) { return hash ^ m_zobrist[r][c][player]; }置换表要注意替换策略——表满了之后是覆盖最老的还是深度最浅的。简单做法是覆盖深度最浅的条目因为深层搜索结果更有价值。还有一个实战技巧开局库。五子棋开局前几步有固定套路可以预先存几十个常见开局的最佳应对AI启动时直接查库既快又稳。这个用简单的数组或文本文件加载就行不需要数据库。最后说一个我自己的习惯每次调完评估函数的权重不要只测一两局就下结论。我一般会写一个自动对弈的测试模式让AI和自己下100局统计胜率和平均步数。权重改动对棋力的影响靠肉眼观察几局根本看不出来只有批量测试才能发现“改了之后AI变弱了”这种问题。这个测试模式实现很简单把人类回合也换成AI调用循环执行直到分出胜负记录结果就行。希望帮到你。本文还有配套的精品资源点击获取