☰
Qt黑白棋小游戏:博弈AI与Alpha-Beta剪枝的实现
2026/9/29 16:47:53 网站建设 项目流程

简介:这是一份基于QT与C++开发的黑白棋游戏完整工程,面向具备基础编程能力、想通过实战项目掌握GUI开发与网络编程的开发者。项目涵盖双人黑白棋的完整规则,包括合法落子判定、翻转棋子、实时计分与胜负检测,并实现了人机对战和网络对战两种玩法,网络模块基于TCP/UDP通信,涉及消息序列化、心跳包与超时重传等机制。压缩包共15个文件,以C++源文件(.cpp/.h)和QT界面文件(.ui/.pro)为核心,另附多张界面截图与素材图片,整体大小约1.12MB,目录清晰,可直接用QT Creator打开编译运行。已有350人学习下载。通过阅读源码可以系统理解QT的UI设计器布局、事件循环与信号槽机制、游戏状态管理以及QTcpSocket网络通信流程,同时也能学习到算法设计与代码组织方法,是一份适合课程设计或自学进阶的完整范例。

1. QT 黑白棋:从 8×8 棋盘到 6 层搜索,一套小游戏代码能当博弈模板用

如果你搜过“QT 黑白棋”或者“黑白棋代码”,大概率会见到一类结果:能画棋盘、能点格子落子,但一追问“合法落子怎么判断”“AI 为什么突然变傻”就没了下文。这套项目的价值正好在中间层,它把黑白棋拆成棋盘存储、落子逻辑、AI 搜索三层,再包一个 Qt Widgets 界面。无论你是刚学 Qt 想找完整小项目练手,还是在给自己的 C++ 小游戏代码库补一个回合制博弈 AI 样例,这套代码都值得下载回来跑一遍。下面我从规则约定讲起,把每个函数为什么这么写、参数怎么调、最容易翻车的位置一并过一遍。

2. 黑白棋规则与项目骨架:棋盘坐标、棋子状态和 Qt 工程的组织方式

先声明一点:写这种游戏代码最怕的不是算法难,而是“约定没定死就开写”。黑白棋规则本身几句话就能说完,但落到代码里,棋盘存成一维还是二维、棋子用 0/1 还是 0/1/2、先手固定黑还是可变,这些选择会一路影响后面所有函数。先定规矩再写逻辑,后面能省一半排错时间。

2.1 黑白棋规则里的三个必须定死的约定

黑白棋规则是:黑棋先手,双方轮流在 8×8 棋盘落子,落子位置必须能在一个或多个方向上“夹住”至少一枚对方棋子,被夹住的棋子当场翻成己方;没有合法落子时自动跳过回合;两个玩家都不能落子时终局,棋盘上棋子多者胜。

第一个约定是棋子状态。我习惯用EMPTY = 0、BLACK = 1、WHITE = -1,而不是BLACK = 1、WHITE = 2。原因是黑白棋里“对方”这个动作出现频率极高,合法性判断要对比当前玩家的对面,翻转时要找对方的棋子,AI 评估时要让当前棋子与对方棋子做差值;用-player表示对手,代码里就不需要任何颜色分支。几个关键函数都是围绕这个小技巧展开的。

第二个约定是初始棋局。标准黑白棋开局四子落在棋盘正中央的 2×2 区域内,我用 0-based 坐标写出来就是这个形状:

. . . . . . . . . . . . . . . . . . . . . . . . . . . ● ○ . . . . . . ○ ● . . . . . . . . . . . . . . . . . . . . . . . . . . .

四个格子的坐标是(3,3)黑色、(3,4)白色、(4,3)白色、(4,4)黑色。黑棋占左上和右下对角。

第三个约定是坐标换算。棋盘 8×8,我统一用row * 8 + col表示格子的唯一索引,行号范围 0~7,列号范围 0~7。后面 AI 搜索里遍历落子点时,拿到一个索引idx,可以通过idx >> 3取行、idx & 7取列,比维护二维坐标数组省事。

