☰
C++实现不围棋AI:MCTS算法与OpenGL界面开发实战
2026/10/10 13:21:26 网站建设 项目流程

简介:这是一份基于C++实现的不围棋(NoGo)完整游戏源码,融合蒙特卡洛树搜索(MCTS)AI与OpenGL/glut图形界面,支持人机对战,适合有一定C++基础、希望研究博弈树搜索或桌面游戏开发的学习者。压缩包共57个文件,包含14个cpp与14个h源码、17个bmp贴图,以及VC++解决方案/工程文件,整体约2.2MB,结构清晰便于直接编译运行或参考改造。目前已有298人学习下载,作为期末大作业项目具备一定参考价值。项目完整实现了9×9棋盘不围棋规则,包括禁自杀、禁空手、吃子判负等特殊胜负判定,以及黑棋首手禁中心等附加规则;AI部分提供MCTS和Minmax双版本对比,并附带Botzone_MCTS提交模块,可配合jsoncpp适配在线对弈平台。图形界面采用OpenGL的glut工具库,配套菜单、按钮、棋盘贴图与存档管理(SaveManager),还包含Minmax实现与游戏规则模块,适合用于算法实验、课程设计或进一步二次开发。

1. 计算概论期末大作业:用MCTS和GLUT把「不围棋」做成能玩的AI小游戏

不围棋是围棋的「反向规则」:普通围棋吃子赢,不围棋只要提掉对方一片棋,落子一方立刻判负。于是整盘棋变成一场「让子博弈」——你要把自己的棋走薄、做成对手不敢碰的形状,同时避开一提就输的陷阱。这个期末大作业正好把 C++ 的核心语法、蒙特卡洛树搜索(MCTS)选点、OpenGL 的 glut 工具库界面三件事串成一体,非常适合想从语法练习跨到完整项目的学生。两周内能跑出一个既支持双人对战、又支持人机对战的棋盘程序,答辩时既有算法深度又有可视化效果。

2. 不围棋规则与棋盘核心:先让「吃子者判负」在代码里立住

不围棋的规则逻辑和普通围棋只有几行之差,但差之毫厘谬以千里。普通围棋是「提子+禁自杀」,不围棋是「禁自杀+提子者判负」,因此判断落子是否合法、什么时候终局,就成了整个程序最基础也最容易翻车的部分。棋盘选 9 路而非 19 路,是因为不围棋的合法着法天然比围棋少一大截,9 路能让 MCTS 在同样的计算时间里深挖更多步,期末演示时 AI 反应也更快。

2.1 棋盘表示与「气」的计算:用洪泛法找出一整块棋的边界

棋盘用固定大小的二维数组存最省心。9 路就是 9x9,状态用 0 表示空、1 表示黑、2 表示白。判断一块棋有没有气,本质是从某个棋子出发做洪泛搜索,找到整块连通棋子的全部邻接空点。这里的经典误区是:气数按「空点去重」算,而不是按「邻接次数」数。如果 BFS 时不记录哪些空点已经被访问过,一块棋明明只有 3 口气,代码可能数出 5 口。只判断「是否为 0」时问题不大,但后面 MCTS 启发式要用气数排序,数错就会影响棋力。

#include <array> #include <queue> #include <utility> constexpr int BOARD_SIZE = 9; constexpr int EMPTY = 0, BLACK = 1, WHITE = 2; using BoardState = std::array<std::array<int, BOARD_SIZE>, BOARD_SIZE>; const int kDirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 统计 (x, y) 所在整块同色棋子的气数(空点去重) int countLiberties(const BoardState& board, int x, int y) { bool visited[BOARD_SIZE][BOARD_SIZE] = {false}; bool libVisited[BOARD_SIZE][BOARD_SIZE] = {false}; int color = board[x][y]; int libs = 0; std::queue<std::pair<int, int>> q; q.push({x, y}); visited[x][y] = true; while (!q.empty()) { auto [cx, cy] = q.front(); q.pop(); for (auto [dx, dy] : kDirs) { int nx = cx + dx, ny = cy + dy; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; if (board[nx][ny] == color && !visited[nx][ny]) { visited[nx][ny] = true; q.push({nx, ny}); } else if (board[nx][ny] == EMPTY && !libVisited[nx][ny]) { libVisited[nx][ny] = true; libs++; } } } return libs; }

