新闻详情

新闻详情

首页 / 资讯中心 / 详情

Qt黑白棋小游戏:博弈AI与Alpha-Beta剪枝的实现

发布时间:2026/9/29 16:48:00来源:尧图网络
Qt黑白棋小游戏:博弈AI与Alpha-Beta剪枝的实现
简介这是一份基于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()、信号cellClickedMainWindowMainWindow.h / MainWindow.cpp回合控制、悔棋、胜负弹窗onCellClicked()、switchTurn()这样分层的核心原因是“把界面和逻辑切开”。BoardWidget里绝对不出现翻转棋子的代码AI里不碰任何QPainter对象。如果你看到一份代码把绘制和游戏规则写在同一个类里短期跑着没事后面加悔棋、加 AI 线程、加动画时一定会到处打补丁。2.3 棋盘数据用一维 QVector 还是二维数组棋盘存储我选一维QVectorint长度固定 64。对比二维数组int board[8][8]一维表示的优势体现在 AI 部分搜索树里每一层都要复制棋盘状态QVectorint的复制语义天然支持值拷贝传给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; QVectorint 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 棋盘或者加入坐标缓存只需要改这一个函数。参数说明QVectorint cells的元素取值只有三个0、1、-1。注意QVectorint的默认构造不会自动填充长度所以构造函数里必须显式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 QVectorint 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 的索引收集起来QVectorint getLegalMoves(const QVectorint board, int player) { QVectorint 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(QVectorint 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(QVectorint board, int* currentPlayer) { QVectorint 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 QVectorint 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 QVectorint 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(QVectorint board, int player, int depth, int alpha, int beta) { if (depth 0) { return evaluateBoard(board, player); } QVectorint 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) { QVectorint 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 QVectorint board, int player, int maxDepth) { QVectorint 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) { QVectorint 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(QVectorint moves, const QVectorint 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再通过信号发给MainWindowvoid 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在QVectorint初始化时默认值 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; QFutureint future QtConcurrent::run([this]() { return ai.bestMove(board.cells, currentPlayer, aiDepth); }); QFutureWatcherint *watcher new QFutureWatcherint(this); connect(watcher, QFutureWatcherint::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 QFutureWatcherint(this)已经把生命周期挂在 MainWindow 上。5.5 踩坑记录信号槽重入导致一局棋自动下完现象用户点击落子后AI 自动回一子然后双方又开始连续落子像自动对弈一样停不下来。原因applyMove里调用了switchTurnswitchTurn内部又发了一个turnChanged信号槽函数里再次调用onCellClicked一个信号链把整局棋驱动到了终局。解决把“走棋流程”和“回合切换”拆开。onCellClicked里只负责验证、落子、更新界面然后显式调用一次switchTurnswitchTurn返回后判断是否有棋可下有则继续等用户或 AI 动作而不是在槽函数里递归触发自己的点击处理。核心是避免在槽函数内部发出触发同一个槽的信号。5.6 踩坑记录悔棋栈在重开一局时没清空现象点击“重新开始”后第一次悔棋突然恢复到上一局的残局棋盘而不是本局开局状态。原因悔棋功能用QVectorQVectorint history保存历史棋盘但重开时只调用了board.reset()没有history.clear()。解决重开一局时先清空历史栈再重置棋盘。这属于典型的“界面状态和逻辑状态不同步”问题。我习惯把悔棋栈放在Board类里而不是MainWindow因为棋盘的快照本来就是棋盘自己的责任放在窗口里很容易在重开时漏清理。6. 验证技巧固定种子自对弈回归改 AI 前先跑一轮哈希比对AI 类代码改起来最怕“看着合理一跑变弱”。评估函数的权重、搜索深度的调整经常是这里加 10、那里减 5最后整体行为大变。我现在的做法是给项目加一个无界面自对弈模式用固定随机种子跑完整局然后把所有落子记录合成一个哈希值作为行为指纹。#include QCryptographicHash #include QRandomGenerator QByteArray runFixedGame(quint32 seed) { QRandomGenerator rng(seed); QVectorint 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) { QVectorint 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 次结果相同这比凭感觉调参可靠得多。从那以后我每次改评估函数或剪枝逻辑都会强制先跑一遍固定种子自对弈确认规则行为没被带偏再拿两个版本对弈看胜率确认强度没掉才提交。这个习惯救过我很多次希望你也能用上。本文还有配套的精品资源点击获取
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Elasticsearch AI Indices 实战:让 agents 用 ES|QL 保留答案,无需通读内容 2026/9/29 20:33:33

Elasticsearch AI Indices 实战:让 agents 用 ES|QL 保留答案,无需通读内容

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Towards Low-Resource StarGAN Voice Conversion using Weight Adaptive Instance Norm:TaoToken 统一 Key 接入 2026/9/29 20:33:33

Towards Low-Resource StarGAN Voice Conversion using Weight Adaptive Instance Norm:TaoToken 统一 Key 接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
AI辅助论文写作与知网查重:合规使用工具和流程详解 2026/9/29 20:33:33

AI辅助论文写作与知网查重:合规使用工具和流程详解

我收到很多同学问同一个问题:"有没有那种导师不会主动教,但能让我在知网查重里零痕迹的AI神器?" 说实话,第一次看到这个问题我心里挺矛盾的。AI辅助写论文是完全正当的事,我也一直在用;但把"…

阅读更多 →
C#接入ActiveMQ实战:NMSActiveMQ实现消息队列收发与持久化 2026/9/29 20:33:33

C#接入ActiveMQ实战:NMSActiveMQ实现消息队列收发与持久化

简介:ActiveMQ是一款成熟可靠的开源消息中间件,在企业级异步通信、系统解耦与流量削峰等场景中被广泛使用。这套基于C#语言、采用WinForm开发的演示程序,提供了消息发送端与接收端的完整实现,涵盖生产者、消费者、界面交互等核心模…

阅读更多 →
LangGraph 实战:用 StateGraph 编排带分支与循环的 Agent 2026/9/29 20:33:33

LangGraph 实战:用 StateGraph 编排带分支与循环的 Agent

1. 从"能跑通"到"能编排":LangGraph 到底补了哪块短板很多人第一次接触 Agent 开发,都是从 LangChain 的AgentExecutor起步的。写个提示词、挂两个工具、跑一个while循环,看起来就"智能"了。但真把它放到稍微复…

阅读更多 →
DeepSeek V4 接入 Vscode 中方法:TaoToken 统一 Key 配置 settings.json 骨架与验证 2026/9/29 20:33:26

DeepSeek V4 接入 Vscode 中方法:TaoToken 统一 Key 配置 settings.json 骨架与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