ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt开发RPG连连看:从消除算法到游戏闭环工程实践

Qt开发RPG连连看:从消除算法到游戏闭环工程实践

简介:这是一份基于Qt框架与C++实现的RPG风格连连看课程设计项目,面向初学Qt或桌面游戏开发的读者。项目将经典连连看玩法与角色扮演元素融合,通过消除配对获得经验值、角色升级解锁特性,重点展示QGraphicsView绘图、信号槽事件交互、QTimer计时及QRC资源加载等知识点,而非依赖复杂算法。压缩包共26个文件,约1.99MB,包含5个cpp源文件、4个h头文件、3个ui界面文件、1个pro工程配置与qrc资源文件,另有jpg游戏素材、txt规则说明和pdf设计文档,结构清晰便于按模块查阅。目前已有372人学习下载。解压后可在Qt Creator中直接打开工程运行,对照源码与界面文件理解UI搭建、事件处理和游戏循环设计思路,也能学习如何将简单游戏逻辑与RPG元素结合,是动手实践Qt开发的入门参考。

1. 用 Qt 做的 RPG 连连看:不是换皮,是玩法闭环

看到「RPG形式的连连看」这个描述,你可能会以为只是给消除棋盘套了一层角色立绘和背景图。实际拆完这套 Qt 工程之后,我的判断是:它把「探索地图 → 触发消除关卡 → 消除结果反哺角色成长」做成了一个完整闭环。玩家在 RPG 地图上控制角色行走,靠近石碑、宝箱这类触发点后进入连连看棋盘,消除得分会转化成金币、经验或道具,再用来解锁新区域、强化角色状态。适合三类人:想用 Qt 做完整项目练手的学生、要给课程设计加玩法深度的开发者、以及想研究 QGraphicsScene 场景框架与消除算法怎么协作的从业者。接着往下看,我会把工程结构、消除算法、RPG 交互和编译踩坑一条条拆开讲。

2. RPG 工程框架:把消除棋盘嵌进 QGraphicsScene 地图

2.1 工程模块划分:从 main.cpp 到游戏状态机

这套 Qt 工程拿到手后,第一件事不是急着点运行,而是先看目录结构。常见做法是分成五个模块:入口与主窗口、场景视图层、棋盘逻辑层、RPG 状态层、资源管理。主窗口通常是一个QMainWindow,中央区域挂QGraphicsView,视图里放QGraphicsScene,RPG 地图和消除棋盘都画在同一个 Scene 上,通过状态切换决定当前响应哪套交互。

// main.cpp 中典型的主窗口装配逻辑(简化) int main(int argc, char *argv[]) { QApplication app(argc, argv); GameWindow window; window.setWindowTitle("Qt RPG Lianliankan"); window.resize(1024, 768); // 创建场景与视图 QGraphicsScene *scene = new QGraphicsScene(&window); QGraphicsView *view = new QGraphicsView(scene, &window); view->setRenderHint(QPainter::Antialiasing); view->setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); // 把地图、玩家、棋盘都挂到 scene 上 GameMap *map = new GameMap(); scene->addItem(map); window.show(); return app.exec(); }

这段代码的关键在于setViewportUpdateMode。RPG 地图上有大量静态瓦片,如果默认用全量更新,角色每走一步都会触发整张地图重绘,1080p 分辨率下掉帧非常明显。BoundingRectViewportUpdate只更新变化区域,这是我拆过的 Qt 游戏项目里性价比最高的一行配置。

场景与视图分离之后,你还需要一个轻量级状态机。常见实现是enum GameState { MapExplore, PuzzleBoard, Dialog },外加一个switchState()方法。进入消除关卡时,把地图图层的交互关掉,把棋盘图层的交互打开;退出关卡时反向操作。这里有一个新手容易忽略的细节:QGraphicsItem默认都会接收鼠标事件,如果地图上的装饰物忘了关闭setAcceptedMouseButtons(Qt::NoButton),它会挡住棋盘棋子的点击,症状就是「地图能走,点了棋子没反应」。

2.2 地图场景与角色移动:QPixmap 瓦片加键盘事件