代码思路是两层 visited:visited 标记棋块内的点,保证每个棋子只入队一次;libVisited 标记已经被算过的气点,保证气数不重复。逻辑上落子后再调这个函数,返回 0 就说明这颗子所在的棋块已经没有气了。参数上,整套逻辑只关心 board 和坐标,不依赖外部全局状态,所以后面 MCTS 模拟时可以直接传临时棋盘副本,不用改全局数组。

2.2 合法落子判断:为什么「先提对方再判自杀」是绕不过去的顺序

不围棋的规则细化成代码只有两条:落子后己方无气,是禁手;落子后对方无气,是终局(落子方判负)。但有一个隐蔽的交叉情况:你落子后己方块无气,同时这个无气块把对方的某个无气块挤住了——按照围棋规则,对方无气块要先被提掉,提掉之后你的块可能又有了气。所以「先判断己方气」会把你自己的合法着法误判成自杀,而「先提对方」则能让判定结果和真实规则一致。

// 把 color 方所有无气棋块从棋盘上移除,返回是否发生过提子 bool captureDeadStones(BoardState& board, int color) { bool captured = false; for (int x = 0; x < BOARD_SIZE; ++x) { for (int y = 0; y < BOARD_SIZE; ++y) { if (board[x][y] == color && countLiberties(board, x, y) == 0) { // 整块置空,用 BFS 清掉这一整块同色棋 std::queue<std::pair<int, int>> q; q.push({x, y}); board[x][y] = EMPTY; while (!q.empty()) { auto [cx, cy] = q.front(); q.pop(); for (auto [dx, dy] : kDirs) { int nx = cx + dx, ny = cy + dy; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; if (board[nx][ny] == color) { board[nx][ny] = EMPTY; q.push({nx, ny}); } } } captured = true; } } } return captured; } // 判断在 (x, y) 落 player 的棋子是否合法(不禁手即可,吃子也算合法) bool isLegalMove(const BoardState& board, int player, int x, int y) { if (board[x][y] != EMPTY) return false; BoardState tmp = board; tmp[x][y] = player; // 先提掉对方所有无气块,释放潜在的气 captureDeadStones(tmp, 3 - player); // 再检查己方这手棋有没有气 return countLiberties(tmp, x, y) > 0; }

captureDeadStones 里用连消带打的方式:遍历遇到无气同色块时,不只清单点,而是 BFS 清整块。原因是气数为 0 的棋块必然是整块同死,只清一个角落会让剩下的同色子变成全新的「无气块」,下一轮遍历虽然也会清掉,但可能影响提子标记和性能。isLegalMove 的思路是拷贝一份棋盘做临时判断,绝不动真实棋盘,这样 MCTS 里反复调用也不会污染局面。参数唯一的注意点是 player 取值 1 或 2,3 - player 就是对手颜色,这个写法在黑白交替时最不容易出错。

2.3 落子与终局判定:一提子就结束,棋盘下满比谁剩得少

对真实对弈来说,只要 applyMove 过程中发生了提子,本局就结束了,胜负是「提子方负」。如果双方都没提子,一直下到棋盘填满或双方都找不到合法着法,就按棋盘上剩余棋子数量判定,剩得少的一方获胜。这个终局规则对应不围棋「主动失子」的理念:你方的棋越薄却还活着,说明你对「不敢吃你」的局面控制得越成功。

