
简介这是一份基于Qt框架与C实现的RPG式连连看桌面游戏课程项目源码面向已掌握C基础、希望熟悉Qt界面开发与信号槽机制的高校学生及游戏开发入门者。项目在经典连连看玩法上叠加角色成长设定通过消除配对获取经验、升级解锁特性适合作为课程设计参考或Qt练手范例。压缩包共26个文件约1.99MB以5个cpp源文件、4个头文件、3个ui界面文件为核心辅以qrc资源文件、pro工程配置、jpg界面素材以及txt说明与pdf文档构成一套可直接用Qt Creator打开编译的完整工程。目录中双人对战与信号模式各自独立成模块便于分块阅读界面布局、事件处理与资源加载的实现思路。目前已有373人学习对于想弄清QGraphicsView绘图、QTimer计时与qrc资源打包细节的读者这份工程提供了可对照运行的实例。1. 这不是普通连连看Qt 里塞进 RPG 地图后先看什么把 Qt 当成只会画按钮的框架容易错过这类包真正有意思的地方它把连连看棋盘塞进 RPG 地图角色走位、NPC 触发、关卡切换和消除判定共用一个事件循环。这个资源是一份用 Qt 制作的 RPG 形式连连看源码包适合想练 Qt Widgets、QGraphicsScene、信号槽和关卡数据驱动的人。它不是纯算法题也不是完整商业游戏胜在把地图探索、棋盘消除、角色属性三条线揉在一个能跑的工程里。想照着复现先别急着改玩法先把构建链、场景坐标和关卡入口跑通这三处不通后面加技能和道具都是空中楼阁。2. 拆包与构建从 .rar 到能跑起来的 Qt Widgets 工程拿到这种压缩包最怕一上来双击.pro或直接cmake .。桌面环境不同、Qt 安装方式不同、工作目录不同三个变量叠在一起编译错误会像滚雪球一样越滚越大。我一般先把包解到独立目录确认构建系统再决定走 qmake 还是 CMake最后才谈运行和改代码。2.1 先判断工程用的是 qmake 还是 CMakeQt Widgets 小游戏常见的工程组织有两类老一点的项目用.pro新一点的项目用CMakeLists.txt。这个包标题只说了 Qt没有给构建系统所以第一步不是猜而是扫目录。压缩包解压后先看顶层有没有.pro、CMakeLists.txt、.qrc这三个文件基本能判断工程骨架.pro决定 qmake 路径CMakeLists.txt决定 CMake 路径.qrc决定地图、图块、关卡数据是不是被编进可执行文件。mkdir -p $HOME/work/qt-rpg-link unrar x 用Qt制作的RPG形式的连连看.rar $HOME/work/qt-rpg-link/ find $HOME/work/qt-rpg-link -maxdepth 3 -type f \ \( -name *.pro -o -name CMakeLists.txt -o -name *.qrc \) -print逻辑说明先建一个不带空格、不带中文的独立工作目录避免 qmake 和 make 在路径转义上出问题find只扫三层防止把素材目录里的杂项全打出来。参数说明maxdepth 3是保守值多数 Qt 工程的构建文件在顶层或一级子目录-type f只看文件不看目录-name里的三个模式分别对应 qmake、CMake 和 Qt 资源系统。提示如果系统没有unrar用能解 rar 的归档工具解到同一目录即可关键是保持目录结构不要只把源码文件拖出来。判断出构建系统后还要看工程是不是纯 Widgets。打开.pro看QT 行常见的是QT core gui widgets如果是 CMake看find_package(Qt和target_link_libraries里有没有Widgets。这一步不是形式主义RPG 地图和棋盘多半依赖QGraphicsView、QGraphicsScene如果工程混了 QML 或第三方模块构建命令会多出依赖后面运行失败时至少知道该查哪一层。2.2 两条构建路径qmake 与 CMake 分别怎么跑有.pro时走 qmake但不要在压缩包外层目录直接执行。qmake 生成的 Makefile 会把相对路径写死后面换目录、换构建类型都容易翻车。我一般先定位.pro进入它所在目录再用 release 配置构建。可执行文件名不要猜构建完看TARGET或构建输出。PRO_FILE$(find $HOME/work/qt-rpg-link -name *.pro -print -quit) if [ -z $PRO_FILE ]; then echo 没有找到 .pro改用 CMake 路径 exit 1 fi PRO_DIR$(dirname $PRO_FILE) cd $PRO_DIR || exit 1 qmake $(basename $PRO_FILE) CONFIGrelease make -j$(nproc)逻辑说明find -print -quit只取第一个.pro避免多工程时误判cd到.pro所在目录再执行 qmake保证相对路径和资源路径按工程预期解析CONFIGrelease减少调试符号运行速度更接近实际体验。参数说明-j$(nproc)用当前机器的核数并行编译内存小的机器可以改成-j2或-j4如果 qmake 不在 PATH 里用 Qt 安装目录下 qmake 的绝对路径不要混用系统自带 qmake 和工程需要的 Qt 版本。有CMakeLists.txt时走 CMake核心是源码目录和构建目录分离。源码内构建会生成一堆CMakeCache.txt、CMakeFiles后面清理麻烦也不利于对比 release 和 debug。cd $HOME/work/qt-rpg-link BUILD_DIR$HOME/work/qt-rpg-link/build-release cmake -S . -B $BUILD_DIR -DCMAKE_BUILD_TYPERelease cmake --build $BUILD_DIR -j$(nproc)逻辑说明-S .指定源码根目录-B指定独立构建目录CMAKE_BUILD_TYPERelease控制优化级别cmake --build不绑定生成器Makefile、Ninja、Xcode 工程都能用。参数说明如果 CMake 报找不到 Qt加-DCMAKE_PREFIX_PATH/path/to/Qt这里的路径指向 Qt 安装前缀不是bin目录具体以本机安装位置为准并行数同样按内存调整编译 RPG 地图资源时内存吃紧就降下来。注意qmake 和 CMake 不要在同一源码目录里来回切换。先选一条路跑通再清掉构建产物换另一条否则缓存文件会互相干扰报错信息很玄学。2.3 运行前检查资源前缀、工作目录和关卡入口构建通过不等于能跑。Qt 工程常见的运行失败不是编译问题而是资源路径和工作目录。地图图块、棋盘图案、关卡 JSON 可能被.qrc编进二进制也可能放在外部目录按相对路径读。两种方式对启动目录的要求完全不同前者用:/前缀后者依赖当前工作目录。先看.qrc里注册了什么前缀再看外部有没有关卡文件能省掉很多“黑屏但没报错”的排查时间。cd $HOME/work/qt-rpg-link # 查看 qrc 注册前缀确认地图、图块、关卡数据是 :/ 路径还是外部文件 grep -n qresource -A4 --include*.qrc -r . 2/dev/null || true find . -maxdepth 5 -type f \( -name *.json -o -name *.xml -o -name *.ini \) -print逻辑说明grep找.qrc里的资源前缀-A4把前缀和文件列表带出来find找外部关卡数据判断运行时是否需要切工作目录。参数说明--include*.qrc只搜资源文件maxdepth 5覆盖常见data/levels层级如果grep没输出可能是 qrc 文件命名不同回到上一步的find结果里确认。运行时先在构建输出目录启动不要从桌面双击。双击的当前工作目录可能是用户主目录外部关卡和相对路径资源会集体找不到。命令行启动能直接看到 Qt 的警告输出比如QRC资源缺失、图片格式插件没加载、OpenGL 上下文创建失败。Windows 下如果提示缺 DLL用 Qt 自带的部署工具补齐运行库Linux 下用ldd看可执行文件依赖这些动作放在第一次运行失败之后做不要提前折腾。3. Qt 场景里接 RPG 地图与棋盘坐标、分层与信号槽RPG 形式连连看最容易写乱的地方是地图和棋盘抢同一个事件循环。角色要移动棋盘要响应点击NPC 要触发对话倒计时还要走。全塞进一个 QWidget 的mousePressEvent里短期能跑长期加一个道具就会互相踩。常见做法是用QGraphicsScene做分层地形一层、棋盘一层、角色和特效一层点击先落到场景再由场景发信号给控制器。3.1 为什么用 QGraphicsScene 承载地图层QWidget 自绘也能画地图但命中测试、图元移动、层叠顺序都要自己维护。QGraphicsScene自带图元管理、坐标变换和zValue层叠角色走一格、棋盘高亮、飘字特效可以分别挂在不同的QGraphicsItemGroup上。对这类小游戏性能不是瓶颈维护边界才是。分层之后角色移动只动 actor 层消除只动 board 层地形层基本不动排查问题时能快速判断是哪一层画错了。// mapscene.h 片段地图层与棋盘层分开避免角色移动时误触消除 class MapScene : public QGraphicsScene { Q_OBJECT public: explicit MapScene(QObject *parent nullptr); signals: void playerMovedTo(const QPoint mapCell); void tileClicked(const QPoint boardCell); public slots: void loadLevel(const LevelData level); private: QGraphicsItemGroup *terrainLayer nullptr; QGraphicsItemGroup *boardLayer nullptr; QGraphicsItemGroup *actorLayer nullptr; QPoint mapOrigin{0, 0}; // 棋盘左上角在地图场景中的格坐标 int cellSize 48; // 一个格子的像素边长按素材改 };逻辑说明场景只负责“显示”和“发事件”不直接改棋盘数据playerMovedTo和tileClicked把两类输入分开控制器可以根据当前状态决定是否响应。参数说明mapOrigin是棋盘在地图中的偏移改成别的值就能把棋盘挪到不同房间cellSize必须和素材像素对齐否则点击会偏半格三个层组用zValue控制前后关系角色层通常高于棋盘层特效层再高一层。3.2 地图格、棋盘格、屏幕坐标的三层映射这类项目翻车最多的地方是坐标。地图有自己的行列棋盘有自己的行列屏幕还有像素坐标。三层混着用表现就是“点 A 消 B”“角色走到墙里”“高 DPI 下点击偏移”。我一般只保留两个转换函数地图格到场景像素场景像素到棋盘格。所有点击先转成场景坐标再减棋盘原点最后才进消除判定。QPoint MapScene::mapCellToScene(const QPoint cell) const { return QPoint(cell.x() * cellSize, cell.y() * cellSize); } QPoint MapScene::sceneToBoardCell(const QPointF scenePos) const { const QPoint mapCell(qFloor(scenePos.x() / cellSize), qFloor(scenePos.y() / cellSize)); return mapCell - mapOrigin; // 负值说明点到棋盘外 }逻辑说明qFloor不能用整数强转替代负坐标或高 DPI 小数坐标下强转会往零截断导致边缘格判定错位减mapOrigin把地图格转成棋盘格返回值小于零或超过棋盘行列就丢弃。参数说明cellSize同时影响绘制和命中改素材尺寸时两处必须一起改scenePos来自mapFromScene之后的场景坐标不要直接拿 QWidget 的鼠标坐标。提示高 DPI 屏幕上先确认QGraphicsView的变换和场景矩形再调cellSize。点击偏移不一定是算法错可能只是视图缩放没对齐。3.3 信号槽解耦角色移动不直接改棋盘角色移动和棋盘消除应该由控制器协调而不是场景里互相持有指针。场景发“角色到了哪一格”控制器判断这一格是不是 NPC、是不是宝箱、是不是棋盘触发点棋盘发“点了哪个格”控制器判断能不能连、要不要扣回合。这样加新玩法时只改控制器场景和棋盘视图不用动。connect(scene, MapScene::playerMovedTo, controller, GameController::onPlayerMoved); connect(scene, MapScene::tileClicked, controller, GameController::onTileClicked); connect(controller, GameController::boardChanged, boardView, BoardView::refresh);逻辑说明场景到控制器是输入信号控制器到视图是状态刷新信号棋盘视图不直接读控制器内部容器只按boardChanged重绘。参数说明默认Qt::AutoConnection在同线程下是直连游戏逻辑一般都在主线程够用如果以后把寻路放到工作线程跨线程连接要显式用Qt::QueuedConnection并且不要在槽里碰视图对象。3.4 用 QTimer 管回合与倒计时RPG 元素一进来时间就不再只是连连看的倒计时还可能是角色行动点、NPC 刷新、buff 持续回合。不要用sleep或忙循环QTimer是 Qt 事件循环里最稳的做法。倒计时建议存“剩余秒数”不要存绝对时间差系统时间一跳绝对时间差会瞬间扣完或者卡住。QTimer *turnTimer new QTimer(this); turnTimer-setInterval(1000); connect(turnTimer, QTimer::timeout, controller, GameController::tickTurn); turnTimer-start();逻辑说明每秒触发一次tickTurn控制器里扣剩余秒数、推进 buff 回合、检查失败条件定时器归控制器或主窗口管理不要挂在临时对象上。参数说明setInterval(1000)是秒级回合想要更细的动画可以另开一个 16ms 渲染定时器但规则计时不要跟着渲染帧走暂停时stop()恢复时重新start()不要用标志位空转。4. Qt 消除逻辑拆解路径判定、道具与角色属性怎么挂钩连连看的核心不是图案而是“两个相同图块能不能用不超过两次拐弯的路径连起来”。RPG 形式又加了一层角色属性可能影响得分、障碍破坏、回合数甚至影响某些图块能不能点。这里最容易犯的错是把属性判断塞进路径判定里导致算法既算连通又算数值后面加道具就改不动。我的做法是路径判定只回答“通不通”属性和道具在控制器外层做门槛和结算。4.1 连通判定的三种写法与选型常见写法有三种递归 DFS 按拐弯数展开、BFS 按状态搜最少拐弯、直线预判加分情况枚举。递归写起来短但棋盘大时栈深和重复访问不好控分情况枚举要对直连、一折、两折分别写边界容易漏BFS 把“格子 进入方向 拐弯数”作为状态逻辑统一调试时也能打印状态。这个项目棋盘不会太大BFS 的可读性更值钱。bool GameController::canLink(const QPoint a, const QPoint b) const { if (a b) return false; if (!board.isInside(a) || !board.isInside(b)) return false; if (board.at(a) Empty || board.at(a) ! board.at(b)) return false; const int W board.width() 2, H board.height() 2; auto idx [W](int x, int y) { return y * W x; }; // 含一圈外边界 std::vectorint best(W * H, 4); // 到该格最少拐弯数4 表示不可达 struct Node { int x, y, dir, turns; }; std::queueNode q; const int dx[4] {1, -1, 0, 0}; const int dy[4] {0, 0, 1, -1}; for (int d 0; d 4; d) { int nx a.x() 1 dx[d], ny a.y() 1 dy[d]; if (nx 0 || nx W || ny 0 || ny H) continue; if (board.at(nx - 1, ny - 1) ! Empty QPoint(nx - 1, ny - 1) ! b) continue; q.push({nx, ny, d, 0}); best[idx(nx, ny)] 0; } while (!q.empty()) { Node cur q.front(); q.pop(); if (cur.x b.x() 1 cur.y b.y() 1) return true; for (int d 0; d 4; d) { int nx cur.x dx[d], ny cur.y dy[d]; if (nx 0 || nx W || ny 0 || ny H) continue; int px nx - 1, py ny - 1; if (board.at(px, py) ! Empty QPoint(px, py) ! b) continue; int turns cur.turns (d ! cur.dir ? 1 : 0); if (turns 2) continue; if (best[idx(nx, ny)] turns) continue; best[idx(nx, ny)] turns; q.push({nx, ny, d, turns}); } } return false; }逻辑说明棋盘外扩一圈虚拟空格让路径可以绕边界走best记录到每个格子的最少拐弯数同一格用更少拐弯到达才继续扩展目标格允许通过其他非空格不允许通过。参数说明turns 2是标准连连看上限改成 3 就变成更宽松的玩法board.at(x, y)要能把越界坐标当Empty处理否则外扩边界会越界起点四个方向先入队避免把起点本身算成拐弯。4.2 把角色属性接进消除规则属性不要写死在canLink里。路径判定只回答连通得分、障碍门槛、连击加成放在clearScore和canClearBlockedTile这类函数里。这样同一套路径算法可以服务普通模式、挑战模式、剧情模式改数值不用碰算法。int GameController::clearScore(const QPoint a, const QPoint b) const { int base 100; // 角色感知越高同色配对额外加成力量影响障碍门槛 int bonus player.perception * 5; if (player.hasTalent(连击)) bonus combo * 20; return base bonus; } bool GameController::canClearBlockedTile(const QPoint cell) const { return player.strength level.blockStrength(cell); }逻辑说明clearScore只算分不改棋盘canClearBlockedTile只做门槛判断真正移除图块仍由控制器统一提交。参数说明base是基础分perception和strength字段必须和关卡 JSON 对齐blockStrength是格子门槛不是全局常量不同关卡可以不同天赋字符串建议集中定义不要散落在多个文件里。4.3 关卡 JSON 字段与存档边界关卡数据用 JSON 最省事字段少、可读、改起来不用重编译。关键是字段职责要清楚地图尺寸归map棋盘位置和行列归board角色初始属性归player规则开关归rules。存档只存“变化量”比如当前关卡、角色属性、已消除格子、剩余回合不要把整张地图快照反复写进存档版本一改就对不上。{ levelId: rpg_link_01, map: { rows: 12, cols: 16, tileSize: 48 }, board: { origin: [4, 3], rows: 8, cols: 10, types: 12 }, player: { hp: 100, strength: 3, perception: 2, movePoints: 6 }, rules: { allowTurns: 2, timeLimitSec: 180, blockStrength: 5 } }逻辑说明origin是棋盘左上角在地图格中的位置和MapScene::mapOrigin对应tileSize和cellSize对应types控制图案种类种类越多越难凑对blockStrength和角色力量做门槛比较。参数说明timeLimitSec是秒级倒计时不要和回合数混用movePoints是角色每回合可走格数和连连看倒计时是两套资源扣哪个由控制器决定。字段作用常见误用board.origin棋盘在地图中的偏移直接写屏幕像素换分辨率就错位board.types图案种类数超过素材数量运行时报缺图player.strength障碍门槛和blockStrength单位不一致rules.allowTurns路径拐弯上限和算法里的硬编码 2 冲突4.4 道具和技能不要直接改棋盘容器道具一多最怕“炸弹”“锤子”“刷新”各自直接改棋盘数组。短期能跑撤销和回放就变成黑匣子。常见做法是让道具提交命令控制器统一改棋盘并记录历史。这样“后悔药”只需要回滚快照自动化回放也能按命令序列重放。// 道具只提交命令由控制器统一改棋盘方便撤销和回放 struct ClearPairCommand { QPoint a, b; }; void GameController::apply(const ClearPairCommand cmd) { history.push(board.snapshot()); board.take(cmd.a); board.take(cmd.b); emit boardChanged(); }逻辑说明命令对象只描述“要做什么”不直接碰视图控制器在apply里先存快照再改数据最后发刷新信号。参数说明history要有上限不然长时间游玩内存会涨快照可以只存被改的格子不必整盘复制回放时按命令序列执行不要依赖鼠标事件。5. 避坑与排查RPG 连连看最容易翻车的五处这类 Qt 小游戏包的血泪经验是编译错误反而好查运行时的“看起来没报错”最磨人。下面五处是我拆类似工程时反复遇到的按现象、原因、解决拆开写照着顺序排查基本能少走一半弯路。5.1 现象编译通过但运行黑屏现象程序启动后窗口出现地图和棋盘全是黑的控制台没有明显错误。原因通常是资源前缀不对或工作目录不对QPixmap加载失败但代码没检查isNull()也可能是场景矩形没设置图元画在可视区域外。解决先在启动时打印关键资源路径和加载结果再从构建输出目录运行确认.qrc前缀和外部关卡路径。把scene-setSceneRect和视图fitInView的调用顺序检查一遍别让图元画到场景外。# 从构建输出目录启动观察 Qt 的资源警告 cd $HOME/work/qt-rpg-link/build-release 2/dev/null || cd $HOME/work/qt-rpg-link ./可执行文件名 21 | tee run.log逻辑说明把标准错误和标准输出一起落到日志Qt 的资源警告、图片插件警告都会出现先切构建目录保证相对路径按预期解析。参数说明可执行文件名按构建输出替换不要照抄tee只是留痕不影响程序运行。解决阶段优先修资源路径不要先改绘制代码。5.2 现象消除后棋盘不刷新或残影现象两个图块逻辑上已经消掉画面上还留着或者下一次点击命中已消失的格子。原因多半是数据层改了但视图没收到刷新信号或者图元删了但命中容器没同步。解决让棋盘数据只通过一个入口修改修改后统一发boardChanged视图刷新时按数据重建图元不要只改颜色。命中测试以数据层为准不要以图元是否存在为准。注意不要在图元点击回调里直接delete图元再改数组顺序一乱就会出现悬空指针或重复删除。5.3 现象切换关卡后角色状态丢失现象第一关角色属性正常切到第二关后力量、感知、天赋全回到默认值或者血量继承了上一关的临时 buff。原因通常是关卡加载时重新构造了角色对象存档字段和关卡字段混在一起。解决把“角色基础属性”“关卡临时状态”“存档持久状态”分成三层读关卡时只重置临时状态持久状态从存档恢复角色对象不要在切关时重建改为重置字段。5.4 现象路径判定偶发误判现象明明能连的两个格子偶尔判不通或者隔墙也能连。原因常见于边界处理不一致有的地方把越界当墙有的地方当空格qFloor和整数强转混用目标格在 BFS 里被提前挡掉。解决统一board.at的越界语义路径判定只认一种坐标体系把canLink的输入限制在棋盘内给 BFS 加调试输出打印起点、终点、拐弯数和路径别靠肉眼猜。5.5 现象打包后资源找不到现象开发目录能跑换一台机器或换一个目录就缺图、缺关卡。原因通常是资源只存在于源码目录没有进.qrc或者运行时依赖当前工作目录。解决需要随二进制走的图块和关卡编进.qrc需要外部可改的关卡保留相对路径并在启动时用QCoreApplication::applicationDirPath()拼绝对路径不要依赖用户从哪里双击。部署时把 Qt 运行库补齐再在干净目录跑一遍。6. 进阶验证用回放脚本和耗时打点卡住性能瓶颈改完玩法后我一般不复肉眼点一局就收工而是录一组操作序列做回放固定关卡、固定随机种子、固定点击顺序断言消除数量、剩余回合、得分。这样改路径判定或属性公式时能立刻知道有没有把旧关卡打穿。回放不需要复杂框架一个 JSON 操作列表加一个控制器入口就够。// 回放入口按操作序列驱动控制器不经过真实鼠标事件 void GameController::replay(const QVectorQPoint clicks) { for (const QPoint cell : clicks) { onTileClicked(cell); if (isLevelFinished() || isFailed()) break; } }逻辑说明回放绕过视图层直接调用控制器能稳定复现规则问题每次点击后检查结束条件避免无效操作污染结果。参数说明clicks的顺序必须和录制时一致随机种子要在加载关卡前固定否则图案分布一变断言全废。卡顿用QElapsedTimer打点不要凭感觉。重点看三个位置关卡加载、棋盘刷新、路径判定批量调用。把耗时打到日志里超过阈值再查图元数量和图集大小。打点位置观察值常见瓶颈关卡加载首次加载毫秒数大图未缩放、JSON 重复解析棋盘刷新单次刷新毫秒数每次重建全部图元路径判定单次判定微秒数BFS 重复扩展、越界判断过多具体技巧是给地图格和棋盘格做双索引地图本文还有配套的精品资源点击获取