简介基于Qt实现的黑白棋翻转棋完整项目源码面向正在学习Qt框架、C游戏开发的初学者也适合需要快速搭建棋类游戏原型的开发者。压缩包共30个文件其中6个cpp与3个h构成核心代码qrc和ui文件负责管理界面与素材资源5张png、3张jpg提供棋盘、棋子及界面配图编译生成的o文件、makefile与可执行程序便于验证运行效果整体约1.4MB。项目实现了玩家轮流落子、合法位置判断、翻转棋子、胜负判定等核心逻辑棋盘状态借助二维数组表示并通过事件响应驱动界面更新可对照学习Qt事件处理、GUI布局、对象封装、资源加载与面向对象设计。已有235人学习下载压缩包内从工程结构、资源引用到交互实现均有清晰脉络适合作为理解Qt游戏开发流程的实践案例也可在此基础上扩展AI对手、联机对战或界面美化。1. 黑白棋源码包先分清哪些能用、哪些是干扰项黑白棋源码包听起来像个玩具但在 Qt 学习路径上它是最容易被低估的项目之一。压缩包里真正的核心是 Othello 源码目录界面截图只是辅助另外那个“基于 qt 的谷歌地图.zip”是干扰项不要被带偏。黑白棋又叫翻转棋规则一句话能讲清双方轮流落子只要在横、竖、斜八个方向能夹住对手棋子就全部翻转成己方棋盘下满或者双方都无处落子时谁的子多谁赢。这份源码把规则落到 Qt 界面里同时覆盖了 GUI 事件、二维数组、绘图和资源加载适合刚学完 C 基础、想看看 Qt 项目怎么组织的人有开发经验的人也能拿它当原型快速改出其他棋类。如果你正在找一份能直接编译的 Qt 练手项目这份包是能看能改的起点。2. 拆解 Qt 黑白棋工程pro 配置、界面类与棋盘数据结构2.1 包里到底放了什么Othello 目录、截图和那个地图压缩包解压后先别急着点开某个.cpp我建议先把目录结构扫一遍。这类工程通常由三个组成部分构成下面用表格说明包内对象类型作用Othello源码目录真正要打开的工程包含.cpp、.h和.pro文件截图/捕获.JPG图片运行效果截屏帮助你确认预期界面截图/结束.png图片终局画面能看到胜负弹窗和最终比分截图/界面.png图片棋盘初始界面截图/流程.png图片游戏状态流程便于理解回合切换基于qt的谷歌地图.zip压缩包与黑白棋无关应该是误打包进来的干扰项直接忽略如果你的习惯和我一样拿到包第一件事是找.pro文件。它相当于 qmake 工程的控制中心描述了这个项目用到哪些源文件、依赖哪些模块、目标程序叫什么。典型内容长这样QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET Othello TEMPLATE app SOURCES main.cpp \ widget.cpp \ boardwidget.cpp HEADERS widget.h \ boardwidget.h RESOURCES resources.qrc这里QT core gui声明使用核心和非 GUI 模块greaterThan(...)这一行是为了兼容 Qt4在 Qt5 和 Qt6 下会额外追加widgets模块。TARGET决定生成的 exe 文件名TEMPLATE app表示这是应用程序而不是库。SOURCES和HEADERS是编译单元列表RESOURCES指向资源集合文件.qrc。如果编译时报找不到某个模块十有八九是这里的声明和你的 Qt 版本不匹配。要注意的是.qrc文件里保存的是资源路径和别名。常见做法是把图片放在/images前缀下代码里用:/images/board.png访问。解压包里既然有截图说明项目作者至少设计了完整界面棋盘图片或棋子图片大概率通过资源文件加载而不是每次用 QPainter 硬画。2.2 界面方案先看懂QWidget 手绘棋盘还是 QGraphicsView 场景黑白棋棋盘是固定 8×8 网格做界面的方式常见有两种继承QWidget重写paintEvent或者用QGraphicsView加QGraphicsScene搭场景。这两种方案在源码里一眼就能分辨前者会有paintEvent(QPaintEvent*)和QPainter后者会创建QGraphicsScene并调用addEllipse()添加棋子。黑白棋最多 64 个格子性质上属于低交互量、低绘制量的界面用QGraphicsView反而显得重。教学项目通常走QWidget路线理由很实在一个paintEvent就能把网格和所有棋子画完鼠标事件直接用mousePressEvent处理没有场景、图元、变换矩阵这些额外概念排查问题时定位更快。如果源码里出现了QGraphicsView那重点要学习的是场景坐标系到视图坐标系的映射这是另一种训练。无论哪种方案画网格的 QPainter 代码逻辑是相通的。我通常会这样画棋盘void BoardWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿棋子边缘更平滑 int side qMin(width(), height()); // 取长宽较小值保证棋盘正方形 int cell side / 8; // 每个格子的像素边长 for (int i 0; i 8; i) { p.drawLine(i * cell, 0, i * cell, 8 * cell); p.drawLine(0, i * cell, 8 * cell, i * cell); } }参数说明qMin(width(), height())是常见做法它避免窗口被拉成长方形后棋盘变形cell由棋盘总边长除以 8 算出后面鼠标坐标换行列时还要用到它循环从 0 到 8 画 9 条线正好组成 8×8 的边界。如果你要把棋盘底色改成墨绿色在画线之前用p.fillRect(rect(), QColor(0, 128, 100))即可。鼠标点击换行列也是这节不可回避的细节。事件对象返回的坐标是相对当前 Widget 的左上角不是屏幕坐标。换算公式是int row event-y() / cellSize; int col event-x() / cellSize; if (row 0 || row 8 || col 0 || col 8) return;event-y()是鼠标点到 Widget 顶部的像素距离除以每个格子的高度就得到行索引。这里必须加边界判断因为窗口边缘可能超出棋盘区域尤其是窗口大小不是正方形时直接按除法结果访问数组会越界。2.3 棋盘状态怎么存二维数组与棋子颜色枚举棋盘是典型的二维网格最常见的存储方式是int board[8][8]或QVectorQVectorint。为了代码可读性我建议定义枚举而不是裸用 0、1、2enum Piece { EMPTY 0, BLACK 1, WHITE 2 }; Piece m_board[8][8];为什么用二维数组而不是一维数组因为黑白棋的翻转规则要沿八个方向递归扫描坐标计算写成row dr、col dc最直观。一维数组也能写但每个方向变换都要额外做一次行列与索引的互换算代码绕且容易错。8×8 的规模非常小二维数组的性能完全够用没有必要为节省 64 个元素的存储空间牺牲可读性。初始化棋盘也有固定套路。黑白棋开局是棋盘正中央摆四颗子交叉排列for (int r 0; r 8; r) for (int c 0; c 8; c) m_board[r][c] EMPTY; m_board[3][3] WHITE; m_board[3][4] BLACK; m_board[4][3] BLACK; m_board[4][4] WHITE;注意数组索引从 0 开始所以中央四个位置是 (3,3)、(3,4)、(4,3)、(4,4)。如果你把截图界面打开对比会发现左上角区域的黑白子正好组成斜向交叉这是黑白棋标准开局。如果初始化顺序写反了游戏一启动就会进入不平衡局面算法再正确也救不回来。在这个小节里我一般还会顺手把当前玩家、双方是否可动的字段写进设计里Piece m_currentPlayer BLACK; bool m_lastMoveHasPassed false; int m_moveCount 0;m_currentPlayer决定当前回合是黑还是白m_lastMoveHasPassed用来记录上一回合是否因无棋可下而跳过连续两次跳过才能触发终局m_moveCount既用于步数统计也用于调试时快速确认界面重绘是否正确。这些字段和m_board一起构成了整个对局状态放在同一个类里后续落子、翻转、胜负判断都在它附近完成会很顺手。2.4 信号槽把界面和逻辑分开棋盘只管画窗口只管提示拆工程时除了看数据结构还要看信号槽怎么连。常见做法是让棋盘BoardWidget只负责绘制和接收鼠标事件然后把“哪一行哪一列发生了落子”通过信号发出去主窗口去处理规则判断、比分刷新、弹窗提示。这样棋盘类不必依赖QMessageBox也不会在鼠标事件里写完一整局逻辑。class BoardWidget : public QWidget { Q_OBJECT public: explicit BoardWidget(QWidget *parent nullptr); signals: void moveMade(int row, int col); protected: void mousePressEvent(QMouseEvent *event) override; };头文件里必须写Q_OBJECT宏否则 Qt 的元对象编译器 moc 不会生成信号槽元代码连接信号时要么报错要么槽函数永远不触发。很多新手拿到老工程第一反应是代码写错了实际是头文件少了这行。主窗口里的连接通常写在构造函数connect(boardWidget, BoardWidget::moveMade, this, MainWindow::onPlayerMove);用新式语法连接时信号参数要和槽函数参数对应。onPlayerMove(int row, int col)收到坐标后调用合法性判断、翻转、重绘再更新状态栏。这种结构的价值是以后你想加网络对战只需要替换onPlayerMove内部的逻辑棋盘绘制代码完全不用动。3. 把源码跑起来开发环境、编译参数与命令行构建3.1 为什么我建议先用 Qt 5.15.2 MinGW 而不是 Qt 6这份源码很可能是按 Qt5 的 API 写的因为QWidget、QPainter这些基础模块在 Qt5 时代已经成熟。Qt6 并没有移除他们但部分模块划分和头文件位置有调整老工程拿过去编译常会出现unknown module(s) in qt。如果错误信息里冒出webenginewidgets而你的源码只是一个黑白棋那基本可以确定是.pro文件里混进了用不到的模块直接删掉那一行再重新 qmake。我一般会装 Qt 5.15.2 LTS 的 MinGW 64 位版本。原因有三个5.15 对 C98/C11 都兼容老代码负担小MinGW 工具链不需要装 Visual Studio上手成本低网上大多数 Qt 教程和编译报错讨论集中在 Qt5遇到问题更容易搜到对照。安装时建议勾选 Qt 5.15.2 下的 MinGW 8.1.0 64-bit同时勾选 Tools 里的 Qt Creator 和对应 MinGW 套件。在线安装器默认只展示新版5.15.2 需要切到 Archive 分类勾选这个过程会花点时间。需要提醒的是单装 Qt 库是不够的编译器也必须装。很多新人在qt下载后的第一反应是只装 runtime随后在 Creator 里发现没有可用的构建套件编译按钮是灰色。正确的做法是确认工具链名称里同时出现 Qt 和 MinGW不仅仅是勾上“Desktop Qt 5.15.2”。3.2 用 Qt Creator 打开工程的三步检查在 Qt Creator 中直接打开Othello.pro后我建议先做三步检查而不是直接按运行。第一步看左侧项目树里是否有.pro被正确解析。如果显示Unconfigured说明还没指定构建套件。在“项目”面板里选 Desktop Qt 5.15.2 MinGW 64-bit点 Configure Project。这步做了之后右键项目重新构建让 qmake 生成 Makefile。第二步打开工具 - 选项 - Kits确认套件的编译器是 MinGW 且版本匹配。不要混用 MSVC 和 MinGW尤其是源码里如果用到Q_OBJECT和 moc混用工具链会生成错误的元对象代码出现难以理解的链接错误。第三步看构建目录。Creator 默认使用 shadow build把编译中间文件放在工程目录外的build-{项目名}-{随机字符串}目录。我习惯把它改成源码同级下的build目录这样方便用命令行清理。如果改代码后运行没反应先怀疑旧构建目录里的 moc 缓存删除整个build目录再重新构建这一步能解决一半的“玄学问题”。3.3 命令行编译流程qmake 到 mingw32-make 跑通全流程有些情况下 Creator 会掩盖真实的编译信息这时候命令行更有用。以 Windows MinGW 为例先设置 PATH 再执行构建set PATHC:\Qt\5.15.2\mingw81_64\bin;%PATH% set PATHC:\Qt\Tools\mingw810_64\bin;%PATH% cd C:\path\to\Othello qmake Othello.pro mingw32-make -j4第一行把 qmake 和 Qt 的 DLL 所在目录加进 PATH第二行把 MinGW 的 g 加进 PATH路径要以你实际安装目录为准。qmake Othello.pro读取工程配置生成 Makefilemingw32-make -j4表示用 4 个任务并行编译。编译完的可执行文件在release或debug子目录下取决于当前构建配置。如果用的是 Linux命令大同小异qmake Othello.pro make -j4 ./Othello区别只是 make 工具不叫mingw32-make生成的可执行文件也没有.exe后缀。命令行编译时最容易看到两个典型报错一个是cannot find -lQt5Widgets说明 Qt 库路径没进 PATH另一个是:-1: error: unknown module(s) in qt说明.pro里的模块声明和当前 Qt 版本不一致。这两个错误在第 5 章避坑部分会展开。3.4 交叉编译到 ARM 板qmake 的 -spec 参数怎么传如果你不是在本机跑而是想把棋牌放到 ARM 嵌入式板子上交叉编译时的 qmake 命令需要额外指定平台规格文件。常见形式是qmake -spec linux-arm-gnueabi-g Othello.pro make -j4-spec告诉 qmake 用哪套交叉编译规则后面跟的是 Qt 源码里mkspecs目录下的平台名称。黑白棋本身没有网络和多线程依赖交叉编译的难点主要在 Qt 库本身是否已为 ARM 编好。如果板子上显示could not find the qt platform plugin linuxfb那是运行时缺少平台插件和编译参数无关得把 plugins 目录拷贝到板子上再设置QT_QPA_PLATFORM_PLUGIN_PATH环境变量。这一节可以作为后面避坑的引子先有个印象。4. 核心算法这样实现落子合法性、翻转规则与终局判定4.1 方向数组与坐标合法性检查八个方向一个数组搞定黑白棋的落子合法性核心是“能不能在某个方向上夹住对手棋子”。最容易写的解法是定义方向数组把八个方向的位移先列出来const int DIRECTIONS[8][2] { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, { 1, -1 }, { -1, -1 } }; bool isValidMove(const Piece board[8][8], int row, int col, Piece player) { if (board[row][col] ! EMPTY) return false; Piece opponent static_castPiece(3 - player); // BLACK1, WHITE2 for (int i 0; i 8; i) { int r row DIRECTIONS[i][0]; int c col DIRECTIONS[i][1]; if (r 0 || r 8 || c 0 || c 8) continue; if (board[r][c] ! opponent) continue; while (r 0 r 8 c 0 c 8) { r DIRECTIONS[i][0]; c DIRECTIONS[i][1]; if (r 0 || r 8 || c 0 || c 8) break; if (board[r][c] player) return true; if (board[r][c] EMPTY) break; } } return false; }这段代码里最容易出错的是opponent 3 - player这行。因为枚举值 BLACK 是 1WHITE 是 2所以 3 减当前玩家恰好得到对手。如果枚举定义不是连续整数这个技巧就失效改读成player BLACK ? WHITE : BLACK更稳妥。board[row][col] ! EMPTY在最前面直接拦掉非空位防止重复落子。随后对每个方向先走一步如果紧邻棋子是空格或越界就继续下一方向如果是对手棋子则沿同一方向一直走直到越界、遇到空位或遇到己方棋子。遇到己方棋子说明这条路能形成夹击返回 true。注意这里要区分第一步和后面的 while 循环很多人在第一步就进入 while会把“隔着空格”的情况也误判为合法。4.2 翻转棋子把合法性检查里走过的方向再来一遍合法性检查只是探测落子后需要真实修改棋盘。翻转的常见做法是复用方向数组扫描每个方向并收集要翻转的坐标统一改成当前玩家颜色。下面这段是示意实现void applyMove(Piece board[8][8], int row, int col, Piece player) { board[row][col] player; Piece opponent static_castPiece(3 - player); for (int i 0; i 8; i) { int dr DIRECTIONS[i][0]; int dc DIRECTIONS[i][1]; int r row dr; int c col dc; if (r 0 || r 8 || c 0 || c 8) continue; if (board[r][c] ! opponent) continue; QVectorQPairint,int toFlip; while (r 0 r 8 c 0 c 8) { if (board[r][c] EMPTY) break; if (board[r][c] player) { for (const auto pos : toFlip) board[pos.first][pos.second] player; break; } toFlip.append(qMakePair(r, c)); r dr; c dc; } } }这个版本和合法性检查的区别在于它用一个toFlip列表记录每一步的坐标直到遇到己方棋子才统一翻转。这样避免在扫描过程中直接改写board否则后续方向判断会受翻转影响出现“吃子又放回”的怪现象。参数说明函数入参里board是引用落子后直接改变调用方数据row/col是已经通过isValidMove校验的目标位置。这里我建议你实际动手时改成 QVector 收集坐标的版本可读性比一边扫描一边改要好得多。翻转完成后调用update()触发重绘棋盘就会显示最新结果。注意update()不会立即重绘它会合并事件并延迟到回到事件循环时统一绘制这样在鼠标连续点击时不会闪烁。4.3 无子可下时的跳过逻辑只判断一次会卡死黑白棋不是每次落子后双方必有位置。经常出现一方把所有空格都围住导致对手完全没有合法落点。规则是当前玩家如果无棋可下则自动跳过轮到对手继续。如果双方连续都无法落子对局才结束。这一步的实现要放在回合切换函数里很多新手只判断一次hasAnyLegalMove就直接结束结果游戏在棋盘还有大量空格时提前弹窗。正确逻辑是先判断当前玩家是否可下不可下则切换到对手再判断对手是否可下对手也不可下才算终局。参考代码void MainWindow::changeTurn() { m_currentPlayer static_castPiece(3 - m_currentPlayer); if (!hasAnyLegalMove(m_board, m_currentPlayer)) { if (!hasAnyLegalMove(m_board, static_castPiece(3 - m_currentPlayer))) { showGameOver(); return; } // 当前玩家无子可下对手有子可下跳过本回合 m_moveCount; m_currentPlayer static_castPiece(3 - m_currentPlayer); } }这里连续调用了两次hasAnyLegalMove第一次判断切换后的玩家第二次判断对手。注意m_moveCount也要递增否则回合计步会少一次。胜负判定时直接遍历棋盘数棋子颜色即可int blackCount 0, whiteCount 0; for (int r 0; r 8; r) for (int c 0; c 8; c) { if (m_board[r][c] BLACK) blackCount; else if (m_board[r][c] WHITE) whiteCount; } QMessageBox::information(this, tr(Game Over), tr(Black: %1, White: %2).arg(blackCount).arg(whiteCount));这里tr包裹字符串的作用是为以后做翻译做准备。测试时最直接的验证方法是把白棋逼到只剩一个位置看程序是否自动跳过黑棋回合。如果你发现点击界面毫无反应那就是没有接上跳过逻辑。4.4 落子前提示把合法位置画出来提升交互体验的常用技巧是在鼠标悬停或轮到某方时用半透明小圆点标出合法落点。做法是先遍历整个棋盘把所有isValidMove为 true 的坐标存下来再在paintEvent里绘制。需要新增一个成员变量QVectorQPairint,int m_legalMoves;每次玩家切换时刷新m_legalMoves绘制时遍历画出小圆点p.setBrush(QColor(0, 255, 0, 80)); p.setPen(Qt::NoPen); for (const auto pos : qAsConst(m_legalMoves)) { p.drawEllipse(QRectF(pos.second * cell cell * 0.3, pos.first * cell cell * 0.3, cell * 0.4, cell * 0.4)); }qAsConst是 Qt 的只读迭代优化避免隐式共享容器被意外拷贝。颜色用带 alpha 通道的绿色半透明效果不会掩盖棋子。这个功能对排错很有用因为如果合法位置画出来的点位和你想的不一致说明isValidMove的越过空格判断有遗漏。5. 避坑排查平台插件缺失、中文字符串乱码和资源加载失败5.1 双击 exe 报 could not find the qt platform plugin窗口闪退现象在 Qt Creator 里按运行一切正常但到构建目录里双击Othello.exe时提示 “could not find the qt platform plugin windows” 或 “This application failed to start because no Qt platform plugin could be initialized”然后窗口直接消失。原因Qt 程序运行时需要通过plugins/platforms/qwindows.dll来创建窗口。Creator 在启动程序前会把 Qt 插件目录加到搜索路径但你独立双击 exe 时没有这个环境插件找不到了。黑白棋源码本身没问题只是没有部署。解决用 Qt 提供的 windeployqt 工具在 exe 所在目录执行windeployqt Othello.exe它会自动把Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll以及platforms目录复制到 exe 旁边再双击就能正常运行。如果是在嵌入式 Linux 上报错会变成形如could not find the qt platform plugin linuxfb原因也是插件路径不对用export QT_QPA_PLATFORM_PLUGIN_PATH/path/to/plugins指定插件目录即可。以后发布任何 Qt 小项目第一步都是 windeployqt不要手动从 Qt 安装目录复制 DLL容易漏掉依赖。5.2 中文字符串在界面里变成乱码现象源码里明明写了“开始”“白棋胜”等中文字符运行时却显示成æ¤å一类的乱码在 Windows 上按 MSVC 编译时尤其容易出现。原因Qt5 默认假定源码里的窄字符串字面量是 UTF-8 编码但老工程的源文件可能是 GBK/GB2312 编码保存。MSVC 在未指定/utf-8时会把源文件按本地代码页解析字节被读取成另一套字符到了 QString 再按 UTF-8 转码自然错位。解决先打开源码另存为 UTF-8 with BOM或在.pro里加 MSVC 编译参数msvc { QMAKE_CXXFLAGS /utf-8 }如果源码里写的是tr(开始)还要确认.ts翻译文件是否存在。快速验证方法是在main.cpp开头调用QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8))。注意 Qt5 中不要混用fromLocal8Bit和toUtf8否则一个中文字符串在两个函数之间转来转去反而更乱。5.3 棋盘图片、棋子图片加载不出来界面一片空白现象网格画出来了但棋盘背景图或棋子图片显示不出来在 Creator 里运行能看到图片单独运行 exe 就消失。原因源码里用了相对路径比如QImage(images/board.png)。相对路径是相对于“当前工作目录”的Creator 运行时工作目录可能是源码目录但双击 exe 时工作目录是 exe 所在目录图片自然找不到。另一个常见原因是.qrc资源文件没有被重新编译代码访问:/images/board.png时提示资源不存在。解决优先把图片放进.qrc或者运行时用applicationDirPath拼绝对路径QString baseDir QCoreApplication::applicationDirPath(); QString boardPath baseDir /resources/board.png; m_boardImage.load(boardPath);这段代码的意义是让程序从自己所在目录找图片不依赖外部工作目录。注意applicationDirPath返回的是可执行文件所在目录不是源码目录所以发布时要把resources文件夹一起拷到 exe 旁边。如果你走.qrc路线双击.qrc文件在 Qt Creator 里能看到前缀和文件映射代码里统一用:/前缀访问两种方式不要混着写。5.4 编译报 cannot mix incompatible Qt library 或 unknown module in qt现象编译到中段报错错误信息形如fatal: cannot mix incompatible Qt library (version 5.6.1) with this library或者:-1: error: unknown module(s) in qt: webenginewidgets。原因第一种错误是 Qt 库版本冲突例如头文件来自 Qt5.15但链接到的 DLL 来自另一个 Qt 版本或者 PATH 里混入了多个 Qt 安装目录。第二种错误是.pro声明了当前安装的 Qt 不存在的模块比如一个纯 Qt Widgets 项目里写了QT webenginewidgets而你根本没有装 Qt WebEngine。解决在 Qt Creator 里重新选择正确的构建套件清理build-*目录后重建打开.pro检查QT 后面的模块把不需要的webenginewidgets删掉。命令行下可以用qmake -query查看QT_INSTALL_HEADERS和QT_INSTALL_LIBS如果两个路径指向不同的 Qt 版本就是环境变量 PATH 顺序出了问题。我的经验是不要用set PATH...;%PATH%这种方式长期追加每次编译用一个新的命令行窗口单独设置路径避免多个版本互相污染。5.5 对方无子可下时程序卡死既不跳过也不结束现象对局进行到中途轮到白棋但白棋没有任何合法位置点棋盘没有反应游戏既不跳过白棋回合也不结束只能强制关闭窗口。原因落子后只简单切换了m_currentPlayer没有立刻检查新玩家是否还有合法落点。结果就是程序一直在等一个永远不存在的点击事件局面“卡死”。这与 4.3 的跳过逻辑直接相关是最容易忽视的规则细节。解决在每次切换回合时调用hasAnyLegalMove并按照规则处理连续跳过。代码已在 4.3 给出。要验证这个修复是否生效可以人为摆一个“白棋被堵死”的局面例如把白棋的全部合法格都变成黑棋然后让黑棋走一步正常结果应该是界面自动跳过白棋、重新轮到黑棋。如果你发现状态栏显示回合数没有增加说明m_moveCount的递增位置不对要把跳过的回合也计入。6. 进阶改造给黑白棋加悔棋、棋步高亮与落子预览6.1 用快照栈做悔棋别只存最后一步如果悔棋只记录对手的位置和颜色那么翻转过去的棋子没法还原。黑白棋一次落子最多可能翻掉一条线十几颗子单步信息根本不够用。更稳的做法是保存整块棋盘快照8×8 一共 64 个枚举值快照开销非常小QVectorQVectorquint8 m_boardHistory; void MainWindow::saveSnapshot() { QVectorquint8 snapshot(64); for (int r 0; r 8; r) for (int c 0; c 8; c) snapshot[r * 8 c] m_board[r][c]; m_boardHistory.append(snapshot); } void MainWindow::onUndo() { if (m_boardHistory.isEmpty()) return; QVectorquint8 last m_boardHistory.takeLast(); for (int r 0; r 8; r) for (int c 0; c 8; c) m_board[r][c] static_castPiece(last[r * 8 c]); update(); }这段代码用二维数组拉成一维快照好处是后续如果要写存档QVectorquint8可以直接落到文件里不用额外定义序列化格式。悔棋按钮的启用状态要和m_boardHistory.isEmpty()绑定否则连点悔棋会把初始棋盘也清空。需要特别注意的是快照保存的时机必须在applyMove成功后而不是在mousePressEvent里否则非法落子也会产生快照悔棋会回不到正确的步数。6.2 最后一个落子位置画高亮圈棋步高亮能直观反馈玩家操作的位置。维护一个QPoint m_lastMove初始化为QPoint(-1, -1)落子合法后更新坐标重绘时画一个空心圆if (m_lastMove.x() 0) { QPen pen(QColor(255, 215, 0), 2, Qt::SolidLine); pen.setWidth(3); p.setPen(pen); p.setBrush(Qt::NoBrush); p.drawEllipse(QRectF(m_lastMove.y() * cell cell * 0.15, m_lastMove.x() * cell cell * 0.15, cell * 0.7, cell * 0.7)); }注意QRectF的偏移量是格子边长的 15%这样圆环在棋子边缘内侧不会遮住棋子中心颜色。颜色用金黄偏深色不要在纯白棋子上画纯白高亮。如果你还想加落子预览在mouseMoveEvent里判断坐标是否合法合法就画半透明圆代码结构和这个一致。这个改造做完以后界面手感会明显上一个档次也能帮你更直观地看出isValidMove的错误边界。从那以后我每次拿新的 Qt 小项目都会先花十分钟把.pro、资源路径和构建目录扫一遍再碰界面逻辑至少能少踩一半前面这些坑。希望帮到你。本文还有配套的精品资源点击获取