简介:这是一份基于 VC6.0 与 MFC 框架开发的五子棋人机对战完整工程,面向希望学习 Windows 桌面应用开发、游戏逻辑与入门级 AI 算法的 C++ 学习者。资源含源代码、界面资源文件及可执行程序,涵盖棋盘状态管理、落子判定、Minimax 或 Alpha-Beta 剪枝思路的人机决策、人人对战模式以及获胜数据记录等关键模块,能够帮助读者从零搭建 MFC 交互式小游戏项目。压缩包共 37 个文件,约 3.55MB,主要包括 h/cpp 源码、obj 编译中间文件、rc 资源脚本、ico/bmp 图标位图,以及 dsw/dsp/sln 等工程配置,并附带 README 说明,目录结构典型清晰,适合对照学习与二次修改。资源已有 195 人浏览学习,对于刚接触 MFC 编程或对棋类 AI 感兴趣的程序员来说,是一份可直接运行的参考资料,也可以作为课程设计或毕业设计的起点,便于理解事件驱动界面与游戏逻辑如何结合。
1. MFC五子棋人机:老框架里最值得手写一遍的博弈入门
见过类似“mfc.rar_MFC五子棋人机”这种命名的压缩包,打开之后绝大多数是一个基于对话框的MFC工程:15×15棋盘、黑白双方交替落子、程序扮演一方跟你对弈。这个项目在Windows C++课程里当了很多年经典大作业,放在今天仍然值得照着思路重写一遍,因为MFC五子棋人机把界面、消息、AI策略三样东西压在一个几千行的工程里,能同时逼你过一遍GDI绘图、消息映射和博弈搜索。我见过两类初学者:一类卡在棋盘画不出来、点击没反应,说明对MFC的消息处理还没建立手感;另一类界面做得像回事,人机却只会随机落子,问题出在评分函数和搜索深度没吃透。这篇笔记沿着这两条主线走,先用对话框工程把棋盘和落子跑通,再讲AI的参数怎么定,最后集中写踩坑记录。适合想用C++手写棋类AI、又不想一上来就碰复杂引擎的人,也适合手头已经有个能跑的项目、却说不出每段代码为什么这么写的人。
2. 棋盘与落子的MFC实现:数据结构、消息映射和双缓冲绘图
2.1 15×15棋盘数据结构:为什么二维数组比链表和一维数组都稳
五子棋的棋盘由15条横线和15条竖线组成,落子的位置是交叉点。数据结构的选择直接影响后面所有人机搜索代码的写法。常见做法是用一个二维数组:
// GobangDlg.h 中声明成员变量 #define BOARD_SIZE 15 int m_board[BOARD_SIZE][BOARD_SIZE]; // 0-空, 1-黑方, 2-白方 CPoint m_lastMove; // 最后一步落子,悔棋和AI分析都要用为什么不用链表或者一维数组?因为五子棋的落子、清盘、判胜负几乎全部围绕“位置”展开,二维数组随机访问是O(1),写出来的代码也最直观。一维数组用board[row * BOARD_SIZE + col]也能存储,但后续评估函数里要反复遍历横、竖、斜四个方向,一维数组的索引换算会掩盖掉最容易写错的行列关系。链表在棋盘大小固定的前提下没有任何优势,动态增删棋子的需求并不存在,反而让遍历棋型变得别扭。
这里有个实际工程细节:m_board初始值必须保证是0。很多初学者在构造函数里定义了int m_board[15][15];就以为它是空的,实际上成员变量在进入构造函数之前并不保证是0,Debug版内存被填充为0xCC,Release版是随机值。正确做法是在构造函数初始化列表里清零,或者在OnInitDialog里memset(m_board, 0, sizeof(m_board))。这一个动作能省掉后面排查Release版崩溃的至少两小时,后面避坑章节还会展开。
2.2 从鼠标点击到棋盘行列:OnLButtonDown里的坐标换算与边界处理
MFC对话框里鼠标点击消息给的是客户区像素坐标,需要换算成棋盘的行列索引。这里牵扯到两个固定参数:棋盘起始边距和格子大小。换算公式里必须加半个格子的偏移用于四舍五入,否则鼠标点在某条线附近时,落子会落到错误的交叉点。
void CGobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { int margin = 30; // 棋盘左边距,兼作上边距 int cell = 36; // 格子边长,棋盘线间距 // 加 cell/2 做四舍五入,落到最近交叉点 int col = (point.x - margin + cell / 2) / cell; int row = (point.y - margin + cell / 2) / cell; if (col < 0 || col >= BOARD_SIZE || row < 0 || row >= BOARD_SIZE) return; // 点在棋盘外,忽略 if (m_board[row][col] != 0) return; // 该交叉点已有棋子,忽略 m_board[row][col] = 1; // 玩家执黑先行 m_lastMove = CPoint(col, row); Invalidate(FALSE); // FALSE 避免强制擦背景,减少闪烁 // 异步通知AI开始思考,不能在这里直接调用AI搜索 PostMessage(WM_AI_THINK, 0, 0); CDialogEx::OnLButtonDown(nFlags, point); }这里的cell / 2是整个换算的核心:当鼠标偏离某个交叉点不足半格时,仍归类到这个交叉点,手感上宽容很多。边界判断必须在换算之后检查,因为(point.x - margin)可能算出负数,除以 cell 后得到负索引,数组越界是MFC程序里最难察觉的崩溃来源之一。Invalidate(FALSE)里的 FALSE 也很有讲究:TRUE 会触发 WM_ERASEBKGND 擦掉整块客户区再重绘,视觉上就是闪;FALSE 直接把新的棋盘内容画上去,闪得不明显。
2.3 双缓冲与OnEraseBkgnd:让棋盘不再闪烁的GDI三件套
MFC对话框不像文档视图程序有现成的视图刷新框架,棋盘绘制必须在OnPaint里自己完成。直接往屏幕DC上画图,每次刷新都会经历“擦背景—画线—画棋子”的过程,眼睛看到的就是闪烁。双缓冲是传统且可靠的解法:先在一块内存位图上画完整张棋盘,再一次拷贝到屏幕。
void CGobangDlg::OnPaint() { CPaintDC dc(this); // 设备DC CRect rcBoard; rcBoard.SetRect(20, 20, MARGIN + (BOARD_SIZE - 1) * CELL_SIZE + 20, MARGIN + (BOARD_SIZE - 1) * CELL_SIZE + 20); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcBoard.Width(), rcBoard.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 1. 棋盘木色背景 memDC.FillSolidRect(0, 0, rcBoard.Width(), rcBoard.Height(), RGB(210, 180, 140)); // 2. 画网格线 CPen pen(PS_SOLID, 1, RGB(80, 60, 40)); CPen* pOldPen = memDC.SelectObject(&pen); for (int i = 0; i < BOARD_SIZE; i++) { memDC.MoveTo(i * CELL_SIZE, 0); memDC.LineTo(i * CELL_SIZE, (BOARD_SIZE - 1) * CELL_SIZE); memDC.MoveTo(0, i * CELL_SIZE); memDC.LineTo((BOARD_SIZE - 1) * CELL_SIZE, i * CELL_SIZE); } memDC.SelectObject(pOldPen); // 3. 画棋子 for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) if (m_board[r][c] != 0) DrawPiece(&memDC, c, r, m_board[r][c]); // 4. 整张图一次拷贝到屏幕 dc.BitBlt(rcBoard.left, rcBoard.top, rcBoard.Width(), rcBoard.Height(), &memDC, 0, 0, SRCCOPY); // 恢复旧位图,防止GDI对象泄漏 memDC.SelectObject(pOldBmp); }双缓冲的关键参数有三个:位图尺寸必须与棋盘绘制区域一致,否则画出来的棋盘会被裁剪;SelectObject返回的旧位图指针必须保存并在函数结束前恢复,否则位图资源越积越多,最终GDI句柄耗尽;BitBlt的拷贝区域和内存DC的坐标系要对应,别把整张客户区拷贝覆盖掉对话框上的按钮。
另外一个配套动作是重载OnEraseBkgnd并直接返回 TRUE:
BOOL CGobangDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统背景已经“擦过”,实际上什么都不做 }原因很简单:对话框默认会用背景色擦除客户区,这一步先于 OnPaint 执行,底色和新画的棋盘一交替,双缓冲也压不住闪烁。让 OnEraseBkgnd 什么都不做,加上双缓冲,闪烁问题才算彻底压住。
3. 人机AI的评估与搜索:棋型评分表、候选点裁剪和Alpha-Beta参数
3.1 棋型评分表:七档分数怎么定,活四冲四的性价比账
五子棋AI的“智商”几乎全在评估函数上。常见做法是把棋盘上的棋型按威胁程度打分,AI在每一步选一个让己方得分最高、同时让对方威胁最小的点。基础棋型分七档:
// 棋型评分表,player=1黑, player=2白 const int SCORE_FIVE = 1000000; // 连五,直接赢了 const int SCORE_LIVE_FOUR = 100000; // 活四,堵不住 const int SCORE_RUSH_FOUR = 10000; // 冲四,只有一格能堵 const int SCORE_LIVE_THREE = 5000; // 活三,下回合可成四 const int SCORE_SLEEP_THREE = 1000; // 眠三,被堵了一头 const int SCORE_LIVE_TWO = 500; // 活二,还在发育 const int SCORE_SLEEP_TWO = 100; // 眠二这个表决定了AI的棋风。如果活四和冲四的分差拉得不够大,AI会去堵一个威胁不大的眼二,放着对手的冲四不管;如果活三分数给得太高,AI会优先铺活三而不去冲四,错过一杆直接带走的机会。我调试下来感觉,评分表里档与档之间至少要差一个数量级,AI的偏好才稳定,连五、活四、冲四这三个大分值项尤其不能手软。
评估一个落点不能只看它是几连,还要看周围有没有空位、有没有被封堵。下面是一个按方向统计棋型的函数骨架:
int CountShape(int board[][BOARD_SIZE], int row, int col, int dx, int dy, int player) { int count = 1; // 当前模拟落子本身 int block = 0; // 被封堵的端数 int empty = 0; // 遇到的空位数 // 正向扫描 for (int i = 1; i < 5; i++) { int nr = row + dx * i; int nc = col + dy * i; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) { block++; break; } if (board[nr][nc] == player) count++; else if (board[nr][nc] == 0) { empty++; break; } else { block++; break; } } // 反向扫描逻辑相同,方向取反 // ...(代码略) if (count >= 5) return 5; // 五连 if (block == 0 && empty > 0) return 4; // 活四 if (block == 1 && count == 4) return 3; // 冲四 if (block == 0 && count == 3) return 3; // 活三 // 更细的活三、眠三、活二、眠二需要在记录空位位置后继续细分 return 0; }实际的棋型判断比这个骨架复杂,因为“活三”和“眠三”的区别取决于空位是在棋型的哪一侧,需要在空白位置继续向远处探测。网上流传的许多五子棋人机实现都在这里简化了,结果是AI经常走出“两头不着边”的三连,进攻效率很低。建议在这个函数里把空位位置记录完整再返回结果,不值钱的代码量,却值回老多棋力。
3.2 候选点裁剪:把15×15棋盘砍到20个点的经验公式
如果每下一步都把整个棋盘的空位遍历一遍,再对每个空位做深度搜索,15×15的棋盘浩浩荡荡225个点,搜索到第3层都已经卡顿。实际工程里必须做候选点裁剪:只检查已有棋子周围一定范围内的空位。
void GetCandidateMoves(int board[][BOARD_SIZE], std::vector<CPoint>& moves) { bool marked[BOARD_SIZE][BOARD_SIZE] = { false }; int dirs[8][2] = { {-1,-1},{-1,0},{-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board[r][c] == 0) continue; for (int d = 0; d < 8; d++) { int nr = r + dirs[d][0]; int nc = c + dirs[d][1]; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) continue; if (board[nr][nc] == 0 && !marked[nr][nc]) { marked[nr][nc] = true; moves.push_back(CPoint(nc, nr)); // 注意先列后行 } } } } // 棋盘空空如也时给个天元点,否则moves为空,AI会无棋可下 if (moves.empty()) moves.push_back(CPoint(BOARD_SIZE / 2, BOARD_SIZE / 2)); }这个裁剪方式是把所有已有棋子周围一圈的空位全部收集起来。实战中盘面稍微铺开后,候选点大概在20到60个之间,比225个小得多,搜索深度才能往上走。如果嫌候选点多,可以再收紧为周围两格,但那样开局阶段容易漏掉关键落子。我一贯的做法是先保留“周围一圈”,等搜索深度加到4层以上再考虑收紧。
3.3 搜索深度与剪枝:AlphaBeta函数里的段位分界线
评估函数决定AI看到“好不好”,搜索决定AI看到“后面几步怎么变”。最基础的做法是极大极小搜索,用Alpha-Beta剪枝把无用的计算砍掉。
int AlphaBeta(int depth, int alpha, int beta, int player, int board[][BOARD_SIZE], CPoint& bestMove) { if (depth == 0) return EvaluateBoard(board, player); // 静态评估,里面包含威胁分 std::vector<CPoint> cands; GetCandidateMoves(board, cands); for (size_t i = 0; i < cands.size(); i++) { int r = cands[i].y; int c = cands[i].x; board[r][c] = player; CPoint tmpMove; int score = -AlphaBeta(depth - 1, -beta, -alpha, 3 - player, board, tmpMove); board[r][c] = 0; // 恢复棋盘,这一步忘掉就全盘错乱 if (score > alpha) { alpha = score; bestMove = CPoint(c, r); } if (alpha >= beta) break; // beta剪枝,这个分支不用再看了 } return alpha; }3 - player是切换黑白双方的小技巧:玩家是1,另一方便是2,1和2轮流转。board[r][c] = 0的恢复必须执行,哪怕剪枝跳出去也要保证棋盘状态原样返回——这是递归搜索里最常出bug的地方,我见过不止一个项目的AI下完一手后棋盘上莫名其妙多出几颗黑白子,就是恢复逻辑漏了。
深度的选择直接决定棋力。深度1等于看一步,AI只会堵眼前的冲四;深度2能挡活三,但看不到连续进攻;深度3是多数业余AI的水平线,会追着活三进攻,普通玩家已经下不过它;深度4开始有连杀预判,有了“人机”的样子,响应时间也拉长到一秒级。这个项目里,普通难度放到深度2,困难难度放到深度4。再往上加到深度5,桌面级CPU也要喘口气,除非把搜索改成迭代加深并加时限,否则棋力增长换来的体验损失不值得。
4. 基于对话框的MFC工程落地:工程选型、工作线程和悔棋栈
4.1 对话框程序还是文档视图:五子棋选前者的三个决定性差异
网上搜“基于MFC的对话框程序工程代码”,一半以上是类似的小游戏项目,五子棋属于最合适的那一类。MFC里做界面有两条路:基于对话框(CDialogEx)和文档视图(CView/CFrameWnd)。五子棋我选对话框程序,理由很实:
第一,对话框程序只有一个窗口,落子的鼠标消息和重绘逻辑都集中在一个类里,不用跟文档、视图、框架三层结构纠缠。第二,对话框资源编辑器能直接放“重新开始”、“悔棋”按钮和状态栏文本,省掉手写控件创建代码,项目文件也更接近题目提交的形式。第三,文档视图是为“数据与显示分离”设计的,棋盘本身不是一份可序列化的业务文档,硬套文档视图反而别扭。
典型的对话框工程骨架长这样:
class CGobangDlg : public CDialogEx { public: CGobangDlg(CWnd* pParent = nullptr); virtual BOOL OnInitDialog() override; afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnBnClickedRestart(); private: void InitBoard(); // 清空棋盘 void ComputeAIMove(CPoint& move); // 工作线程里执行搜索 public: int m_board[BOARD_SIZE][BOARD_SIZE]; int m_side; // 当前轮到谁 volatile BOOL m_bAIThinking; // AI是否正在思考 std::vector<Step> m_stepList; // 落子记录,悔棋用 };消息映射是MFC的命脉,漏掉一行就会变成“功能死活不触发”:
BEGIN_MESSAGE_MAP(CGobangDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_BN_CLICKED(IDC_BTN_RESTART, &CGobangDlg::OnBnClickedRestart) ON_MESSAGE(WM_AI_DONE, &CGobangDlg::OnAIDone) END_MESSAGE_MAP()ON_MESSAGE(WM_AI_DONE, ...)是自定义消息的入口,后面AI线程把计算结果传回主线程全靠这一条映射。初学者常常把自定义消息的宏体和WM_PAINT写重,编译过、运行没反应,先检查消息映射三要素:消息ID、处理函数声明、处理函数实现。
4.2 AI计算放进工作线程:用PostMessage避免界面假死
把AI搜索直接写在OnLButtonDown的同一线程里,搜索的几百毫秒内窗口无法重绘、按钮无法点击,Windows任务栏会直接提示“未响应”。五子棋这种轻量级AI也扛不住,深度4的搜索在Debug版里能卡上一秒多。正确做法是让AI在一个工作线程里算,算完通过消息把结果发回主线程。
UINT AIThreadProc(LPVOID pParam) { CGobangDlg* pDlg = (CGobangDlg*)pParam; CPoint move; pDlg->ComputeAIMove(move); // 完整搜索在后台线程执行 // 把落子坐标打包进LPARAM,走消息回到主线程 ::PostMessage(pDlg->GetSafeHwnd(), WM_AI_DONE, 0, (LPARAM)(move.y * BOARD_SIZE + move.x)); return 0; }对话框里启动线程:
void CGobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { // ...省略落子与边界判断... if (m_bAIThinking) return; // AI还没想完,忽略新点击,防止线程堆叠 m_bAIThinking = TRUE; AfxBeginThread(AIThreadProc, this, THREAD_PRIORITY_NORMAL); }WM_AI_DONE处理函数在主线程里补齐AI的落子并重绘。这条链路有三个要点:后台线程绝不能直接调Invalidate或任何CWnd的成员函数,MFC对象非线程安全,强行调用会导致崩溃或GDI状态错乱;PostMessage是异步的,线程可能在消息被处理前就退出,所以一定要用GetSafeHwnd()而不是窗口指针本身;m_bAIThinking标志位用来拦住玩家在三五毫秒内连点多次造成的线程叠加。
4.3 悔棋与重新开始:用一个栈记录落子序列就够了
悔棋在人机对战里是个不可少的兜底功能。规则是连续撤销两步——玩家的那步和AI应对的那步。用向量当栈用,记录每一步的落子位置和落子方:
struct Step { int row, col, player; }; void CGobangDlg::OnBnClickedUndo() { if (m_stepList.size() < 2) return; // 不到两步没法悔,AI还没有着过 Step aiStep = m_stepList.back(); m_board[aiStep.row][aiStep.col] = 0; m_stepList.pop_back(); Step humanStep = m_stepList.back(); m_board[humanStep.row][humanStep.col] = 0; m_stepList.pop_back(); m_bAIThinking = FALSE; // 如果悔棋发生在AI思考中,还要把思考状态复位 Invalidate(FALSE); }为什么要一次撤两步?因为五子棋人机是严格交替落子,只撤玩家那步会让AI的棋子悬在半空,后续AI思考会以错误的局面为基础。重新开始更简单:清空m_board、清空m_stepList、把m_side复位,顺手把m_bAIThinking也一并复位。这里最容易漏的是最后一项,AI线程还在跑,用户点了重新开始,线程回来照旧落子,棋盘上就凭空多出一步。
5. MFC五子棋避坑排查:五个让新手翻车的经典场景
5.1 点击落子没反应:消息映射、控件遮挡和WS_CLIPCHILDREN三层排查
现象:程序跑起来了,棋盘也画出来了,鼠标点上去硬是没有任何反应。
原因常见有三层。第一层是消息映射漏了ON_WM_LBUTTONDOWN(),函数写了但系统压根没把消息分派到它。第二层是对话框里放了按钮或静态控件,棋盘区域被某个控件挡着,消息被子控件消费掉,根本没到对话框的消息循环。第三层是对话框有WS_CLIPCHILDREN样式时,子控件覆盖区域的消息不会发给父窗口。
排查顺序就按这个来:先看消息映射宏是否存在;再在OnLButtonDown里设断点,看有没有进来过;最后打开资源编辑器,确认棋盘区域没有叠着透明或不可见的控件。解决第三层最简单的方法是棋盘区域不放任何控件,宁可对话框上下留白,也别把按钮和棋盘交错摆放。MFC教程里那些翻车案例,多半都在这里交了学费。
5.2 棋盘闪个不停:OnEraseBkgnd和双缓冲哪个先做
现象:每次落子、每次窗口移动,棋盘像坏了的电视一样闪得睁不开眼。
原因是对话框在重绘时先触发 WM_ERASEBKGND,用默认背景色把客户区擦成灰白色,紧接着 OnPaint 画棋盘,两个动作之间有一帧的空白间隔,视觉上就成了闪烁。解决方法是同时做两件事:重载OnEraseBkgnd直接返回 TRUE,截断擦背景动作;然后把OnPaint改成双缓冲绘制,也就是章节2.3里的写法。要注意Invalidate(TRUE)会强制系统发 WM_ERASEBKGND,哪怕双缓冲也压不回去,所以窗口刷新一律用Invalidate(FALSE)。这两件事缺一不可,只做双缓冲不做OnEraseBkgnd,会出现棋盘内容不闪、但背景一圈在闪的“边闪”现象。
5.3 人机思考时窗口“未响应”:UI线程被搜索递归堵死
现象:点下棋子后,窗口标题栏出现“未响应”,过一两秒才恢复。
原因:AI搜索是纯计算,写成递归后每一层的调用量很大,如果在主线程里跑,窗口消息循环被阻塞,系统就会判定窗口未响应。解决:把ComputeAIMove放进AfxBeginThread的工作线程,算完用PostMessage发回WM_AI_DONE,主线程只负责落子和Invalidate。这个方案本身不难,难在两类隐蔽问题:一是玩家在AI思考期间连续点击,多个线程堆叠导致搜索错乱,要用m_bAIThinking标志拦;二是工作线程里不小心调用了SetDlgItemText这类UI函数,运行到一半突然断言崩溃。记住一条线:线程函数里只算数值,界面更新永远回到主线程。
5.4 Debug正常Release崩:m_board里的脏数据与初始化漏网之鱼
现象:Debug版下无论怎么点都正常,一编译Release版,开局没几步就崩溃,或者AI落子莫名其妙跳到角落。
原因:Debug编译器把未初始化的栈和堆内存填成固定值,m_board成员变量常表现得像0,也就是“空”,于是代码侥幸跑通。Release版的内存是随机值,m_board[row][col]里可能有2、有1,甚至可能是负数,AI评估函数一读就越界或者死循环。解决:在构造函数初始化列表里直接把m_board清零,m_bAIThinking、m_side也一并赋初值,别依赖默认初始化。这个坑我在MFC项目里踩过至少三四回,血泪经验就是:成员变量一律显式初始化,没有例外。
5.5 棋子画成方形色块:GDI画刷泄漏与SelectObject的恢复顺序
现象:棋盘上落子后,棋子不是圆形的,而是方方的色块,连棋盘的线都糊成一片。
原因是画棋子时没有正确设置GDI画刷,或者用了FillSolidRect这种矩形填充函数来画圆导致像素块堆积。解决是把棋子绘制封装成独立函数,用Ellipse配合画刷来画:
void CGobangDlg::DrawPiece(CDC* pDC, int col, int row, int player) { int r = CELL_SIZE * 2 / 5; // 棋子半径是格子的0.4倍 CPoint center(MARGIN + col * CELL_SIZE, MARGIN + row * CELL_SIZE); CBrush brush(player == 1 ? RGB(20, 20, 20) : RGB(240, 240, 240)); CBrush* pOldBrush = pDC->SelectObject(&brush); pDC->Ellipse(center.x - r, center.y - r, center.x + r, center.y + r); pDC->SelectObject(pOldBrush); // 别忘了恢复旧的画刷 }这个函数还有一个隐蔽问题:GDI对象泄漏。如果每次落子都创建画刷而不恢复旧对象,画到几十颗棋子之后,程序的GDI句柄会耗尽,新棋子就画不出来,界面像是死掉一样。SelectObject保存返回值、画完恢复,这是一个MFC程序里必须养成的习惯,跟内存泄漏一样需要零容忍。
6. 再往前一步:禁手规则、落子回放与AI参数验证
6.1 给黑棋加上禁手规则
普通五子棋玩久了,可以给AI加一层禁手规则,规则是黑棋禁止双三、双四和长连。实现上不需要改变搜索主体,只要在落子后多加一步合法性校验:
bool CheckForbidden(int board[][BOARD_SIZE], int row, int col) { if (board[row][col] != 0) return false; int three = 0, four = 0, overline = 0; int dirs[4][2] = { {0,1},{1,0},{1,1},{1,-1} }; for (int d = 0; d < 4; d++) { int s = CountLineShape(board, row, col, dirs[d][0], dirs[d][1]); if (s >= 5) overline++; // 一条线上五子以上 else if (s == 4) four++; else if (s == 3) three++; } if (overline > 0) return true; // 长连禁手 if (three >= 2 || four >= 2) return true; // 双三、双四禁手 return false; }注意禁手规则只适用于黑棋,白棋仍然照常落子。既然加入了禁手,评估函数也要同步调整:黑棋在搜索时如果某落点触发禁手,直接给一个负无穷分,强迫AI避开。很多五子棋网页版人机不做禁手,MFC这个人机项目里加一条也不难,加了之后黑棋AI的棋风会从“猛攻”变成“谨慎”,攻防转换更有意思。
6.2 落子回放与参数验证
AI参数改来改去,怎么知道改得值不值?我习惯把每局落子序列写到文本文件,每行row,col,player,重开后可以逐条回放,也方便对比参数修改前后AI在同一局面的不同选择。
void CGobangDlg::SaveMovesToFile(const CString& path) { CStdioFile file; if (!file.Open(path, CFile::modeCreate | CFile::modeWrite)) return; for (const auto& s : m_stepList) { CString line; line.Format(_T("%d,%d,%d\r\n"), s.row, s.col, s.player); file.WriteString(line); } file.Close(); }验证方法有两种。第一种是人工对局,让AI先手和后手各下几盘,重点看有没有明知对手有冲四却还在铺活二这种低级失误。第二种是自动过招:做成“AI对AI”,让评估函数里黑白双方互换,跑五十局,统计平均手数和胜负关系。评分权重的改动放到同一组棋谱数据里跑,数字会告诉你API调好没有。我把评估函数当黑匣子用,记录每次改动的试验手数,再拿回放文件复盘出错位置——这一步省掉的调试时间远超写代码的时间。希望这些细节能帮你的MFC五子棋人机项目从能跑走到能打。
本文还有配套的精品资源,点击获取