struct Move { int x, y; }; // 返回值:0=未终局 1=黑胜 2=白胜 3=平局 int applyMove(BoardState& board, int player, Move mv, bool& gameOver) { board[mv.x][mv.y] = player; // 先提对方,再查己方 bool captured = captureDeadStones(board, 3 - player); if (captured) { gameOver = true; return 3 - player; // 提子方判负 } // 未提子:若己方也无气,理论上不该发生(isLegalMove 已过滤禁手) if (countLiberties(board, mv.x, mv.y) == 0) { gameOver = true; return 3 - player; // 兜底,把自杀也按落子方负处理 } gameOver = false; return 0; } // 终局按剩余棋子数判定:少者胜 int finalWinnerByStones(const BoardState& board) { int black = 0, white = 0; for (const auto& row : board) { for (int cell : row) { if (cell == BLACK) black++; if (cell == WHITE) white++; } } if (black < white) return BLACK; if (white < black) return WHITE; return 3; }

applyMove 里故意留了一个兜底分支:万一上层调用没做禁手检查,直接落了一手自杀棋,局面不该继续下去。把这种异常输入当成「落子方负」,既符合规则直觉又防止 MCTS 在极端情况下死循环。真正的吃子终局判断在 captureDeadStones 返回 true 时立刻 return,避免继续往下走棋。

3. 蒙特卡洛树搜索:不围棋为什么是 MCTS 的天然主场

蒙特卡洛树搜索不是暴力穷举,而是「用随机模拟的统计结果指导选择」。普通围棋合法着法几百个,纯 MCTS 想达到强棋力需要海量模拟;不围棋因为自杀禁手天然砍掉大量着法,9 路棋盘上每步合法选择通常只有个位数到几十个,MCTS 几百次迭代就能看出明显的「会下棋」的迹象。这是选题的关键:MCTS 不是唯一选择,但对不围棋这种分支少、局面小的游戏,它是实现量最小、效果最可感知的方案。

3.1 四步走:选择、扩展、模拟、回溯,节点里到底该存什么

MCTS 的每个节点代表一个棋盘局面。节点需要记录:当前轮到谁走、访问次数、胜场数、父节点、子节点列表和尚未尝试的着法。棋盘本身直接以值拷贝的方式存在节点里,虽然看着笨重,但 9x9 的 std::array 拷贝一次只有 324 字节,一局模拟几千次拷贝完全没有性能压力。

#include <memory> #include <vector> #include <cmath> #include <limits> struct MCTSNode { BoardState board; int playerToMove; // 轮到谁走:1 或 2 int visitCount = 0; double winScore = 0.0; // 以本节点轮到的一方为视角的胜率 MCTSNode* parent = nullptr; Move moveFromParent; // 从父节点走到本节点的那手棋 std::vector<Move> untriedMoves; std::vector<std::unique_ptr<MCTSNode>> children; MCTSNode(BoardState b, int p, MCTSNode* par) : board(b), playerToMove(p), parent(par) {} };

winScore 的视角是全程序最容易写乱的地方。我采用的约定是:每个节点的 winScore 都表示「轮到 playerToMove 走棋的这一方」的累计胜场。这样从根节点往下,黑子节点和白子节点交替出现,回溯时每上一层结果视角翻转一次,子节点胜率天然是黑白各自的胜率,选点逻辑因此变得直白——根节点是黑方时,选白子节点中胜率最低的那个,就是黑方的最好着法。

3.2 选择与扩展:UCB1 公式怎么平衡「试试新招」和「走熟路」

选择阶段从根节点开始,反复用 UCB1 公式在兄弟节点里挑一个走,直到走到一个还没完全展开的节点。UCB1 的经典形式是:子节点平均胜率加上一个探索项C * sqrt(log(父节点访问次数) / 子节点访问次数)。平均胜率项保证「已知的好招」会被多走,探索项保证「没怎么试过的招」也有机会被翻牌。C 是探索常数,我一般不调太大,不围棋分支少,C 设 0.9 到 1.2 就够用,太大 AI 会像多动症一样乱试。