2.2 工程里的类划分:Board、AI、BoardWidget 各管什么

这套代码我用标准 Qt Widgets 工程组织,核心是四个类:Board只负责数据,AI只负责搜索,BoardWidget只负责绘制和鼠标事件,MainWindow负责把游戏流程串起来。

类文件职责关键接口
BoardBoard.h / Board.cpp棋盘数据、初始棋局、重置reset()、cellAt()
AIAI.h / AI.cpp合法落子列表、评估、搜索bestMove()、alphaBeta()
BoardWidgetBoardWidget.h / BoardWidget.cpp绘制棋盘、响应鼠标paintEvent()、mousePressEvent()、信号cellClicked
MainWindowMainWindow.h / MainWindow.cpp回合控制、悔棋、胜负弹窗onCellClicked()、switchTurn()

这样分层的核心原因是“把界面和逻辑切开”。BoardWidget里绝对不出现翻转棋子的代码,AI里不碰任何QPainter对象。如果你看到一份代码把绘制和游戏规则写在同一个类里,短期跑着没事,后面加悔棋、加 AI 线程、加动画时一定会到处打补丁。

2.3 棋盘数据用一维 QVector 还是二维数组

棋盘存储我选一维QVector<int>,长度固定 64。对比二维数组int board[8][8],一维表示的优势体现在 AI 部分:搜索树里每一层都要复制棋盘状态,QVector<int>的复制语义天然支持值拷贝,传给flipDiscs、evaluateBoard时不用额外做维度转换。

// Board.h 中与存储直接相关的部分 class Board { public: static const int SIZE = 8; static const int EMPTY = 0; static const int BLACK = 1; static const int WHITE = -1; QVector<int> cells; Board() : cells(64, Board::EMPTY) {} int index(int row, int col) const { return row * SIZE + col; } int cellAt(int row, int col) const { return cells.at(row * SIZE + col); } void reset() { cells.fill(Board::EMPTY, 64); cells[3 * 8 + 3] = Board::BLACK; cells[3 * 8 + 4] = Board::WHITE; cells[4 * 8 + 3] = Board::WHITE; cells[4 * 8 + 4] = Board::BLACK; } };

逻辑说明:cells初始全部为 0,天然对应空位。index()负责把行列坐标换算成一维索引,cellAt()是读取接口,reset()把棋盘恢复到开局状态。为什么要单独抽出index()而不是到处写row * 8 + col?因为一旦后面要改成 10×10 棋盘或者加入坐标缓存,只需要改这一个函数。

参数说明:QVector<int> cells的元素取值只有三个,0、1、-1。注意QVector<int>的默认构造不会自动填充长度,所以构造函数里必须显式cells(64, EMPTY)。reset()里使用字面量索引3 * 8 + 3,虽然看起来像魔法数字,但配合注释在开局棋盘场景下可读性反而比index(3,3)更高。

实际编码里我见过有人用char board[8][8]存储,然后用0/1/2表示空、黑、白。这种写法在判断颜色时问题不大,但到了 AI 评估函数里,每次都要写if (v == BLACK) ... else if (v == WHITE) ...,代码量几乎翻一倍。用1/-1后,评估函数可以天然用加减法表达“当前玩家占优还是劣势”,这是选型时最值得抄走的一点。

3. 核心落子逻辑:八个方向扫描的合法性检测、翻转实现与终局判定

这一章是黑白棋代码的“地基”,也是新手最容易写错的地方。合法性检测和翻转其实是同一套方向遍历逻辑的两面:前者只判断能不能下,后者真正动手翻棋子。下面这段示例代码讲解会从方向数组开始,把这两个函数一次讲透。

3.1 合法性检测 isLegalMove

