ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++ Qt 黑白棋实战:从棋盘逻辑到AI搜索的完整工程解析

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 版本,配套 MinGW(Windows)或系统自带的 GCC/Clang(Linux、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 后按 Ctrl+R 运行,第一次运行如果弹窗提示缺少Qt5Core.dll之类,是因为动态库没在可执行文件旁边,Qt Creator 运行时会自动处理,但你要是把 exe 单独拷出去双击,就得用windeployqt把依赖拷齐。

# 把 exe 拷到空目录后,用 windeployqt 补齐运行依赖 windeployqt Reversi.exe

windeployqt会扫描 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::array<std::array<int,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::array<std::array<int, 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::vector<QPoint>,全部收集完再统一改色,中间不要动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::vector<Board>栈,每次落子前把当前局面压栈,悔棋时弹栈覆盖。这比记录每一步的翻转坐标再逆操作要省心得多,代价是每步多存 64 个 int,对这个体量完全无所谓。第三处是存档,把grid_和当前回合序列化成一个文本文件,读档时反序列化回来,格式用「8 行每行 8 个数字 + 一行回合」就够,不用上 JSON 库。

改 AI 这块我踩过最深的坑是评估函数的符号。有一版我把对手棋子的权重也按正分累加,结果 AI 疯狂送子,因为它的评估里「对方棋子多」反而成了好事。从那以后我每次改评估函数,都强制拿一个已知局面的手算分数对一遍,确认 AI 视角和对手视角的符号没反,再往下调权重。希望这些能帮到你,少走几段弯路。

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

返回列表