简介:一款基于Qt/C++开发的RPG风格连连看桌面游戏项目,作为大学课程作业,面向Qt与C++入门学习者,演示了经典消除玩法与角色成长系统的结合思路。压缩包共26个文件,约1.99MB,涵盖cpp源文件、h头文件、ui界面文件、qrc资源文件、pro工程配置,以及jpg图像素材、txt说明和pdf文档,结构清晰,便于直接编译运行与对照学习。项目使用QGraphicsView绘制游戏面板、QTimer控制计时,通过信号与槽机制处理交互事件;RPG元素体现在经验值获取、角色升级等设计上。已有372人学习/下载。阅读源码可掌握Qt界面搭建、资源系统打包、简单游戏逻辑实现等知识,适合希望将基础C++能力落地为完整桌面应用的初学者参考。
1. 用Qt做RPG式连连看:这个组合到底在解决什么
拿到「用Qt制作的RPG形式的连连看.rar」这个包,如果你只是想在棋盘上实现「两个相同方块连一连」,那大概浪费了标题里那三个字母。RPG形式的连连看不是给每一关加一张背景图、给玩家加一个头像,而是让「消除」这件事成为角色成长、剧情推进和关卡目标里最核心的驱动项。玩家每消除一对,获得的不是单纯分数,而是经验值、道具充能和触发事件的关键资源,这样打的每一局都有养成层面的理由。
这个方向适合两类人:一类是有 C++ 基础、想用 Qt 做一款带完整玩法循环的练习项目的开发者,把数据结构、图形视图框架和游戏循环一次性串起来;另一类是已经实现过基础连连看、意识到单纯记分太单薄、想给它加入关卡与成长系统的从业者。我建议先以「棋盘 + 消除 + 成长循环」为最小范围来做,而不是上来就铺剧情。标题里的 RPG 形式可以理解为一种规则包装,我的做法是让核心搜索算法稳下来以后,再把角色、关卡、道具一层层叠上去,这比先在界面上做文章更不容易做成一堆贴图。
2. 先把游戏骨架立起来:Qt架构分层与绘图选型
2.1 为什么把棋盘数据与界面分开管理
RPG 化的连连看最怕一件事:棋盘数据和界面状态搅在一起。你加一个角色升级技能,就发现技能的回血逻辑要操作 QLabel 的 text;你加一个道具特效,又发现消除判定和图形节点绑死在同一条代码路径里。这种写法在小 Demo 阶段跑得通,一旦关卡数量上到两位数,改哪里都像拆地雷。
我习惯把项目拆成三层:数据层只存放「哪一行哪一列是什么类型的图块、是否可用」,规则层负责连通判定、消除、掉错、技能结算,表现层由 QGraphicsScene 管理所有看得见的画面元素。数据层里面用结构体描述一块石头,这是最便宜的抽象方式:
// 图块描述,放在 model.h 里 struct Piece { int row = 0; int col = 0; int type = 0; // 类型编号,0 表示空位 bool enabled = true; }; class GameModel : public QObject { Q_OBJECT public: int rows = 10; int cols = 14; QVector<QVector<Piece>> board; void initBoard(int r, int c) { rows = r; cols = c; board.resize(r); for (int row = 0; row < r; ++row) { board[row].resize(c); for (int col = 0; col < c; ++col) { Piece p; p.row = row; p.col = col; board[row][col] = p; } } } };这段代码把棋盘抽象成二维数组,每一格子都是独立结构体。enabled字段很容易被忽略,但它解决了一个实际问题:消除动画播放的瞬间,棋盘逻辑已经该格子清空了,可界面上的图块还在飞。让数据层先标记为不可用,再让表现层等动画结束,可以有效避免「逻辑上消了、画面上还在」的错乱。
2.2 QGraphicsScene 与 QWidget 自绘怎么选
实现连连看界面,常见做法有三种:摆满 QLabel、继承 QWidget 重写 paintEvent、使用 QGraphicsScene + QGraphicsItem。三者的取舍不是越底层越好,而是看你到底要做什么类型的动画。
| 方案 | 适合场景 | 主要代价 |
|---|---|---|
| QLabel 数组 | 快速验证功能,不做特效 | 数量少时尚可,8x10 以上操作卡顿 |
| 自绘 QWidget | 想保留完全控制权 | 碰撞、层级、动画都要自己写 |
| QGraphicsScene | 图块节点多、需要补间动画 | 概念多,初期有学习成本 |
我一般选 QGraphicsScene,因为连连看同时存在大量静态图块和少量移动中的图块,图形项框架天然支持这两种状态。静态图块用QGraphicsPixmapItem,移动图块在动画结束前保持独立状态,结束后再合并到场景里。这样不需要每次刷新都重画全部棋盘,性能也更可控。
2.3 用信号槽串联「点击-消除-刷新」流程
极简但完整的 QGraphicsView 骨架可以这样写:
class GameView : public QGraphicsView { Q_OBJECT public: explicit GameView(GameModel* model, QWidget* parent = nullptr) : QGraphicsView(parent), m_model(model) { m_scene = new QGraphicsScene(this); setScene(m_scene); rebuildScene(); // 根据 model 生成初始图元 } signals: void gridClicked(int row, int col); protected: void mousePressEvent(QMouseEvent* event) override { QPointF scenePos = mapToScene(event->pos()); // 把场景坐标换成棋盘行列号 int col = static_cast<int>(scenePos.x() / CELL_SIZE); int row = static_cast<int>(scenePos.y() / CELL_SIZE); emit gridClicked(row, col); } private: GameModel* m_model; QGraphicsScene* m_scene; static constexpr int CELL_SIZE = 56; // 每个格子的像素尺寸 };mapToScene是这里的关键调用,它把鼠标在控件上的坐标换算成场景坐标,再做一步整数除法就得到棋盘行列。CELL_SIZE是全局常量,它同时决定渲染和点击换算,只要一个地方改,两处同步。假如你在高 DPI 屏幕上发现点击位置偏移,优先检查这一步是不是直接用整数除以像素数,后面避坑章我会再展开。
控制器接住点击信号后,依次做「找路径 → 判定可达 → 消除 → 掉落填充」,每一步都通过信号通知视图刷新。视图不直接检查模型,模型不直接操纵图元,调试时打印各层信号就可以定位问题,这就是分层最大的回报。
3. RPG元素怎么落在连连看上:关卡、角色与道具
3.1 从无限棋盘到剧情地图:关卡结构设计
RPG 化的第一步是给关卡建一个结构化的描述,而不是把地图写死在代码里。我见过很多 Demo 把 12 行 14 列的地图类型直接写成一个二维数组,放在 main.cpp 里,改一关就得重新编译一次。这种做法在原型阶段没问题,但一旦想加「第三章需要 15 行 18 列、本关限步 35 步」,代码就会爆炸。
更通用的做法是用一个配置结构描述每关的规则,棋盘类型则由一个独立数组表示。结构体负责「这一关怎么玩」,数组负责「这一关长什么样」。
struct StageConfig { QString stageName; // 关卡名,比如「迷雾森林」 int rows = 10; int cols = 14; int moveLimit = 0; // 0 表示不限步 int timeLimitSec = 0; // 0 表示不限时 QVector<int> keyCollectTypes; // 本关必须消除的类型,比如 7、9 int keyCollectCount = 0; // 需要消除多少个指定类型才能过关 QString bgMusic; // 背景音乐资源名 };关键在于keyCollectTypes+keyCollectCount。这组字段把 RPG 的任务目标翻译成了连连看的语言:不是「打败怪物」,而是「消除 12 个冰晶形状图块」。图块类型仍然只有整数,但它被赋予叙事含义后,就成了剧情目标和玩法目标之间的桥梁。
3.2 角色属性如何影响消除规则
角色养成的点不应该只是「显示一个等级数字」,而是要跟消除结算有实际交互。我做的设计是:角色等级影响每次消除的得分倍率,技能冷却进度影响玩家能否主动回血或洗牌。这样玩家每次消除都同时在做三件事:推进棋盘、积累经验、积攒技能条,三个目标彼此重叠,玩法深度才会出现。
class PlayerInfo { public: int level = 1; int exp = 0; int expNext = 100; void gainExp(int value) { exp += value; while (exp >= expNext) { exp -= expNext; level++; expNext = expNext * 1.4 + 50; // 升级所需经验递增 } } int scoreMultiplier() const { // 消除得分倍率 return 100 + (level - 1) * 15; } int skillPoints() const { // 技能条,0~100 return qMin(100, skillPoint); } private: int skillPoint = 0; };参数里最值得调的是expNext的增长曲线。如果倍率固定 1.4,前 5 级很快,后续越来越慢。你可以把 1.4 换成 1.2 到 1.5 之间对比一下手感,升太快会让玩家对每局失去期待,升太慢又显得养成系统像是黑匣子。这里的scoreMultiplier我故意加到了公式里,这样每升一级玩家都能直接感受到「我的消除变值钱了」。
3.3 道具体系:洗牌、炸弹、冻结的规则位置
道具应该作用在规则层,而不是表现层。拿「洗牌」为例,它的逻辑简单但容易触发新的死局,因此实现时要格外小心:洗牌后必须再次检查是否还存在可消除对,否则玩家就白白浪费了一个道具。
class SkillSystem { public: bool shuffle(GameModel* model, GameView* view) { // 收集当前所有非空图块,打乱后重新放入棋盘 QVector<Piece> pieces; for (auto& row : model->board) { for (auto& p : row) { if (p.enabled && p.type != 0) { pieces.push_back(p); } } } std::shuffle(pieces.begin(), pieces.end(), std::mt19937(std::random_device{}())); int idx = 0; for (auto& row : model->board) for (auto& p : row) if (p.enabled && p.type != 0) p.type = pieces[idx++].type; view->rebuildScene(); return true; // 真实项目这里要检查是否至少存在一对可消 } };有没有注意到洗牌操作完全改的是 model 里的 type,表现层只是最后被通知重建一次画面。这样设计的好处是,以后如果要加「炸弹道具把周围九个格子直接消除」,改动同样只发生在 model 和 controller 两层,表现层对这个能力一无所知,也就不用为每一种道具单独写一套画面刷新逻辑。
4. 连连看核心判定:两次拐弯的连通算法
4.1 把搜索问题抽象成状态:坐标、方向与拐弯次数
连连看的连通规则可以精确描述为「两个同类格子之间,能画出一条不经过任何障碍的折线,折线最多有两次 90 度转弯」。很多人会把0次拐弯和1次拐弯分别写函数,再用一堆 if 判断两个拐弯的中间点。能跑,但代码极难维护,因为拐弯点可能落在棋盘外的虚拟空路上,各种边界情况可以写出一堆补丁。
我把问题改成广度优先搜索:搜索状态是一个三元组(row, col, direction),方向表示当前路径到达这个格子时是从哪个方向过来的。每走一步,如果方向不变,拐弯次数不变;如果方向旋转 90 度,拐弯次数加一;拐弯次数超过 2 就剪枝。这样逻辑就变成了一个统一的寻路问题,而不是散落的几何判断。
4.2 BFS实现路径可达判断与最短路径
代码可以直接放在 GameModel 里,它完全不依赖界面类型:
struct SearchState { int row; int col; int dir; // 0: 上, 1: 右, 2: 下, 3: 左;-1 表示起点 int turns; }; bool GameModel::canConnect(const QPoint& a, const QPoint& b) const { if (a == b) return false; if (pieceAt(a).type != pieceAt(b).type) return false; if (pieceAt(a).type == 0) return false; const int INF = 999; QVector<QVector<QVector<int>>> visited( rows, QVector<QVector<int>>(cols, QVector<int>(4, INF))); std::queue<SearchState> q; q.push({a.x(), a.y(), -1, 0}); // 起点方向未知 const int dr[4] = {-1, 0, 1, 0}; const int dc[4] = {0, 1, 0, -1}; while (!q.empty()) { auto cur = q.front(); q.pop(); for (int nd = 0; nd < 4; ++nd) { int nr = cur.row + dr[nd]; int nc = cur.col + dc[nd]; if (!isInside(nr, nc)) continue; // 终点允许穿过,其他格子必须为空 if (QPoint(nr, nc) != b && pieceAt(nr, nc).type != 0) continue; int nextTurns = (cur.dir == -1 || cur.dir == nd) ? cur.turns : cur.turns + 1; if (nextTurns > 2) continue; if (visited[nr][nc][nd] <= nextTurns) continue; visited[nr][nc][nd] = nextTurns; if (QPoint(nr, nc) == b) return true; q.push({nr, nc, nd, nextTurns}); } } return false; }这段代码的核心是visited三维数组。为什么不能用二维?因为路径到达同一个格子时方向不同,剩余可用拐弯次数不同,用二维会漏掉可行路径。你可以在纸上画一个「先向下再向右再向下」的例子,会发现二维 visited 会提前把格子标记为已访问,导致一条可能的方向被堵死。搜索到终点立即返回,不代表路径一定最短,但连连看只关心可达性,不需要最短路径,所以这个细节可以保持简单。
4.3 消除、掉落与补牌的时序衔接
判定成功后,紧接着要做三件事:清除两个格子、上方图块下落、填补新图块。这三步的顺序错一个,画面就可能出现「消掉了又长回来」的诡异现象。
我的做法是:第一步先修改数据层,把两个格子的 type 置 0,然后调用一个fillBoard()让所有空位补满。补满的策略有两种,常见的是「格子整体下落,新图块从棋盘底部生成」,另一种是「删除空位后整体向左压缩」。连连看通常采用前者,因为棋盘本来就是满的,下落逻辑更自然。
补满时注意一个隐性问题:如果只从底部生成新块,一局玩到最后会出现「几乎所有旧块都沉底」的现象,玩家可消除的对子越来越少。更稳定的是按比例混合生成,比如 30% 的新类型、70% 保留旧类型,具体比例可以调,我第一次做时随手全用随机类型,结果中后期关卡可用对子迅速消失,玩家被迫反复用洗牌道具。
5. 血泪避坑:图形刷新、动画与资源加载的踩坑记录
5.1 动画未结束就消除,画面直接抽风
- 现象:两个图块连线后,路径上出现残影,或者刚点完第二块界面闪了一下,图块像瞬移了。
- 原因:直接改 model 后就立即删除了 QGraphicsItem,导致正在播放的动画对象被销毁,Qt 的动画框架在下一个事件循环里访问到空指针。
- 解决:先让 controller 通知 view 播放路径动画,动画的
finished信号里再执行真正的删除或类型清除。我在项目里统一封装了一个PlayAnimThenApply函数,动画期间把 model 的 enabled 字段设为 false,防止玩家连点其他格子。
5.2 棋盘逻辑与界面不同步的三种典型翻车
- 现象之一:点击一个已经被消除的格子,界面仍然高亮。
- 现象之二:掉落动画播放完成后,滞空格子的坐标归位错误。
- 现象之三:关卡配置解析完,棋盘显示的行列数与配置不符。
这些问题的共同原因都是「视图在向模型要数据,而不是模型向视图发通知」。解决方法是把「棋盘已变化」当作一个明确的信号,比如emit boardChanged(),让所有图元的坐标、类型都从 board 数组重新读取并更新。不要试图在每次操作后手动修改零散的图元,一次性重建场景,代码简单且不容易漏。
5.3 图片资源加载失败与中文字体发虚
- 现象:在开发机上一切正常,复制到另一台电脑后图块变灰或空白,对话文字变成方块。
- 原因:使用了本地绝对路径
C:/Users/.../assets/tile.png,换机器自然找不到。中文渲染则是 Qt 默认字体可能没有中文字形,导致文字被用替代字形绘制,看起来像乱码。 - 解决:把图片全部放进
.qrc资源文件,使用:/assets/tile.png这种 Qt 资源路径;主窗口代码里显式指定字体,例如QFont("Microsoft YaHei UI", 10),并在打包时确认字体可用。qrc 的另一个好处是程序打包后资源不会散落,这也是我后来养成习惯、所有图片音效都统一走资源系统的原因。
5.4 耗时操作阻塞了界面线程
- 现象:加载关卡时窗口卡住数秒,切换场景动画掉帧,点击无响应。
- 原因:在 GUI 线程里做了大型棋盘生成、随机填充或图片解码,这些操作堵塞了事件循环。
- 解决:大棋盘初始化可以放到
QtConcurrent::run,完成后通过信号回到主线程更新视图。注意一个误用:很多人以为开个std::thread就行,但 QGraphicsScene 的所有操作都必须在主线程,所以更新逻辑不能在线程函数里直接写,要走信号槽回到主线程。
5.5 高 DPI 模式下点击错位
- 现象:4K 屏上鼠标点击明显偏移半个格子,排查后发现坐标换算没问题,但整体区域就是错位。
- 原因:程序没有开启高 DPI 感知,坐标在缩放后被 Qt 重新计算。
- 解决:在 main() 里第一行调用
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough),事件坐标换算时用event->position()替代event->pos(),避免浮点数转整数时的精度丢失。记得mapToScene收到的坐标也要用浮点类型,整数坐标在高分屏上永远是隐患。
6. 用JSON配置驱动RPG地图:让剧情关卡可维护
这种项目的维护成本大多不在算法,而在关卡内容。我最早把剧情、地图、道具限制全写在 C++ 源文件里,每加一关就要重新编译,后来发现不如把所有关卡数据抽成 JSON,程序启动时统一加载。JSON 在 Qt 里有现成的解析支持,不需要引入第三方库,而且策划或美术同事可以直接编辑,不用碰代码。
一个典型的关卡配置文件长这样:
{ "stage": 3, "name": "迷雾森林", "rows": 12, "cols": 14, "move_limit": 40, "time_limit_sec": 0, "objective": { "type": 7, "count": 12 }, "skills": ["shuffle", "bomb"], "board": [ [1, 2, 3, 4, 2, 1, 3, 5, 6, 7, 8, 9], [5, 6, 7, 8, 1, 4, 2, 3, 4, 5, 6, 7] ] }读取解析的逻辑用两段代码就能完成:
QJsonObject root = QJsonDocument::fromJson(jsonBytes).object(); int rows = root.value("rows").toInt(); int cols = root.value("cols").toInt(); QJsonObject objective = root.value("objective").toObject(); int objType = objective.value("type").toInt(); int objCount = objective.value("count").toInt();board字段是个二维数组,直接用 QJsonArray 遍历填充到 GameModel 里,这样就实现了「关卡的规则数据完全由配置驱动」。我踩过的细节是:JSON 里写着time_limit_sec: 0表示不限时,而不是「没有这个字段就报错」;如果字段缺失,toInt()默认返回 0,这两个行为要区分清楚,否则关卡会莫名其妙变成零秒开局直接结束。
这个配置文件的价值在后期尤为明显。角色技能、经验曲线、每个阶段的 BOSS 关,甚至不同关卡的掉块类型池,都可以逐步挪进 JSON 或更细粒度的配置表里。我的教训是:不要在项目第一天就铺 JSON,先在代码里把玩法规则调稳定,再抽配置;但也不要拖到最后,当关卡数超过 5 个时再抽,返工量会大好几倍。把这套配置格式定清楚,你的 RPG 连连看项目就能持续加内容,而不用每次改玩法都要重编整个工程,希望帮到你。
本文还有配套的精品资源,点击获取