简介:一份基于Qt框架实现的黑白棋(翻转棋)完整游戏源码包,面向初步接触Qt与C++游戏开发的读者,可用于学习事件驱动编程、界面布局、棋子翻转与胜负判定等核心逻辑。压缩包共三十个文件,大小约一点四兆字节,主要包含六个C++源文件与三个头文件,以及Qt工程配置文件、界面描述文件、图片资源、编译中间产物和构建脚本,目录结构清晰,便于对照代码理解运行效果。已有二百三十六人浏览学习。工程中可读到棋盘二维数组存储、落子合法性判断、翻转棋子算法、回合切换和结束检测等典型实现,同时涉及Qt界面组件使用和资源文件加载方式。源码按模块拆分为主程序、棋盘、棋子及游戏逻辑类,适合通过修改界面或扩展规则巩固面向对象设计与图形界面开发能力,也可作为后续开发其他棋类游戏的起点。
1. 基于Qt的黑白棋游戏源码:一份能编译、能跑通、能改着玩的Qt练手工程
“基于Qt的黑白棋游戏源码.zip”大概是很多初学者下载的第一份Qt游戏工程:一个实现了黑白棋(翻转棋,Othello)的完整项目,里面既有游戏规则逻辑,也有GUI界面和棋子绘制代码,还附了运行截图。这份资源最大的价值在于,它把事件驱动、窗口绘制、规则算法放在同一个工程里,你改一行翻转逻辑,界面上立刻能看到效果,反馈非常直接。适合两类人:一类是刚学完C++、想接触Qt但缺完整项目练手的初学者;另一类是想快速搭棋类游戏原型、不想从零写界面的开发者。如果你只想要一个双击就能跑的exe,这份源码反而不适合——它需要你自己配置编译环境。
2. 先盘包:源码、截图和那个“乱入”的谷歌地图压缩包
拿到压缩包,我习惯先解压看一遍文件分布,再决定怎么打开工程。从包里的文件名能看出,作者把运行截图、流程说明和源码分开放了,这对后来的使用者很友好:你不用先编译,光看图就能知道这个程序做完是什么样。
| 文件/目录 | 用途 |
|---|---|
| 界面.png | 棋盘界面的运行截图 |
| 流程.png | 游戏整体流程的截图 |
| 结束.png | 终局画面截图 |
| 捕获.JPG | 运行过程中的画面捕捉 |
| 源代码 Othello | 工程源码目录,包含 .pro、.cpp、.h |
| 基于qt的谷歌地图.zip | 与黑白棋无关,疑似打包时混入 |
2.1 几张截图暴露的信息:界面、流程和结束态
先看界面.png,能看到一个标准的 8×8 棋盘,中间已有四颗初始棋子(两黑两白交错放置),左右或底部通常有一块状态区域显示当前轮到哪一方、双方棋子数。这对后面的代码阅读很有用:你看到界面上有哪些元素,就知道源码里大概有哪些类、哪些控件。
流程.png一般是回合切换或落子流程的示意图。黑白棋的流程是黑先、白后,轮到某一方时,如果没有任何合法落子位置,则自动跳过;双方都无子可落时游戏结束。如果截图里能看到“跳过”或“切换玩家”的字样,那对应代码里一定有一个判断合法落子是否存在的函数。
结束.png通常是终局弹窗,显示黑方几个、白方几个、谁获胜。从这里能反推出源码里有一个统计棋子数量的函数和一个弹出 QMessageBox 的逻辑。这几张图既是给使用者看的预览,也是给调试者看的参照物——编译运行后如果界面元素的位置不对,对比截图就能发现是哪里的边距参数写错。
2.2 源代码 Othello:目录结构、工程文件和阅读顺序
“源代码 Othello”文件夹是工程主体。按命名习惯,里面通常有一个 .pro 文件(qmake 工程文件)、main.cpp,以及若干个 .h/.cpp 对。.pro 文件里写了项目依赖了哪些 Qt 模块,比如QT += core gui widgets,这行决定了工程是在哪个 Qt 版本上编译的,也是后面配置环境时最先要看的地方。
我一般建议的阅读顺序是:先看 .pro,确认模块依赖;再看 main.cpp,看入口怎么初始化;然后看主窗口类(名字通常是 MainWindow),了解界面结构;最后看棋盘和逻辑部分,这部分往往封装成单独的类。这样读的好处是,你不会一上来就被一堆类的头文件绕晕。很多初学者拿到源码后直接双击 .pro 编译,发现报错一大堆,其实很多时候只是 Kit 选错,和代码本身没关系,后面第 5 章我会专门说。
如果 .pro 里看到TARGET后面跟着 Othello,那说明生成的 exe 会叫 Othello.exe;SOURCES和HEADERS里列出的文件,就是全部需要阅读和编译的源文件。对照这些条目去检查文件是否缺失,也比逐个猜要快。
2.3 资源文件与那个谷歌地图压缩包
包里的“基于qt的谷歌地图.zip”是一个明显的乱入文件:标题、摘要、源码目录都和谷歌地图没有任何关系。它在压缩包里大概率是作者打包时误选进去的,我的建议是直接忽略,不要解压,更不要因为好奇去打开,里面即使是完整的地图示例工程,也和当前的黑白棋源码无关,只会分散你的注意力。
这个包里的棋盘和棋子可能有两种实现方式:一种是直接用 QPainter 画网格和圆圈,不依赖外部图片;另一种是在 qrc 资源文件里放棋盘背景图、棋子 PNG,通过 QPixmap 加载。从截图看,黑白棋的棋盘形态很规整,多数教学项目会直接用 QPainter 绘制,省去图片资源路径带来的坑。但不管哪种方式,代码里一定涉及坐标换算:鼠标点击位置要换算成格子坐标,棋子绘制位置要由行列坐标算成像素坐标。这部分看第 4 章的绘制逻辑就会很清楚。
3. 黑白棋核心规则实现:翻转、合法落子与胜负判定
黑白棋规则看着简单,写代码时却有很多边界要小心:落子必须夹住至少一颗对方棋子、翻转只针对被夹住的方向、某个点合法但翻转数为 0 时不能落。先把规则在代码层面拆清楚,再去看界面会轻松得多。
3.1 棋盘表示:二维数组与八个方向向量
棋盘就是 8×8,大多数实现直接用二维数组表示,值为 0、1、2 分别代表空位、黑棋、白棋。方向向量是整份代码里复用率最高的数据,所有合法性判断、翻转逻辑都建立在它之上。
const int BOARD_SIZE = 8; // board[r][c]:0 空位,1 黑棋,2 白棋 int board[BOARD_SIZE][BOARD_SIZE]; // 八个方向:下、上、右、左、右下、右上、左下、左上 const int DIRECTIONS[8][2] = { {1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1} };用二维数组表示棋盘,优点是直观、便于调试,打印一个for循环就能把整个棋盘输出来。定义 BLACK 和 WHITE 两个常量会让代码可读性高很多:
const int EMPTY = 0; const int BLACK = 1; const int WHITE = 2;方向向量的顺序无所谓,但必须是四正向加四斜向,一共 8 个,少一个方向就会漏翻转。我习惯把BOARD_SIZE写成常量而不是到处写死 8,这样后面如果想改成 10×10 的变体棋盘,只改一行就够了。
3.2 合法落子与翻转逻辑:一次扫描、两重循环
黑白棋里“合法落子”的定义是:落在空位,且至少在一个方向上满足“紧邻的是对方棋子,继续走下去能遇到己方棋子”。判断合法和实际翻转是两件独立的事:判断时不改棋盘,翻转时才改。分开写,后面做 AI 评估时会非常方便。
bool isValidMove(int row, int col, int player) { if (board[row][col] != EMPTY) return false; int opponent = (player == BLACK) ? WHITE : BLACK; for (int d = 0; d < 8; d++) { int r = row + DIRECTIONS[d][0]; int c = col + DIRECTIONS[d][1]; // 第一步必须落在棋盘内,且是对方棋子 if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) continue; if (board[r][c] != opponent) continue; // 沿当前方向继续推进,找到己方棋子才算合法 while (true) { r += DIRECTIONS[d][0]; c += DIRECTIONS[d][1]; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (board[r][c] == EMPTY) break; if (board[r][c] == player) return true; } } return false; }这里容易写错的是循环条件的顺序:必须先判断越界,再访问board[r][c],顺序反了就会出现越界访问,在 Qt 里表现为运行时崩溃或qDebug打印一堆异常值。另一个常见错误是只检查紧邻的棋子是不是对方,却忘了继续往深处走,导致中间隔着多颗棋子时判定错误。
翻转逻辑要把“沿方向数出多少颗对方棋子”和“逐颗翻过来”两步分开:
void flipDiscs(int row, int col, int player) { int opponent = (player == BLACK) ? WHITE : BLACK; for (int d = 0; d < 8; d++) { int r = row + DIRECTIONS[d][0]; int c = col + DIRECTIONS[d][1]; int count = 0; // 第一步:数这个方向上连续有多少颗对方棋子 while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == opponent) { r += DIRECTIONS[d][0]; c += DIRECTIONS[d][1]; count++; } // 只有终点是己方棋子且 count > 0,才说明这段被夹住了 if (count > 0 && r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == player) { // 第二步:沿原路退回,逐颗改为己方颜色 for (int i = 0; i < count; i++) { r -= DIRECTIONS[d][0]; c -= DIRECTIONS[d][1]; board[r][c] = player; } } } }把合法判断和翻转分开还有个好处:在 GUI 里可以先用合法判断遍历棋盘,把所有可落子点标记出来显示给玩家,等玩家点击某个点后,再走一遍flipDiscs真正翻转棋子。很多带“提示可落子位置”的黑白棋程序都是这个结构。
3.3 无子可下的 pass 与终局判定
黑白棋的跳过规则常被新手忽略:某方没有合法位置时自动跳过,但对方可能还有位置,所以不能直接判负,要两边都无子可下才算终局。
bool canPlaceAnywhere(int player) { for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (isValidMove(r, c, player)) return true; } } return false; } int countDiscs(int player) { int count = 0; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board[r][c] == player) count++; } } return count; }回合切换的逻辑一般是:当前玩家落子并翻转后,切换到对手;切换前先判断对手是不是canPlaceAnywhere,如果否,则跳过对手继续判断当前玩家是否还能落子;如果双方都不能,游戏结束,用countDiscs统计比分。这个“pass”逻辑在截图流程.png 里应该能看到,代码实现时通常放在回合切换的函数里,而不是单独一个类。
如果把双方都无子可下直接当作游戏结束,代码会简单很多,但会漏掉“单方面无子可下”的中间状态。从学习角度看,完整实现 pass 才是合格的。
4. Qt界面与交互:从QPainter绘制到鼠标事件
黑白棋的界面核心就是一个可以点击的棋盘控件。Qt 里常见的做法是自定义一个继承 QWidget 的棋盘类,重写paintEvent负责绘制,重写mousePressEvent负责响应点击。整个交互逻辑围绕这两件事展开:界面负责显示状态,逻辑类负责改状态。
4.1 界面搭建:棋盘绘制、状态栏与布局
棋盘绘制用 QPainter。网格线用drawLine,棋子用drawEllipse,一个双重循环遍历二维数组,就能把棋盘画完整。关键参数是格子大小和边距,它们决定了后面鼠标坐标换算的基准。
class BoardWidget : public QWidget { protected: void paintEvent(QPaintEvent *event) override; }; void BoardWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); const int cellSize = 60; // 每个格子的像素宽高 const int offset = 20; // 棋盘左边距和上边距 // 画 9 条横线和 9 条竖线,组成 8x8 网格 for (int i = 0; i <= BOARD_SIZE; i++) { painter.drawLine(offset + i * cellSize, offset, offset + i * cellSize, offset + BOARD_SIZE * cellSize); painter.drawLine(offset, offset + i * cellSize, offset + BOARD_SIZE * cellSize, offset + i * cellSize); } // 根据棋盘数据画棋子 for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board[r][c] == EMPTY) continue; painter.setBrush(board[r][c] == BLACK ? Qt::black : Qt::white); painter.drawEllipse(offset + c * cellSize + 4, offset + r * cellSize + 4, cellSize - 8, cellSize - 8); } } }paintEvent不是主动调用的,而是由 Qt 在窗口需要重绘时触发。你改了棋盘数组后,调用update()通知 Qt 重绘,paintEvent才会执行。这里一个常见误区是直接在paintEvent里改棋盘状态,这会造成重绘和逻辑耦合,调试时很难分清是谁先改了数据。正确的结构永远是:逻辑层改数据,界面层只把数据显示出来。
主窗口这边,一般是一个 QMainWindow,中央放棋盘,底部用 QStatusBar 显示当前玩家和比分,再加一个 QLabel 或 QMenuBar 放“新游戏”入口。这类源码的界面布局差异不大,重点还是棋盘控件的两个事件函数。
4.2 鼠标点击事件:从屏幕坐标到棋盘坐标
鼠标点击后,第一件事是把像素坐标换算成行列坐标,然后交给逻辑层判断落子是否合法。
void BoardWidget::mousePressEvent(QMouseEvent *event) { const int cellSize = 60; const int offset = 20; int col = (event->pos().x() - offset) / cellSize; int row = (event->pos().y() - offset) / cellSize; // 点击落在棋盘外或空格对应的位置不合法 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; if (!isValidMove(row, col, currentPlayer)) { emit invalidMoveTriggered(row, col); // 可让状态栏提示“不能落在这里” return; } placeDisc(row, col, currentPlayer); update(); // 重绘棋盘 switchPlayer(); }坐标换算有一个隐藏细节:event->pos()返回的是相对当前控件的坐标,如果棋盘控件嵌在布局里,千万不要用event->globalPos(),否则坐标系错位后点击和实际落子位置会整体偏移。换算公式里- offset对应左边距,/ cellSize得到 0~7 的行列号,这是最标准的写法。
落子合法时,先调用逻辑层的placeDisc(内部做翻转),再update()触发重绘,最后切玩家。切玩家时要马上判断下一位是否有合法位置,没有就再跳,这就是第 3 章说的 pass 逻辑。如果你在界面上点了合法位置却发现棋子没翻转,大概率是翻转函数只在某个方向生效,可以用qDebug()把flipDiscs执行前后的棋盘打出来检查。
4.3 悔棋与提示:把游戏体验补齐
一份教学向的黑白棋源码,做到“双人对战+合法落子判断”就算完整了,但如果你要拿它做毕业设计或作品集,通常会加悔棋和可落点提示。悔棋最省事的实现是快照栈:每次落子前保存整个棋盘,悔棋时从栈里弹出一份覆盖回去。
QVector<QVector<int>> historyStack; void pushSnapshot() { QVector<int> snapshot; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { snapshot.append(board[r][c]); } } historyStack.append(snapshot); } void undoMove() { if (historyStack.isEmpty()) return; QVector<int> snapshot = historyStack.takeLast(); for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { board[r][c] = snapshot[r * BOARD_SIZE + c]; } } update(); }快照方式的空间占用极小(8×8 个 int,一次悔棋最多几千字节),但逻辑最简单,不容易把回合状态改乱。悔棋时还要记得把 currentPlayer 也回退一格,否则会出现“悔棋后轮到对手下”的怪现象。提示可落子位置则是在 paintEvent 里遍历全盘,把isValidMove为真的位置画一个半透明的圆点,代码量不大,但对局体验提升很明显。
5. 编译与运行避坑:Qt 5.15.2 + MSVC2019_64 下的拦路虎
我在拆这类源码包时最深的体会是:代码本身的问题通常半小时能定位,环境问题却能卡一整天。下面几条是我在 Windows + Qt 5.15.2 + MSVC2019_64 环境下实际踩过的坑,按“现象 → 原因 → 解决”列出来,照着排查能省很多时间。
5.1 编译报错 dependent qtwidget:Kit 选错或路径漂移
现象:用 Qt Creator 打开 .pro 后,编译信息里出现类似:-1: error: dependent '..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets' does not exist的报错,整个项目无法生成 Makefile。
原因:Qt Creator 的 Kit 配置和源码预期的不一致。报错里的相对路径表明 qmake 在按.pro所在目录向上回溯去找 Qt 安装路径,如果当前 Kit 指向的 Qt 版本不是 5.15.2 msvc2019_64,或者编译器不是 MSVC2019_64,生成 Makefile 时头文件路径就对不上。
解决:在 Qt Creator 左侧进入“项目 → Build”,把构建套件(Kit)切换到和源码匹配的那一个。如果列表里没有,先去“工具 → 选项 → Kits”添加,确保 Qt 版本选的是 5.15.2/MSVC2019_64、编译器选 MSVC2019_64,CMake 还是 qmake 无所谓,因为 .pro 走的是 qmake。切完 Kit 后,执行“构建 → 清理”,再重新“执行 qmake”,最后重新构建。三步缺一不可,只清不重建或只换 Kit 不清理都会残留旧的 Makefile。
5.2 中文注释乱码甚至报错:编码格式不统一
现象:源码压缩包里的 .h/.cpp 文件用 Qt Creator 打开后,中文注释变成乱码,有时候连QString::fromLocal8Bit之类的字符串都显示异常,极端情况下会报C2001之类的预处理错误。
原因:源码是在 GBK 编码环境下编写的,而 Qt Creator 默认按 UTF-8 读取,两边编码不一致。这不是源码本身的 bug,是环境差异。
解决:用 Qt Creator 打开乱码文件后,菜单“编辑 → 选择编码”,重新载入时选择 GBK/GB2312,确认乱码恢复后,“编辑 → 编码 → 保存为 UTF-8”,把文件统一转成 UTF-8。注意整个工程的所有 .h/.cpp 都要转,混着来会出现一部分正常一部分乱码。从那以后我拿到中文源码包,第一件事就是把编码全转成 UTF-8,再开始读代码。
5.3 Debug 正常 Release 闪退:变量未初始化
现象:Debug 模式编译运行一切正常,切到 Release 模式后,点击棋盘或新游戏时程序直接闪退,而且报错位置不固定,有时在翻转棋子时,有时在切换玩家时。
原因:Release 模式下变量未初始化的问题会被放大。棋盘数组如果在声明时没有清零,Debug 模式可能碰巧读到 0,Release 模式下就可能是随机值,导致board[r][c]拿到异常内容,进而让翻转循环越界。这种闪退是 Qt 崩溃里最难定位的一类,因为报错栈往往指向 Qt 内部绘图函数。
解决:在棋盘类的构造函数里显式清零。memset(board, 0, sizeof(board))或者用双重循环把所有格子赋为 EMPTY,再设置初始四子。凡是成员变量,初始化列表里都给一个确定值,不要依赖编译器默认行为。如果已经闪退,切到 Debug 编译并开“Qt 调试器”运行,崩溃栈会给出更具体的位置。
5.4 程序能跑但棋子不显示:QPainter 绘制条件被跳过
现象:编译运行后网格正常,但棋盘点不上也画不出来,或者在某个状态下棋盘变空。
原因多发生在绘制逻辑里。常见的有三种:一是棋盘数组根本还没初始化成初始布局,开局四子没有 set;二是paintEvent里遍历board的循环被if (isEmpty) continue早早跳过,而isEmpty判断写反了;三是窗口尺寸比棋盘绘制区域小,内容被截掉。
解决:先在paintEvent里加qDebug() << "paint",确认重绘确实触发;再打印棋盘数组看初始四子是否在;最后检查resize出来的窗口最小尺寸,cellSize * BOARD_SIZE + offset * 2就是要保证的最小宽高。界面不显示的问题九成是数据没初始化,剩下的是坐标算错,按这个顺序排查比瞎改绘制代码有效得多。
6. 进阶:给源码加一个“贪心AI”,并验证你的改动
人工对弈跑通之后,下一步最自然的改造是加一个最简单的电脑玩家。贪心策略的思路很直接:在 AI 回合,遍历所有合法落子点,模拟落子后统计己方棋子数,选择能让自己棋子最多的一步。这个策略不擅长下黑白棋,但足够让单人调试时有个对手,也能作为以后做极大极小搜索的起点。
int evaluateMove(int row, int col, int player) { // 保存现场,模拟落子后统计分数,再还原 int backup[BOARD_SIZE][BOARD_SIZE]; memcpy(backup, board, sizeof(board)); flipDiscs(row, col, player); int score = countDiscs(player); memcpy(board, backup, sizeof(board)); return score; } void aiMove(int player) { int bestRow = -1, bestCol = -1, bestScore = -1; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (isValidMove(r, c, player)) { int score = evaluateMove(r, c, player); if (score > bestScore) { bestScore = score; bestRow = r; bestCol = c; } } } } if (bestRow >= 0) { placeDisc(bestRow, bestCol, player); qDebug() << "AI chose:" << bestRow << bestCol << "score:" << bestScore; } }改造时把原来switchPlayer之后轮到玩家手动落子的逻辑改成分支:如果当前玩家是 AI,就调用aiMove,然后让 Qt 用QTimer::singleShot延迟几百毫秒再切换,避免界面瞬间跳变。验证方式很简单:在aiMove里加一行qDebug输出每次选择的位置和分数,手动走一遍有多个合法点的局面,看它是否真的选了翻转数最多的那一步;再连续跑十几局,确认双方都无法落子时游戏能正常结束。
我当时拿到这份源码后做的一个小实验是给贪心 AI 加了一个边角偏好:分数相同时优先选四个角的位置。改动只有十几行,但胜率立刻有了肉眼可见的提升,也让我第一次感受到黑白棋的“位置价值”远比单纯翻转数重要。从那以后,我每次拿到别人给的 Qt 源码,都不会急着双击 .pro 去编译,而是先读一遍工程文件、统一编码、配好 Kit,确认环境再动手,这个习惯帮我挡掉了至少一半的编译坑。希望你也能借这份代码把 Qt 的事件驱动和绘制逻辑真正跑通,再根据自己的想法去改它,希望帮到你。
本文还有配套的精品资源,点击获取