news 2026/9/15 14:38:00

五子棋AI项目实战:基于Qt和C++的界面搭建与事件处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
五子棋AI项目实战:基于Qt和C++的界面搭建与事件处理

做五子棋AI这个项目,是我特别推荐给算法入门者的一条练手路线。五子棋规则足够简单,棋盘只有15x15,但搜索空间又不像围棋那么夸张,正好用来讲清楚极大极小搜索和α-β剪枝算法这两块博弈树的核心思想;界面部分用Qt和C++来做,轻快直观,而且绘图逻辑和算法逻辑能拆得干干净净。这篇是系列的第一篇,先把“为什么做”和“界面怎么做”讲透,AI引擎的搜索部分放到下一篇展开。无论你是刚学完C++想找个实战项目,还是对博弈树算法感兴趣但不想啃数学推导,这篇文章都能让你跟着思路搭出一个能跑、能点、能落子的五子棋界面,并且为后面的AI对抗做好准备。

1. 为什么做五子棋AI:先从算法视角想清楚

1.1 五子棋是博弈搜索的绝佳载体

很多人在学极大极小搜索和α-β剪枝算法时会卡住,核心原因不是算法本身难,而是找不到一个直观的载体。下棋类的棋类游戏是最标准的回合制博弈模型:我走一步,你走一步,双方轮流决策,每一步都会改变棋盘状态,最终有人获胜或者双方打平——这正是博弈树想表达的东西。

五子棋在这个模型里占据一个很舒服的位置。走法数量不多,每步可选落点最多也就225个,实际操作中搜索深度到4到6层已经是相当能打的水平;它不像国际象棋那样有复杂的棋子走法规则,也不像围棋那样需要处理大量分支。你不需要先学一堆棋规才能理解搜索过程,只要知道“五个连成一线就算赢”就够了。把精力集中在算法本身,而不是棋局规则上,这对我这种喜欢做减法的人来说特别重要。

从数据结构角度看,五子棋的棋盘天然就是二维数组,空位、黑子、白子可以用0、1、2表示,拷贝和恢复棋盘状态非常方便。这个特性对后续实现极大极小搜索特别有利,因为搜索过程需要反复临时落子、再撤销落子,数据结构的简单直接决定了搜索函数的实现成本。我第一次写AI搜索时用的是二维vector,后来改成一维数组,发现搜索速度提升明显,这块经验在下一篇讲算法时再细说。

1.2 极限极小搜索和α-β剪枝到底解决什么问题

简单说,极大极小搜索的思路是:假设双方都足够聪明,当前玩家会选择一个对自己评分最高的走法,对手则会选择一个让我方评分最低的走法。于是搜索函数会自动做交替——当前层求最大值,下一层求最小值,再下一层又求最大值。这样层层传递下来,最终能把未来几步的局面走向量化成一个分数。

但这个朴素算法有个致命问题:搜索树的分支会爆炸。每层大概有几十乃至上百个合法落点,搜索4层就是百万量级节点,再往上要几千万甚至上亿次评估,纯暴力搜索在普通电脑上根本跑不动。α-β剪枝就是在极大极小搜索的基础上做优化:如果当前分支已经确定比已知的最优结果差,那就不用继续搜下去了。结果不变,但搜索节点数能减少到原来的几分之一甚至几十分之一,这才让“让AI思考两三秒再落子”变得可行。

我之所以要在界面设计之后才写算法,是因为界面层必须先把棋盘状态和交互逻辑稳定下来,算法才有可靠的“输入输出接口”。先搭界面,再接入引擎,每一步都能肉眼看到反馈,调试起来也轻松得多。反过来如果先写搜索算法再临时补界面,你会发现算法和界面耦合在一起,改一个地方的代码到处报错,那种体验我不想再来第二次。

1.3 这个项目里,界面层要承担哪些职责

从项目整体功能拆解来看,第一篇的界面部分其实由下面这几块组成:

  • 棋盘展示:绘制15x15网格、交叉点上的星位、已落的黑白棋子。
  • 落子交互:通过鼠标点击棋盘,把像素坐标换算成行列坐标,完成落子。
  • 状态反馈:显示当前该谁下、游戏是否结束、胜方是谁。
  • 接口预留:把棋盘状态和落子动作封装好,让后面的AI引擎可以直接读取和修改状态,而不是去操作界面控件。

这四块里,前三块是“看得见”的部分,第四块是“看不见”但最影响后续开发质量的。我见过太多入门项目把棋盘状态放在widget里到处改,结果算法一接入就乱了套。所以这篇文章里我会花不少篇幅讲如何把“棋盘数据”和“界面显示”拆开,这算是我踩过坑之后总结出来的心得。

