简介消除类游戏一直是游戏开发入门的经典题材而将RPG元素融入其中后玩法深度和策略性都会显著提升。RPG式连连看把传统的棋子连通消除转化为战斗行为玩家通过连通棋子积累能量、释放技能并需在回合限制内击败敌人。实现这类玩法的核心在于两折连通判定算法它决定了棋盘的交互手感同时棋盘生成与洗牌策略需要保证开局有解、中期不死局。通过回合制状态机管理玩家与敌人的行动流程可以构建完整的战斗闭环。在工程实践上使用Qt Widgets与QJson即可高效搭建跨平台原型将关卡配置外置为JSON文件实现逻辑与数值分离便于后期调整和扩展。这套设计模式不仅适合独立开发者试水也适用于课程设计或作品集展示展现出界面、事件系统、数据结构与关卡脚本的综合能力。1. RPG式连连看是什么别把它当成纯休闲消除如果你玩过那类“消宝石打怪兽”的游戏就会明白RPG式连连看不是在普通连连看上套个剧情而是把“消除”本身变成攻击手段玩家通过连通消除同类型棋子来攒能量、放技能对面的角色有血条、有回合限制被消掉的棋子会折算成伤害或防御效果。这个标题里的项目正是这么一回事——用Qt做一款完整可玩的RPG消除游戏。它能解决的痛点很直接既有连连看算法验证了棋盘连通性又让游戏有了“战斗目标”而不是无限消下去的空转。适合有C基础、想用Qt当完整项目的独立开发者也适合拿来做毕业设计或作品集展示因为它能同时展示界面、事件系统、数据结构与关卡设计。2. 核心玩法与算法选型消除判定、回合制状态机和关卡脚本怎么设计2.1 角色属性和消除伤害的换算关系要先定死RPG式连连看和传统连连看最大的差别是棋盘上的消除动作要有“战斗结果”。常见的做法是把棋子类型对应成宝石、技能材料或人物职业每种类型有一个基础伤害倍率。角色属性则沿用标准RPG设定HP、MP、攻击力、防御力、行动点。回合开始时玩家有一定步数消耗步数做消除消除成功后剩余步数清零进入敌人回合。这里最需要注意的一点是伤害不应只看消除了多少对还要看连击。当玩家在一个回合内连续消除多对伤害应该乘以连击系数否则战斗会变成纯粹的“比手速”策略感会大幅下降。我一般会把连击系数设计成线性成长但封顶例如第1次消除伤害系数1.0第2次1.3第3次1.6最高2.0这样既能鼓励连续消除又不会让数值膨胀失控。另一个常见设计是“消除即充能”每消除一对棋子角色MP上限的10%被填充MP攒满后可释放技能。技能可以是清屏、随机转换棋子类型、或者限制对手行动。这类效果最终都落到棋盘数据上所以玩法和数据结构之间要留好接口。2.2 两折连通判定这是连连看的“算法心脏”传统连连看的消除规则是两个相同棋子在横竖方向上最多通过两个拐弯连接路径上的格子必须为空。实现时最稳定的方法不是A*寻路而是把棋盘看作“空白格子可通行”的图做一个限制拐弯次数的搜索。搜索的代价很小因为棋盘通常只有几十个格子没必要上复杂寻路算法。两折连通分成三种情况判断直接连通、一个拐角连通、两个拐角连通。直接连通只要同行或同列且中间没有棋子阻挡一个拐角连通则找两个候选拐角点判断两条直线上是否都无阻挡两个拐角连通则穷举一行或一列的“桥”位置。这个判断函数会用得非常频繁比如生成棋盘时要检查是否存在可消对玩家点击后要检测目标能否连通无解时要决定是否自动洗牌所以函数本身的性能会直接决定游戏手感。我建议把棋盘格子类型用整数表示空位用0表示。判断路径时注意端点本身不能算阻挡中间格子必须为空。边界处理稍有不慎就会把原本能连通的棋子误判为不可达下面会专门讲这个坑。2.3 棋盘生成要防止“开局无解”和“后期死局”棋盘生成如果只是随机填棋子很容易出现初始状态就不存在可消对的情况。更常见的是玩着玩着局面变成死局棋子类型配不上或者没有可连通路径。传统做法有两种一种是生成时保证至少存在一对可消的棋子另一种是在检测到死局时自动洗牌。两种都要做生成时保底可以提升开局体验中期死局自动洗牌则挽回游戏流程。生成时保底可以在随机填充后扫描全盘把所有可消对找出来如果计数为0就重新生成最多重试几次。这个逻辑简单但在大型棋盘上可能重复多次。更智能的做法是按“可消对优先”的方式手动布棋子但实现复杂度较高小型游戏不建议一开始就上。自动洗牌则要注意洗牌不能改变棋子的种类总数否则会影响已经设定的敌人血量和关卡难度。洗牌的实现我一般用两种策略一种是彻底随机重排所有非空格子另一种是只做多次随机交换。后者更温和不容易导致新死局。但无论哪种洗牌后都要重新检测是否有可消对没有就再洗一次设定一个最大尝试次数防止极端情况死循环。2.4 关卡脚本用JSON承载别把关卡写死在代码里RPG式连连看的内容量比普通连连看高很多每个关卡有敌人、血量、攻击力、棋盘尺寸、棋子类型数、回合上限。这些如果写死在C代码里后期调整数值就要重新编译。常见做法是放到JSON文件里程序启动时统一加载。Qt自带的QJsonDocument就能搞定不需要引入第三方库。一份典型的关卡配置包含这些字段关卡编号、棋盘行数、列数、棋子类型数、敌人名称、敌人HP、敌人攻击力、回合数限制、特殊事件标记。特殊事件可以是“我方初始获得一个全屏道具”或“第三回合敌人会变身”。棋盘本身不一定在前端配置也可以配置成一组初始棋子但对小项目来说让程序随机生成棋盘、只配置参数就够了。这样拆分后游戏逻辑和内容数值就是两层C只负责读配置、运行消除逻辑和战斗流程策划或你自己调数值时只需要改JSON文件。这个分层后续也方便接编辑器或者第三方工具Qt的项目实战中很多团队就是靠这一步把“课程设计”升级成“可交付产品”的。3. 用Qt实现一个最小可玩版本关键代码与参数说明3.1 技术栈选型Qt Widgets和Qt Quick不要一开始就纠结每次有人问“用Qt做游戏该用Widgets还是QML”我给的答复都是先看你什么平台最急。如果你只做Windows桌面验证玩法Qt Widgets就是最快的路径因为它的QPainter绘制、QMouseEvent事件、QSignalMapper这些组件都非常成熟。如果你明确要上Android或iOSQt Quick/QML会更省力因为触控手势和动效组件都是现成的。但不要因此以为Qt Widgets做不了触屏。Qt Widgets在Android上也能跑只是界面尺寸适配和触控手感需要额外调整。我的经验是单人小项目先用QWidget搭出完整逻辑界面糙点没关系等玩法验证没问题再把视图层迁移到QML逻辑层保持不变。如果你搜过qt qml和qt mvvm框架会发现很多正式项目也是这样分层不是全盘推翻重写。我建议的最小技术栈是这样模块用途理由QWidget / QMainWindow主窗口与战斗面板构建快状态管理直观QGraphicsView / QGraphicsScene棋盘渲染与点击检测支持大量图元时性能稳定QTimer回合计时、动画节奏单线程事件循环内可靠触发QJsonDocument关卡配置加载无需额外依赖QPropertyAnimation消除动画轻量级Widgets可用3.2 棋盘生成与连通判定代码块下面这段是棋盘生成的最小实现包含防无解的保底逻辑。请注意其中使用了Qt容器和Qt的随机数接口在Qt 5.15.2环境下可以直接编译运行。// board.h #pragma once #include QVector #include QPoint #include QRandomGenerator class Board { public: Board(int rows, int cols, int typeCount) : m_rows(rows), m_cols(cols), m_typeCount(typeCount) { m_grid.resize(rows); for (int r 0; r rows; r) m_grid[r].resize(cols); } void generate() { QRandomGenerator *rng QRandomGenerator::global(); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { m_grid[r][c] rng-bounded(1, m_typeCount 1); } } // 保底如果没有任何可消对就重置几次 int guard 0; while (!existsConnectablePair()) { for (int r 0; r m_rows; r) for (int c 0; c m_cols; c) m_grid[r][c] rng-bounded(1, m_typeCount 1); if (guard 5) break; // 5次后强制接受避免死循环 } } bool existsConnectablePair() const { for (int r1 0; r1 m_rows; r1) for (int c1 0; c1 m_cols; c1) for (int r2 r1; r2 m_rows; r2) for (int c2 (r2 r1 ? c1 1 : 0); c2 m_cols; c2) if (m_grid[r1][c1] m_grid[r2][c2] canConnect({r1, c1}, {r2, c2})) return true; return false; } // 核心判断两个点是否不超过两个拐弯连通 bool canConnect(const QPoint a, const QPoint b) const { if (a b) return false; if (m_grid[a.x()][a.y()] ! m_grid[b.x()][b.y()]) return false; // 0拐同行或同列直连 if (a.x() b.x() clearLine(a.x(), a.y(), b.y())) return true; if (a.y() b.y() clearLine(a.y(), a.x(), b.x())) return true; // 1拐两个候选拐角点 QPoint turn1(a.x(), b.y()); if (isEmpty(turn1) clearLine(a.x(), a.y(), turn1.y()) clearLine(turn1.x(), turn1.y(), b.y())) return true; QPoint turn2(b.x(), a.y()); if (isEmpty(turn2) clearLine(turn2.x(), turn2.y(), a.y()) clearLine(b.x(), turn2.y(), b.y())) return true; // 2拐穷举桥列和桥行 for (int c 0; c m_cols; c) { if (isEmpty({a.x(), c}) isEmpty({b.x(), c}) clearLine(a.x(), a.y(), c) clearLine(a.x(), c, b.x()) clearLine(b.x(), c, b.y())) return true; } for (int r 0; r m_rows; r) { if (isEmpty({r, a.y()}) isEmpty({r, b.y()}) clearLine(a.y(), a.x(), r) clearLine(r, a.y(), b.y()) clearLine(r, b.x(), b.y())) return true; } return false; } private: bool clearLine(int fixed, int from, int to) const { int step (from to) ? 1 : -1; for (int i from step; i ! to; i step) { if (fixed 0) return false; // 参数保护 // 这里按横线或竖线统一按“坐标轴数组”处理 } // 实际实现里把行、列区分开 return true; } bool isEmpty(const QPoint p) const { return p.x() 0 p.x() m_rows p.y() 0 p.y() m_cols m_grid[p.x()][p.y()] 0; } int m_rows, m_cols, m_typeCount; QVectorQVectorint m_grid; };这段代码里的clearLine函数是简化示意真正落地时要分“固定行上判断列区间”和“固定列上判断行区间”两个版本。一个常见的翻车点是在区间判断时把from和to端点也算进去导致两个紧挨着的可行棋子反而判为不可连解决办法是只检查from step到to - step之间的格子。参数上建议棋盘行数20、列数14、类型数6。这个数组尺寸能保证可消对较多又不会让穷举两点连通的复杂度拖慢界面。如果你做的是8位复古风格的小屏可以把棋盘缩到12×8、类型数5手感会更紧凑。3.3 用QTimer驱动回合切换和伤害结算连环消除的流程必须做得“排队”而不是一次性算完否则动画没播完伤害还没跳数字敌人已经反击了。我习惯用QTimer的单发模式做状态机推进。每一次玩家点击两个有效棋子后不是立刻执行全部伤害而是把这次消除放入事件队列弹出一个“”伤害数字等数字动画结束后再推进到敌人行动。关键代码如下// 回合状态机的简单骨架 enum class BattleState { PlayerTurn, EnemyTurn, Win, Lose }; class BattleCore : public QObject { Q_OBJECT public: BattleCore(QObject *parent nullptr) : QObject(parent) { m_timer.setSingleShot(true); connect(m_timer, QTimer::timeout, this, BattleCore::onStateStep); } void startNewTurn() { m_state BattleState::PlayerTurn; m_actionPoints 10; // 每回合玩家可消除的步数或行动点数 Q_EMIT stateChanged(m_state); } void onPlayerEliminated(int damage) { if (m_state ! BattleState::PlayerTurn) return; m_enemyHp - damage; m_chainCount; // 连击伤害提升在此处计算并在界面显示连击数字 if (m_enemyHp 0) { m_state BattleState::Win; Q_EMIT stateChanged(m_state); return; } if (--m_actionPoints 0) { m_state BattleState::EnemyTurn; m_timer.start(600); // 600毫秒后进入敌人回合 } } private slots: void onStateStep() { if (m_state BattleState::EnemyTurn) { m_playerHp - m_enemyAttack; m_chainCount 0; // 敌人回合打断连击 if (m_playerHp 0) { m_state BattleState::Lose; Q_EMIT stateChanged(m_state); return; } m_state BattleState::PlayerTurn; m_actionPoints 10; Q_EMIT stateChanged(m_state); } } signals: void stateChanged(BattleState s); private: QTimer m_timer; BattleState m_state BattleState::PlayerTurn; int m_actionPoints 10; int m_enemyHp 50; int m_enemyAttack 8; int m_playerHp 100; int m_chainCount 0; };这里的600毫秒是敌人的“思考延迟”不要太短否则玩家刚看到消除动画就被切到敌方回合容易觉得节奏失控。行动点数方面10步对20×14的棋盘比较舒服如果棋盘减少到12×8建议行动点数调到6到8不然一回合把棋盘消掉大半战斗就变成割草了。3.4 在Qt Creator里跑起来的最小步骤新建项目时选择Qt Widgets Application基类选QMainWindow。把上面的Board类和BattleCore类文件加入工程界面放一个QLabel做棋盘、两个QLabel显示敌我血量。棋盘渲染用最原始的QPainter绘制矩形即可不需要上QGraphicsView也能先跑通逻辑。在mainwindow.cpp里给QWidget安装mousePressEvent根据点击坐标除以格子尺寸得到行号和列号。两个点击点记录在成员变量里第二次点击时调用board.canConnect()判断。如果可连则把两个格子置0再调用battleCore.onPlayerEliminated(计算伤害)。这里就能看到一个完整的“点击→判定→消除→扣血”闭环。如果你是用Qt 5.15.2离线安装包搭的环境记得在安装时勾选Qt Charts、Qt Multimedia这些非必要模块的按钮可以不去勾选因为本项目用不到勾了反而增加编译时间。编译器选MinGW 8.1.0 64bit就够不必纠结必须上MSVC。4. 战斗界面与关卡内容从静态面板变成可扩展的资源配置4.1 用Qt Designer排布战斗界面但别把逻辑也塞进去Qt Designer适合拖控件排布局左边放敌人头像和血条中间放棋盘右边放玩家技能按钮底部放行动点提示。拖完之后用uic生成ui_mainwindow.h这是Qt最标准的界面设计流程。如果你不想用代码手写布局这就是最快的方式。但有一点要提醒不要在Qt Designer的槽函数里写战斗逻辑。界面设计器的定位是“长什么样”不是“怎么工作”。正确的做法是在MainWindow构造函数里把界面控件和BattleCore的信号连接起来通过connect把状态变更、血条刷新、棋盘重绘全部串联起来。这样以后换皮肤、换分辨率、甚至换QML界面都不需要改战斗逻辑。4.2 棋子图片资源三种分辨率档位和九宫格绘制棋盘上的棋子图片如果直接用多个尺寸的PNG资源管理会非常乱。我的建议是只准备三档32×32用于小型棋盘48×48用于标准棋盘64×64用于高DPI屏。图片格式用PNG且带透明通道不要让棋子图片自带正方形底板因为消除时的“路径高亮”需要把路径画在棋子下面。绘制时用QPainter的drawPixmap把图片画到指定格子底色和选中边框用drawRect画。如果你想做出“格子棋盘格纹”效果可以先用一个QBrush画浅色底纹再在上面画棋子。这里能用到的Qt绘图知识点很多但注意不要一上来就搞复杂滤镜和高光先把图片原样画出再逐步调整。首次实现只要一个QPixmap cache把每种图片加载一次并缩放好避免每帧都做缩放否则移动端会很卡。4.3 用QJson读取关卡配置的代码示例现在把JSON读进内存是Qt项目实战里最常用的方式。下面给出一个完整可用的读取函数骨架#include QFile #include QJsonDocument #include QJsonObject #include QJsonArray #include QDebug struct LevelConfig { int rows 20; int cols 14; int typeCount 6; int enemyHp 50; int enemyAttack 8; int turnLimit 10; QString levelName; }; bool loadLevel(const QString path, LevelConfig out) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { qWarning() cannot open: path; return false; } QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(file.readAll(), err); if (err.error ! QJsonParseError::NoError) { qWarning() json error: err.errorString(); return false; } QJsonObject root doc.object(); out.rows root.value(rows).toInt(20); out.cols root.value(cols).toInt(14); out.typeCount root.value(typeCount).toInt(6); out.enemyHp root.value(enemyHp).toInt(50); out.enemyAttack root.value(enemyAttack).toInt(8); out.turnLimit root.value(turnLimit).toInt(10); out.levelName root.value(name).toString(未命名关卡); return true; }参数说明toInt(20)里的20是默认值当JSON缺少该字段时不会让程序崩溃。关卡文件的路径用相对路径会省很多事建议放在可执行文件同级下的levels/目录。如果你用Qt资源系统.qrc打包JSON则路径要写成:/levels/level1.json并且记得在.qrc文件中加入该JSON文件。4.4 特效和动画从Widgets到QML的迁移边界很多人一看到“RPG形式”就忍不住在动画上投入大量精力结果消除判定还没写完就陷入动画与状态不同步的泥潭。我的经验是先用QPropertyAnimation做透明度渐变和位移加上音效玩家的爽快感已经能达到及格线。这个阶段不要碰粒子系统不要碰着色器。当你发现QPropertyAnimation已经无法满足复杂的技能演出比如全屏火焰、连击特效时再迁移到Qt Quick。此时建议采用qt mvvm框架里常见的分层思路C保持核心逻辑和数据结构不变只通过Q_PROPERTY暴露给QML层QML负责渲染、动画和用户交互绑定。这样逻辑不重写只改表现层风险可控。搜qt qml路线的教程会发现很多项目已经把“QML做UIC做逻辑”定为标准实践这个方向是对的但别在项目初期踩进去。5. 编译与运行期的5个典型坑位排查5.1 qt.qpa.plugin: could not find the qt platform plugin linuxfb现象在树莓派或某些嵌入式Linux设备上编译完程序运行时报错qt.qpa.plugin: could not find the qt platform plugin linuxfb in然后程序直接退出。原因Qt的插件目录没有被打包到部署目录中或者程序运行时找不到platforms插件。这个问题在桌面Linux和嵌入式Linux交叉编译时几乎都会遇到。解决把Qt安装目录下plugins/platforms文件夹整体复制到可执行文件同级目录并保证libQt5Gui.so等动态库能被系统找到。运行时加上-platform linuxfb参数或者设置环境变量QT_QPA_PLATFORMlinuxfb。如果你是交叉编译还要检查交叉编译器的sysroot里是否有对应平台的Qt库。5.2 unknown module(s) in qt: webenginewidgets现象在Qt Creator中打开项目qmake报错:-1: error: unknown module(s) in qt: webenginewidgets。原因工程文件helloworld.pro里写了QT webenginewidgets但你安装Qt时没有勾选WebEngine组件或者安装的是Open Source版而WebEngine需要额外的系统依赖。解决如果不需要浏览器组件直接从.pro文件里删掉这一行。如果确实需要则回到Qt安装器勾选Qt WebEngine组件补装。还有一类隐藏情况是从别人电脑拷贝工程时.pro文件里残留了对方用到的第三方模块打开后逐个排查即可。5.3 Qt编译时候cannot find -lpublic现象编译时链接阶段报错cannot find -lpublic很多人看到“public”就懵了以为是C的public关键字问题。原因实际上这是因为工程文件里某个第三方库文件名叫libpublic.a或libpublic.so而系统搜索路径下没有这个库更常见的是某些老项目把公共库命名为public拷贝工程时丢了对应库文件。你在网上下载的Qt项目压缩包里经常会出现这种情况。解决先搜索解压目录里有没有libpublic*文件没有就去确认作者提供的依赖说明。临时处理方式是在.pro文件里加LIBS -L$$PWD/libs -lpublic但前提是你的确有这个库。如果没有检查代码里是否直接include了不存在的库头文件把对应代码注释掉或替换成Qt自带功能。这里建议不要硬编造一个public库项目里没有就移除引用。5.4 Qt程序很容易闪退报0000005错误现象游戏运行一段时间后突然崩溃系统提示访问违规地址错误码是0x0000005。原因这个错误在Qt游戏中十有八九是空指针或野指针调用。最常见的场景是玩家点击了一个格子这个格子在上一帧已经被置为0但事件处理中仍把它当作有效棋子访问或者动画回调中删除图元后界面还在引用同一个图元。解决在mousePressEvent开头检查格子的有效性在访问图元指针前判断是否为nullptr。如果你使用QGraphicsItem删除图元后要调用prepareGeometryChange并确保场景同步。如果你的代码里用了普通C指针建议全部替换为QPointer或QSharedPointer能从根上规避悬垂指针。调试时打开Qt Creator的调试模式跑一遍崩溃时调用栈会直接指向具体行号定位很快。5.5 Qt安装在非默认路径后CMake报错指向旧路径现象从别处拷贝一个Qt项目用CMake配置时出现CMake Error at C:/Qt/Qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5/Qt5Config.cmake但自己电脑上Qt明明装在D盘。原因CMake缓存里保留了旧的CMAKE_PREFIX_PATH指向另一个开发者的Qt安装路径。这是拷贝工程最常见的环境残留问题。解决删除项目下的CMakeCache.txt和CMakeFiles目录重新运行CMake并在配置时把CMAKE_PREFIX_PATH指向你本机的Qt路径。如果你用的是MSVC编译工具链还要确认Qt版本和Visual Studio版本匹配例如Qt 5.9.4的msvc2017_64不能配MSVC2019工具链。这是环境问题不是代码问题清理干净后一般都能恢复。6. 发布与验收把这套玩法变成可持续迭代的小项目6.1 用windeployqt做Windows版本的最小发布清单开发完成后发布Windows版本最常用的工具是windeployqt.exe。在Qt命令行环境里进入release目录运行windeployqt yourgame.exe它会自动把Qt模块DLL和插件复制到exe同目录。但这只是“自动补依赖”你还要人工检查几处容易漏掉的东西。检查项说明常见遗漏platforms/qwindows.dllWindows窗口插件没有它exe启动即失败自动部署一般会带上但改名后可能漏资源文件棋子图片、JSON关卡、音效所在目录windeployqt不复制你自己写的资源文件Qt插件目录imageformats、iconengines等如果你截图存盘或读特殊图片格式需要保留Qt5Core.dll等基础库确保exe同级目录不出现重名旧DLL从旧目录拷贝项目时最常踩中部署完成后把整个文件夹放到一台没有装Qt的Windows机器上跑一遍。这一步能过滤掉绝大多数动态库版本错误。如果启动时弹出缺少DLL用Dependency Walker或Process Explorer查看加载失败的模块直接补对应文件即可。这个流程跑顺后你的Qt项目才算真正能交付给别人玩。6.2 想上Android交叉编译时NDK版本要对齐如果你希望这个RPG连连看能在手机上跑Qt官方推荐的方式是用Qt Creator自带的Android工具链。配置Android套件时最影响成败的是NDK版本与Qt版本的对应关系。以Qt 5.15系列为例对应NDK r21系列相对稳妥新一点的NDK r23在部分Qt版本上会出现链接器无法识别目标文件的报错。同时建议在AndroidManifest.xml中明确声明需要触控权限和全屏显示。连连看这类游戏不需要网络权限不必要的权限就删掉避免上架时审核被问。Android打包后首次运行卡白屏常见原因是Qt的libplugins未完整打包检查APK中的lib/arm64-v8a目录是否包含libQt5Core.so和platforms/libqandroid.so这两个缺一个都会秒退。我的最后一条经验当初我做类似项目时一上来就折腾QML特效和动态粒子结果连续三周被花哨动画拖住消除算法反而写得粗糙。后来我强制自己先用QWidget把玩法跑通再把表现层换成QML核心逻辑一行没动。这个过程虽慢但每一步都能验证不会把问题堆到最后。如果你的目标是“做完并持续维护”就从最小可玩版本开始先把输赢闭环做出来再谈画面和数值。希望这条路径能帮到你少踩一遍我踩过的坑。本文还有配套的精品资源点击获取