☰
Qt Widgets实战:从零开发五子棋博弈程序全解析
2026/10/21 22:28:37 网站建设 项目流程

Qt 这个东西,我最初接触时觉得又老又重,后来在“补作业”式地突击学习过程中,才发现自己对这个框架的误解有多大。当时为了交一个期末课程设计,我硬着头皮选了一个“无人应战的智能博弈棋局”题目,说白了就是做一个能自己和人对弈、也能自动落子的棋类小游戏。本以为画个棋盘、写个判断输赢的函数就完事,结果一上手才发现,从窗口框架、绘图机制到 AI 逻辑、线程调度,每一个环节都藏着坑,而把这些坑一个个填平的过程,远比“补作业”本身更有价值。

这篇总结就是我从零开始用 Qt Widgets 实现一个五子棋博弈程序、并把过程整理成学习资料的全记录。它不光是“界面长什么样”的展示,更包含了我对 Qt 信号槽机制、事件系统、模型视图思维以及 AI 评估函数的真实理解。不管你是刚准备学 Qt 的新手,还是已经会写几个小工具但没碰过绘图与博弈逻辑的开发者,这篇文章都能让你少走一些弯路。

1. 项目整体设计与思路拆解

1.1 这个项目到底在做什么

所谓“无人应战的智能博弈棋局”,核心就是一个棋类对弈程序,但特别之处在于对局的某一方可以由程序接管。也就是说,两个人可以轮流在界面上落子,也可以开启“人机对战”模式,让内置的 AI 根据当前局面自动选择落子位置。我用的是五子棋,因为规则足够简单,但要实现一个能“有点脑子”的 AI 也不是那么容易,这正好把 Qt 的界面能力和算法能力都锻炼到了。

从需求拆解来看,这个项目包含几个独立又耦合的部分:

  • 棋盘绘制:需要支持网格、落子标记、胜负高亮,这部分依赖 Qt 的绘制系统。
  • 交互响应:鼠标点击棋盘落子、按钮切换模式、菜单栏控制重启,这部分依赖事件系统与信号槽。
  • 游戏逻辑:落子合法性判断、胜负判定、悔棋栈管理,这部分是纯逻辑,和 Qt 关系不大。
  • AI 决策:评估函数、搜索树剪枝,这部分也是纯算法,但需要和界面线程隔离,否则会卡界面。
  • 状态管理:当前轮到哪方、对战模式是什么、是否已结束,这部分用一个中央状态类来管理。

这个拆分方式很关键。很多新手在写类似项目时,喜欢把所有逻辑都塞进一个继承自 QWidget 的类里,结果画棋盘和算胜负混在一起,改一处要动全身。我按照“界面、逻辑、控制”三个层面切分,虽然前期多写了一些类,但后期调试和维护轻松很多,尤其是 AI 卡死的时候,不用在界面代码里找原因。

1.2 为什么选 Qt Widgets 而不是 QML、或者干脆用网页

做一个棋类游戏,可以选的方向很多,为什么偏偏是 Qt Widgets?我当时的考虑有几层。

第一,课程要求里明确不能依赖浏览器,必须是一个可独立运行的桌面程序,这就排除了纯网页方案。第二,实现双人鼠标交互、定时刷新、弹窗提示这些能力,Qt Widgets 提供的事件和信号槽机制非常直接,不需要像网页那样维护 DOM 和 Canvas 的状态同步。第三,Qt 的跨平台特性意味着我在某系统上写完,换一台设备也能编译运行,提交作业时不用为环境折腾太久。

至于 QML,它做动画和“酷炫”界面确实更强,但学习成本多了一门 QML 语言和模型视图绑定概念,对于一个需要快速完成的课程设计来说,Widgets 的 QPainter 绘制方案更直观,也更容易理解绘图坐标系和重绘机制。很多教材和资料都在讲 Widgets,遇到问题更容易搜到答案。

注意:如果你的目标不是课程作业,而是做一个产品原型,那么 QML 也许更合适。但如果你想搞懂桌面程序的事件循环、控件体系和绘图底层,Widgets 是更好的学习载体。

1.3 资料选型:从“学完再用”变成“边用边学”

这个项目的另一个隐藏主题是“如何高效收集和整理学习资料”。我最初花了一周去看 Qt 的入门书,记了一堆笔记,但到了真正动手时发现什么都不会。后来换了策略:先定一个目标项目(就是五子棋),然后倒推需要哪些知识点,遇到不会的再针对性查资料。

