简介这是一份基于Qt框架与C开发的连连看桌面游戏项目在经典消除玩法上加入角色设定、经验值与升级等RPG元素整体定位为适合初学者的课程实践源码。项目不涉及复杂算法重点在于图形界面搭建和基础游戏逻辑实现适合正在学习Qt或准备完成同类作业的开发者参考。资源包共26个文件压缩后1.99MB包含cpp/h/ui等源码与界面文件、jpg图像素材、qrc资源打包配置、pro工程文件以及pdf说明文档结构紧凑便于直接导入Qt Creator阅读与构建。目前已有372人学习下载具有一定参考热度。通过该项目读者可掌握QGraphicsView绘图、信号与槽交互、QTimer计时等常用Qt技术同时理解如何将消除逻辑与轻量RPG数值系统结合对入门Qt游戏开发有实际帮助。1. 用Qt把连连看做成RPG这玩法我拆开看了一遍第一次看到「用Qt制作的RPG形式的连连看」这个资源描述时我以为是标题党——连连看就是连连看怎么和RPG扯上关系解压源码跑起来才发现这个项目把两套玩法揉在了一起玩家不再只是闷头消方块而是通过消除棋盘上的彩色方块来攻击敌人消除的方块类型决定伤害属性连续消除能触发技能关卡末尾还有BOSS战。技术与玩法两头都有货技术侧它用Qt Widgets QPainter手写棋盘渲染、自绘Ui组件路径检测采用「最多两次拐弯」的经典寻路方案玩法侧它引入了血条、法力值、技能冷却、多关卡推进这些标准RPG元素又没有扔掉连连看「找一对、消一对」的核心操作。对正在学Qt的人这个资源的价值在于它不是那种只有按钮和文本框的练习Demo而是一个「图形界面 数据结构 游戏循环」三者都有真实落地的完整工程大概两三千行代码能当课程设计、毕业设计也能当深入学习Qt事件系统和自绘控件的练手项目。这篇笔记我按「它怎么组织代码 → 核心算法怎么实现 → RPG玩法怎么挂上去 → 跑起来改起来要注意什么」的顺序来拆尽量让你读完直接能上手。2. 项目骨架拆解界面、逻辑、数据三层怎么分工先说结论这个项目的代码组织方式并不复杂但它很规矩地把「显示」「逻辑」「数据」分开了。看源码根目录就是一套标准的Qt Widgets工程结构按功能能拆成三个层次。2.1 目录结构与模块职责解开压缩包之后顶层结构大致是这样RPG_Lianliankan/ ├── RPG_Lianliankan.pro # Qt工程文件qmake ├── main.cpp # 程序入口 ├── mainwindow.h/cpp # 主窗口框架负责界面组装 ├── gamewidget.h/cpp # 棋盘的QWidget核心游戏视图 ├── board.h/cpp # 棋盘数据模型纯逻辑不涉及绘制 ├── pathfinder.h/cpp # 寻路算法判断两个方块能不能连 ├── role.h/cpp # RPG角色数据血量、蓝量、等级 ├── monster.h/cpp # 敌人数据与AI简单状态机 ├── battle.h/cpp # 战斗结算消除后伤害如何计算 ├── skill.h/cpp # 技能数据与冷却管理 ├── resources/ │ ├── images/ # 方块图标、角色头像、怪物贴图 │ └── sounds/ # 消除音效、技能音效 └── levels/ # 关卡配置JSON或自定义文本格式我第一眼看这个结构就觉得很舒服board里全是纯粹的数据操作——生成棋盘、检测剩余方块、重排gamewidget则是把所有和QPainter、鼠标事件相关的活儿都包下来了battle和skill是在数据结构之上叠加的RPG层。这意味着你要给连连看加新玩法基本不需要动底层寻路代码。编译方式很直接装上Qt 5.15或Qt 6.x建议5.15兼容性更稳用Qt Creator打开.pro文件直接构建就行。这个项目没有引入Qt之外的第三方库依赖只有Qt Core / Qt Gui / Qt Widgets三个模块所以不管在哪个桌面平台上都能编过。2.2 界面层设计自绘控件比QSS堆砌更灵活这个项目没有用QSS做皮肤而是通过重写paintEvent用QPainter画了整个游戏画面。主界面分为三个区域左侧是棋盘区右侧是角色信息面板血条、蓝条、技能图标与冷却遮罩底部是消息栏显示战斗日志、连击提示。这么做的好处很实际连连看的方块种类往往带属性特效冰系方块泛蓝光、火系方块泛红光用QPainter直接在绘制函数里叠加颜色滤镜、做选中高亮动画比用QSS控制背景图要容易得多。而且方块状态变化频繁每次消除都要刷新重绘用QPainter可以精确控制重绘区域void GameWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 先绘制棋盘格子背景 for (int row 0; row rows_; row) { for (int col 0; col cols_; col) { QRectF cellRect(col * cellSize_, row * cellSize_, cellSize_, cellSize_); painter.fillRect(cellRect, QColor(30, 35, 40)); painter.setPen(QPen(QColor(60, 65, 70), 1)); painter.drawRect(cellRect); } } // 再绘制每个方块图标 属性特效 for (int row 0; row rows_; row) { for (int col 0; col cols_; col) { int type board_-cellAt(row, col); if (type Board::EMPTY) continue; QRectF iconRect(col * cellSize_ padding_, row * cellSize_ padding_, cellSize_ - 2 * padding_, cellSize_ - 2 * padding_); drawBlock(painter, type, iconRect, row, col); } } // 最后画选中高亮和连线预览如果开启了提示 if (selectedRow_ 0) { QRectF selRect(selectedCol_ * cellSize_, selectedRow_ * cellSize_, cellSize_, cellSize_); painter.setPen(QPen(QColor(255, 215, 0), 2, Qt::DashLine)); painter.drawRect(selRect); } }这里的核心参数是rows_、cols_、cellSize_三者决定了整个棋盘在窗口中的实际像素布局。鼠标点击时把QMouseEvent里的坐标除以cellSize_取整就能映射到棋盘行列void GameWidget::mousePressEvent(QMouseEvent *event) { int col static_castint(event-pos().x()) / cellSize_; int row static_castint(event-pos().y()) / cellSize_; // 边界检查 if (row 0 || row rows_ || col 0 || col cols_) return; handleCellClick(row, col); }注意这里的坐标映射没有考虑窗口边距偏移因为它采用了全窗口绘制棋盘、不留边距的做法。如果你准备在外面包一层装饰性边框必须在映射公式里减去边框宽度这是最常见的翻车点之一。事件处理上的一个细节是鼠标单击只负责选择方块消除判定全部放在handleCellClick里异步进行——先board_-canConnect()判断连通性再battle_-applyDamage()计算伤害然后update()触发重绘。它没有用QTimer做逐帧游戏循环而是事件驱动这一点是Qt Widgets写棋类/消除类游戏和用游戏引擎写的最大区别。3. 核心算法拆解连连看连通性判定与防死局机制这个项目真正硬核的部分在这里。连连看的规则简单到一句话两个相同方块的连线最多拐两次弯且路径上的格子必须为空。但把这句话翻译成代码牵扯到网格BFS、剪枝、死局检测三个问题。3.1 最多两次拐弯的寻路算法典型的多拐弯寻路方案有两种BFS广度优先拐点统计和「沿直线枚举 中间点验证」。这个项目用的是后者效率好且容易理解。思路是这样的两个点A和B能连通只有三种情况——直线连接0次拐弯、一个拐点1次拐弯、两个拐点2次拐弯。bool PathFinder::canConnect(const Board board, const QPoint a, const QPoint b) { // 1. 边界条件两个点必须不同、内容相同且非空 if (a b) return false; if (board.cellAt(a.x(), a.y()) ! board.cellAt(b.x(), b.y())) return false; if (board.cellAt(a.x(), a.y()) Board::EMPTY) return false; // 2. 直线连接同行或同列中间无障碍 if (isLineEmpty(board, a, b)) return true; // 3. 一个拐点拐角点 c 必须同时与 a、b 共线且为空 QPoint c1(a.x(), b.y()); // 拐点方案一 QPoint c2(b.x(), a.y()); // 拐点方案二 if (board.cellAt(c1.x(), c1.y()) Board::EMPTY isLineEmpty(board, a, c1) isLineEmpty(board, c1, b)) { return true; } if (board.cellAt(c2.x(), c2.y()) Board::EMPTY isLineEmpty(board, a, c2) isLineEmpty(board, c2, b)) { return true; } // 4. 两个拐点枚举 a 点出发的延长线找能连到 b 的中转点 for (int row 0; row board.rows(); row) { QPoint waypoint(row, a.y()); if (row a.x()) continue; // a - waypoint 必须无障碍拐点1是(a.x(), a.y())与(row, a.y())的连接 // waypoint - b 必须满足单拐点条件 QPoint knee(row, b.y()); if (board.cellAt(waypoint.x(), waypoint.y()) Board::EMPTY isLineEmpty(board, a, waypoint) board.cellAt(knee.x(), knee.y()) Board::EMPTY isLineEmpty(board, waypoint, knee) isLineEmpty(board, knee, b)) { return true; } } for (int col 0; col board.cols(); col) { // 对称地枚举列方向 // 代码结构与上面一致只是换轴 } return false; }isLineEmpty的实现是逐格检查判断两个点是否共线若不共线直接返回false共线则遍历中间的格子只要有一个非空就返回false这里要注意端点本身不算障碍物。这种写法的算法复杂度是O(n)量级——枚举一行加一列每个方向做一次直线扫描。10×12的棋盘上最坏情况几十次迭代完全不需要担心性能。实际项目里QPainter绘制帧率才是性能瓶颈寻路根本不占时间。如果要在棋盘上显示连线动画只需要在这个函数里把路径记录下来返回一个QVectorQPoint而不是boolstruct ConnectPath { bool valid; QVectorQPoint points; // 依次是 a、拐点1、拐点2、b可能没有拐点 };这是很自然的扩展方向。项目原始版本只是高亮选中方块然后直接消除如果要加连线特效就必须这样改。3.2 死局检测与自动重排棋盘上剩下的方块不存在任何可消除对时必须能够自动检测并重排否则玩家会被卡死。死局检测的做法很简单遍历棋盘上所有非空格子两两配对调用上面的canConnect看是否有任意一对成立。复杂度是O(N²)但棋盘十几乘十几的规模一次全盘扫描也就是一两千次寻路调用毫秒级完成。Board::hasValidMove()的典型实现bool Board::hasValidMove() { QVectorQPoint cells; for (int r 0; r rows_; r) { for (int c 0; c cols_; c) { if (cellAt(r, c) ! EMPTY) cells.append(QPoint(r, c)); } } for (int i 0; i cells.size(); i) { for (int j i 1; j cells.size(); j) { if (cellAt(cells[i].x(), cells[i].y()) cellAt(cells[j].x(), cells[j].y()) pathFinder_-canConnect(*this, cells[i], cells[j])) { return true; } } } return false; }这里on的耗时都集中在这一行条件判断里先比较方块内容类型类型不同直接短路跳过寻路大量配对根本走不到canConnect那一步。所以实际运行比理论复杂度快得多。自动重排的逻辑是检测到没有可消除对时先打乱剩余方块位置再次检测如果打乱三次仍然有死局概率极低但存在就重新生成整张棋盘。这个「三次后重建」的兜底策略值得注意因为极端情况下方块分布可能让重排永远失败——不写这个兜底就会出现一个玩家无法消除、游戏卡死的状态。void Board::shuffleOrRebuild() { for (int attempt 0; attempt 3; attempt) { shuffleRemaining(); if (hasValidMove()) return; } // 三次重排仍然死局直接重建棋盘 generateBoard(); }这里还有一个细节RPG模式下棋盘是有限的资源——每消除一批方块玩家恢复一定法力值。重排时如果保留怪物掉落的特殊道具方块比如回复药水重排结果会影响战斗节奏。原始项目的处理是特殊道具不参与重排原样保留在格子里算是一个很细心的设计。4. RPG系统实战战斗如何与消除玩法结合如果只是做一个普通连连看这个项目不会有这种吸引力。RPG化的核心在于消除不再是唯一目标而是达成攻击的手段棋盘就是你的技能面板方块组合就是技能指令。4.1 战斗系统设计方块类型驱动伤害战斗系统的核心逻辑在battle.cpp里。简化后的流程是玩家消除一对或一组相同方块 → 根据方块属性确定技能/伤害 → 敌人掉血 → 敌人攻击回合 → 玩家扣血 → 检查胜负对应的核心代码结构大概是struct DamageResult { int damage; QString skillName; bool crit; int mpGain; // 消除后回复的法力值 }; DamageResult BattleSystem::resolveElimination(int blockType, int comboCount) { DamageResult result; // 基础伤害表不同方块类型对应不同属性攻击 static const QHashint, int baseDamage { {BlockType::Fire, 15}, {BlockType::Water, 12}, {BlockType::Earth, 18}, {BlockType::Wind, 10}, {BlockType::Holy, 25} }; int damage baseDamage.value(blockType, 10); // 连击加成一次消除4个以上方块时每多一个增加15%伤害 if (comboCount 3) { damage damage * (comboCount - 2) * 15 / 100; } // 暴击判定5%基础概率连击数超过5时额外加10% result.crit (QRandomGenerator::global()-bounded(100) (comboCount 5 ? 15 : 5)); if (result.crit) { damage damage * 2; result.skillName 暴击; } result.damage damage; result.mpGain 5 comboCount * 2; return result; }参数说明blockType对应棋盘方块的类型枚举comboCount是一次操作中实际消除的方块数量——注意这个数量不是配对的两个而是连锁反应中所有被消除的方块总数。伤害平衡的核心参数是基础伤害表和连击加成曲线想调难度改这两个数字就可以了。战斗回合的处理用了Qt的信号槽机制。消除发生后发一个blockEliminated(int type, int count)信号BattleSystem接收到信号后结算伤害再发damageApplied(int amount, bool crit)信号通知UI层弹字飘伤害connect(gameWidget, GameWidget::blockEliminated, battleSystem, BattleSystem::onBlockEliminated); connect(battleSystem, BattleSystem::damageApplied, gameWidget, GameWidget::showDamageNumber);这种事件驱动的架构确保了「消除动作」和「战斗结算」解耦。以后想改成「消除后敌人掉血但有延迟」比如加一个弹道飞行动画只需要在信号中间插入一个QTimer动画控制不需要改Board任何逻辑。4.2 技能系统冷却与法力管理技能和法力是这个项目从「连连看变体」进化为「RPG」的关键。角色释放主动技能不靠点击按钮而是通过「按特定顺序消除特定类型方块」来触发——这是一个很有趣的交互设计。举例来说设定「火火水」三连先消除一对火方块紧接着消除一对水方块中间间隔不超过3秒触发烈焰风暴。实现上用了一个QQueueint记录最近几种类型方块消除void BattleSystem::onBlockEliminated(int blockType, int count) { recentPattern_.enqueue(blockType); if (recentPattern_.size() 3) recentPattern_.dequeue(); // 连续多次消除同类型则视为组合技能的一部分 if (recentPattern_.size() 3) { QString patternKey QString::number(recentPattern_[0]) _ QString::number(recentPattern_[1]) _ QString::number(recentPattern_[2]); if (skillTable_.contains(patternKey)) { activateSkill(skillTable_[patternKey]); recentPattern_.clear(); // 技能触发后重置序列 } } }skillTable_是QHashQString, SkillDefinitionkey是「类型序列」value是技能定义名称、消耗法力、伤害倍率、冷却时间。技能触发后必须检查冷却时间和法力值是否满足否则就直接忽略本次触发——但这个逻辑里有个小坑如果技能在冷却中recentPattern_已经被清掉了玩家的三连就浪费了。原始项目的做法是冷却中的技能会打出一个「技能冷却中」的提示文本但不重置序列让玩家有机会在冷却结束后继续凑第三个消除。这个细节能改出完全不同的手感属于体验调优点。法力恢复策略是每消除一对方块回复5点法力消除4个及以上额外回复。而技能消耗通常在20-40法力之间。这样保证玩家必须连续进行多次消除才能攒出大招游戏节奏被法力值自然控制住了。5. 避坑记录Qt连连看RPG项目里我踩过的五个坑这类项目看着简单实际动手改的时候到处都是坑。以下问题我按「现象 → 原因 → 解决」的顺序写都是基于这个项目场景下大概率会遇到的。5.1 QPainter绘制坐标和设备像素比的错位现象在Windows上100%缩放下一切正常放到125%缩放的屏幕上运行时鼠标点击位置和方块绘制区域错位越靠右下角偏差越大。原因Qt 5.6之后默认支持高DPI缩放QPainter绘制时内部坐标映射到了设备像素而mousePressEvent里拿到的event-pos()是逻辑像素坐标直接用逻辑坐标除以cellSize_得到的行列索引在缩放场景下是错的。解决用devicePixelRatioF()做换算。更稳的做法是给GameWidget设置Qt::AA_EnableHighDpiScaling后在工程入口统一开启然后在事件处理里把坐标转换写清楚qreal dpr devicePixelRatioF(); int col static_castint(event-pos().x() * dpr) / cellSize_;另一种省事方案是直接固定窗口尺寸、禁用窗口缩放。但如果你的目标是做正式能发布的东西高DPI适配必须做这个坑绕不开。5.2 消除动画播放时棋盘状态和画面不同步现象加了消除动画后方块缩小消失、技能特效闪烁玩家在动画未播放完时快速点击另一个位置会发生「明明消除成功了但那一对又出现在棋盘上」的灵异事件。原因动画期间棋盘数据已经更新为EMPTY但点击事件触发的canConnect判断读取到的是旧状态或者反过来数据更新了但绘制端的缓存QImage没有失效导致残留画面。解决加一个isAnimating_互斥锁标志动画播放期间忽略鼠标事件void GameWidget::mousePressEvent(QMouseEvent *event) { if (isAnimating_) return; // 动画期间不接受点击 // 正常处理…… } void GameWidget::playEliminateAnimation(const QVectorQPoint positions) { isAnimating_ true; // QVariantAnimation 驱动透明度变化…… // 动画结束后调用 update() 重绘并置回 isAnimating_ false }这个方案是最简单的缺点是一旦动画卡住游戏就永远锁死。稳妥做法是给动画加一个定时兜底QTimer单发一次动画如果超过500ms没结束就强制复位isAnimating_。5.3 棋盘重排时shuffle算法出现「假随机」现象死局重排后新棋盘的可消除对数量明显偏少甚至刚重排完又死局玩家反复看到「无法消除重新洗牌」的提示。原因shuffleRemaining()如果用qrand()原地打乱一个QVector而方块种类分布极端不均匀比如火方块出现了10次其他种类各2次打乱后依然难以形成有效对。解决不要简单shuffle改成「统计每类方块剩余数量 → 重新构造一个向量 → 按数量填充类型 → 再洗牌一次」。而且洗牌前必须把特殊道具排除在外它们不参与棋盘配对。5.4 Qt信号槽连接使用lambda时捕获了悬空的this指针现象程序不定期崩溃调试器堆栈指向一个lambda表达式内部该lambda里调用了board_-canConnect()但调用时board对象已经被释放了。原因典型错误写法之一——在构造函数中连接了某个一次性信号lambda里按引用捕获了this当窗口关闭时信号仍然被触发比如QTimer还在跑此时this指向已销毁对象。解决所有连接使用Qt::UniqueConnection并且在窗口析构函数中确保所有QTimer停止。最稳的做法是用QPointer保护关键对象QPointerBoard safeBoard board_; connect(timer, QTimer::timeout, this, []() { if (safeBoard.isNull()) return; // 安全使用…… });顺带说一句QPointer只对QObject派生类有效Board如果没继承QObject就得换std::weak_ptr方案来保护。5.5 关卡配置文件中文路径导致的加载失败现象在中文Windows用户名比如C:\Users\张三\下运行关卡读取失败但英文路径一切正常。原因项目用QFileQTextStream读取关卡JSON时默认的QTextCodec没有设置UTF-8中文路径下的文件名编码转换出错导致QFile::exists()返回false。解决除了依赖Qt6默认UTF-8行为Qt 5.15开始也默认UTF-8还要在main函数里显式设置编码并且在读取关卡时打印错误日志方便排查int main(int argc, char *argv[]) { QApplication app(argc, argv); QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8)); // …… }6. 二次开发把固定关卡改成闯关地图以及扩展新玩法拿到这个资源后最常见的需求是「我要交作业/发版本不能跟原版一模一样」。我建议第一刀切在关卡系统上——因为原始项目的关卡是一组静态配置改动性价比最高。6.1 关卡结构改造从静态配置到可扩展的JSON关卡表原始项目在levels/目录下放了一组文本关卡配置。改造成「闯关地图」的思路很简单把配置范式升级为多关卡表每个关卡带独立的棋盘尺寸、方块种类数、怪物血量、回合限制{ level: 1, board: { rows: 8, cols: 10, blockTypes: 4 }, enemy: { name: 史莱姆, hp: 60, attack: 6 }, objective: { type: eliminate, target: 20 }, timeLimit: 60, unlockSkill: fireball }第1关的大方块种类少、血量低第5关之后提高种类数、加入更频繁的洗牌机制、怪物技能也从单次攻击变成AOE。这种配置驱动的方式让数值策划完全不碰代码就能调难度也是这个项目最容易扩展的一点。加载代码只需要在LevelManager里做一个QJsonDocument::fromJson 遍历解析就好。重点是把「配置解析」和「配置校验」分开——JSON的blockTypes字段如果大于整张棋盘可容纳的块数比如8×10的棋盘却配了12种方块生成棋盘时会有方块放不下的问题。我一般会在LevelManager里加一个validateLevel(level)的函数加载后先校验再进游戏不通过就弹错误提示并退回上一关。6.2 给技能系统加「连锁消除」新玩法我尝试过给这个项目加连锁玩法也就是玩家在3秒内连续消除多对伤害递增改造点在两个地方。第一在BattleSystem里维护一个comboTimer每次消除成功就重新计时如果在3秒内连续消除comboCount递增否则重置为1。第二把连击数和伤害计算的映射关系做成可配置表int BattleSystem::damageWithCombo(int baseDamage, int combo) { // combo1: 100%; combo2: 110%; combo3: 130%; combo4以上: 160% static const int comboBonus[] {0, 0, 10, 20, 30, 40, 50}; int bonus combo 6 ? 60 : comboBonus[combo]; return baseDamage * (100 bonus) / 100; }每次触发连击时消息栏显示「2连消伤害提升10%」RPG的感觉一下子就出来了。这个改动只动battle.cpp和gamewidget.cpp两个文件不涉及棋盘数据模型。6.3 二次开发完成后怎么验证改完代码后我习惯做三轮验证第一轮功能验证——按配置跑每一关确认消除、伤害、技能触发、怪物攻击、死亡判定、下一关解锁这条完整链路没有断。重点关注边界情况第一对消除就触发暴击、关卡时间耗尽瞬间恰好消除了最后一对、怪物死亡瞬间玩家刚好也被打死这种情况我认为平局处理比判定玩家失败更合理。第二轮数值平衡验证——因为RPG的核心是数值体验我在测试时手写了一个自动消除脚本用QTest::mouseClick模拟快速消除操作跑100局看通关率平均值。如果第5关的通关率低于30%说明数值设计太硬要给玩家增加法力恢复或提高技能伤害。第三轮资源回归验证——确认所有新增的贴图、音效资源在release打包后能被正常加载。检查点只有一个代码里用的是相对路径还是qrc资源路径。这个项目推荐用qrc方式打包资源进exe否则把可执行文件发给别人时会出现图标全部丢失、音效不响的经典翻车现场。6.4 一句实在话做这种「把经典玩法RPG化」的项目最重要的不是把代码写得多么高深而是让玩家在打开游戏的前两分钟就能感受到「这不是换皮连连看」——消除一个火方块时怪物头上冒出红色的伤害数字连击时屏幕边缘闪烁技能特效这种即时反馈比任何复杂的系统设计都更有说服力。我每次给这类项目加新功能习惯先拿出十分钟玩一局原版再动手改代码让「手感」而不是「架构」指导需求排期改出来的版本才有人愿意玩下去。希望这篇拆解能帮你快速跑通这个项目并找到自己的改造方向。本文还有配套的精品资源点击获取