C++ Qt 黑白棋实战:从棋盘逻辑到AI搜索的完整工程解析 简介这是一份面向C与Qt初学者的黑白棋完整项目源码适合想通过小游戏实践GUI开发、巩固算法与数据结构的学习者。压缩包共28个文件约1.12MB以cpp源文件、h头文件、o目标文件为主另含pro工程文件、ui界面文件、Makefile及可执行程序覆盖从工程配置到编译产物的完整链路。已有364人学习下载。代码围绕8×8棋盘展开包含合法落子判断、横竖斜方向棋子翻转与连锁反应处理、游戏结束判定等核心逻辑并借助QGraphicsView等类实现鼠标点击交互与界面信息展示还涉及存档加载的序列化思路和基于最小-最大搜索加Alpha-Beta剪枝的AI设计。读者可据此理解Qt信号槽与事件响应机制掌握棋盘状态更新与界面刷新配合方式并参考工程结构自行扩展动画、计分或单人模式是衔接C语法与桌面应用开发的实践素材。1. 一个棋盘类项目为什么值得拿来练手很多人第一次接触 Qt 是从计算器、记事本这类界面 Demo 开始的控件摆一摆、信号槽连一连跑起来就完事了。但真到要写一个能玩、能判断胜负、还要有 AI 对手的完整小游戏时问题就全冒出来了棋盘状态怎么存、落子合法性怎么判、翻转逻辑写在哪一层、界面和规则怎么解耦。黑白棋Reversi恰好是个体量刚好的靶子——规则不复杂但足够把「数据模型 规则引擎 界面渲染 搜索算法」这条链路完整走一遍。这份 C Qt 的黑白棋工程适合两类人一类是刚学完 C 语法、想找个能跑起来的完整项目练手的另一类是写过界面但没系统做过游戏状态管理的想看看别人怎么把棋盘逻辑和 Qt 控件拆开。下面我按「能干什么 → 怎么跑 → 代码怎么组织 → 怎么改」的顺序把这个工程拆开讲。2. 环境准备与首次编译从零把工程跑起来2.1 工具链选型与版本对齐这个工程是标准的 Qt Widgets 项目核心依赖只有 Qt 本身没有引入第三方库所以环境准备的重点是「Qt 版本 编译器」这一对组合别错配。常见做法是用 Qt 5.15 或 Qt 6.x 的 LTS 版本配套 MinGWWindows或系统自带的 GCC/ClangLinux、macOS。Qt 6 之后部分 API 有调整比如QString的一些隐式转换被收紧如果工程是按 Qt 5 写的直接拿 Qt 6 编译可能会在字符串拼接处报错这时候要么降版本要么按报错逐处补QStringLiteral。工程文件一般是.pro形式用 qmake 构建。打开后先确认QT 那一行有没有把widgets带上纯界面项目漏了这行会在链接阶段报一堆找不到QWidget的符号。我一般习惯先不急着改代码原样编译一次看能不能出可执行文件能出再谈改造出不来就先解决环境。# 用 qmake 生成 Makefile 并编译Linux/macOS 下 qmake Reversi.pro make -j4 # Windows MinGW 环境下通常是 qmake Reversi.pro mingw32-make -j4上面命令里-j4是并行编译的核数机器好可以开到 8。qmake负责读.pro生成平台相关的构建脚本make才是真正编译。如果qmake这一步就报「找不到 Qt 安装」说明 Qt 的 bin 目录没进 PATH或者该用 Qt 自带的命令行终端Qt Creator 里那个「Qt 5.x MinGW Terminal」来执行。2.2 用 Qt Creator 打开与运行配置不想折腾命令行的话直接用 Qt Creator 打开.pro文件最省事。打开后左侧「项目」面板会让你选 Kit这里要选和你安装的 Qt 版本一致的那个 Kit选错了会出现「编译通过但运行闪退」或者「找不到平台插件」的经典问题。选好 Kit 后按 CtrlR 运行第一次运行如果弹窗提示缺少Qt5Core.dll之类是因为动态库没在可执行文件旁边Qt Creator 运行时会自动处理但你要是把 exe 单独拷出去双击就得用windeployqt把依赖拷齐。# 把 exe 拷到空目录后用 windeployqt 补齐运行依赖 windeployqt Reversi.exewindeployqt会扫描 exe 的导入表把需要的 Qt DLL、平台插件platforms/qwindows.dll一并复制过来。这一步是 Windows 下分发 Qt 程序的标配漏了平台插件程序启动会直接报「This application failed to start because no Qt platform plugin could be initialized」。2.3 首次运行该看到什么编译成功后运行正常应该出现一个固定大小的棋盘窗口8×8 的格子初始四颗棋子摆在中央呈交叉状黑棋先行。鼠标悬停或点击空格子时如果该位置合法应该有落子反馈不合法则无反应或提示。如果窗口出来了但棋盘是空白多半是paintEvent里没触发重绘或者棋盘数据初始化没跑如果点击没反应先查信号槽有没有连上再查落子合法性判断是不是把所有位置都判成了非法。这两类问题在下面排查章节会展开。3. 工程结构拆解棋盘数据、规则引擎与界面如何分层3.1 目录与类的职责划分这类 Qt 小游戏工程结构上通常分三块界面层主窗口、棋盘控件、规则层棋盘状态、落子与翻转计算、AI 层搜索与评估。目录大致长这样Reversi/ ├── Reversi.pro # qmake 工程文件 ├── main.cpp # 程序入口创建 QApplication 和主窗口 ├── mainwindow.h/.cpp # 主窗口菜单、状态栏、棋盘控件容器 ├── boardwidget.h/.cpp # 棋盘控件绘制、鼠标事件、坐标换算 ├── board.h/.cpp # 棋盘数据模型8x8 状态、落子、翻转、胜负判断 └── aiplayer.h/.cpp # AI 对手合法走法枚举、极小化极大搜索、评估函数职责上Board是纯逻辑类不依赖任何 Qt 界面头文件只用一个二维数组或std::arraystd::arrayint,8,8存状态0 表示空、1 表示黑、2 表示白。BoardWidget继承QWidget负责把Board的状态画出来并把鼠标点击换算成行列坐标交给Board处理。MainWindow负责把两者拼起来管回合切换和状态栏文字。这种「逻辑不碰界面」的分层是这类项目最值得学的地方后面加 AI、加悔棋、加存档都靠它。3.2 棋盘状态的数据结构棋盘状态用定长二维数组最直接8×8 一共 64 格不需要动态扩容。用int存每格状态比用enum class在数组下标访问时少一层转换写起来顺手。关键是要把「当前轮到谁」也作为状态的一部分否则每次判断都得从外部传进来容易漏。// board.h #pragma once #include array class Board { public: static constexpr int SIZE 8; enum Cell { Empty 0, Black 1, White 2 }; Board(); void reset(); // 重置为初始四子局面 bool isValidMove(int row, int col, int player) const; bool applyMove(int row, int col, int player); // 落子并翻转非法返回 false int countPieces(int player) const; // 统计某方棋子数 bool hasAnyValidMove(int player) const; // 是否还有合法走法 int cellAt(int row, int col) const { return grid_[row][col]; } private: std::arraystd::arrayint, SIZE, SIZE grid_{}; int flipCount(int row, int col, int player, int dr, int dc) const; };grid_用std::array而不是裸数组好处是能直接值拷贝、能拿到size()调试时也更好看。Cell枚举定义在类内外部用Board::Black引用避免全局命名污染。flipCount是私有辅助函数给定方向和起点数一数这个方向能翻几颗落子合法性判断和实际翻转都复用它避免两处逻辑写岔。3.3 落子合法性判断与八方向翻转黑白棋的核心规则就一条落子后从落子点出发沿八个方向只要某一方向上出现「连续对方棋子 己方棋子」的结构中间那些对方棋子全部翻成己方。合法性判断就是「至少有一个方向能翻」。这段逻辑写错整个游戏就没法玩所以值得逐行看。// board.cpp节选 int Board::flipCount(int row, int col, int player, int dr, int dc) const { int opponent (player Black) ? White : Black; int r row dr, c col dc, count 0; while (r 0 r SIZE c 0 c SIZE grid_[r][c] opponent) { count; r dr; c dc; } // 必须越过对方棋子后落在己方棋子上才算有效 if (count 0 r 0 r SIZE c 0 c SIZE grid_[r][c] player) return count; return 0; } bool Board::isValidMove(int row, int col, int player) const { if (row 0 || row SIZE || col 0 || col SIZE) return false; if (grid_[row][col] ! Empty) return false; static const int dirs[8][2] {{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}}; for (auto d : dirs) if (flipCount(row, col, player, d[0], d[1]) 0) return true; return false; }flipCount的循环条件是「在界内且是对方棋子」一旦碰到空或己方就停。停下来的位置如果正好是己方棋子且中间至少翻了一颗count 0这个方向才有效。注意边界判断要写在循环条件里不能等越界了再补否则数组下标会越界读。dirs数组把八个方向一次性列出来比写八段重复代码清爽得多这也是这类棋盘游戏最常见的写法。applyMove在确认合法后对每个有效方向再走一遍翻转把中间棋子改色。这里有个容易翻车的点翻转必须基于「落子前」的状态判断方向有效性不能边翻边判否则刚翻过去的棋子会影响后续方向判断。稳妥做法是先收集所有要翻的坐标最后统一改。3.4 界面绘制与鼠标坐标换算BoardWidget的paintEvent负责画格子和棋子mousePressEvent负责把像素坐标换算成行列。棋盘一般画成正方形格子边长 控件宽度 / 8坐标换算就是整除。// boardwidget.cpp节选 void BoardWidget::paintEvent(QPaintEvent*) { QPainter p(this); int cell qMin(width(), height()) / Board::SIZE; for (int r 0; r Board::SIZE; r) for (int c 0; c Board::SIZE; c) { QRect rect(c * cell, r * cell, cell, cell); p.fillRect(rect, QColor(34, 139, 34)); // 棋盘绿底 p.setPen(Qt::black); p.drawRect(rect); int v board_-cellAt(r, c); if (v ! Board::Empty) { p.setBrush(v Board::Black ? Qt::black : Qt::white); p.drawEllipse(rect.adjusted(4, 4, -4, -4)); // 留边距画圆 } } } void BoardWidget::mousePressEvent(QMouseEvent* e) { int cell qMin(width(), height()) / Board::SIZE; int c e-pos().x() / cell; int r e-pos().y() / cell; emit cellClicked(r, c); // 交给主窗口处理回合与合法性 }rect.adjusted(4,4,-4,-4)是给棋子留边距不然圆会贴着格子边视觉上很挤。cellClicked用信号抛出去而不是在控件里直接改棋盘这样控件只负责「报告点了哪」回合切换、AI 响应都放在主窗口职责清晰。qMin(width(), height())保证控件被拉成非正方形时棋盘仍然按短边算不会画歪。4. AI 对手与搜索让机器会下棋的那几十行4.1 合法走法枚举与评估函数AI 的第一步是把当前局面下所有合法走法列出来这一步直接复用Board::isValidMove遍历 64 格即可。第二步是给每个走法打分最朴素的是「棋子数差」但黑白棋里棋子多不一定是好事角位价值极高、边位次之、靠近角的星位是陷阱。常见做法是给每个格子一个权重落子得分 权重 翻转收益。// aiplayer.cpp节选 static const int WEIGHT[8][8] { { 100, -20, 10, 5, 5, 10, -20, 100}, { -20, -50, -2, -2, -2, -2, -50, -20}, { 10, -2, 1, 1, 1, 1, -2, 10}, { 5, -2, 1, 1, 1, 1, -2, 5}, { 5, -2, 1, 1, 1, 1, -2, 5}, { 10, -2, 1, 1, 1, 1, -2, 10}, { -20, -50, -2, -2, -2, -2, -50, -20}, { 100, -20, 10, 5, 5, 10, -20, 100} }; int AI::evaluate(const Board b, int player) const { int score 0; for (int r 0; r Board::SIZE; r) for (int c 0; c Board::SIZE; c) { int v b.cellAt(r, c); if (v player) score WEIGHT[r][c]; else if (v ! Board::Empty) score - WEIGHT[r][c]; } return score; }权重表里四个角是 100因为角一旦占住就翻不掉角旁边的格子是 -20 到 -50因为占了角旁等于把角送给对方。这张表是黑白棋 AI 的经典起点数值可以调但结构基本就这样。evaluate返回的是「站在 player 视角的净分」搜索时要在极大极小之间取反符号别搞混。4.2 极小化极大搜索与深度控制有了评估函数就可以做极小化极大搜索AI 走一步假设对手也走最优往下推若干层取对自己最有利的分。层数越深越强但耗时指数增长8×8 棋盘分支因子在开局能有十几深度 4 到 5 是性能和棋力的平衡点。// aiplayer.cpp节选 int AI::minimax(Board board, int depth, int player, bool maximizing, int aiColor) { if (depth 0 || !board.hasAnyValidMove(Board::Black) !board.hasAnyValidMove(Board::White)) return evaluate(board, aiColor); int best maximizing ? INT_MIN : INT_MAX; for (int r 0; r Board::SIZE; r) for (int c 0; c Board::SIZE; c) { if (!board.isValidMove(r, c, player)) continue; Board next board; // 值拷贝避免污染当前层 next.applyMove(r, c, player); int val minimax(next, depth - 1, 3 - player, !maximizing, aiColor); best maximizing ? qMax(best, val) : qMin(best, val); } return best; }Board next board这里用值拷贝是关键std::array让拷贝成本可控64 个 int比手动回溯翻转状态安全得多。3 - player是黑白互换的小技巧1 和 2 相加为 3。maximizing标志表示当前层是 AI 还是对手AI 层取最大、对手层取最小。深度设太大在开局会明显卡顿实际工程里一般配合 alpha-beta 剪枝把明显劣势的分支提前砍掉这里为了讲清主干先不展开。4.3 把 AI 接进回合流程AI 算完后要落子落子后要触发界面重绘和回合切换。常见做法是在主窗口里判断「当前回合是白方且白方是 AI」就调 AI 算一步拿到坐标后走和人类玩家一样的落子路径保证逻辑只有一份。// mainwindow.cpp节选 void MainWindow::onCellClicked(int r, int c) { if (gameOver_ || current_ ! Board::Black) return; // 人类固定执黑 if (!board_.applyMove(r, c, Board::Black)) return; boardWidget_-update(); switchTurn(); if (!gameOver_) QTimer::singleShot(300, this, MainWindow::aiMove); } void MainWindow::aiMove() { QPoint move ai_.chooseMove(board_, Board::White); if (move.x() 0) { board_.applyMove(move.x(), move.y(), Board::White); boardWidget_-update(); } switchTurn(); }QTimer::singleShot(300, ...)是给 AI 一个 300 毫秒的延迟不然 AI 瞬间落子人眼反应不过来体验很差。chooseMove返回QPoint(-1,-1)表示无棋可走这时要跳过该方回合而不是判负。switchTurn里还要处理「一方无子可下则对方继续」的规则这块漏了会出现双方都卡住不动的情况。5. 避坑与排查编译能过但玩起来不对的几种情况5.1 点击格子没反应现象是窗口正常、棋盘正常但鼠标点下去毫无反馈。原因通常有两个一是mousePressEvent里坐标换算用了控件全宽而不是棋盘实际边长导致算出的行列越界被isValidMove直接拒掉二是信号cellClicked没连到主窗口的槽或者连了但槽里第一行就return了。解决方式是先在mousePressEvent里打印r、c确认坐标落在 0 到 7 之间再检查connect那行有没有拼错信号名。Qt 5 之后推荐用函数指针形式的connect拼错会在编译期报错比字符串形式安全。5.2 落子后棋子翻转方向不对现象是能落子但翻的棋子位置和预期不符或者该翻的没翻。原因多半是flipCount和实际翻转用了两套方向判断或者翻转时基于「已修改」的棋盘继续判断。血泪经验是先把所有要翻的坐标收集到一个std::vectorQPoint全部收集完再统一改色中间不要动grid_。另外八个方向的dr、dc别写反{-1,0}是向上、{0,-1}是向左写反了翻转会跑到对称位置去。5.3 AI 一步要算好几秒甚至卡死现象是轮到 AI 时界面冻结鼠标点不动。原因是极小化极大在开局分支多、深度又设得大纯递归没有剪枝耗时爆炸。解决分两步先把深度降到 3 到 4 验证功能再加 alpha-beta 剪枝如果还慢就在搜索前先按权重给走法排序优先搜高分走法剪枝效率会高很多。另外搜索函数里传Board用值拷贝是安全的但别在循环里反复构造大对象std::array的拷贝成本可以接受换成std::vector动态分配就会明显拖慢。5.4 一方无棋可走时游戏卡住现象是某一方明明还有空格但轮到它时没有任何合法走法程序既不跳过也不结束界面就停在那。原因是回合切换逻辑只做了「黑变白、白变黑」没处理「对方无合法走法则继续本方」的规则。正确做法是切换回合后检查新回合方hasAnyValidMove没有就再切一次如果双方都没有才判游戏结束并按棋子数定胜负。这个判断要放在每次落子之后统一做别散落在各个分支里。5.5 换台机器运行报缺少平台插件现象是在自己机器上跑得好好的拷到别人电脑双击就弹「no Qt platform plugin could be initialized」。原因是只拷了 exe没拷platforms/qwindows.dll和 Qt 核心 DLL。解决是用windeployqt处理一遍或者手动把 Qt 安装目录下plugins/platforms整个文件夹和几个核心 DLL 放到 exe 同级目录。注意platforms文件夹名不能改Qt 是按固定路径找插件的。6. 二次开发从能玩到好玩我一般改这几处拿到这个工程后最值得先动的是 AI 的棋力因为它决定了这个项目是「玩具」还是「能跟人下几盘」的东西。我的习惯是先加 alpha-beta 剪枝改动集中在minimax函数多传两个参数alpha、beta在最大化层更新alpha、最小化层更新beta一旦alpha beta直接返回。这一步通常能把同样深度下的节点数砍掉一大半深度从 4 提到 6 也不至于卡死。int AI::minimax(Board board, int depth, int player, bool maximizing, int aiColor, int alpha, int beta) { if (depth 0) return evaluate(board, aiColor); int best maximizing ? INT_MIN : INT_MAX; for (int r 0; r Board::SIZE; r) for (int c 0; c Board::SIZE; c) { if (!board.isValidMove(r, c, player)) continue; Board next board; next.applyMove(r, c, player); int val minimax(next, depth - 1, 3 - player, !maximizing, aiColor, alpha, beta); if (maximizing) { best qMax(best, val); alpha qMax(alpha, best); } else { best qMin(best, val); beta qMin(beta, best); } if (beta alpha) return best; // 剪枝 } return best; }剪枝的收益在开局最明显因为开局分支多、很多走法一眼就差。加完之后建议做个简单验证固定一个开局局面分别用剪枝前后跑深度 4对比返回的最佳走法是否一致、耗时差多少。如果走法变了但棋力没降说明剪枝写对了如果走法变得很离谱多半是alpha、beta的更新方向搞反了。第二处值得改的是悔棋。因为Board是值类型悔棋最简单的实现是在主窗口维护一个std::vectorBoard栈每次落子前把当前局面压栈悔棋时弹栈覆盖。这比记录每一步的翻转坐标再逆操作要省心得多代价是每步多存 64 个 int对这个体量完全无所谓。第三处是存档把grid_和当前回合序列化成一个文本文件读档时反序列化回来格式用「8 行每行 8 个数字 一行回合」就够不用上 JSON 库。改 AI 这块我踩过最深的坑是评估函数的符号。有一版我把对手棋子的权重也按正分累加结果 AI 疯狂送子因为它的评估里「对方棋子多」反而成了好事。从那以后我每次改评估函数都强制拿一个已知局面的手算分数对一遍确认 AI 视角和对手视角的符号没反再往下调权重。希望这些能帮到你少走几段弯路。本文还有配套的精品资源点击获取