黑白棋的合法落子条件是“从这个空格出发,某个方向上先遇到一连串对方棋子,再遇到一枚己方棋子”。记住这句话,代码就变成了一次简单的方向扫描。

#include <QVector> class Board; // 前置声明,下面用到 Board::EMPTY 等常量 static const int DIRS[8][2] = { {-1,-1}, {-1,0}, {-1,1}, { 0,-1}, { 0,1}, { 1,-1}, { 1,0}, { 1,1} }; bool isLegalMove(const QVector<int>& board, int row, int col, int player) { if (row < 0 || row >= 8 || col < 0 || col >= 8) return false; if (board[row * 8 + col] != Board::EMPTY) return false; const int opponent = -player; for (int d = 0; d < 8; ++d) { int nr = row + DIRS[d][0]; int nc = col + DIRS[d][1]; // 方向上紧邻的格子必须是对手棋子,否则这个方向无效 if (nr < 0 || nr >= 8 || nc < 0 || nc >= 8) continue; if (board[nr * 8 + nc] != opponent) continue; // 沿该方向继续推进,直到碰到己方棋子或边界/空格 nr += DIRS[d][0]; nc += DIRS[d][1]; while (nr >= 0 && nr < 8 && nc >= 0 && nc < 8) { int v = board[nr * 8 + nc]; if (v == Board::EMPTY) break; if (v == player) return true; nr += DIRS[d][0]; nc += DIRS[d][1]; } } return false; }

逻辑说明:函数先做边界和空格检查,然后遍历八个方向。每个方向的第一步必须是对手棋子,否则这个方向连“夹子”的基本结构都不成立。确认第一步是对方棋子后,继续沿着方向走,碰到空格就停,碰到己方棋子则说明整段被夹住,直接返回 true。

参数说明:row和col是落子位置,player是当前行动方的棋子值。因为用了WHITE = -1,对手直接用-player表达。八个方向里我没有写满二维数组的四个斜角,但数组下标 0 到 8 实际覆盖了横、竖、两条对角线的全部组合,这种写法比写八个函数调用更直观,也不会漏方向。

这里有个常见误用:有人会在判断时先收集“这个空格能被哪些方向夹住”,再统一判断是否存在至少一个方向。这个思路没问题,但实现时千万注意不要把一个方向的临时结果带到另一个方向。每轮循环都要从row + DIRS[d][0]重新开始走,而不是在之前方向的基础上继续累加。

有了单点判断后,生成合法落点列表就很简单了。遍历 64 个格子,把isLegalMove为 true 的索引收集起来:

QVector<int> getLegalMoves(const QVector<int>& board, int player) { QVector<int> moves; moves.reserve(32); for (int r = 0; r < 8; ++r) { for (int c = 0; c < 8; ++c) { if (isLegalMove(board, r, c, player)) moves.append(r * 8 + c); } } return moves; }

逻辑说明:reserve(32)是预分配容量。黑白棋棋局中合法落点数量通常在 5 到 20 之间,偶尔极端局面会到 30 左右,预分配 32 能避免大量 rehash。

3.2 翻转函数 flipDiscs

合法性判断只负责“点头”,真正落子时要翻转棋子。翻转函数和isLegalMove的区别在于:合法性只要发现一个方向成立就返回,翻转则必须把八个方向全部检查完,把所有被夹住的棋子一次翻掉。

