ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC五子棋人机实战:从双缓冲绘图到Alpha-Beta剪枝的AI实现

MFC五子棋人机实战:从双缓冲绘图到Alpha-Beta剪枝的AI实现 简介这是一份基于 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];就以为它是空的实际上成员变量在进入构造函数之前并不保证是0Debug版内存被填充为0xCCRelease版是随机值。正确做法是在构造函数初始化列表里清零或者在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并直接返回 TRUEBOOL CGobangDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统背景已经“擦过”实际上什么都不做 }原因很简单对话框默认会用背景色擦除客户区这一步先于 OnPaint 执行底色和新画的棋盘一交替双缓冲也压不住闪烁。让 OnEraseBkgnd 什么都不做加上双缓冲闪烁问题才算彻底压住。3. 人机AI的评估与搜索棋型评分表、候选点裁剪和Alpha-Beta参数3.1 棋型评分表七档分数怎么定活四冲四的性价比账五子棋AI的“智商”几乎全在评估函数上。常见做法是把棋盘上的棋型按威胁程度打分AI在每一步选一个让己方得分最高、同时让对方威胁最小的点。基础棋型分七档// 棋型评分表player1黑, player2白 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::vectorCPoint 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::vectorCPoint 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另一方便是21和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::vectorStep 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五子棋人机项目从能跑走到能打。本文还有配套的精品资源点击获取
返回列表