2. 界面层整体设计:棋盘逻辑和显示逻辑必须拆开

2.1 为什么选择Qt配合C++,而不是纯控制台或者Web页面

可能有读者会问,做五子棋AI用控制台程序不也能跑吗?确实能,但你想看到AI每一手棋是怎么思考的,只能在黑底白字的终端里打印坐标,看着就费劲,更别说调试极大极小搜索时的各种中间状态了。有图形界面,你能直接看到棋盘上的落子趋势,直观理解AI为什么这么走,这对学习算法本身是有帮助的。

选Qt则是看中它三个方面。第一,QPainter画2D图形足够顺手,画网格、画棋子、绘制坐标变换这些功能都是现成的,不需要引入额外库;第二,Qt的信号槽机制天然适合处理“用户点击棋子”这种事件驱动的逻辑,而且能很方便地把界面和业务逻辑解耦;第三,Qt本身对C++的支持非常成熟,跨平台也稳定,你换台电脑重新编译基本不用改代码。

编译器方面,Windows下我建议直接用MSVC2019或MSVC2022的64位版本,装好对应版本的Visual C++ Redistributable运行库就行。如果只是自己学习,MinGW也可以,但后面如果你要接一些第三方C++库,MSVC的兼容性会好很多。第一次安装Qt时要特别注意:Qt 6.x对C++17支持更完整,如果只是做一个五子棋,Qt 6.5 LTS和Qt 5.15.2都能胜任,我个人更建议直接上Qt 6,省得以后升级。

2.2 棋盘Widget的职责边界:画和点,不负责决策

我在一开始设计BoardWidget类时定下一条原则:这个类只做两件事——绘制棋盘和响应鼠标点击,不持有任何游戏规则、AI逻辑。它需要的数据只有一份棋盘状态引用,以及绘制所需的几何参数(边距、格子大小等)。

为什么不直接让BoardWidget去判断“这个位置能不能下”?因为后面AI要接管落子,而AI根本不关心你的鼠标点在哪里,它只关心当前棋盘状态。如果把规则判断写在董事会Widget里,AI接入时就得绕过界面,反而麻烦。我的做法是让BoardWidget对外暴露两个足够干净的接口:setBoardData同步棋盘状态,以及cellClicked信号通知外部“用户点了某个行列”。至于这一手合不合法、轮到谁、要不要交给AI去下,全部由上层控制器去处理。

这种职责划分用一句话总结就是:BoardWidget是“显示器+鼠标外设”,不是“裁判”。把这个边界定清楚,后续写AI引擎时,你只需要调用棋盘状态类,根本不碰界面代码。

2.3 数据结构先行:15x15棋盘状态怎么存

界面显示需要棋盘状态,AI搜索也需要棋盘状态,所以棋盘状态不应该属于界面,而应该是一个独立的数据类。我用一个简单的GoBangBoard类来管理:

class GoBangBoard { public: static constexpr int kBoardSize = 15; enum class Piece : int { Empty = 0, Black = 1, White = 2 }; Piece at(int row, int col) const { return data_[row][col]; } bool place(int row, int col, Piece piece) { if (row < 0 || row >= kBoardSize || col < 0 || col >= kBoardSize) { return false; } if (data_[row][col] != Piece::Empty) { return false; } data_[row][col] = piece; return true; } void clear() { std::fill(&data_[0][0], &data_[0][0] + kBoardSize * kBoardSize, Piece::Empty); } const std::array<std::array<Piece, kBoardSize>, kBoardSize>& rawData() const { return data_; } private: std::array<std::array<Piece, kBoardSize>, kBoardSize> data_{}; };

这里用一个二维数组存黑子、白子和空位。实际搜索算法我会建议后面改成一维数组,因为memcpy恢复现场更快,但界面显示阶段二维数组的可读性更好,怎么方便怎么来。

这个类目前看起来很简单,但它已经包含了两个重要能力:落子前合法性检测(行越界、列越界、坐标非空),以及统一的棋盘状态访问入口。等到写AI时,极大极小搜索需要在虚拟棋盘上连续落子再撤销,到时候只需要复用place方法,再加上一个remove方法就能支撑整个搜索过程。

3. 棋盘绘制与落子交互的实现细节

3.1 几何参数计算:边距、格距和棋盘适配