void flipDiscs(QVector<int>& board, int row, int col, int player) { const int opponent = -player; board[row * 8 + col] = player; // 先把当前棋子放上去 for (int d = 0; d < 8; ++d) { int nr = row + DIRS[d][0]; int nc = col + DIRS[d][1]; // 第一步必须是对手棋子,否则这个方向不参与翻转 if (nr < 0 || nr >= 8 || nc < 0 || nc >= 8) continue; if (board[nr * 8 + nc] != opponent) continue; int flips[64]; // 记录本方向被夹住的棋子索引 int cnt = 0; bool closed = false; nr += DIRS[d][0]; nc += DIRS[d][1]; while (nr >= 0 && nr < 8 && nc >= 0 && nc < 8) { int v = board[nr * 8 + nc]; if (v == Board::EMPTY) break; if (v == player) { closed = true; break; } flips[cnt++] = nr * 8 + nc; nr += DIRS[d][0]; nc += DIRS[d][1]; } if (closed) { for (int i = 0; i < cnt; ++i) board[flips[i]] = player; } } }

逻辑说明:落子先写进棋盘,再逐方向向外找“闭合点”。flips数组只记录方向上的中间棋子,一旦闭合点确认就全部翻成当前玩家。之所以不在翻转前调用isLegalMove,是因为那样会把八个方向重新扫描一遍,性能损耗在 AI 搜索里会被放大数十倍。

参数说明:flips[64]是栈上的局部数组,最大长度 64,实际上一个方向最多 6 枚中间棋子,用 64 是留足余量。closed表示该方向确实被己方棋子闭合,只有closed为 true 时才执行翻转。注意先落子再翻转的顺序很重要,否则当前落子位置还是空的,后续方向判断会多一次分支。

这段代码里最隐蔽的坑是“重复翻转”。比如一个方向上夹住两枚棋子,另一个方向也经过其中一枚;翻转顺序是无所谓的,因为两个方向都确认闭合后才统一改写,不会出现第一方向翻完、第二方向又把它当成对方棋子的问题。

3.3 过手与终局判定:switchTurn 和 gameResult

黑白棋有个特殊规则:没有合法落子时不能强制停赛,而是自动跳过回合。这个逻辑写在switchTurn里最合适。

bool switchTurn(QVector<int>& board, int* currentPlayer) { QVector<int> moves = getLegalMoves(board, *currentPlayer); if (!moves.isEmpty()) return true; // 当前玩家有棋可下,回合不变 // 当前玩家无棋可下,换到对方 *currentPlayer = -*currentPlayer; moves = getLegalMoves(board, *currentPlayer); if (moves.isEmpty()) return false; // 双方都无棋可下,终局 return true; }

逻辑说明:第一轮判断当前玩家是否还有合法落子,有就直接返回;没有就把currentPlayer取反,再判断对方有没有合法落子。对方也没有时返回 false,表示整局结束。这里用指针改currentPlayer,是因为调用方需要知道回合是否被跳过,返回值用来驱动终止判断。

终局计分不要依赖 AI 的评估函数,直接数棋盘上的棋子即可:

int gameResult(const QVector<int>& board) { int black = 0, white = 0; for (int idx = 0; idx < 64; ++idx) { if (board[idx] == Board::BLACK) ++black; else if (board[idx] == Board::WHITE) ++white; } if (black > white) return Board::BLACK; if (white > black) return Board::WHITE; return 0; // 平局 }

逻辑说明:遍历全部 64 格,统计黑棋和白棋数量。返回值和BLACK/WHITE常量一致,0 表示平局。switchTurn返回 false 后马上调用gameResult就能拿到胜负结果,不需要额外状态位。

4. AI 引擎:评估函数、MinMax 搜索与 Alpha-Beta 剪枝的参数实践

黑白棋的 AI 本质是“搜索 + 评估”。搜索负责往前想几步,评估负责给局面打分。这里最值得讲清楚的是评估函数怎么设计,以及 Alpha-Beta 剪枝的几个参数怎么配合。

4.1 评估函数:为什么不能只数棋子数

很多初学者写黑白棋 AI 时第一个想法是“谁棋子多谁赢”,然后 AI 就会疯狂抢子,结果中盘被翻盘。黑白棋的博弈特点恰恰是“大优势会被翻盘”,因为棋盘只有 64 格,终局前棋子的多寡说明不了任何问题。评估函数至少要有三个成分:位置权重、机动性、角落价值。

位置权重表是经典做法,每个格子一个权值,表示落在该格子的棋子对局面的长远价值:

int evaluateBoard(const QVector<int>& board, int player) { static const int WEIGHT[8][8] = { { 50, -10, 20, 10, 10, 20, -10, 50 }, {-10, -20, -5, -5, -5, -5, -20, -10 }, { 20, -5, 1, 1, 1, 1, -5, 20 }, { 10, -5, 1, 0, 0, 1, -5, 10 }, { 10, -5, 1, 0, 0, 1, -5, 10 }, { 20, -5, 1, 1, 1, 1, -5, 20 }, {-10, -20, -5, -5, -5, -5, -20, -10 }, { 50, -10, 20, 10, 10, 20, -10, 50 } }; int rawScore = 0; for (int idx = 0; idx < 64; ++idx) { int v = board[idx]; if (v == Board::EMPTY) continue; int w = WEIGHT[idx >> 3][idx & 7]; if (v == Board::BLACK) rawScore += w; else rawScore -= w; } // 机动性:合法落点越多,后续选择空间越大 int myMoves = getLegalMoves(board, player).size(); int opMoves = getLegalMoves(board, -player).size(); rawScore += (myMoves - opMoves) * 8; // 从当前玩家视角返回分数 return (player == Board::BLACK) ? rawScore : -rawScore; }

逻辑说明:权重表里角落给 50,这是全盘最高价值;角落旁边的X位给-10甚至-20,因为落子在那里等于把角落拱手送给对手;中心四格给 0 或 1,因为中盘棋子会被反复翻转,位置价值最低。机动性成分用合法落点数差乘以 8,帮助 AI 选择能给对手留下更少选择的走法。最后根据player的视角翻转符号,保证负极大值搜索里的“分数越大对自己越有利”成立。

参数说明:(myMoves - opMoves) * 8里的系数 8 是经验值,调大 AI 会变得更保守,倾向于压缩对手空间;调小则更激进抢位置。WEIGHT表里角落 50、边缘 20、中心 1 的梯度可以整体缩放,不影响相对关系,但会影响搜索的稳定性判断。这个表我一般不轻易动,改一次评估函数就要跑一次自对弈回归,否则很容易出现“改了权重 AI 反而下不过原来”的玄学情况。

4.2 MinMax 搜索与负极大值写法

黑白棋搜索树用 MinMax 模型:当前玩家选分数最大的走法,对手选分数最小的走法。实现里我用负极大值写法,它和 MinMax 等价但代码更紧凑。

int alphaBeta(QVector<int>& board, int player, int depth, int alpha, int beta) { if (depth <= 0) { return evaluateBoard(board, player); } QVector<int> moves = getLegalMoves(board, player); if (moves.isEmpty()) { // 当前玩家无棋可下,检查对方是否也无棋可下 if (getLegalMoves(board, -player).isEmpty()) return evaluateBoard(board, player); // 终局局面 // 对方有棋可下,跳过本回合 return -alphaBeta(board, -player, depth - 1, -beta, -alpha); } int best = -1000000; for (int mv : moves) { QVector<int> nb = board; flipDiscs(nb, mv >> 3, mv & 7, player); int score = -alphaBeta(nb, -player, depth - 1, -beta, -alpha); if (score > best) { best = score; if (best > alpha) alpha = best; if (alpha >= beta) break; // 剪枝 } } return best; }

逻辑说明:负极大值的核心是-alphaBeta(...),递归返回值是“下一层的对手视角最优分数”,取负后变成当前玩家视角的分数,天然实现 MinMax 的轮流取大小。无棋可下时不是直接返回一个固定值,而是先判断对方是否也无棋可下,避免把可继续对局的局面误判成终局。

参数说明:depth <= 0的叶子节点直接返回评估值。alpha初始为负无穷、beta初始为正无穷,搜索过程中alpha只会变大,beta只会变小;当alpha >= beta说明当前分支已不可能影响最终选择,直接 break。

实际应用时,调用方先拿到合法落点列表,再对每个候选走法执行flipDiscs后调用alphaBeta,选出分数最高的那一步:

int AI::bestMove(const QVector<int>& board, int player, int maxDepth) { QVector<int> moves = getLegalMoves(board, player); if (moves.isEmpty()) return -1; // 无棋可下 sortMoves(moves, board, player); // 走法启发式排序,提升剪枝效率 int best = -10000000; int bestMove = moves.first(); int alpha = -10000000, beta = 10000000; for (int mv : moves) { QVector<int> nb = board; flipDiscs(nb, mv >> 3, mv & 7, player); int score = -alphaBeta(nb, -player, maxDepth - 1, -beta, -alpha); if (score > best) { best = score; bestMove = mv; } if (best > alpha) alpha = best; } return bestMove; }

逻辑说明:bestMove返回棋盘的索引值,调用方拿到后用mv >> 3和mv & 7拆成行列。这里要注意alpha的更新方式:不是每个节点都单独管理best == alpha,而是在循环里把alpha同步推进,保证后续候选走法的剪枝条件更早触发。

4.3 深度设置、走法排序与难度调节

Alpha-Beta 剪枝效果严重依赖走法顺序。好的走法越早被搜索,剪掉的无效分支越多,搜索树越小。我给落点列表做一次简单启发式排序:

int moveHeuristic(int mv) { int r = mv >> 3; int c = mv & 7; if (r == 0 || r == 7 || c == 0 || c == 7) return 5; // 边缘 if (r == 1 || r == 6 || c == 1 || c == 6) return 1; // 靠近边缘 return 0; // 中心 } void sortMoves(QVector<int>& moves, const QVector<int>& board, int player) { std::sort(moves.begin(), moves.end(), [](int a, int b) { return moveHeuristic(a) > moveHeuristic(b); }); }

逻辑说明:边缘落子的价值普遍高于中心,把边缘走法排到前面,搜索时会更快遇到高价值分支,剪枝效率明显提升。这里刻意不读取棋盘内容,只按位置排序,是为了避免在排序阶段引入额外复杂度。

参数说明:难度调节的表我见过几种做法:简单模式深度 2 只比随机强一点;普通模式深度 4 响应在百毫秒级;困难模式深度 6 到 8,配合走法排序和终局快速判断。深度 6 以上,搜索节点数会指数级增长,普通桌面机上 8 层搜索往往要几秒,这时就该考虑把 AI 计算丢到工作线程里,否则界面会卡成幻灯片。

5. QT 棋盘绘制避坑与常见问题:坐标换算、信号重入和 AI 计时的五处雷区

界面部分看起来简单,其实坑最密。绘制和鼠标事件都是直接面对用户的操作,一个问题比 AI 逻辑错误更显眼。这章我把真正踩过的雷按“现象 → 原因 → 解决”写清楚,排错时照着排查即可。

5.1 用 QPainter 绘制 8×8 棋盘

棋盘绘制我放在BoardWidget的paintEvent里,用QPainter画网格和棋子:

void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); int margin = 12; int cell = qMin((width() - 2 * margin) / 8, (height() - 2 * margin) / 8); // 画棋盘网格线 for (int i = 0; i <= 8; ++i) { painter.drawLine(margin + i * cell, margin, margin + i * cell, margin + 8 * cell); painter.drawLine(margin, margin + i * cell, margin + 8 * cell, margin + i * cell); } // 画棋子 for (int r = 0; r < 8; ++r) { for (int c = 0; c < 8; ++c) { int v = board.cellAt(r, c); if (v == Board::EMPTY) continue; painter.setBrush(v == Board::BLACK ? Qt::black : Qt::white); painter.setPen(Qt::black); painter.drawEllipse(margin + c * cell + 4, margin + r * cell + 4, cell - 8, cell - 8); } } }

逻辑说明:cell由qMin(width, height)决定,保证窗口拉伸成非正方形时棋盘不变形。网格线画 9 条横线、9 条竖线,正好围出 8×8 的格子。棋子是圆,半径由cell减去内边距得出。

参数说明:margin是棋盘外边框,取值 12 像素。cell - 8是棋子的直径,减掉 8 像素是留出棋子间的空隙。这里有个最容易翻车的点:margin和cell在paintEvent里每次重算,而不是存成成员变量,否则窗口 resize 后棋子和网格会错位。

5.2 鼠标点击坐标换算成棋盘格

鼠标点击后要把像素坐标换算成row和col,再通过信号发给MainWindow:

void BoardWidget::mousePressEvent(QMouseEvent *event) { if (event->button() != Qt::LeftButton) return; int margin = 12; int cell = qMin((width() - 2 * margin) / 8, (height() - 2 * margin) / 8); int col = (event->pos().x() - margin) / cell; int row = (event->pos().y() - margin) / cell; if (row < 0 || row >= 8 || col < 0 || col >= 8) return; emit cellClicked(row, col); }

逻辑说明:换算公式必须和paintEvent的margin保持一致。先减margin,再除以cell,得到的商在 0~7 范围内才是有效格子。越界判断不能省,用户点击棋盘外区域时row和col会超出范围。

我在这个函数里栽过一跤:第一次写的时候直接用event->pos().x() / cell,没有先减margin,结果点击棋盘最右列时识别到第 7 格,但点击左边框附近的空格时识别到第 0 格,视觉和落点始终差半格。这个问题不调试看不出来,因为坐标差 12 像素后,只有点击靠近格子边界时才明显。

5.3 踩坑记录:颜色常量弄反导致黑棋变白棋

现象:AI 执黑先手,落子后棋盘上显示的是白子,AI 的棋盘数据明明是黑。

原因:绘制代码里用v == Board::BLACK ? Qt::black : Qt::white,而Board::WHITE = -1,在QVector<int>初始化时默认值 0 是EMPTY,但某些分支里用了if (v != Board::EMPTY)判断,之后v == Board::BLACK和v == Board::WHITE都匹配不上,最终走了Qt::white。

解决:绘制棋子前统一用if (v == Board::BLACK) ... else if (v == Board::WHITE) ...这种显式枚举判断,不要用三元表达式把“非黑即白”当默认假设。我第一次改这个 bug 时花了二十分钟,因为代码里有两处绘制逻辑,只改了其中一个。

5.4 踩坑记录:AI 同步计算把界面卡死

现象:点击困难模式后,界面卡顿 1 到 3 秒,窗口标题显示“未响应”,鼠标点击无反馈。

原因:bestMove里的搜索直接在 UI 线程执行,AI 搜索深度 6 以上时节点数量暴增,事件循环被阻塞。

解决:用QtConcurrent::run把搜索切到后台线程,通过QFutureWatcher拿到结果后再回到 UI 线程更新棋盘。代码骨架如下:

void MainWindow::onAITurn() { aiRunning = true; QFuture<int> future = QtConcurrent::run([this]() { return ai.bestMove(board.cells, currentPlayer, aiDepth); }); QFutureWatcher<int> *watcher = new QFutureWatcher<int>(this); connect(watcher, &QFutureWatcher<int>::finished, this, [this, watcher]() { int mv = watcher->result(); watcher->deleteLater(); aiRunning = false; if (mv >= 0) applyMove(mv >> 3, mv & 7); else switchTurn(); }); watcher->setFuture(future); }

逻辑说明:aiRunning标志位必须提前置 true,防止用户在 AI 思考期间再点击棋盘触发重复搜索。watcher用deleteLater()释放,避免局部变量在 lambda 执行前被销毁。注意 lambda 捕获this时,要保证watcher在 MainWindow 存活期间存在;这里的new QFutureWatcher<int>(this)已经把生命周期挂在 MainWindow 上。

5.5 踩坑记录:信号槽重入导致一局棋自动下完

现象:用户点击落子后,AI 自动回一子,然后双方又开始连续落子,像自动对弈一样停不下来。

原因:applyMove里调用了switchTurn,switchTurn内部又发了一个turnChanged信号,槽函数里再次调用onCellClicked,一个信号链把整局棋驱动到了终局。

解决:把“走棋流程”和“回合切换”拆开。onCellClicked里只负责验证、落子、更新界面,然后显式调用一次switchTurn;switchTurn返回后判断是否有棋可下,有则继续等用户或 AI 动作,而不是在槽函数里递归触发自己的点击处理。核心是避免在槽函数内部发出触发同一个槽的信号。

5.6 踩坑记录:悔棋栈在重开一局时没清空

现象:点击“重新开始”后,第一次悔棋突然恢复到上一局的残局棋盘,而不是本局开局状态。

原因:悔棋功能用QVector<QVector<int>> history保存历史棋盘,但重开时只调用了board.reset(),没有history.clear()。

解决:重开一局时先清空历史栈,再重置棋盘。这属于典型的“界面状态和逻辑状态不同步”问题。我习惯把悔棋栈放在Board类里而不是MainWindow,因为棋盘的快照本来就是棋盘自己的责任,放在窗口里很容易在重开时漏清理。

6. 验证技巧:固定种子自对弈回归,改 AI 前先跑一轮哈希比对

AI 类代码改起来最怕“看着合理,一跑变弱”。评估函数的权重、搜索深度的调整,经常是这里加 10、那里减 5,最后整体行为大变。我现在的做法是给项目加一个无界面自对弈模式,用固定随机种子跑完整局,然后把所有落子记录合成一个哈希值,作为行为指纹。

#include <QCryptographicHash> #include <QRandomGenerator> QByteArray runFixedGame(quint32 seed) { QRandomGenerator rng(seed); QVector<int> board(64, Board::EMPTY); int turn = Board::BLACK; QStringList record; // 手动设置开局 board[3 * 8 + 3] = Board::BLACK; board[3 * 8 + 4] = Board::WHITE; board[4 * 8 + 3] = Board::WHITE; board[4 * 8 + 4] = Board::BLACK; while (true) { QVector<int> moves = getLegalMoves(board, turn); if (moves.isEmpty()) { turn = -turn; if (getLegalMoves(board, turn).isEmpty()) break; record << "PASS"; continue; } int mv = moves.at(rng.bounded(moves.size())); flipDiscs(board, mv >> 3, mv & 7, turn); int r = mv >> 3; int c = mv & 7; record << QString("%1%2").arg(r).arg(c); turn = -turn; } QByteArray joined = record.join(",").toUtf8(); return QCryptographicHash::hash(joined, QCryptographicHash::Md5); }

逻辑说明:这个函数不依赖 Qt 界面,只依赖棋盘规则和合法落子逻辑,所以可以直接在纯命令行环境跑。记录里包含每一步的落子坐标和 pass 标记,哈希后得到一局棋的指纹。改代码后跑同一批种子,如果哈希不一致,说明规则实现行为已经变化。

配合一个简单的回归指标,可以判断 AI 强度是否退化:

测试项方法通过标准
规则一致性固定 20 个种子,前后哈希比对哈希一致
AI 强度对比改前 AI 对改后 AI 各执黑白各下 10 盘新 AI 胜率不低于 45%
搜索稳定性固定局面下记录第一手落点连续 3 次结果相同

这比凭感觉调参可靠得多。从那以后我每次改评估函数或剪枝逻辑,都会强制先跑一遍固定种子自对弈,确认规则行为没被带偏,再拿两个版本对弈看胜率,确认强度没掉才提交。这个习惯救过我很多次,希望你也能用上。

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

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

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

立即咨询