整理资料时我建立了一个三层结构:

  • 第一层是官方文档与示例:Qt 自带的examples目录是宝藏,尤其是widgets模块里的painting和game相关示例,直接打开看代码比看文档快十倍。
  • 第二层是视频与教程笔记:看别人怎么做信号槽、怎么做绘图,记录他们提到的“坑”,比如重绘事件要调用update()而不是repaint(),比如 lambda 表达式捕获局部变量时要注意生命周期。
  • 第三层是自己整理的错误排查表:把编译错误、运行时崩溃、界面空白等按现象分类,后面踩到同类的坑直接翻表。

这个习惯让我在后续几个小项目里都受益。资料本身并不稀缺,稀缺的是把资料转化成自己可用的“操作手册”的能力。

2. 核心细节解析与实操要点

2.1 信号槽机制:项目能不能跑起来的关键

Qt 最核心的机制就是信号槽(Signals & Slots)。我最初理解它就是一“函数回调”,但其实它比回调更安全也更灵活。信号槽的本质是:某个事件发生时,一个对象可以发出一个信号,另一个对象的某个槽函数会被自动调用。连接关系可以跨越不同的对象,甚至在两个线程之间传递。

在五子棋项目里,我用了几个典型的信号槽场景:

  • 棋子按钮(比如“重新开始”)发出clicked信号,连接到控制类的resetGame槽函数。
  • 当 AI 线程计算出最佳落点后,通过信号把坐标传回界面线程,界面负责画棋子。这里必须注意跨线程信号槽连接方式,默认的直连模式在高并发下会引发数据竞争。
  • 当某一方获胜时,游戏逻辑类发出gameFinished信号,界面类收到信号后弹出提示并更新状态栏。

新手最容易犯的一个错误是:在槽函数里执行耗时操作,然后界面卡死。比如我最初在棋局结束后直接在一个槽函数里“思考”下一步,结果界面鼠标变成等待状态。后来把所有耗时计算放到std::thread或者QtConcurrent中,用信号槽把结果传回主线程,才解决了卡顿。

实操建议:如果你发现界面操作卡住,先检查是不是在 GUI 线程里做了大量计算。把耗时任务丢到后台线程,通过信号把结果传回来,这是 Qt 开发基本素养。

2.2 棋盘绘制:QPainter 与坐标系的相爱相杀

棋盘的绘制是重绘事件里的重头戏。我继承QWidget并重写paintEvent,在函数里用QPainter画网格、画交叉点、画棋子。

关键步骤有三个:

void BoardWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 画网格 for (int i = 0; i < gridCount; ++i) { painter.drawLine(start + i * cellSize, start, start + i * cellSize, start + (gridCount - 1) * cellSize); painter.drawLine(start, start + i * cellSize, start + (gridCount - 1) * cellSize, start + i * cellSize); } // 画棋子 for (const auto &move : history) { QBrush brush(move.player == Player::Black ? Qt::black : Qt::white); painter.setBrush(brush); painter.setPen(Qt::black); painter.drawEllipse(boardPosToPixel(move.pos), radius, radius); } }

其中一个容易忽略的细节是坐标转换。棋盘网格是逻辑坐标(行列号),而鼠标点击返回的是物理像素坐标。如果不做映射,棋子就会画到格子外面。我在BoardWidget里维护了一个点集,存储每个交叉点的像素坐标,鼠标事件通过反向查表找到最近的行列号,绘制时再通过行列号找到对应的像素中心,这样视觉和对局逻辑就能对齐。

绘制还有一个性能问题:如果没有变化,就不要在paintEvent里做重复计算。我用了一个dirty标志,只有落子、悔棋、重置时才更新棋盘缓存,否则直接绘制缓存位图,这样在大棋盘翻转时也不会闪屏。

2.3 游戏逻辑与状态机:别把规则写在界面里

游戏逻辑是项目里最“安静”的部分,但也是最容易写崩的部分。我的做法是把所有规则封装到一个GameCore类中,它不依赖任何 Qt 界面控件,只保存棋盘状态和落子历史:

  • bool isMoveValid(int row, int col):检查是否越界、是否已有棋子。
  • void placePiece(int row, int col, Player player):落子并推入历史栈。
  • Player checkWin(int row, int col):从落子点向四个方向(横、竖、两个对角线)统计连子数量,大于等于 5 则判胜。
  • void undo():出栈并删除棋盘对应棋子。