double ucb1(const MCTSNode* child, int totalVisits, double C) { if (child->visitCount == 0) { return std::numeric_limits<double>::infinity(); // 未访问的节点优先级最高 } double exploit = child->winScore / child->visitCount; double explore = C * std::sqrt(std::log(totalVisits) / child->visitCount); return exploit + explore; } MCTSNode* selectBestChild(MCTSNode* node, double C) { MCTSNode* best = nullptr; double bestValue = -std::numeric_limits<double>::infinity(); for (auto& childPtr : node->children) { MCTSNode* child = childPtr.get(); double value = ucb1(child, node->visitCount, C); if (value > bestValue) { bestValue = value; best = child; } } return best; }

这里把「未访问节点返回无穷大」放在 ucb1 里,作用等价于每个孩子至少先被尝试一次。常见误用是把 C 开很大试图增加探索——那样的话早期看起来乱走,后期又因为 log 增长太慢很快收敛回纯贪心。一个稳妥做法是调参时只看结果不看过程:用 CLI 自对弈让不同 C 值互博,谁赢用谁。

3.3 模拟阶段:纯随机能把棋下「像人」,启发式采样才能让棋下「像棋」

模拟(playout)是从叶子节点出发,双方快速落子直到终局。完全随机的模拟对不围棋来说效果很差:不围棋的棋理是「把自己的棋走薄」,但纯随机会均匀地往所有空位下单,模拟结果几乎跟棋力无关。一个非常简单的启发式改动就能大幅提升棋力——落子后己方棋块气数越少的着法越优先选。这个启发式的直觉是:气少的棋块对对手构成「不能碰」的心理威慑,同时也在消耗棋盘空间,符合不围棋「主动失子」的目标。

#include <algorithm> #include <random> std::mt19937 g_rng(std::random_device{}()); // 从合法着法里按气数升序采样,优先选「把自己走薄」的点 Move pickHeuristicMove(const BoardState& board, int player, const std::vector<Move>& legalMoves) { std::vector<std::pair<int, Move>> scored; scored.reserve(legalMoves.size()); for (Move mv : legalMoves) { BoardState tmp = board; tmp[mv.x][mv.y] = player; int libs = countLiberties(tmp, mv.x, mv.y); scored.push_back({libs, mv}); } std::sort(scored.begin(), scored.end()); // 只从前 1/3 的低气数着法里随机选,保持一定多样性又不至于乱走 int limit = std::max(1, (int)scored.size() / 3); std::uniform_int_distribution<int> dist(0, limit - 1); return scored[dist(g_rng)].second; }

注意排序的对象 pair 默认先按 libs 排,再按坐标排,所以 limit 取前三分之一就是「气数最薄的一批着法」。这个启发式不会把 AI 变成死板机器,因为选点仍有随机性,只是整体概率偏向低气数区域。如果你觉得棋力还不够,可以进一步把采样分布从均匀改成指数权重,但期末作业用前三分之一均匀采样已经能打出「敢于送吃、善于包围」的风格。逻辑上要记住:气数最小不代表最好,气数为 1 的棋块是「对手一提你就输」的诱饵,AI 在模拟时反倒是安全的——对手不敢提,这手棋就相当于占了个对方不敢碰的位置。

3.4 主循环与超时控制:把「思考时间」卡在 500 毫秒内

MCTS 主循环就是重复「选择 → 扩展 → 模拟 → 回溯」四步,跑够指定迭代次数后就从根节点的子节点里挑一个胜率最高的着法。实际对弈里更实用的是按时间停止:用户落子后给 AI 最多几百毫秒思考,到点就停。实现上直接用std::chrono每次迭代前检查一次时间,到点跳出循环。

#include <chrono> Move mctsGetMove(const BoardState& board, int player, int iterations) { MCTSNode root(board, player); root.untriedMoves = generateLegalMoves(board, player); auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); for (int i = 0; i < iterations; ++i) { if (std::chrono::steady_clock::now() > deadline) break; MCTSNode* node = &root; // 选择:一路走到需要展开或已经终局的节点 while (node->untriedMoves.empty() && !node->children.empty()) { node = selectBestChild(node, C_UCB); } // 扩展:还有没尝试的着法就展开一个孩子 if (!node->untriedMoves.empty()) { node = expand(node); } // 模拟:从展开出的局面跑到底 int winner = simulatePlayout(node->board, node->playerToMove); // 回溯:从叶子向根更新胜率和访问次数 backpropagate(node, winner); } // 选根节点下胜率最高(对根玩家)的子节点 return chooseBestMove(&root, player); }

主循环参数上有三个自由量:迭代次数上限、时间上限、模拟步数上限。时间上限是硬约束,迭代次数只是防止死循环的保险。9 路棋盘上我一般设定 500 毫秒,对应几百次到上千次迭代,视觉效果是「AI 似乎想了想但没有明显卡顿」。如果跑 13 路,同样时间迭代次数会掉一半,因为这时的合法着法更多、每步模拟更长。想让 AI 变强,优先加时间而不是加迭代次数上限,因为迭代次数只是结果不是目标。

4. OpenGL + glut 界面:把棋盘从控制台搬到窗口里

glut 是很老的工具库,但胜在简单:初始化窗口、注册回调、进入消息循环,三个步骤就把 OpenGL 上下文和事件系统搭起来了。界面层的设计目标是「纯展示」——棋盘状态仍然由前面的 C++ 逻辑维护,渲染函数只负责把数组画到窗口上,鼠标回调负责把点击位置换算成棋盘坐标。

4.1 glut 初始化与 OpenGL 上下文的作用:窗口创建后 GL 函数才有意义

OpenGL 的函数调用依赖一个「当前上下文」。glutCreateWindow 之前,任何 glClear、glColor 调用都没有实际效果,因为 GPU 不知道你在为哪个窗口画东西。glutCreateWindow 成功执行后,这个窗口的 OpenGL 上下文被自动设为当前上下文,后面所有绘制函数才进入有效状态。这个上下文的作用还包括维护深度缓冲、模板缓冲和版本信息,OpenGL 版本不同,能用的函数集合也不同。

#include <GL/glut.h> constexpr int WINDOW_W = 600; constexpr int WINDOW_H = 600; constexpr int MARGIN = 30; constexpr float CELL = (WINDOW_W - 2.0f * MARGIN) / (BOARD_SIZE - 1); constexpr float STONE_R = CELL * 0.42f; void initGLUT(int argc, char** argv) { glutInit(&argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB); glutInitWindowSize(WINDOW_W, WINDOW_H); glutInitWindowPosition(100, 100); glutCreateWindow("No-Go - 不围棋"); // 正交投影:直接按像素坐标画图,y 轴向下对齐鼠标坐标系 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluOrtho2D(0, WINDOW_W, WINDOW_H, 0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glClearColor(0.85f, 0.62f, 0.42f, 1.0f); // 注册回调 glutDisplayFunc(display); glutReshapeFunc(reshape); glutMouseFunc(mouseClick); glutKeyboardFunc(keyboard); }

gluOrtho2D(left, right, bottom, top) 里我把 bottom 设成 WINDOW_H、top 设成 0,这样客户区的 y 坐标从上向下增长。这个选择和 glut 鼠标回调返回的鼠标 y 坐标方向一致,避免渲染坐标和事件坐标之间反复换算。display mode 用了 GLUT_DOUBLE,双缓冲模式下绘制完成后必须调用 glutSwapBuffers 才显示,否则画面会闪烁或者什么都看不到。GLUT_RGB 指定颜色模式,配合 glClearColor 的背景色使用。

4.2 画棋盘与画棋子:GL_LINES 画线、三角扇近似圆

棋盘是九条横线加九条竖线,用 GL_LINES 原语一次提交所有线段,效率没问题。线段坐标直接由 MARGIN、CELL 和格子索引算出。棋子是圆形,OpenGL 没有内置的圆原语,最通用的做法是三角扇:圆心一个顶点,圆周按 30 个小段取点,把所有扇形三角形凑成一个圆。30 段对棋子来说边缘已经很平滑,再多就浪费顶点。

void drawBoard() { glClear(GL_COLOR_BUFFER_BIT); glColor3f(0.1f, 0.1f, 0.1f); glLineWidth(1.5f); glBegin(GL_LINES); for (int i = 0; i < BOARD_SIZE; ++i) { float pos = MARGIN + i * CELL; // 横线 glVertex2f(MARGIN, pos); glVertex2f(WINDOW_W - MARGIN, pos); // 竖线 glVertex2f(pos, MARGIN); glVertex2f(pos, WINDOW_H - MARGIN); } glEnd(); // 星位点缀,9 路棋盘画 5 个点 glPointSize(4.0f); glBegin(GL_POINTS); glVertex2f(MARGIN + 2 * CELL, MARGIN + 2 * CELL); glVertex2f(MARGIN + 2 * CELL, MARGIN + 6 * CELL); glVertex2f(MARGIN + 4 * CELL, MARGIN + 4 * CELL); glVertex2f(MARGIN + 6 * CELL, MARGIN + 2 * CELL); glVertex2f(MARGIN + 6 * CELL, MARGIN + 6 * CELL); glEnd(); } void drawStone(int gx, int gy, int color) { float cx = MARGIN + gx * CELL; float cy = MARGIN + gy * CELL; if (color == BLACK) glColor3f(0.1f, 0.1f, 0.1f); else glColor3f(0.95f, 0.95f, 0.95f); glBegin(GL_TRIANGLE_FAN); glVertex2f(cx, cy); // 圆心 const int kSegments = 30; for (int i = 0; i <= kSegments; ++i) { float angle = i * 2.0f * 3.1415926f / kSegments; float vx = cx + STONE_R * cosf(angle); float vy = cy + STONE_R * sinf(angle); glVertex2f(vx, vy); } glEnd(); }

画棋子的颜色区分很简单:黑色棋设为深灰,白色棋设为浅灰,背景是木色,三种颜色对比足够清晰。如果你想让黑白棋更有立体感,可以在圆心稍偏的位置再画一个小高光圆,但这属于纯视觉优化,和 AI 逻辑无关,期末阶段可以不做。注意 glPointSize 对 GL_POINTS 才生效,星位这种散点用它最省事。

4.3 鼠标坐标换算与双缓冲刷新:点下去的那一瞬间发生了什么

glut 鼠标回调返回的坐标是像素值,原点在窗口左上角。换算成棋盘坐标只需要把像素坐标减去棋盘起始边距,再除以格子间距,四舍五入到最近的交叉点。很多人直接做整数除法,结果点边缘位置时落子错位。另外,AI 落子不能直接在鼠标回调里同步执行,因为 MCTS 要跑几百毫秒,事件循环会被卡死。正确做法是设一个 timer 回调,等本次绘制结束后再让 AI 走棋。

bool gameOver = false; int currentPlayer = BLACK; int gameMode = 0; // 0=双人 1=人机 void mouseClick(int button, int state, int mx, int my) { if (button != GLUT_LEFT_BUTTON || state != GLUT_UP) return; if (gameOver) return; int gx = static_cast<int>(std::round((mx - MARGIN) / CELL)); int gy = static_cast<int>(std::round((my - MARGIN) / CELL)); if (gx < 0 || gx >= BOARD_SIZE || gy < 0 || gy >= BOARD_SIZE) return; if (gameMode == 0) { if (tryMove(gx, gy, currentPlayer)) { currentPlayer = 3 - currentPlayer; glutPostRedisplay(); } } else if (gameMode == 1 && currentPlayer == BLACK) { // 玩家执黑,AI 执白 if (tryMove(gx, gy, BLACK)) { currentPlayer = WHITE; glutPostRedisplay(); glutTimerFunc(10, aiMoveTimer, 0); } } } void aiMoveTimer(int) { Move best = mctsGetMove(board, WHITE, 0); if (tryMove(best.x, best.y, WHITE)) { currentPlayer = BLACK; } glutPostRedisplay(); }

mouseClick 里的关键点是先用 round 四舍五入,再判断边界。因为玩家点到格子中间偏左半格的位置时,它实际上想下在右侧交叉点上,floor 会错误地落在左侧。aiMoveTimer 用 long 类型参数是 glutTimerFunc 回调的固定签名,即使不用它也得照写。每次落子后调用 glutPostRedisplay 通知 GLUT 重绘,这是双缓冲模式下最常见的刷新方式;如果忘了调用,界面会像死机一样停在旧画面。

5. 编译运行全流程:从 GLUT 环境配置到 6 个典型报错排查

环境配置往往是新手花时间最多的地方。glut 有老牌实现,也有 freeglut 这种兼容品,链接时稍微搞错一个库名字符串就编译不过。这里给出一套我常用的构建方案,以及 6 个我自己或学生实际踩过的坑,按「现象 → 原因 → 解决」的顺序写,希望你能在出问题时不至于重装系统。

5.1 一套能跑的构建命令:Windows 下用 MinGW + freeglut 最快

Windows 上我推荐用 MSYS2 装 MinGW-w64,然后用 pacman 装 freeglut 和 opengl32,比 Visual Studio 手动配置库路径省心。装完后一条命令就能编译出可执行文件,不依赖 IDE 的工程文件。

# 安装依赖(MSYS2 终端) pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-freeglut # 编译:把源文件列全,链接 freeglut、opengl32、glu32 g++ -O2 -std=c++17 main.cpp board.cpp mcts.cpp render.cpp \ -o nogo.exe \ -lfreeglut -lopengl32 -lglu32

命令里的 -O2 是优化开关,MCTS 的模拟循环非常吃 CPU,开优化后同样时间能多跑一半迭代,所以这个参数不能省。-lfreeglut 对应 freeglut 库,注意老版 GLUT 在某些发行版里叫 -lglut。如果你的系统同时装了多个 OpenGL 库,链接顺序也很讲究,把依赖库放在源文件同一行命令的末尾,避免链接器找不到符号。生成 nogo.exe 后直接双击运行,窗口应该立刻弹出。如果需要发给没装开发环境的同学跑,对方机器得装对应架构的 Visual C++ 2015-2022 Redistributable,否则会弹「找不到 DLL 入口点」或直接报缺 vcruntime140.dll,这个跟代码无关,属于运行库部署常识。

5.2 坑一:明明合法能吃的棋,AI 却永远不下

现象:棋盘上对方有一块没气的棋,AI 却视而不见,宁可去别处落子。原因是 isLegalMove 里没有先提对方就查己方气,导致把「吃了对方子之后己方有气」的着法误判成自杀禁手。这类错误的表现不是崩溃,而是 AI 好像「变笨了」,因为它的合法着法集合比真实规则少了一大截。解决方法是严格按「先 captureDeadStones 再 countLiberties」的顺序写判断逻辑,可以专门写个单元测试:摆一个对方无气块在角落里,断言那手能吃子的点 isLegalMove 返回 true。

5.3 坑二:同一盘棋每次重开都一模一样,AI 像个复读机

现象:固定玩家第一步后,AI 后续应对完全可预测,甚至每次运行程序开局都一样。原因是 use rand() 没有给随机种子,或者给了srand(time(0))但 MCTS 模拟循环里多次快速调用 time(0) 得到相同种子,导致整局模拟退化成同一条必然路径。MCTS 的价值就在于用随机性探索不同分支,随机源死了,算法就退化成贪心搜索。解决方法是直接用 C++11 的<random>库,在文件顶部构造一个std::mt19937 g_rng(std::random_device{}()),所有模拟采样都用这个 rng。注意随机设备和 mt19937 组合的初始化开销极小,不用每次采样时重新构造。

5.4 坑三:glut 窗口黑屏,或报 failed to initialize graphics backend for opengl

现象:程序启动后窗口存在但画面全黑,或者在远程桌面、虚拟机里直接报初始化失败就退出。原因是 OpenGL 上下文创建依赖 GPU 驱动和显示环境,远程桌面和部分虚拟机默认不提供硬件加速的 OpenGL 上下文,老 GLUT 又不会优雅降级。解决的方法是:先确认本机能不能跑其他 OpenGL 程序,不能跑就换物理机或者开启虚拟机的 3D 加速;代码层面建议加一个--nogui命令行开关,跳过所有 GLUT 初始化,改用控制台自对弈模式,让 MCTS 核心逻辑脱离界面独立可测。这样远程调试算法时不依赖图形环境,拷到有 GPU 的机器上再开界面。

5.5 坑四:MSVC 下 fopen 报错,安全函数警告刷屏

现象:用 Visual Studio 编译时,代码里写fopen("mcts_log.txt", "w")直接报 C4996,提示用 fopen_s。原因是微软默认把 fopen 列为不安全函数,编译期强制告警。解决方法是三种任选:项目属性预处理器定义_CRT_SECURE_NO_WARNINGS;或者在源文件顶部#define _CRT_SECURE_NO_WARNINGS;或者直接用fopen_s。期末阶段我推荐第一种,因为日志文件用 fopen 写完的可读性和可移植性最好,你不想为了消警告改一堆代码。

5.6 坑五:点棋盘边缘落子错位,AI 又迟迟不动

现象:点在边线上下一格的位置,棋子落到相邻交叉点上;人机模式里玩家落子后界面卡住几十秒。原因有两个,一是坐标换算用了 int 强转而不是 round,二是 AI 在鼠标回调里同步跑 MCTS,事件循环被阻塞。坐标换算修正办法是把(mx - MARGIN) / CELL用 std::round 处理;AI 阻塞的办法是改 glutTimerFunc 异步触发,配合双缓冲重绘。卡住几十秒不是死循环,而是同步计算期间窗口系统无法响应任何消息,看起来很像崩溃,实际上鼠标回调一返回画面就恢复了,但这种体验对答辩演示是致命的。

6. 验证 AI 棋力:写一个 CLI 自对弈脚本,让不同参数的 AI 自己打一场

界面上人工试玩几局只能得到「感觉还行」的模糊结论,答辩时评委大概率会问「你怎么知道 MCTS 比随机下棋强」。所以我在主程序里留了一个--nogui参数:不初始化 OpenGL,直接用命令行跑自对弈。做法是黑方用迭代次数较多的 MCTS,白方用迭代次数较少的 MCTS(或者纯随机着法),统计 20 局里强棋方的胜率。这个过程能在几分钟内给出可量化的对比数据,也能帮你调 C 值和迭代次数。

void runSelfPlay(int iterationsStrong, int iterationsWeak) { int strongWins = 0; for (int g = 0; g < 20; ++g) { BoardState board{}; int player = BLACK; bool over = false; int winner = 0; while (!over) { int iter = (player == BLACK) ? iterationsStrong : iterationsWeak; Move mv = mctsGetMove(board, player, iter); applyMove(board, player, mv, over, winner); player = 3 - player; if (over) break; } if (winner == BLACK) strongWins++; } printf("strong(black) win rate: %.0f%%\n", strongWins / 20.0 * 100.0); }

自对弈脚本的注意点是不要用固定棋盘初始化,每局开始时把棋盘数组清零,让 AI 从完全空盘开始下。你可能会发现强 AI 并非必胜,因为不围棋的随机性比围棋大,而且先手优势不明显,这些现象本身就是可以写进报告的内容。我建议对比三组:200 次迭代对随机 playout、500 次迭代对 200 次迭代、1000 次迭代对 100 次迭代,每组跑 20 局以上,再配合单步耗时记录,就能画出一张「迭代次数 vs 胜率」的小表。

我自己做这个题时,第一版把禁手判断顺序写反,MCTS 自己提了自己的大龙,窗口里黑白两色在棋盘上「互杀」,场面一度非常壮观。后来我给程序加了个--debug开关,每次模拟结束打印着法和胜负原因,才真正看懂 MCTS 在干什么。如果你时间有限,先跑通 9 路、200 次迭代、纯随机模拟,再加启发式采样,不要一上来追求 13 路和超高强度——调参的坑比你想象的多,从最小可行版本开始迭代才是最快的路径。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询