RPG 地图这块,工程里用的是经典的瓦片拼接方案。每块瓦片固定 64x64 像素,地图数据用二维数组描述,比如 1 代表草地、2 代表墙壁、3 代表水。渲染时用一个循环遍历数组,把对应的 QPixmap 绘制成 QGraphicsPixmapItem,或者重写QGraphicsItem::paint()一次性绘制整张地图。

// GameMap::paint() 中瓦片地图的绘制思路(简化) void GameMap::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { for (int row = 0; row < mapRows; ++row) { for (int col = 0; col < mapCols; ++col) { int tile = mapData[row][col]; QPixmap pix = tileset.at(tile); // 预加载的瓦片表 painter->drawPixmap(col * TILE_SIZE, row * TILE_SIZE, pix); } } }

为什么这里直接在paint()里画,而不是为每个瓦片创建一个QGraphicsPixmapItem?这是性能考量。一张 20x15 的地图就是 300 个 item,如果每块瓦片都走独立的 item,场景项数量膨胀后,碰撞检测和事件分发都会变慢。地图这种静态元素,重写paint()是最优解;角色、NPC、宝箱这类需要响应事件的动态元素,才单独做成 item。

角色移动一般用键盘事件驱动。注意,处理移动时应该判断目标格子的瓦片类型,墙壁和河面需要阻止通行,否则角色会「漂」过去。常见做法是在keyPressEvent里先计算目标坐标,再查询mapData对应位置的值:

void Player::keyPressEvent(QKeyEvent *event) { QPointF nextPos = pos(); qreal step = moveSpeed; // 像素/帧,64px 格子可一次走一格 if (event->key() == Qt::Key_W) nextPos.ry() -= step; if (event->key() == Qt::Key_S) nextPos.ry() += step; if (event->key() == Qt::Key_A) nextPos.rx() -= step; if (event->key() == Qt::Key_D) nextPos.rx() += step; // 换算目标瓦片坐标并检测阻挡 int tileX = static_cast<int>(nextPos.x()) / TILE_SIZE; int tileY = static_cast<int>(nextPos.y()) / TILE_SIZE; if (!map->isBlocked(tileX, tileY)) { setPos(nextPos); } }

这里的moveSpeed我一般会设成 64 的约数,比如 16 或 32,这样按一下方向键刚好走四分之一格或半格,视觉上有 RPG 的「逐步移动」手感。如果想要像素级平滑移动又不想穿墙,可以在移动前单独检测 x 方向和 y 方向,避免斜向直接穿过墙角。这个工程里如果是连续按方向键横扫移动,墙角的判定就要靠这种分轴检测兜底。

2.3 消除棋盘与 RPG 场景的切换方式

拆完我发现一个值得借鉴的设计:消除棋盘和 RPG 地图共用一个 QGraphicsScene,切换方式是通过setVisible()控制图层显隐,而不是销毁重建场景。棋盘背景用一个半透明黑色遮罩压在地图上面,视觉上像进入了独立副本,底层地图仍保留上下文。

void GameWindow::enterPuzzle(int levelId) { // 进入消除关卡:暂停地图交互,显示棋盘层 mapLayer->setFlag(QGraphicsItem::ItemIsFocusable, false); puzzleLayer->setVisible(true); puzzleLayer->startLevel(levelId); // 棋盘背景遮罩,挡住底层地图细节,聚焦消除区域 maskLayer->setVisible(true); currentState = GameState::PuzzleBoard; }

startLevel内部会根据关卡配置初始化棋盘的行列数、图案种类数,以及该关卡通关需要的目标分数。常见配置是 8x10 的棋盘配 6 种图案,图案种类越多越容易无解;8 行 10 列这个比例是我见过最稳的组合,太少没有消除难度,太多棋盘面积挤占 RPG 地图视野。切换时注意把玩家的键盘事件拦截掉,否则会出现你在消除界面里按方向键,角色在地图底层偷偷移动的灵异现象。

3. 连连看核心算法:拐点连通判定与可消除性检测

3.1 消除规则与棋盘建模:从二维数组到图形项

连连看的规则一句话概括:两个相同图案的格子,如果能用不超过两条竖直或水平线段相连(中间不能经过其他图案或出了棋盘边界),就可以消除。对应到实现层面,棋盘逻辑层用一个二维数组board[row][col]存放图案编号,界面层用QGraphicsPixmapItem或者一块自定义绘制区域来呈现。

这里有一个重要的前置约定:棋盘外围要预留一圈「虚拟空道」。也就是说,逻辑数组的行列数比可见棋盘各多两行两列,边界值全部视为空。这样做的好处是,路径检测时不用写一堆if (row < 0 || row >= rows)的越界判断,连通的线条可以合法地走棋盘外圈。很多单机版连连看消除不了的怪问题,追根溯源都是这个外圈虚拟空位没留够。

// 棋盘初始化:为二维数组分配外圈空道 const int PADDING = 1; // 实际项目中我习惯外圈留 1 格空道 int logicRows = visibleRows + PADDING * 2; int logicCols = visibleCols + PADDING * 2; // board[row][col] == 0 表示空;>0 表示图案编号 board.resize(logicRows); for (int row = 0; row < logicRows; ++row) { board[row].resize(logicCols, 0); } // 在可视区域内填充图案 for (int row = PADDING; row < visibleRows + PADDING; ++row) { for (int col = PADDING; col < visibleCols + PADDING; ++col) { board[row][col] = randomPattern(); } }

注意,PADDING设成 1 还是 2 需要看你要不要支持「线绕过棋盘外圈再进来」这种走法。设 1 时线能贴着棋盘最外沿走;设 0 时外圈不可通行,两条线在拐角处连不出去,难度会明显上升。实践中我一般保留 1,因为这是大多数连连看玩家默认的规则,不容易产生「明明看着能连但点不了」的抱怨。

3.2 直线与单拐点判定:核心代码与边界条件

直线判定最简单,也是最容易被想当然的地方。检查两点之间是否连通,需要横向或纵向逐格扫描,只要这些格子全都为空(包括目标点自身之外的空位),再排除端点自身是空的情况即可。记住一个坑:两个端点本身必须是非空图案,而且它们图案编号相等,否则直接返回不可消除。

bool GameBoard::isLineEmpty(int r1, int c1, int r2, int c2) { // 同一行:逐列检查中间是否有非空 if (r1 == r2) { int minCol = qMin(c1, c2); int maxCol = qMax(c1, c2); for (int col = minCol + 1; col < maxCol; ++col) { if (board[r1][col] != 0) return false; } return true; } // 同一列:逐行检查 if (c1 == c2) { int minRow = qMin(r1, r2); int maxRow = qMax(r1, r2); for (int row = minRow + 1; row < maxRow; ++row) { if (board[row][c1] != 0) return false; } return true; } return false; }

这个函数里最容易写错的地方是循环边界。for循环从min + 1开始到max结束,排除两个端点本身,又不漏掉它们之间的任何一格。如果你写成了<=或者从min开始,就会出现两个相邻格子反而判为不可连通,或者端点被自己挡住的荒谬问题。

单拐点判定就是拐一次弯能连上。实际做法是枚举拐点位置,然后验证两条线段是否都为空。拐点必须在与两个端点同行或同列的位置,所以最多只有两个候选点:(r1, c2)和(r2, c1)。需要一起判断:如果拐点是图案格子,说明路径被堵,不可达。

bool GameBoard::canConnectOneCorner(int r1, int c1, int r2, int c2) { // 拐点1:(r1, c2),线1为同一行,线2为同一列 if (board[r1][c2] == 0 && isLineEmpty(r1, c1, r1, c2) && isLineEmpty(r1, c2, r2, c2)) { return true; } // 拐点2:(r2, c1) if (board[r2][c1] == 0 && isLineEmpty(r1, c1, r2, c1) && isLineEmpty(r2, c1, r2, c2)) { return true; } return false; }

这里有一个我踩过的细节:候选拐点本身必须为空。如果拐点正好落在另一个未消除的图案上,两条直线虽然各自为空,但路径整体穿过了图案,视觉上就是一根线穿过一块砖,必须判为不可连。有些简化版的教程代码会漏掉这一行board[corner] == 0判断,复现之后你会看到明明有图案挡在中间还能消除的诡异画面。

3.3 双拐点判定与路径可视化:最坏情况与优化

双拐点判定是连连看算法的核心难点。两个拐点意味着路径由三段直线段组成,枚举的方式是:分别扫描两个端点所在的行和列,找到一对「空节点」作为两个拐点,使得四条连线都为空。常见实现是分别从 r1 所在行、c1 所在列出发,收集所有空节点,再从这些节点出发尝试连通到 r2。

bool GameBoard::canConnectTwoCorners(int r1, int c1, int r2, int c2) { // 第一类:垂直方向扫描,拐点在 r1/r2 的延伸行上 for (int row = 0; row < logicRows; ++row) { int cornerA_r = row, cornerA_c = c1; int cornerB_r = row, cornerB_c = c2; if (board[cornerA_r][cornerA_c] == 0 && board[cornerB_r][cornerB_c] == 0 && isLineEmpty(r1, c1, cornerA_r, cornerA_c) && isLineEmpty(cornerA_r, cornerA_c, cornerB_r, cornerB_c) && isLineEmpty(r2, c2, cornerB_r, cornerB_c)) { return true; } } // 第二类:水平方向扫描,拐点在 c1/c2 的延伸列上 for (int col = 0; col < logicCols; ++col) { int cornerA_r = r1, cornerA_c = col; int cornerB_r = r2, cornerB_c = col; if (board[cornerA_r][cornerA_c] == 0 && board[cornerB_r][cornerB_c] == 0 && isLineEmpty(r1, c1, cornerA_r, cornerA_c) && isLineEmpty(cornerA_r, cornerA_c, cornerB_r, cornerB_c) && isLineEmpty(r2, c2, cornerB_r, cornerB_c)) { return true; } } return false; }

这个双循环的最坏时间复杂度是 O(rows + cols),直线判定又是 O(max(rows, cols)),整体其实非常快,8x10 的棋盘完全不需要缓存优化。但有两个前提:isLineEmpty里不能做额外的内存分配;board用二维QVector或者std::vector<std::vector<int>>而不是QList。

代码里还有一个值得注意的优化点:先把会导致线段必然不为空的极端情况排除掉。比如扫描行时,如果row所在行与 r1、r2 的行都完全不同,那么cornerA_r到r1的这一段必然要跨好几行,isLineEmpty会直接扫描。为了减少不必要的调用,可以先判断扫描行是否等于端点行或者端点列等于扫描列,再进isLineEmpty。我实际测试过,加上这两个短路条件后,一次点击的判定时间从微秒级进一步缩到几乎可忽略,但代码可读性确实会下降,建议先用直白版本把功能跑通,再按性能需求裁剪。

消除动画的实现,一般分两步:先画一条路径预览线(QGraphicsPathItem),确认可连后播放淡出或缩放动画,再从逻辑数组把对应位置置 0。路径预览线建议用setZValue(10)保证画在棋子上方,否则线会被图案遮挡,玩家会以为你的判定逻辑有误。

4. RPG 交互落地:金币、背包与关卡状态循环

4.1 玩家状态与背包数据结构

如果不做 RPG 交互层,这个项目就是普通连连看。它之所以叫 RPG 形式,关键在于消除结果能反哺探索状态的循环。先看玩家状态与背包的数据结构,这块设计直接决定后期扩展是否顺手。

struct PlayerData { int hp; int maxHp; int gold; int exp; int level; QMap<QString, int> items; // 道具名 -> 数量 }; struct RewardRule { int perPairGold; // 每消除一对奖励金币数 int comboBonus; // 连续消除连击额外奖励 int clearExp; // 通关经验值 };

工程里通常把PlayerData存在一个全局单例或者主窗口成员变量中,棋盘层只通过信号上报消除结果,不直接改玩家数据。这样做的好处是,棋盘逻辑可以单独单元测试,不必和 RPG 界面耦合。RewardRule建议从配置文件读取,这样调整平衡性不需要重新编译。

背包这块我习惯用QMap<QString, int>而不是QHash,因为道具数量变化需要序列化保存,QMap按键排序后输出稳定,存档时生成的文件可读性好。道具的作用可以设计为「提示一次可连的图案」「原地打乱棋盘」「增加 30 秒限时」,每用一次从背包扣一个数量。

4.2 消除结果如何反馈到 RPG 层:奖励发放与信号槽

消除对和消除关卡的奖励发放是分层通信的经典场景。棋盘PuzzleBoard不知道外边谁是玩家,它只负责在消除成功后发出信号,由主窗口决定怎么发金币、加经验。Qt 信号槽的松耦合在这里体现得最明显。

// PuzzleBoard 中判定成功消除后的信号 void PuzzleBoard::onPairRemoved(const QPoint &posA, const QPoint &posB) { // 先做界面层动画,再发信号防止时序问题 Q_UNUSED(posA); Q_UNUSED(posB); pairCountRemaining--; emit pairRemoved(1); // 通知外部:成功消除了一对 if (pairCountRemaining == 0) { emit puzzleCleared(levelId); // 通知外部:本关完成 } }

主窗口侧的连接写法如下:

// GameWindow 中把消除事件转成 RPG 奖励 connect(puzzleBoard, &PuzzleBoard::pairRemoved, this, &GameWindow::onPairRemoved); void GameWindow::onPairRemoved(int count) { int goldGain = count * currentRule.perPairGold; int expGain = count * baseExp; playerData.gold += goldGain; playerData.exp += expGain; // 更新右上角 HUD 显示 hudLabel->setText(QString("金币: %1 经验: %2 等级: %3") .arg(playerData.gold) .arg(playerData.exp) .arg(playerData.level)); }

这个流程里最需要小心的坑是:在动画还没播放完时不要把棋盘数组对应位置清空太早或太晚。正确顺序是:先标记这对图案为「消除中」状态、播放动画,动画结束信号里再真正把数组置 0 并发送pairRemoved。如果你在点击判定通过时就立刻置 0,动画还在半空中,玩家紧接着点另一个图案,会发现原来那一对已经「隐形消除」了,棋盘逻辑和视觉对不上。镜像的坑是置 0 太晚,动画结束但数组里还有图案,同一对能被重复点击,奖励会翻倍刷。

4.3 关卡解锁与存档:把进度存进本地配置

RPG 地图上分布着多个消除关卡入口,常见设计是关卡需要满足条件才能解锁,比如充值金币、击败守门怪或达到指定等级。关卡解锁状态与存档数据,用一个简单的QSettings或者 JSON 文件持久化到本地即可。

这部分的工程落地分成两步。第一步,进入关卡前校验解锁条件:

bool GameWindow::canEnterLevel(int levelId) { LevelConfig cfg = levelTable.value(levelId); if (cfg.unlockType == UnlockByGold && playerData.gold < cfg.requireGold) { return false; } if (cfg.unlockType == UnlockByLevel && playerData.level < cfg.requireLevel) { return false; } return true; }

第二步,通关后写入存档。注意保存的时机要在puzzleCleared信号处理和界面跳转之间,否则玩家强退后进度丢失,体验很糟糕。我习惯用两段式保存:先更新内存中的playerData和unlockedLevels,再调saveGame()把整个状态写进文件。

存档格式用 JSON 比用QSettings的ini更直观,因为玩家数据带嵌套结构(背包、关卡解锁、地图坐标)。用QJsonDocument序列化PlayerData时,先转成QVariantMap再包进QJsonObject,反序列化时反向操作。一个实际问题:存档文件要写到QStandardPaths::AppDataLocation,而不是程序运行目录。很多 Qt 新手把存档直接扔在 exe 旁边,Windows 下如果装在 Program Files 目录会导致写入权限不足,表现为「通关了但重启后进度没存住」,原因就是权限,不是代码逻辑错了。

5. 编译与运行避坑:Qt 版本适配和五个高频翻车点

5.1 MinGW 与 MSVC 混用导致链接失败

现象:下载的工程在自家电脑上编译,报一堆unresolved external symbol或moc.obj : error LNK2001,但代码明明看起来完整。

原因:这套资源大概率是在某个 Qt 套件下开发并测试通过的,而你本机安装的 Qt 编译器套件可能不同。Qt 的moc预处理器生成的辅助文件与编译器 ABI 有关,MinGW 编译出的.obj和 MSVC 的.obj格式完全不同,不能跨套件链接;此外第三方库(比如音效解码库)如果也是按特定套件编译的,混用必炸。

解决:解压后先看一眼.pro或CMakeLists.txt里声明的套件相关选项,然后在 Qt Creator 左侧选择与原作者一致的套件。如果原作者用的是 MSVC 2019,而你只有 MinGW,最简单的方式是把工程在新套件下重新执行一次「全量构建」,让 Qt Creator 重新生成Makefile与moc文件,不要直接复用原工程的构建产物目录。

5.2 中文注释与字符串乱码

现象:代码里读取"金币"、"背包"之类的中文字符串,运行后界面显示乱码;或者源文件里中文注释在编译时报C4819警告,甚至直接编译错误。

原因:老版 Qt 工程源文件常保存为 GBK 编码,而新版 Qt 6 / 新版编译器默认按 UTF-8 解析。当编译器把 GBK 字节流当作 UTF-8 解码时,多字节中文会被拆成乱码字符序列,字符串字面量直接进内存时也就成了乱码。

解决:用 Qt Creator 或 VS Code 把工程内所有.cpp.h文件统一转成 UTF-8 编码(带 BOM 更保险,MSVC 不会误判),然后在.pro文件里加一行QMAKE_CXXFLAGS += /utf-8强制编译器按 UTF-8 处理。如果你不想动源码编码,也可以把所有中文字符串改成QStringLiteral(...)并用转义序列,但工程里的 JSON 配置和界面资源文件同样包括中文,最省事的方案就是统一编码,一劳永逸。

5.3 棋盘格子坐标与鼠标点击坐标换算偏差

现象:点击某个棋子时,高亮或消除的是隔壁一格,越靠近棋盘边缘越明显,甚至第一行和最后一行完全选不中。

原因:棋子QGraphicsPixmapItem的pos()坐标是图片左上角,而鼠标事件的scenePos()是场景绝对坐标。如果棋盘整体被放置在地图(ox, oy)偏移处,直接用scenePos / tileSize求行列会把偏移量算成棋盘外的索引,导致点击位置整体偏移。

解决:点击处理里先减掉棋盘原点偏移再除格宽。

int GameBoard::scenePosToRow(qreal sceneY) const { int row = (sceneY - boardOriginY) / tileHeight; if (row < 0 || row >= visibleRows) return -1; // 棋盘外 return row; } int GameBoard::scenePosToCol(qreal sceneX) const { int col = (sceneX - boardOriginX) / tileWidth; if (col < 0 || col >= visibleCols) return -1; return col; }

这两行看着简单,但我在拆别的项目时常见到省略boardOrigin的写法。只要棋盘不是从场景 (0,0) 开始,这个偏移必须处理。调试手法:在mousePressEvent里临时输出scenePos、计算出的行列和棋子的boundingRect,坐标对不上时一眼就能看出来。

5.4 消除动画与棋盘数组刷新时序竞争

现象:快速点击两对可消除的图案时,有时画面会「闪一下」,或者其中一对被消除后另一对判定突然失败。更严重的会出现消除后数组里残留空位图案,界面却消失了。

原因:这是典型的动画回调和逻辑更新顺序冲突。第一对图案的动画还在播放中,第二对点击已经触发,而此时第一对图案在数组里可能还没置 0。如果两条动画的回调同时操作board,索引错位后逻辑数组和界面 item 列表就对不上了。

解决:在棋盘对象里加一个isAnimating标志位,动画播放期间屏蔽新点击。更稳妥的做法是把逻辑置 0 放到动画开始的瞬间而不是动画结束:先把这对图案标记为「已选中待消除」,动画只是视觉表现,点击判定时跳过处于待消除状态的图案。实战中我会同时做两件事:animation->start()前立即更新数组,点击判定时检查board[r][c]是否为 0。这样即使动画还在播,对局逻辑也始终基于最新数组,不会出现重复消除和残留。

5.5 背景透明 PNG 的黑色方框

现象:RPG 地图上的角色和棋子图片,设计稿里是透明背景 PNG,放进游戏后却出现黑色或白色方框,边缘锯齿也很重。

原因:QPixmap加载 PNG 时如果没设置 Alpha 通道处理,在QGraphicsView默认的QPainter::CompositionMode_Clear下透明区域会渲染成背景色。另外QGraphicsPixmapItem的setShapeMode(QGraphicsPixmapItem::BoundingRectShape)会把透明像素也纳入碰撞区域,点击透明区域也会响应事件,视觉上像是图片自带边框扩大了。

解决:加载图片后显式调用pixmap.setDevicePixelRatio(1.0)并确认图片格式是QImage::Format_ARGB32;给 item 设置setTransformationMode(Qt::SmoothTransformation)消除放大锯齿。透明区域点击误触则用setShapeMode(QGraphicsPixmapItem::MaskShape),按不透明区域计算形状,透明边缘的部分就不会响应点击。

6. 进阶玩法:把关卡配置抽成数据驱动,扩展出多个 RPG 主题

拆到最后,这套工程给我最大的启发是它的配置层还能继续抽。目前棋盘尺寸、图案种类、奖励数值、关卡解锁条件都写在代码或零散常量里,改一个参数要翻好几个文件。如果想让它变成一套能换皮的框架,我建议把LevelConfig改成数据驱动,用一份或多份 JSON 描述所有关卡,代码只负责执行规则。

{ "levels": [ { "id": 1, "name": "遗迹入口", "boardRows": 8, "boardCols": 10, "patternTypes": 6, "targetPairs": 20, "reward": { "perPairGold": 10, "clearExp": 50 }, "unlock": { "type": "gold", "value": 0 } }, { "id": 2, "name": "深林祭坛", "boardRows": 10, "boardCols": 12, "patternTypes": 7, "targetPairs": 30, "reward": { "perPairGold": 15, "clearExp": 80 }, "unlock": { "type": "level", "value": 2 } } ] }

外层再加一个theme.json,把瓦片图、棋子图标、角色立绘、背景音乐的路径按主题索引。换主题时只替换资源路径和关卡数值,代码一行不用改。这样你就得到了一个「RPG 连连看引擎」而不是「某一个连连看 Demo」,后续做海洋主题、太空主题、校园主题都是往配置表里加数据的工作。

封装方式上,我一般会在LevelConfig结构体之上再包一个LevelConfigLoader,用QJsonDocument读取解析,外加一个validate()方法检查:棋盘尺寸是否为正偶数、图案种类是否不小于 2、目标对数是否不超过棋盘可容纳的最大对数。这里有一个玄学级细节:棋盘可容纳的最大消除对数等于行列总数的四分之一,比如 8x10 共 80 格,最多可配 40 对,但实际生成图案时按 35 对配置,因为完全铺满时无解概率会陡增。数据驱动配置改起来成本低,测试时可以先跑 1000 局生成棋盘,统计无解率,再倒推最合适的图案密度,这比在代码里反复改常量靠谱得多。

第二个建议做的是「消除提示与冒险道具」。根据board当前状态遍历所有没有消除的棋子对,用第 3 章的连通判定逐个检查,把可消除的对收集进一个列表。之后「提示」道具就是从这个列表随机选一对高亮闪烁。注意提示功能有可能帮不上忙:如果列表为空,说明当前棋盘无解,需要自动洗牌。洗牌实现也有讲究,不要简单全部随机重排,否则洗完后可能依然无解。我习惯的做法是保存当前棋盘的所有非空图案序列,用不同随机种子执行有限次std::shuffle,每次重排后做一次无解检测,最多尝试 20 次,仍无解再原样恢复并提示玩家手动重开。这套逻辑放在PosiblePairsFinder类里,跟界面完全无关,可以单独写测试用例,也是整个项目里最值得抄走的模块。

从那以后我每次拿到新的 Qt 游戏工程,都会强制走一遍这套流程:先看编译套件和编码,再找棋盘坐标换算的坑,最后把配置项全部列出来问一句「这些能不能抽成数据文件」。这个习惯帮我避开了至少三位数的重复排错时间,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进