这个状态机设计的一个体会是:把规则抽象得足够干净,后续所有界面操作都只是调用这些接口,这样 AI 也可以复用同样的棋盘数据,不需要和界面绘制耦合。比如模式切换到“人机对战”后,AI 拿到一个GameCore的拷贝,算出落点,再调用同样的placePiece接口,整个过程对 UI 层是透明的。

2.4 AI 评估函数:从“完全随机”到“知道堵你一下”

这个项目里最有趣的部分是 AI。一开始我写了一个完全随机的 AI,效果就是“智障棋手”。后来换成了基于评估函数的贪心算法:对棋盘上每个空位打分,分数由该位置在四个方向上的连子潜力决定。这个思路最简单,但效果立竿见影。

评估函数的核心在于怎么给“潜力”打分。我用了长度加权:一个方向上有 1 个己方棋子加 10 分,2 个连子加 50 分,3 个连子加 200 分,4 个连子加 1000 分,成五直接判胜。同时还要考虑对方棋子的威胁,如果一个位置能让对方成四,那 AI 必须优先堵住,分数要高到足以覆盖己方普通进攻。

int evaluatePoint(int row, int col, Player player) { int total = 0; for (auto &dir : directions) { int cnt = 1; int block = 0; cnt += countDirection(row, col, dir.first, dir.second, player, block); cnt += countDirection(row, col, -dir.first, -dir.second, player, block); total += scoreTable[cnt] + (block == 2 ? 0 : powerfulBonus); } return total; }

这里有一个很难描述但实际很关键的体验:评估分数必须同时考虑进攻和防守。我最初只算己方棋子,结果 AI 闭着眼睛自己连,完全不理会对方已经四子连珠。后来把“对方成四”的威胁加入评分,并赋予比己方普通进攻更高的权重,AI 一下就从“自嗨型”变成了“会堵人型”。

3. 实操过程与核心环节实现

3.1 环境准备与首个窗口:从安装到跑通 Hello Qt

如果是从零开始,第一步是装开发环境。我建议直接安装官方的一体化安装包,不要自己去拼各种库。安装时选择对应的编译器套件,注意 32 位和 64 位的选择要和你系统一致,否则后续编译会遇到链接器报错。

装好之后先不要急着写五子棋,先跑通一个“显示空白窗口”的最小程序,验证工具链没问题。这个过程的代码极简单,但意义重大:

#include <QApplication> #include <QWidget> int main(int argc, char *argv[]) { QApplication a(argc, argv); QWidget w; w.setWindowTitle("Qt 环境测试"); w.resize(800, 600); w.show(); return a.exec(); }

跑通之后,把它扩展成带菜单栏和状态栏的主窗口,加入棋盘控件。我用QMainWindow作为顶层窗口,BoardWidget作为centralWidget,右边放一个控制面板,里面有几个按钮和模式选择框。布局用的是QHBoxLayout和QVBoxLayout,没有用网格布局,因为棋子面板需要自由拉伸,而控制面板固定宽度即可。

3.2 让棋盘响应鼠标:事件重写与坐标转换

鼠标交互是整个程序最直观的部分。我在BoardWidget里重写了mousePressEvent,把点击位置转换成棋盘行列,再判断合法性并更新界面。关键代码如下:

void BoardWidget::mousePressEvent(QMouseEvent *event) { if (gameOver || currentMode == Mode::AIThinking) return; QPoint pos = event->pos(); int row, col; if (!pixelToBoard(pos, row, col)) return; if (!gameCore->isMoveValid(row, col)) return; gameCore->placePiece(row, col, currentPlayer); update(); if (gameCore->checkWin(row, col)) { emit gameFinished(currentPlayer); return; } switchPlayer(); }

这里的重点是pixelToBoard函数。它的实现原理是:遍历预先算好的所有交叉点像素坐标,计算点击点与各点的欧氏距离,取距离最近且小于某阈值(比如棋子半径加几个像素)的交叉点作为目标行列。如果不设阈值,用户点击两个格子中间也会被“吸附”,体验很差。

坐标转换还有一个隐藏问题:窗口缩放。如果允许用户拖动窗口改变大小,网格像素坐标会变化,所以交叉点坐标不能在构造函数里一次性算好,必须在resizeEvent里重新计算,或者用相对比例计算。我用了后一种方法,在每次绘制时按当前宽高重新计算网格线位置,效果稳定。

