ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

六子棋Qt实战:从棋盘绘制到AI博弈的完整实现

2026/9/20 15:30:19 拓冰建站 浏览量
六子棋Qt实战:从棋盘绘制到AI博弈的完整实现 简介这是一套基于Qt开发的六子棋Connect6对战项目源码适合计算机相关专业学生用于课程设计、期末大作业或Qt项目实战练习。资源包含完整的C源码、头文件、Qt界面设计文件、工程配置文件以及界面截图和操作音效能够帮助学习者快速理解棋盘绘制、落子逻辑、胜负判定与界面交互的实现思路。整个压缩包共30个文件以cpp/h源文件、png图像、wav音频为主另含ui界面和pro工程配置整体大小16.97MB结构紧凑且易于导入Qt环境直接运行。截至目前已有810人学习下载项目经过实践验证并取得98分说明其完成度和规范性较高。学习者可借此掌握从界面搭建到游戏算法实现的完整流程也可在现有代码基础上扩展人机对战、网络对战等功能还可作为课程设计或期末大作业的参考范本是提升Qt开发能力的实用资源。1. 项目概述与选题思路1.1 为什么选择六子棋作为 Qt 实战项目拿到这份“qt项目实战六子棋项目源码.zip”的时候我先看了一下压缩包的结构里面包含完整的 Qt Widgets 工程文件、核心逻辑头文件与实现文件、资源文件以及一个已经编译好的演示程序。整体工程不大但麻雀虽小五脏俱全——棋盘绘制、鼠标交互、落子规则、胜负判定、人机对战 AI、打包发布全都覆盖了。六子棋这个选题比较讨巧。它的知名度远不如五子棋和围棋但规则反而更复杂一层属于“有一点门槛但门槛不高”的棋类。它和我之前写过的一些 Qt 小工具完全不一样那些项目大多围绕控件布局、数据展示、串口通信展开而六子棋需要你同时处理状态管理和算法逻辑对 Qt 事件循环、绘图系统、信号槽机制的锻炼非常直接。规则先交代一下六子棋Gomoku 的一种变体在 10x10 或 15x15 棋盘上进行双方轮流落子关键区别在于前五手正常落子从第六手开始每落一颗新子就必须从棋盘上移除一颗自己最旧落子时间最早的棋子保持盘面上双方各有五颗活子。先形成五子连珠的一方获胜。这里的“五子连珠”特指横、竖、斜三个方向连续五个同色棋子。有的变体要求恰好五个有的要求至少五个这个项目里我按“至少五个”处理在下文的胜负判定里能看到对应的实现。正因为有“每下一子必须移除旧子”的约束六子棋的策略深度比普通五子棋高不少。你不能只想着进攻还得时刻关注旧棋子的位置防止移除之后给对方让出空间。这个特性也让 AI 的评估函数比普通五子棋多了几个维度后面我会详细拆解。1.2 项目整体架构设计打开工程之后我看到项目采用的是经典的 Qt Widgets 单窗口结构整体分三层界面层QMainWindow 承载菜单栏、状态栏和棋盘绘制区棋盘区由一个自定义的 QWidget 子类完成所有绘制和事件响应。逻辑层棋盘状态类Board负责二维数组存储、落子/移除合法性判断、胜负判定AI 类AiEngine负责计算电脑落子位置。数据层用 enum 定义棋子状态空、黑、白用结构体封装落子坐标和落子顺序严格区分“棋子的身份”和“棋子的先后顺序”。我特别注意到这版源码没有追求复杂的 MVVM 架构也没有把逻辑塞进 QML而是老老实实用 QPainter 自绘棋盘。这个选择在实际开发里非常理性六子棋的界面复杂度没有那么高QWidget 自绘能够完全掌控每一步绘制的细节调试也方便不需要引入额外的抽象层。有人可能会问那为什么不直接用 QGraphicsView或者用一堆 QLabel 当棋子我在后面的技术对比里详细说。2. 核心规则与数据结构实现2.1 棋盘的存储方案与坐标约定六子棋的棋盘本质是一个二维网格。这版源码采用了固定尺寸 10x10对应的存储结构是一个二维整型数组const int BOARD_SIZE 10; int m_grid[BOARD_SIZE][BOARD_SIZE]; // 0 表示空1 表示黑子2 表示白子如果你要扩展成 15x15只需要改动 BOARD_SIZE以及后续 AI 扫描时的边界判断即可。坐标约定上源码统一用(row, col)表示行列而不是笼统的(x, y)这样在绘制和逻辑判断时可以少踩不少坑——row对应屏幕的 y 方向col对应屏幕的 x 方向别搞反。棋盘绘制时核心函数是paintEvent。代码里用QPainter先画背景色再画网格线最后画星位和棋子。绘制顺序有个小讲究背景铺底色、网格线、棋子、最后覆盖一层交互提示比如上一步落子的高亮标记。如果反过来网格线会被棋子盖住棋盘观感会差很多。棋子本身不是简单的drawEllipse完事源码里给它加了简单的径向渐变效果让黑子白子看起来有立体感。这部分代码不多但很实用——同样是 QPainter 画圆有高光和没高光的效果差距非常大。QRadialGradient gradient(center, radius); if (m_grid[row][col] BLACK) { gradient.setColorAt(0.0, QColor(80, 80, 80)); gradient.setColorAt(1.0, QColor(20, 20, 20)); } else { gradient.setColorAt(0.0, QColor(255, 255, 255)); gradient.setColorAt(1.0, QColor(200, 200, 200)); } painter.setBrush(gradient); painter.drawEllipse(center, radius, radius);2.2 落子与移除顺序追踪的巧妙处理六子棋最特殊的一条规则是从第六手开始每落一子必须移除一颗自己最旧的棋子。源码里处理这个逻辑的方式很优雅没有用复杂的数据结构而是给每个棋子记录了一个“落子次序编号”struct Move { int row; int col; int order; // 全局递增的落子序号 };维护一个QVectorMove blackMoves和QVectorMove whiteMoves每次落子后 push 进去。如果需要移除最旧棋子直接从blackMoves.first()或者whiteMoves.first()取出即可因为按落子顺序 push 进去后first 一定是最早下的那颗。这种“顺序追踪”的思路在棋类游戏里值得记住。如果你只存一个棋盘数组不额外记录落子顺序那么六子棋的移除规则实现起来就会特别绕——你必须遍历整个棋盘找出己方最早下的棋子效率低且容易出错。源码里用两个变长数组就把这个问题解决了。移除棋子时还有一个细节并不是所有己方棋子都可以被移除。源码里规定靠近棋盘中央的禁移区域也叫“无争区”不允许作为移除目标。这个区域在代码里用常量定义const int NO_MOVE_AREA_START 3; const int NO_MOVE_AREA_END 6; // 行和列都在 3~6 范围内的中间区域不可移除这条规则实际上给局面增加了博弈空间——你把旧子落在中央就等于给自己上了个“保险”对方移除不了但同时中央区域空间有限并不是无限量供应所以玩家需要在“防守旧子”和“进攻布局”之间做权衡。2.3 胜负判定五子连珠的扫描算法胜负判定是棋类项目的核心检验点。源码里的判定函数checkWin(int row, int col, int player)只检查最后一步棋的位置而不是全盘扫描——这个设计很讲究因为每下一步只需要判断这一步是否形成了五连大大减少了无效计算。判定逻辑是四方向扫描分别是水平、垂直、主对角线左上到右下、副对角线右上到左下。每个方向从当前棋子出发往正方向和反方向数连续同色棋子的数量加起来加一当前棋子本身如果总数不小于 5 则胜。bool Board::checkWin(int row, int col, int player) { // 四个方向向量水平、垂直、两个对角线 const int dirs[4][2] { {0, 1}, // 水平方向 {1, 0}, // 垂直方向 {1, 1}, // 右下对角线 {1, -1} // 左下对角线 }; for (int d 0; d 4; d) { int count 1; // 正方向 for (int i 1; ; i) { int nr row dirs[d][0] * i; int nc col dirs[d][1] * i; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_grid[nr][nc] ! player) break; count; } // 反方向 for (int i 1; ; i) { int nr row - dirs[d][0] * i; int nc col - dirs[d][1] * i; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_grid[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }代码本身不难但有两个新手容易踩的坑第一边界判断必须在访问数组之前否则m_grid[-1][5]会读取到未定义内存这是崩溃隐患第二判定完成后要区分“恰好五连”和“多于五连”如果在长连规则上想做成“恰好五连才赢”需要额外判断两端外延是否被对方棋子或边界堵住源码默认采用“至少五连即胜”的宽松规则如果你想改成严格规则可以自己加上两端检查。2.4 移除后的胜负复查六子棋里还有一个不太容易想到的逻辑移除棋子之后也有可能导致对方暴露出五连。比如对方有四颗子连在一起第五颗位置正好是你的一颗旧子挡住的你把这颗旧子移走空位出来了对方下一手直接补上去就赢了。源码里把这个情况处理得很干净removeStone()之后会立即对周边八个方向做一次胜负复查如果发现移除后对方形成了五连直接判定对方获胜。这一步是为了保证“移除棋子不能给自己挖坑”的规则一致性。从代码结构上看这个复查并不是扫描全盘而是只检查移除位置周围的棋子连线开销非常小逻辑却非常关键。3. 人机对战 AI 的设计与实现3.1 评估函数从“数棋子”到“打分”六子棋 AI 在源码里占据了相当篇幅也是整个项目里最有含金量的部分。它的核心思路是遍历棋盘上每一个空格子为每个位置计算一个分数分数从两个角度出发——如果我在这个位置落子对我是多少分如果对手在这个位置落子对对手是多少分。两者的加权和就是最终评分。简单的“数棋子”只能判断当前局面好坏但六子棋的盘面上双方始终各保留五颗子局势变化非常快。源码里的评分表对不同棋型做了量化核心分值大致如下在我的实现中略有调整棋型分值五连直接获胜100000活四两端畅通的四连50000冲四一端被堵的四连5000活三两端畅通的三连3000眠三一端被堵的三连500活二200眠二50这个分值表不是随便拍脑袋定的它遵循“获胜优先级远大于活棋优先级”的思想。活四之所以是五万而不是一万是因为活四下一步必然变五连所以活四和“已经赢了”几乎等价只比直接获胜低一个级别。冲四只有一端畅通对方还来得及堵住所以分值低一个量级。在实际评估函数里源码对每个方向滑动窗口进行覆盖扫描int evaluateDirection(int row, int col, int dr, int dc, int player) { // 以 (row, col) 为起点沿方向连续数 5 个格子 int count 0; // 当前方向 player 棋子的数量 int block 0; // 被堵住的一端数量 for (int i 0; i 5; i) { int nr row dr * i; int nc col dc * i; // 越界视为被堵 if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) { block; continue; } if (m_grid[nr][nc] player) count; else if (m_grid[nr][nc] ! EMPTY) block; } // 长度不足 5 的窗口忽略 if (count block 5) return 0; // 根据 count 和 block 的情况查表返回棋型分 return scoreTable[count][block]; }这个函数会以每个空格为中心扫描所有方向的五格窗口统计窗口内己方棋子的数量和被阻挡的数量从而判断出活三、眠三等棋型。每次评估需要扫描四方向乘五格棋盘是 10x10空位最多 100 个计算量完全可控单次评估耗时在毫秒级人机对战完全感觉不到卡顿。3.2 进攻与防守的权衡AI 在拿到所有空格的评分之后如果只按照“己方分数最高”落子就会变成纯粹的进攻型 AI很容易被对手识破。源码里做了一个非常常见的“攻守兼备”策略综合分 己方分数 防守系数 × 对手分数。int totalScore myScore defenseWeight * oppScore;defenseWeight一般取 0.9 左右。这意味着对手有威胁的位置AI 也会优先去堵但堵的优先级略低于自己直接形成大棋型的优先级。这个参数可在源码顶部直接调整默认值经过实战测试在“过于激进”和“完全防守”之间平衡得比较好。如果你想让 AI 更聪明一点可以再加一步如果myScore已经达到获胜分值比如出现了活四直接落子不再看对手如果oppScore达到获胜分值必须优先堵截对手的棋眼。这是最简单的“基础博弈”逻辑源码里已经做了这部分剪枝。六子棋和五子棋在 AI 策略上还有一个重要差异被移除棋子同样是攻防的一部分。源码里 AI 评估时除了计算“落子在哪”还会额外模拟一步“如果落子后要移除旧子哪个旧子被移除后对己方最有利”。这个逻辑放在chooseStoneToRemove()函数里它会遍历己方所有可移除的旧子模拟移除后评估局面分数选择让己方局面最优的那颗。这一步很关键因为六子棋不是简单的加法旧子的位置直接决定了新子落下去之后整个棋形的走向。有些新手玩家会把 AI 做成只算落子不算移除玩起来就会发现 AI 经常移除掉自己关键位的棋子表现得异常愚蠢——这个坑我在自己的一版草稿里踩过调试了整整一个下午才发现是移除策略没写。3.3 AI 难度的三档调节机制有的六子棋源码只有单一 AI 强度对战几次就腻了。这份源码额外设计了简单、普通、困难三档难度实现方式很有意思不是调搜索深度而是用概率干扰简单难度AI 有 30% 的概率在评分前五的位置里随机选一个看起来会“手抖”。普通难度直接取评分第一的位置不引入随机性。困难难度评分第一的基础上加入两步搜索即模拟对手下一步回应再评估自己后续的最佳应对。困难难度的两步搜索在代码里并不复杂——本质上就是“先复制棋盘状态假设对手在最优点落子重新评估局面再选择自己最优点”。由于六子棋每方活子数固定是五颗搜索空间被限制得很小两步搜索在 10x10 棋盘上依然能在几十毫秒内完成。如果你想让 AI 更强可以从两步搜索扩展到四步搜索但要注意搜索深度每增加一层时间开销会指数级增长。在六子棋这种早期局面上一步评估加两步搜索已经能打败大多数休闲玩家了。4. Qt 界面交互与绘制细节4.1 为什么选择 QWidget 自绘而不是 QGraphicsView很多 Qt 初学者拿到棋类项目第一反应是用 QLabel 摆放棋子图片或者用 QGraphicsView 加 QGraphicsItem。这份源码没有这么做而是从 QWidget 派生了一个棋盘类重写paintEvent和mousePressEvent。我试过各种棋类界面方案客观来说如果只是静态棋盘QLabel 摆放 100 个棋子控件确实能快速完成但后续做落子动画、AI 思考提示、上一步高亮、可移除区域标记控件方案会变得非常痛苦——你得维护 100 个 QLabel 的状态同步稍微一个更新漏了界面和逻辑就脱节了。QGraphicsView 的思路更接近“场景-视图”模型功能强大但学习曲线陡峭而且对于六子棋这颗体量的项目来说等于请了个杀鸡用的牛刀。QWidget 自绘的实际好处是绘制状态完全由自己的变量控制逻辑层一变update()触发重绘界面立刻同步。自定义绘制可以轻松实现渐变、圆角、高亮标记视觉效果不输给 QGraphicsView。调试方便——你可以在paintEvent里临时画辅助线、打印坐标找问题非常快。4.2 鼠标事件与坐标映射棋盘绘制区并不等于整个窗口所以鼠标点击后需要做一次坐标换算。源码里采用统一的计算公式int col (event-pos().x() - boardMargin cellSize / 2) / cellSize; int row (event-pos().y() - boardMargin cellSize / 2) / cellSize;这里的boardMargin是棋盘边距cellSize是每个格子的像素宽度。加cellSize / 2是为了做四舍五入——落子位置取最近交叉点而不是格子内部。如果不加这个偏移量玩家点在格子的左下角可能会误判到相邻的交叉点体验非常差。坐标换算之后还要做合法性检查目标位置是否在棋盘内、是否已经有棋子、当前是不是玩家回合、游戏是否结束。这些检查按顺序排列任何一条不满足就直接 return不产生任何界面更新。鼠标事件处理完落子之后紧接着触发 AI 落子。但注意AI 计算不能阻塞界面线程。源码里用QTimer::singleShot(200, this, Board::aiMove)做了一个 200 毫秒的延时后调用 AI模拟“电脑在思考”的过程同时也给界面留出刷新时间。如果你在 AI 计算的时候界面卡住不动检查一下是不是 AI 逻辑写在鼠标事件回调里同步执行了。4.3 双缓冲绘制与防闪烁Qt 的 QWidget 默认开启了双缓冲机制大部分情况下你不需要手动处理闪烁。但如果你的paintEvent里绘制逻辑特别复杂或者你手动调用了setAttribute(Qt::WA_OpaquePaintEvent)关闭了背景填充就需要注意闪烁问题。源码里的处理方式很标准在构造函数里设置setAttribute(Qt::WA_OpaquePaintEvent)避免无意义的窗口背景重绘然后所有的绘制逻辑都在paintEvent内完成。每次落子或移除棋子后调用update()而不是repaint()——update()会把重绘请求合并到事件循环里连续多次更新只会触发一次实际重绘性能更好。这里有一个经典坑不要在paintEvent里调用update()。如果绘制过程中又触发更新请求会造成递归重绘轻则 CPU 飙高重则程序直接崩溃。源码里所有状态变更都封装在逻辑函数中不在绘制函数里直接改数据这个习惯在编写 Qt 绘图代码时一定要养成。4.4 状态栏、菜单与游戏流程控制完整的六子棋程序不能只有棋盘还需要基础的控制逻辑。源码的 QMainWindow 里建了菜单栏包含“新游戏”“悔棋”“退出”三个动作。这三个动作的实现各有讲究新游戏清空棋盘数组、清空两个落子队列、重置落子序号和回合标记、update()刷新界面。悔棋六子棋的悔棋比五子棋复杂因为不仅要把新落的子撤掉还要把移除的旧子放回去。源码里的处理方式是维护一个“历史操作栈”每步记录完整状态快照棋盘数组、落子队列、当前回合悔棋时直接恢复快照即可。退出qApp-quit()但关闭前需要判断当前是否有未完成的对局弹 QMessageBox 确认。这段代码虽然简单但是养成“危险操作前确认”的习惯很重要。状态栏方面源码实时显示当前对局状态比如“轮到黑方落子”“黑方获胜”“白方获胜”“平局”。这些信息通过信号槽从 Board 类发送到 MainWindow由 MainWindow 负责更新 QStatusBar。界面层不直接访问棋盘内部数据而是通过信号传递状态变化这个设计保持了界面和逻辑的低耦合值得学习。5. 打包发布与常见问题排查5.1 用 windeployqt 打包成绿色运行包如果你只在自己的 Qt 开发环境里运行程序那当然没问题但要把这份六子棋源码发给朋友对方电脑上没装 Qt程序就跑不起来。最常见的报错是windows no qt platform plugin could be initialized reinstalling the applicat这个报错的本质是 Qt 找不到 platforms 插件qwindows.dll。解决办法是用 Qt 官方提供的 windeployqt 工具自动收集依赖。在 Windows 命令行下进入 Qt 的 bin 目录比如C:\Qt\6.5.0\mingw_64\bin然后执行windeployqt.exe D:\build\SixInARow.exe执行完成后程序目录下会自动生成 platforms、styles、iconengines 等插件目录以及 Qt6Core.dll、Qt6Gui.dll、Qt6Widgets.dll 等运行库。这时候把整个目录压缩成 zip 发给别人解压后双击 exe 就能直接运行。如果你用的是 MSVC 编译器还需要确认 Visual C RedistributableVC 运行库是否已经安装到目标机器上。最简单的方法是把 vcruntime140.dll、msvcp140.dll 一起复制到程序目录下做成真正的零依赖绿色版。源码附带了一个package.bat脚本内部就是先编译、再 windeployqt、最后压缩成 zip 的流程这个脚本可以直接照抄进自己的项目。5.2 我实际踩过的三个坑第一个坑是中文字体显示成方块。如果目标机器没有安装对应的中文字体Qt 默认字体回退可能失败。解决方案是在 main.cpp 里统一设置字体QFont font(Microsoft YaHei); font.setPixelSize(14); app.setFont(font);第二个坑是打包后缺少 QRadialGradient 所需的资源。这个渐变效果本身不需要额外图片资源但如果你在代码里用到了:/images/这种资源路径记得在 .pro 文件里添加RESOURCES res.qrc并用rcc工具把资源编译进可执行文件。否则打包后的程序在打开时可能会因为找不到资源而闪退。第三个坑比较隐蔽QPainter 绘制抗锯齿效果在部分老旧显卡驱动下会异常。表现是棋子边缘出现锯齿或者拖影这通常不是代码问题而是目标机器的显卡驱动太老QPainter::Antialiasing渲染异常。代码层面没有好的修复办法只能是换一台机器测试或者升级驱动。不过这种问题在实际分享过程中遇到频率不高知道即可。5.3 快速判断程序依赖是否完整的技巧拿到一个 Qt 程序报错“缺少 dll”或者“无法初始化平台插件”时我习惯用 Dependencies 工具也可以直接用 Process Explorer查看 exe 的依赖列表。红色的条目就是缺失的库。如果发现Qt6Widgets.dll缺失直接把它连同 Qt6Gui.dll、Qt6Core.dll 一并复制进去如果发现 platforms 目录不存在就手动创建 platforms 文件夹并把 qwindows.dll 放进去。更简单的办法是把程序目录下的文件数量和平台插件目录列出来对比一下我在排查时经常遇到的情况是windeployqt 执行成功但程序仍然报错最后发现是执行命令时没有使用与编译器匹配的 Qt 版本。比如用 MSVC 编译器构建的动态库却用 MinGW 版本的 windeployqt 打包版本不一致就会漏掉关键依赖。这个坑解决起来不麻烦重跑一次对应版本的 windeployqt 即可但排查过程确实很磨人。6. 我觉得这份源码值得深入改的几个方向我在读完这份六子棋源码并实际编译运行之后最大的感受是它并不追求炫技而是把每一个环节都做得稳扎稳打。数据结构用最基础的数组和队列AI 用最简单的评分函数加两步搜索界面用最朴素的 QPainter 绘制但组合在一起就是一个体验完整的棋类小游戏。如果你看完这份源码也想动手改一改我个人建议从以下几个方向入手把固定 10x10 棋盘改成玩家可选 10x10、15x15、19x19对应的禁移区域和 AI 评分窗口也要跟着动态调整。给 AI 增加更深的搜索层数结合 alpha-beta 剪枝让困难难度更接近“下不过”的程度。增加联机对战。用 QUdpSocket 或 QTcpSocket 把落子数据发到局域网对端这比按键操作的新游戏好玩得多。把 QPainter 棋盘迁移到 OpenGL 窗口支持平滑的落子动画和移除动画体验会上一个台阶同时你能学到 Qt 的 OpenGL 集成方式。其中增加联机对战是我觉得最值得做的扩展。六子棋的每一步数据非常轻量化只需要传“坐标、棋子颜色、移除的旧子坐标”这三条信息加在一起不超过十个字节用 UDP 协议即可不需要复杂的序列化框架。我后来在另一个聊天工具项目里复用了这套数据协议思路完全一致。最后再说一点调试心得棋类项目的逻辑 bug 很难通过肉眼发现我当时在 AI 评估函数里写错了一个方向向量导致 AI 只能识别水平方向的棋型垂直方向完全不防守。这种问题靠运行看界面很难定位最好在代码里加一个“调试标记模式”点击某个快捷键后把每个格子的评分以文本形式打印到控制台对照棋盘一眼就能看出哪里算错了。这个习惯我一直沿用到现在。本文还有配套的精品资源点击获取