画棋盘前要解决一个问题:窗体大小变化时,棋盘不能跟着乱变形。我采用固定边距加动态格距的方案。假设棋盘区域是正方形的,左右上下都留出相同边距margin,那么格子尺寸可以用widget的宽度算出:

int margin = 30; double cellSize = (widgetWidth - 2.0 * margin) / (kBoardSize - 1);

注意除以的是kBoardSize - 1,也就是14,而不是15。15路棋盘有15根横线和15根竖线,相邻线之间有14个间隔,所以格距要用14来算。这个细节一开始搞错的话,棋子会整体偏移半个格子,看起来特别别扭。

resizeEvent里重新计算cellSize_并触发update(),这样窗口拉伸时棋盘会自动缩放。如果不重写resizeEvent,窗口变化后棋盘还是按旧参数绘制,就会出现网格和棋子错位的问题。等会儿提到的像素坐标到棋盘坐标的换算,也必须使用同一套几何参数,否则点击落子的位置和实际绘制位置对不上。

3.2 用QPainter绘制网格、星位和棋子

绘制代码我放在paintEvent里,主要分三层:先画背景和网格,再画星位,最后画所有已经落下的棋子。

void BoardWidget::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 背景 painter.fillRect(rect(), QColor("#DEB887")); // 网格 QPen gridPen(QColor("#5A4A3A"), 1); painter.setPen(gridPen); for (int i = 0; i < kBoardSize; ++i) { QPointF startH = boardToPixel(i, 0); QPointF endH = boardToPixel(i, kBoardSize - 1); painter.drawLine(startH, endH); QPointF startV = boardToPixel(0, i); QPointF endV = boardToPixel(kBoardSize - 1, i); painter.drawLine(startV, endV); } // 星位 drawStarPoints(painter); // 棋子 drawPieces(painter); }

boardToPixel负责把行列坐标换算成像素坐标,核心代码就这个:

QPointF BoardWidget::boardToPixel(int row, int col) const { double x = margin_ + col * cellSize_; double y = margin_ + row * cellSize_; return QPointF(x, y); }

画棋子时要注意:黑子不要填纯黑色,否则在深色木纹背景下看起来一团死黑;白子也最好不要填纯白,加一点渐变效果会更有立体感。我自己写的时候用QRadialGradient画棋子,稍微加一点高光和阴影,视觉效果立刻好很多。这块不用花太多时间,但如果界面质感太差,后面测试AI时每天对着屏幕也会影响心情。

3.3 鼠标落子:像素坐标和棋盘坐标怎么互相换算

这是界面里最容易踩坑的地方。用户点击窗口上的某个像素点,你要算出它最近的是哪个交叉点。换算公式是行列坐标反解像素坐标:

void BoardWidget::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { return; } QPointF pos = event->position(); int row = qRound((pos.y() - margin_) / cellSize_); int col = qRound((pos.x() - margin_) / cellSize_); if (row < 0 || row >= kBoardSize || col < 0 || col >= kBoardSize) { return; } emit cellClicked(row, col); }

这里的qRound做了四舍五入,所以即使你点在两格中间,它也会自动吸附到最近的交叉点。对玩家来说这就像点到了“正确的位置”,实际用起来很舒服。如果想更严格一点,可以再加一个判定:如果点击位置距离交叉点超过某一阈值(比如0.4个格距)就忽略,这样可以避免用户明明点在格子中央却被错误吸附到旁边。量产品五子棋都是这么处理的。

注意Qt 6里QMouseEvent::pos()返回的是相对widget的QPoint,而在高分屏缩放开启的情况下,有精度要求的场合最好用event->position(),它返回的是QPointF,能避免整数精度丢失。这个小细节后来在我适配高分屏时帮了大忙,后面会专门说。

3.4 信号槽解耦:界面只发“事件”,主窗口只收“结果”

BoardWidget本身不判断“该谁落子”,它只负责发出cellClicked(int row, int col)信号。主窗口里的Controller逻辑接收到信号后才做判断:

void MainWindow::onCellClicked(int row, int col) { if (game_.isGameOver()) { return; } if (game_.currentPlayer() != GoBangBoard::Piece::Black) { // 当前是AI回合,玩家不允许落子 return; } if (!boardWidget_->placePiece(row, col, GoBangBoard::Piece::Black)) { // 该位置已经有棋子了 return; } game_.switchPlayer(); // 触发AI走棋,下一篇会实现 startAiThinking(); }

MainWindow持有Game对象,而Game持有GoBangBoardBoardWidget不直接修改Game的数据,而是通过信号通知主窗口,由主窗口调用GoBangBoard::place来更新数据,再调用boardWidget_->update()刷新显示,这样整个数据流是单向的:界面输入 → 控制器判断 → 数据变更 → 界面刷新。好处是等AI引擎写好之后,AI的落子结果也走同一个流程,界面代码不用改。

boardWidget_->placePiece其实就是一个便捷方法,内部做了两件事:调用GoBangBoard::place更新数据,再调用update()刷新界面。

bool BoardWidget::placePiece(int row, int col, GoBangBoard::Piece piece) { bool ok = board_.place(row, col, piece); if (ok) { update(); } return ok; }

4. 踩坑实录:Qt环境配置和绘图的常见问题

4.1 编译器、运行库和Qt版本的那点事

很多人第一关就挂在环境上。这里说三个我自己见过最多的坑。

第一个坑是“error: microsoft visual c++ 14.0 or greater is required”。这句话看着像Qt报错,其实大多数情况是你在用pip安装某个Python包时需要编译C++扩展,和Qt环境关系不大。解决办法是装一个Build Tools for Visual Studio,里面勾选MSVC工具链和Windows SDK。但如果你的Qt项目本身已经能正常编译,就没必要折腾这个,别被这个报错误导。

第二个坑是编译完的exe在别人电脑上跑不起来,提示缺少vcruntime140.dll或者VCRUNTIME140_1.dll。这是因为Qt本身用MSVC编译,你的程序也依赖对应版本的C++运行库。解决办法有两个:要么打包时把运行库一起带上,要么写一个简单的部署脚本,用windeployqt把Qt相关DLL全部拷贝到程序目录。windeployqt这个工具是Qt官方自带的,路径一般在Qt安装目录的bin下面,执行起来会自动复制依赖的Qt模块,但不会复制MSVC运行库,所以还需要额外处理运行库的问题。

第三个坑是选择Qt版本的时候犹豫不决。我的建议很简单:本地没有商业需求就选开源的,版本上直接选Qt 6.5 LTS或更高的6.x版本,不要停在5.15。道理很简单,Qt 6.x对C++17支持更好,很多新项目都以Qt 6为主,你现在学旧版,过几个月还得再适应一次。

4.2 重绘闪烁和棋子残影问题

绘制棋盘时如果代码写得比较随意,很容易出现两个现象:窗口拉伸时棋盘疯狂闪烁,以及棋子移动后留下残影。

闪烁的根本原因是在一次绘制循环里,窗口被清屏重画,但重画动作和屏幕刷新没有协调好。QWidget默认启用了双缓冲,按理说不该闪。但如果你直接对某个子控件反复调用update(),或者绘制过程中有大量耗时操作,还是会闪。我习惯的做法是把棋盘缓存到一张QPixmap里,只有棋子数量变化时才重画这个缓存,paintEvent里只做一次drawPixmap。这样即使窗口被频繁重绘,也只是简单地贴个图,不会有复杂的画线画圆的计算,自然也不闪烁。

残影的情况更多见的原因是我前面说的坐标换算出错。你画棋子的位置用的是boardToPixel,但擦除旧棋子或者判断新棋子位置时用的却是另一套系数,比如格子大小更新了但旧棋子坐标没有重新换算,结果就是旧棋子显示在错误的位置。这个问题没有捷径,只能从代码层面保证:所有几何参数只从boardToPixelpixelToBoard两个函数进出,不要自己在paintEvent里临时写一套坐标计算。

4.3 高分屏适配:为什么棋盘看起来模糊又错位

现在很多笔记本都是150%缩放,如果你不处理DPI,Qt的窗口会被系统拉伸,看起来模糊。Qt 5需要在main函数里先设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling),然后再创建QApplication,Qt 6默认开启高分屏支持,不用手动设置。

但光缩放还不够,鼠标坐标的映射也要用浮点。我一开始用的event->pos(),在150%缩放下,鼠标点击的像素坐标是经过系统缩放的,结果落点总和我预期差半个格子。换成event->position()之后,坐标精度就对了。

还有一个很隐蔽的问题:如果窗口背景用了paintEvent里调用fillRect填充,在高分屏下可能出现边缘发虚的情况。这是因为Qt默认的缩放模式下,widget不需要自适应像素密度时,painter渲染坐标系未必和物理像素对齐。最好设置Qt::AA_UseHighDpiPixmaps,同时保证绘制时抗锯齿开启,能很大程度上缓解这个视觉问题。

这块总结成一个检查顺序:先确认Qt版本是否默认开启DPI,再确认鼠标事件用的浮点坐标,最后检查绘制的抗锯齿是否打开。按这个顺序排查,90%的模糊和错位问题能解决。

4.4 关于“棋盘写好了但AI还没接”的调试技巧

在AI引擎实现之前,为了让界面能跑起来并验证交互,我通常会临时加一个“人类对弈模式”:黑棋和白棋都由玩家轮流点击落子。这样就能在还没有AI的情况下,把整个落子、状态切换、胜负判断的流程完整测一遍。

MainWindow里加一个临时变量bool humanVsHumanMode_,当这个变量为true时,无论当前是黑棋还是白棋,都由玩家落子。这样做的好处是很快就能发现棋盘绘制、坐标换算、信号连接哪一环有问题,而且后面AI接进来时,你只需要再写一个AiEngine::move()方法,把其中一方的落子来源从“鼠标点击”替换成“AI计算结果”,其他代码逻辑完全复用。这也是我一直坚持界面逻辑和算法逻辑分离的原因——好处在联调阶段会体现得非常明显。

5. 这一步做完之后,AI接口应该挂在哪里

界面这层搭完之后,下一步接AI引擎前,需要先把接口想好。我在设计时给AI引擎预留了一个最小接口,就三个方法:

class AiEngine { public: struct Move { int row = -1; int col = -1; }; // 根据当前棋盘状态计算最佳落子 virtual Move getBestMove(const GoBangBoard& board, GoBangBoard::Piece aiPiece) = 0; };

MainWindow在轮到AI时调用这个接口,拿到Move后,只要像播放器落子一样调用placePiece并刷新界面就行了。AI引擎内部有多深的搜索层数、评估函数怎么设计、估值表怎么存,都在接口后面自己实现,不污染界面代码。

这颗我在写界面时就已经把“AI引擎接口”留好了,后面实现极大极小搜索和α-β剪枝时,你会发现算法部分的代码完全不用改界面,只需要在一个纯逻辑类里不断调用GoBangBoardplaceremove方法就够了。真到了那种时候,你会回来感谢当初这个决定的。

最后再分享一个我在调试界面时的小技巧:如果你想直观地验证棋盘绘制和坐标映射是否正确,可以临时在鼠标点击的位置画一个半径很小的红色圆点,再在对应的交叉点上画一个正常大小的黑白棋子。如果红点和落子位置完全重合,说明你的坐标换算没问题;如果偏移,就回头检查boardToPixelpixelToBoard是否用了同一套margincellSize。这个方法虽然土,但排查起来特别快。界面稳定了,下一篇就是硬核的算法部分了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 14:36:24

Tesseract字库训练全流程指南:从OCR原理到Python落地实践

先说一个我自己的真实经历。早几年接了个票据识别的需求&#xff0c;客户给了一批扫描件&#xff0c;字迹清楚、背景干净&#xff0c;我当时直接用tesseract-ocr的chi_sim默认字库跑&#xff0c;心想这还不简单。结果识别率惨不忍睹&#xff0c;数字串错位、某些字体下的汉字直…

作者头像 李华
网站建设 2026/9/15 14:36:02

前端内存泄漏实战指南:闭包、DOM残留与Chrome DevTools定位

1. 这不是理论题&#xff0c;是线上事故的复盘现场“内存泄漏”这四个字在前端团队里&#xff0c;从来不是面试时背诵的八股文&#xff0c;而是凌晨两点告警群里突然炸开的红色消息&#xff1a;“用户侧内存占用持续攀升&#xff0c;30分钟内上涨400MB&#xff0c;页面卡死率上…

作者头像 李华
网站建设 2026/9/15 14:34:59

告别手动复制:文件夹同步备份与FreeFileSync实战指南

1. 文件夹同步备份到底解决什么问题1.1 为什么手动复制根本不是"备份"先说个我自己的教训。早几年我帮朋友整理工作资料&#xff0c;他电脑里有个叫"设计稿最终版"的文件夹&#xff0c;里面堆了几十个版本&#xff0c;什么"最终版_v3""最终…

作者头像 李华
网站建设 2026/9/15 14:33:49

Loop 窗口管理玩法:从拖来拖去到一次按键到位

Loop 窗口管理玩法&#xff1a;从拖来拖去到一次按键到位 【免费下载链接】Loop Window management made elegant. 项目地址: https://gitcode.com/GitHub_Trending/lo/Loop Loop 是 macOS 上的窗口管理工具&#xff0c;菜单栏常驻&#xff0c;靠一个触发键加上方向或快…

作者头像 李华