3.3 AI 落子的后台线程实现:别把“思考”放在界面线程里

AI 核心决策过程如果直接在mousePressEvent里同步执行,棋盘越大搜索越久,界面必然冻结。我最初的测试中,用简单的贪心算法在一个 15x15 棋盘上计算,耗时只有几毫秒,看不出问题;但后来加入两层 min-max 搜索后,计算时间飙升到秒级,界面明显卡住。解决办法是把 AI 放到单独的线程里运行。

我用std::thread创建了一个工作线程,线程函数里调用 AI 决策函数,得到结果后通过信号槽把落子位置传回主线程。核心代码框架如下:

void Controller::startAIThread() { aiThread = std::thread([this]() { QPair<int,int> bestMove = aiPlayer->getBestMove(boardSnapshot); emit aiMoveReady(bestMove.first, bestMove.second); }); aiThread.detach(); }

这里有一个雷区:线程函数里不能直接访问界面对象,也不能修改棋盘类的共享数据。我的做法是给线程传入一个棋盘状态的深拷贝,AI 在拷贝上做决策,然后把结果发回主线程,主线程再统一调用placePiece修改真正的游戏状态。这样做虽然多了拷贝开销,但避免了数据竞争和诡异崩溃。

友情提示:信号槽连接时,如果在主线程中声明连接方式为Qt::QueuedConnection,信号发出后会排队到接收者所在的线程事件循环中执行。这正是跨线程 UI 更新的安全套路。

3.4 胜负判定与终局处理:让程序知道“游戏结束”了

胜负判定是棋局逻辑里最容易写 bug 的地方。我一开始用每落一子后全盘扫描,结果效率低不说,还经常漏判。后来改成只检查最后落子点周围的四个方向,从该点出发分别数“同色连续棋子”数量,相加后大于等于 5 就判胜。

int countDirection(int row, int col, int dr, int dc, Player player) { int cnt = 0; int r = row + dr, c = col + dc; while (r >= 0 && r < gridCount && c >= 0 && c < gridCount && board[r][c] == player) { ++cnt; r += dr; c += dc; } return cnt; } bool checkWin(int row, int col) { Player p = board[row][col]; for (auto &dir : directions) { int total = 1 + countDirection(row, col, dir.first, dir.second, p) + countDirection(row, col, -dir.first, -dir.second, p); if (total >= 5) return true; } return false; }

终局处理还要考虑“平局”和“禁手”这两个边界情况。五子棋在没有禁手规则时平局几乎不会出现,但程序里仍然要做判断:当所有格子填满且无人胜利时,弹出和棋框。状态栏也要同步显示“当前轮到黑方/白方”“AI 思考中”等信息,这会明显提升程序的完成度。

4. 常见问题与排查技巧实录

4.1 信号槽连不上或者只触发一次

这个问题出现率极高。我排查时发现,最常见原因是发送信号的对象被销毁了,或者接收对象的生命周期比发送对象短。比如把一个QPushButton的信号连到一个局部队列对象的槽函数,那个局部队列在函数结束时被析构,信号自然无法触发。

另一类情况是使用了重载信号,比如QComboBox::currentIndexChanged有int和QString两个版本,连接时必须用QOverload指定类型:

connect(comboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &Controller::onComboChanged);

如果信号槽设计得比较早,排查时可以用QObject::connect的返回值判断是否连接成功,还可以在槽函数里加上qDebug() << "slot called"来确认是否被调用。这是最快的二分定位法。

4.2 棋盘绘制错位和闪烁

绘制错位通常与坐标系统计算有关。我的调试技巧是:在paintEvent里临时绘制网格交叉点的小十字,并把行列号打印出来,肉眼对比点击后的落子位置。事实证明,绝大多数错位都因为计算网格间距时用了整数除法,导致最后一行或最后一列被截断,在函数开头把相关变量转成double类型就好。

闪烁问题则是因为paintEvent重绘太频繁。一个有效的优化是设置Qt::WA_OpaquePaintEvent属性,并在绘制前先用fillRect填充背景色,告诉 Qt 不需要擦除旧背景。另一个方案是开启双缓冲,不过在 Widgets 中重写paintEvent时系统已经默认支持双缓冲,闪烁大多来自频繁update()和背景擦除之间的竞争。

4.3 编译报错:找不到头文件 / 未定义的槽

