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]() { QPairint,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, QOverloadint::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 把它做出来这个过程会比看十集教程更有用。
阅读完成 · 觉得有帮助?