这类问题八成是工程配置出了问题。在 CMake 项目中,我需要在CMakeLists.txt里明确添加:

find_package(Qt6 REQUIRED COMPONENTS Widgets) target_link_libraries(mygame PRIVATE Qt6::Widgets)

如果你用Q_OBJECT宏声明了自定义信号槽,编译器还需要先运行元对象编译器(moc)。在 CMake 项目中,Qt6 已经自动处理了这一步,但如果你手动创建工程忘了加AUTOMOC ON,自定义槽就会变成“未声明的标识符”。遇到这种情况,检查 CMake 中是否设置了set(CMAKE_AUTOMOC ON),或者 qmake 工程中头文件是否被识别。

4.4 AI 线程崩溃与数据竞争

我遇到过一次非常典型的崩溃:AI 线程还在计算时,用户点了“重新开始”,主线程清空了棋盘数据,AI 线程拿到的指针变成野指针,程序就这样毫无预兆地退出。解决办法是在控制器里加一个“线程是否运行中”的判断,重置前先请求线程停止(用一个原子布尔变量标记),等线程结束后再清理数据。

其实最佳实践是不要在线程里操作共享对象,而是把棋盘拷贝传给线程,线程算完后返回结果。我在后续版本中彻底按这个方向重构,再也没有出现过竞态崩溃。这算是这次“补作业”里最值得记住的经验之一。

4.5 界面样式太丑怎么办

程序功能正常后,剩下就是观感。Qt Widgets 的默认样式确实比较朴素,我为项目写了一份简单的QSS样式表,给控制面板加了背景色和圆角边框,给按钮设置了 hover 效果,棋盘的线条和棋子颜色也做了微调。虽然整体依然谈不上惊艳,但比默认状态好了不少,至少让人愿意多玩两把。

如果你想让棋局更生动,还可以加入落子时的音效,或者在最新落子点画一个红圈标出“当前手”。这些细节不复杂,但很能提升完成度。

5. 学习资料整理与后续扩展思路

5.1 三份核心资料,别贪多

这次学习中,我真正看完了、并且反复查阅的资料其实只有三份:

  • Qt 官方自带的examples目录里的绘图和控件示例。
  • 官方文档中关于QPainter、QWidget::paintEvent、QThread和信号槽的页面。
  • 市面上经典的 Qt 入门书的前六章(基础控件、事件系统、绘图、布局、对话框、多线程)。

资料真的不在多,而在于你是不是逐行看懂了示例代码。我几次卡住的点,最后都是回到官方示例找到灵感的,比如棋盘绘制就是参考了官方绘图示例里的“旋转星形”和“画线”代码自己改出来的。

5.2 项目后续可以怎么扩展

如果这个棋局程序还有后续,我最想加的功能是“悔棋 + 存档”。悔棋已经做到了,存档则可以用QJsonDocument把棋盘状态保存成文件,下次启动时恢复。更进一步,如果棋盘做得足够大且 AI 换成基于蒙特卡洛树搜索的算法,这个程序就能变成一个小型研究平台。

我也考虑过把界面迁移到 QML,用动画让落子和吃子效果更流畅。但那需要重新梳理一遍模型视图绑定,目前时间不够,留作后续计划。对于课程作业这个层面,Widgets 版本已经完全够用。

6. 最后补充几个实战细节

界面置中、窗口缩放、棋盘自适应大小这些问题并不难,但很影响使用体验。我把棋盘控件放进一个布局中,并设置合理的sizePolicy,让窗口拉伸时棋盘跟着变大变小,但控制面板宽度保持不变。这样在演示时,把窗口拉大一点,观众能看清棋局细节,不至于死板地固定尺寸。

还有一个体验细节:在 AI 思考期间,光改变状态栏文字其实不够,最好把鼠标光标设为Qt::WaitCursor,并在棋盘上提示“AI 思考中”,否则玩家会误以为程序没响应。这个细节看着微不足道,但在现场演示时能避免很多尴尬。

这次“补作业”最大的收获并不是五子棋本身,而是我从资料检索、方案选型、编码实现到问题排查的完整闭环。Qt 这个框架远比我想象的要庞大,但当你用它真正把一个项目从零写到能玩,很多概念都会从“听说过”变成“用得明白”。如果你也正在为一个课程设计或者小项目发愁,不妨选一个有趣但有明确边界的题目,用 Qt 把它做出来,这个过程会比看十集教程更有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询