☰
Qt C++开发宝可梦回合制游戏:内核与界面解耦的完整实践
2026/10/10 13:59:44 网站建设 项目流程

简介:一款基于Qt/C++的2D宝可梦小游戏完整源码,专为刚接触Qt游戏开发的C++学习者设计,可帮助理解角色扮演游戏的整体架构。项目完整实现了游戏世界、宝可梦、战斗、玩家四大系统:2D俯视角地图包含角色移动与碰撞检测,宝可梦系统具备属性相克、技能和进化玩法,战斗系统采用回合制并带技能选择与动画,玩家系统支持角色管理和背包存储。渲染基于QGraphicsView/QGraphicsScene,支持TMX地图加载,并预留了扩展空间,可继续添加新精灵、新场景与剧情。压缩包共20个文件,以9个cpp、8个h源码为主,cpp实现具体游戏逻辑,h定义类接口,另有pro工程文件管理构建、qrc注册资源、TMX提供地图配置,整体仅19KB,轻量适合快速阅读。目前已有103人学习下载。代码按Battle、GameScene、BattleScene等模块划分,读者可从中掌握Qt游戏项目的模块组织、回合制战斗逻辑和TMX地图加载方法,也可作为课程设计或入门练手的参考。

1. 用QT(C++)做宝可梦小游戏:不靠Unity也能把回合制攥在手里

很多人一说到做“宝可梦”,第一反应是Unity、Godot,其实回合制RPG的核心在“状态流转”和“数值判定”,渲染只是最外一层。用QT(C++)做宝可梦小游戏,恰好能把这两层拆干净:C++负责战斗内核、存档、背包逻辑,QT负责窗口、绘图、信号槽和输入响应。不引入引擎,不写脚本语言,一个连vscode配置c/c++环境都刚搞定的读者,也能在两三千行内跑出一个能玩、能存档、能不闪退的桌面版本。适合拿来做Qt入门后的第一个完整小项目,也适合想验证“C++游戏逻辑能不能和界面解耦”的人。这篇文章讲的就是一条我实际走通的路径:控制台内核先行,QGraphicsScene渲染界面,JSON存档收尾,最后用状态机和自动化测试把流程固化下来。

2. 先把战斗内核写到控制台:C++结构体、属性克制和回合循环

很多入门项目一上来就铺界面控件,结果一半时间花在对齐控件和调样式上,战斗逻辑反而写得一团浆糊。做游戏最稳的路径是先把“不带界面的部分”跑通,再包一层QT壳。宝可梦玩法剥去画面,本质就是一个回合制状态机:选择指令、计算伤害、判定胜负、切换状态。这部分用纯C++写,不进视口、不碰QWidget,跑起来没有任何界面干扰。

2.1 用结构体和枚举给宝可梦建模,别急着画界面

先定义最小数据集。一只宝可梦需要有名字、属性、HP、攻击、防御、速度和技能表。这里用聚合结构体就够,不要一上来就套类继承体系;把行为写成独立函数,后面接信号槽时更顺手。

struct Move { QString name; // 技能名 QString type; // 技能属性,用于克制判定 int power; // 威力 double accuracy; // 命中率,0.0 ~ 1.0 }; struct Pokemon { QString name; // 显示名 QString type; // 属性,如火、水、草 int maxHp; // 最大HP int hp; // 当前HP,qBound(0, hp, maxHp) 随时夹紧 int atk; int def; int speed; QVector<Move> moves; // 最多带四个技能 };

这段代码有两个细节值得说明。QString是Qt的字符串类型,不是std::string,因为后面无论控制台打印还是界面显示都离不开QString的隐式转换;QVector<Move>存放技能列表,C++字符串数组初始化在这里退化为QVector的初始化列表,写起来比裸指针数组安全。把Move单独抽出来是给AI对手复用,后面做野生宝可梦随机技能池时不需要改Pokemon本身。

再用一个Roster函数生成初始数据,相当于“图鉴数据源”的雏形:

QVector<Pokemon> makeRoster() { QVector<Pokemon> list; list.push_back({"小火龙", "火", 120, 120, 60, 50, 65, {{"火花", "火", 40, 1.0}, {"抓", "一般", 40, 1.0}}}); list.push_back({"杰尼龟", "水", 130, 130, 50, 65, 58, {{"水枪", "水", 40, 1.0}, {"撞击", "一般", 35, 1.0}}}); list.push_back({"妙蛙种子", "草", 125, 125, 55, 60, 54, {{"藤鞭", "草", 45, 1.0}, {"撞击", "一般", 35, 1.0}}}); return list; }

这是典型的“表驱动”写法,不要把初始数据散落在各个类的构造函数里。后面换成真实平衡数值时,只需改这一处。注意QVector的初始化列表要求编译器开启C++11以上标准,CMake里对应CMAKE_CXX_STANDARD 17,Qt 5.14.2以上的默认工程模板已经带上了。

2.2 克制表与伤害公式:把对战规则落成可测试代码

属性克制的核心数据我习惯用嵌套QMap表达,这比一堆if else好维护得多:

const QMap<QString, QMap<QString, double>> kTypeChart = { {"火", {{"草", 2.0}, {"水", 0.5}, {"火", 0.5}}}, {"水", {{"火", 2.0}, {"草", 0.5}, {"水", 0.5}}}, {"草", {{"水", 2.0}, {"火", 0.5}, {"草", 0.5}}}, {"电", {{"水", 2.0}, {"草", 0.5}, {"电", 0.5}}} }; double typeMultiplier(const QString& moveType, const QString& targetType) { auto it = kTypeChart.find(moveType); if (it == kTypeChart.end()) return 1.0; return it->value(targetType, 1.0); // 没匹配到的属性按普通伤害算 }

伤害公式不用照搬原版复杂算法,做小游戏重要的是“结果符合直觉”:水枪打火系应该痛,火花打水系应该刮痧。常见做法是基础伤害 = (atk * power / def) + 1,再乘属性克制系数;同属性技能再加1.2倍“本系加成”;最后做一个2%的浮动。

int calcDamage(const Pokemon& attacker, const Move& move, const Pokemon& defender) { double base = 1.0 * attacker.atk * move.power / qMax(1, defender.def) + 1; double stab = (attacker.type == move.type) ? 1.2 : 1.0; double typeMul = typeMultiplier(move.type, defender.type); double variation = 0.98 + QRandomGenerator::global()->bounded(5) / 100.0; int dmg = qRound(base * stab * typeMul * variation); return qMax(1, dmg); // 伤害最差也是1 }

qMax(1, dmg)和qMax(1, defender.def)是Qt全局函数,比手写三目运算符清晰。防御力不可能为0,所以对分母做钳制。随机浮动用QRandomGenerator::global(),这是Qt 5.10以后的标准随机入口,线程安全且一次性初始化,不要再用过时的qsrand。

战斗循环也控制在控制台内:

void runBattle(Pokemon& player, Pokemon& wild) { while (player.hp > 0 && wild.hp > 0) { Pokemon& first = (player.speed >= wild.speed) ? player : wild; Pokemon& second = (player.speed >= wild.speed) ? wild : player; // 先手方随机选第一个技能释放 int dmg = calcDamage(first, first.moves.first(), second); second.hp = qBound(0, second.hp - dmg, second.maxHp); qDebug().noquote() << first.name << "造成" << dmg << "伤害," << second.name << "剩余 HP" << second.hp; } }

顺序判定上我取速度高者先手,同速时玩家先手,避免随机平局破坏可预期性。qBound是把HP夹在0和maxHp之间最省事的写法。这段代码在命令行下可以反复跑,不需要鼠标点击,也不需要考虑窗口刷新,非常适合拿单元测试去验证极端场景——比如满血一击秒杀、攻击力为1不除零。

2.3 拆掉打印,接上信号槽:同一套内核平滑迁到QT

控制台版本跑通后,把“输出”和“输入”换成Qt机制,战斗逻辑一行都不用改。做法是让战斗内核继承QObject,把日志输出变成信号,把外部操作变成槽:

class BattleCore : public QObject { Q_OBJECT public: explicit BattleCore(QObject* parent = nullptr); void startBattle(Pokemon player, Pokemon wild); signals: void message(const QString& text); // 替代 qDebug 输出 void battleEnded(bool playerWin); // 给界面切换胜负画面 void hpChanged(int playerHp, int wildHp); // 给血条更新 public slots: void useMove(int moveIndex); // 由界面按钮触发 void throwBall(); // 捕捉操作 };

界面层只需要连接这些信号:

BattleCore* core = new BattleCore(this); core->startBattle(player, wild); connect(core, &BattleCore::message, this, [](const QString& t){ logWidget->appendPlainText(t); }); connect(core, &BattleCore::hpChanged, this, [](int a, int b){ playerBar->updateBar(a); wildBar->updateBar(b); });

能这样迁移的前提是,第2.2节的伤害公式和回合循环没有直接调用printf或std::cout,而是把所有状态变化通过信号抛出去。界面只是战斗内核的一个“显示屏”,这个思想贯穿整个项目。很多Qt游戏教程把伤害计算写在按钮的lambda里,一换界面就得重写逻辑,这是一开始没把边界划清的恶果。

3. 用QGraphicsScene搭战斗场景:qt绘图、血条和动画帧

战斗逻辑就绪后,界面层的任务只剩三件:渲染宝可梦形象、展示血条、播放攻击动画。这里不建议用一堆QLabel叠位置,而是用QGraphicsScene+QGraphicsView,因为Qt绘图在场景体系里有现成的坐标变换、层级和动画回调,QLabel做这些会非常别扭。

3.1 场景、视图、精灵项:三层结构各管什么

QGraphicsView是窗口部件,负责把场景画到屏幕上;QGraphicsScene是逻辑坐标系,管理所有图形项;QGraphicsItem是单个精灵或血条。常见代码骨架如下:

auto* view = new QGraphicsView(this); auto* scene = new QGraphicsScene(view); view->setScene(scene); view->setRenderHint(QPainter::Antialiasing); view->setSceneRect(0, 0, 640, 360); // 固定战斗场景区域 auto* player = new PokemonItem(":/images/player.png"); player->setPos(80, 200); // 左下角 scene->addItem(player); auto* enemy = new PokemonItem(":/images/enemy.png"); enemy->setPos(420, 120); // 右上角 scene->addItem(enemy);

PokemonItem我习惯直接继承QGraphicsObject,因为它既能被场景管理,又具备QObject的信号能力,方便后续发hpChanged之类信号:

class PokemonItem : public QGraphicsObject { Q_OBJECT public: explicit PokemonItem(const QString& imgPath); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override; };

boundingRect()返回图片的矩形范围,paint()里就一行painter->drawPixmap(offset, pixmap)。注意setPos接收的是场景坐标,y轴向下增长,所以越往下y值越大。很多新手把左下角写成(80, 80),结果两个角色叠在左上角,调半天位置,这就是没弄清坐标系的问题。setSceneRect也要在addItem之前调用,否则视图自动计算场景范围时可能因为空场景得到一个奇怪的默认尺寸。

3.2 血条不贴QLabel:用QGraphicsRectItem自绘

血条这个部件,最典型的错误是每帧更新QLabel的setGeometry,QLabel内部有样式计算成本,频繁刷新会出现肉眼可见的闪烁。更顺手的方案是两个叠加的QGraphicsRectItem:底部红色满宽,顶部绿色按比例变窄。

class HealthBar : public QGraphicsObject { Q_OBJECT public: HealthBar(qreal width, qreal height); void updateBar(int hp, int maxHp) { qreal ratio = qBound(0.0, 1.0 * hp / maxHp, 1.0); current = width * ratio; update(); } protected: void paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) override { p->setPen(Qt::NoPen); p->setBrush(QColor("#cc3333")); p->drawRect(0, 0, width, height); p->setBrush(QColor("#33cc66")); p->drawRect(0, 0, current, height); } };

paint里每次drawRect的第二个矩形宽度来自current,颜色用深红当底、亮绿当上。这里有个细节:current是qreal类型,直接用比例乘宽度,不要在外部转int,否则低血量时血条会以1像素的阶梯跳变。边框用QPen,但干扰视觉,我选择Qt::NoPen。血条更新不重造控件,只触发update()重绘,这是Qt绘图性能好的原因——视图只在需要时重画脏矩形区域。

3.3 QTimeLine做攻击动画:帧回调与坐标插值

攻击动画最常见的做法是“前冲固定距离再退回原点”。用QTimeLine按帧触发pos变化,比起一个QTimer循环更简洁:

QTimeLine* tl = new QTimeLine(400, this); // 400ms 完成一个攻击往返 tl->setUpdateInterval(16); // 约60fps tl->setEasingCurve(QEasingCurve::OutBack); // 冲出去再轻微回弹 QPointF origin = playerItem->pos(); tl->setFrameRange(0, 100); connect(tl, &QTimeLine::frameChanged, this, [=](int frame){ qreal offset = (frame < 50) ? frame : 100 - frame; // 前50帧出拳,后50帧回撤 playerItem->setPos(origin.x() + offset * 1.2, origin.y()); }); connect(tl, &QTimeLine::finished, this, [=]{ playerItem->setPos(origin); core->continueNextTurn(); // 动画结束后才允许下一步操作 }); tl->start();

这里QTimeLine的参数直接决定了手感:400ms是“干脆但不突兀”的时长,短了像瞬移,长了拖沓。frameChanged的信号参数是当前帧号,我把它映射到0到100的进度,以50帧为分界做折返移动。QEasingCurve::OutBack让冲拳过程中带轻微过冲,视觉上比线性运动有弹性。还有一个关键点:动画播放期间必须禁用技能按钮,否则玩家连续点四次,四个QTimeLine叠在一起,精灵位置直接飞出屏幕。用bool isAnimating标志或者在动画开始时setEnabled(false),直到finished再恢复。

3.4 输入响应:让按钮和快捷键走同一个入口

战斗界面的输入不止鼠标点击,键盘快捷键也该支持。常见做法是给每个按钮配一个QShortcut,但快捷键和点击最终都调到同一个方法:

QPushButton* attackBtn = new QPushButton("攻击", this); attackBtn->setShortcut(QKeySequence(Qt::Key_1)); // 按1等于点攻击 connect(attackBtn, &QPushButton::clicked, this, [=]{ core->useMove(0); }); QPushButton* ballBtn = new QPushButton("捕捉", this); ballBtn->setShortcut(QKeySequence(Qt::Key_2)); connect(ballBtn, &QPushButton::clicked, this, [=]{ core->throwBall(); });

这样就不用在view里重写keyPressEvent,避免了按键被其他窗口抢焦点时失效的问题。快捷键和点击汇聚到一个入口,后续加逻辑只需要维护核心类,不用管触发源。界面层越薄,越不容易把游戏逻辑和表现层缠在一起。

4. 背包、图鉴和存档:JSON序列化与QTableView自定义model

打完一场只是单次流程,能让玩家“第二天接着玩”靠的是存档。背包道具、图鉴收集、已捕捉宝可梦列表都属于数据层,我用JSON做持久化格式,因为它容易读、容易改、坏了也容易定位。图鉴列表则要换成QTableView+ 自定义QAbstractTableModel,这是qt表格大数据卡顿优化里最常被点名的一条路线。

4.1 存档结构:用QJsonDocument写出一份后悔药

存档的最小结构包含玩家队伍、拥有的道具数量、图鉴解锁清单和当前场地进度。写档用QJsonObject组装:

bool saveGame(const PlayerState& st, const QString& path) { QJsonObject root; root["version"] = 1; // 版本号,读档兼容用 root["scene"] = st.sceneId; root["gold"] = st.gold; QJsonArray team; for (const Pokemon& p : st.team) { QJsonObject obj; obj["name"] = p.name; obj["type"] = p.type; obj["hp"] = p.hp; obj["maxHp"] = p.maxHp; obj["atk"] = p.atk; obj["def"] = p.def; obj["speed"] = p.speed; team.append(obj); } root["team"] = team; root["bag"] = QJsonObject{{"ball", st.ballCount}, {"potion", st.potionCount}}; QFile f(path); if (!f.open(QIODevice::WriteOnly)) return false; f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); return true; }

toJson(QJsonDocument::Indented)是排过版的JSON,调试时直接打开看到的就是树形缩进结构,一眼能看出哪个字段丢了。存文件路径不要硬编码相对路径,桌面应用装在哪个目录都可能,正确做法是用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)拼一个用户目录下的存档位置。

读档比写档更考验容错:

bool loadGame(PlayerState& st, const QString& path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return false; QJsonParseError err; QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err); if (err.error != QJsonParseError::NoError) return false; // 文件损坏 if (!doc.isObject()) return false; QJsonObject root = doc.object(); if (!root.contains("version") || root["version"].toInt() != 1) return false; st.gold = root["gold"].toInt(); st.ballCount = root["bag"].toObject()["ball"].toInt(); // 逐个字段校验,缺任何一个字段都视为坏档,不要用toInt的默认值硬抗 }

读档时必须对每个字段做contains校验。一个只写到一半的文件,toInt()会静默返回0,玩家打开存档发现金币清零,这种问题比闪退还可怕。版本号字段是后悔药,将来数据结构变化时可以走迁移逻辑,不至于把老档一刀切。

4.2 图鉴从QTableWidget换到QTableView:数据大时的卡顿来源

如果图鉴只有几十条,QTableWidget完全够用;但图鉴条目一多、还要频繁和QVariant数据交互时,QTableWidget的每个格子都是独立Cell控件对象,卡顿会很明显。QTableView只创建可见区域的项,数据从model按需取,这正是表格大数据优化的核心思路。

class PokedexModel : public QAbstractTableModel { Q_OBJECT public: int rowCount(const QModelIndex&) const override { return entries.size(); } int columnCount(const QModelIndex&) const override { return 3; } QVariant data(const QModelIndex& idx, int role) const override { if (!idx.isValid()) return {}; const auto& e = entries[idx.row()]; if (role == Qt::DisplayRole) { switch (idx.column()) { case 0: return e.name; case 1: return e.type; case 2: return e.caught ? "已捕捉" : "未捕捉"; } } return {}; } };
QTableView* table = new QTableView(this); PokedexModel* model = new PokedexModel(this); table->setModel(model); // 视图只显示可见行 table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setSelectionMode(QAbstractItemView::SingleSelection);

data函数在每一行要显示时会触发一次,视图滚动时按需调用几十次,比一次性生成几千个QTableWidgetItem开销低一到两个数量级。这一层也是mvvm思路在Qt里的最轻量实现——model持有数据,view只负责展示,修改数据时发beginResetModel或dataChanged让视图局部刷新。如果未来加排序,model排序后发一次layoutChanged,视图和选中状态会同步重建。

4.3 捕捉判定:随机数、球的数量和失败兜底

捕捉判定是回合制里的概率分支。简化公式:

bool catchPokemon(Pokemon& wild, int& ballCount) { if (ballCount <= 0) { emit message("没有精灵球了"); return false; } ballCount--; double rate = 0.9 - 0.4 * (1.0 * wild.hp / wild.maxHp); // 血量越低越容易 if (wild.hp < wild.maxHp / 2) rate += 0.2; bool ok = QRandomGenerator::global()->generateDouble() < rate; if (ok) { wild.hp = wild.maxHp; // 捕捉后恢复满血 emit caught(wild); } else { emit message("球晃了两下,还是逃出来了"); } return ok; }

generateDouble()返回0到1之间的double,直接和概率比较,比bounded(100)少一次归一化的脑内换算。捕捉失败时野生宝可梦可能会跑掉,还要拖一个“回合继续”的信号出去;如果捕捉成功,图鉴条目里的caught要置true,并发一次dataChanged让表格刷新。这里最容易踩的坑是:捕捉成功时忘了把野生对象从场景里移除,画面留着残影。正确的顺序是:扣球数→概率判定→发消息→移除场景项→追加到队伍数据,每一步依赖前一步成功,不要在一个lambda里堆所有逻辑。

5. 避坑排查:qt崩溃、中文乱码、资源不显示和视图刷新的高频问题

这部分写的是我自己踩过三遍以上的坑,每一条都是“现象→原因→解决”的完整闭环。这些问题不解决,项目规模再大也玩不下去,尤其qt崩溃这种黑匣子问题,最费时间。

5.1 中文乱码:MSVC编码和Qt5的UTF-8冲突

现象:源文件里写的“皮卡丘”,运行时显示成“鐨偣涓”,或者编译告警 C4819。原因是Qt 5在MSVC下默认把源文件按本地代码页(GBK)解析,而文件实际存成了UTF-8,两边对不上。解决分两步。第一步,CMake里给MSVC加/utf-8编译选项:

if(MSVC) add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>") endif()

第二步,字符串常量在代码里统一用QString::fromUtf8(u8"...")或者直接tr("...")包起来,不要在头文件里放裸字符串字面量。Qt 6已经把默认编码换成UTF-8,这个坑在Qt5时代几乎人人碰到。

5.2 QObject生命周期翻车:QGraphicsScene销毁后的悬空指针

现象:打完一场战斗切回主界面,一按“继续”,程序直接闪退,崩溃栈指向QGraphicsScene::addItem附近。原因多半是new了一个PokemonItem后只add进场景,没有设置parent,而你在某个成员变量里缓存了这个指针;场景被销毁时item也会被销毁,但指针还留在成员变量里,下一次战斗直接setPos访问已释放内存。解决方法是两件事同时做:给item在创建时就挂上场景这个parent,并且缓存指针时用QPointer<PokemonItem>:

auto* item = new PokemonItem(":/images/enemy.png"); scene->addItem(item); // item的owner归场景 m_enemy = item; // 普通指针 QPointer<PokemonItem> m_enemy; // 安全版本:场景销毁后自动变nullptr if (m_enemy) m_enemy->setPos(...);

QPointer是Qt专门为QObject派生类设计的“弱引用”,对象销毁后自动置空,比裸指针多了一道保命符。另外,connect lambda里如果捕获了this,而this是场景或窗口对象,要在connect第四个参数传this作为context,否则对象析构后回调仍然可能被触发。

5.3 资源文件不显示:qrc路径前缀惹的祸

现象:代码编译通过,运行后精灵图什么都不显示,QPixmap::isNull()返回true,界面是空白的。原因通常不是图片损坏,而是资源路径拼写问题。qrc文件里如果注册的是/images/pika.png,那代码里就必须写":/images/pika.png";写成"images/pika.png"或者":pika.png"都会静默失败。调试时先确认资源存在:

qDebug() << QFile::exists(":/images/pika.png");

如果返回false,打开qrc文件检查有没有把新图片加进列表。改完qrc记得重新构建,rcc只在构建时重新生成资源;有时候改了图片内容但构建没重新执行,上屏的还是旧图,这是最容易被误判成“代码问题”的一环。除此之外,setPixmap传入空QPixmap时不会报错,只会画一个透明矩形,所以看到空白先怀疑资源路径,而不是怀疑代码逻辑。

5.4 QTableView排序后选中错位:行号会骗人

现象:图鉴点击表头按属性排序后,点第二行,背包里却用了第三条数据。原因是QTableView排序只会影响视图显示顺序,model数据顺序并没变,所以你在onClicked(index)里拿到的idx.row()是“视图行号”,不是model里的实际行。解决方法是给每条数据加一个稳定id,点击时通过行号拿到的数据项再去查唯一id,不要直接用行号去QVector里索引。

connect(table, &QTableView::clicked, this, [=](const QModelIndex& idx){ int modelRow = model->indexToEntry(idx); // 内部查id if (modelRow >= 0) { emit entrySelected(modelRow); } });

或者不要开启排序,只做固定顺序展示,那行号就始终可信。小游戏图鉴通常不需要动态排序,与其花时间同步排序状态,不如直接禁掉表头点击排序,把setSortingEnabled关掉。

6. 进阶:用QStateMachine拆战斗流程,用QTest保住回归

战斗流程从“选择技能”到“动画播放”再到“回合结算”,如果用bool标志串,分支一多必然乱。Qt自带的QStateMachine模块可以把这些步骤建模成状态图,每个状态负责一个阶段,转换条件写在transition里,比手写枚举状态机直观得多。

QStateMachine* sm = new QStateMachine(this); QState* menu = new QState(sm); // 等待玩家选择 QState* anim = new QState(sm); // 播放攻击动画 QState* turnEnd = new QState(sm); // 判定胜负/切换回合 menu->addTransition(attackBtn, &QPushButton::clicked, anim); anim->addTransition(animTimer, &QTimer::timeout, turnEnd); turnEnd->addTransition(core, &BattleCore::turnFinished, menu); sm->setInitialState(menu); sm->start();

状态之间的箭头就是addTransition,触发源可以是按钮点击信号、计时器超时、或者核心类的自定义信号。写清楚状态图之后,新增“逃跑”只需要加一个状态和两条transition,不用去改for循环或switch。

验证这套流程也不用手点——Qt Test里QSignalSpy可以监听任何信号,配合QTest::mouseClick模拟鼠标点击事件做回归:

#include <QTest> #include <QSignalSpy> QSignalSpy spy(core, &BattleCore::turnFinished); QTest::mouseClick(attackBtn, Qt::LeftButton); QVERIFY(spy.count() == 1); // 点一下技能,turnFinished恰好发一次

这段测试把“界面按钮→战斗内核→回合结束信号”整条链路锁死了。我现在的习惯是每次改动核心逻辑,先跑一遍这种回归,再手动进游戏点两轮。以前我总在“感觉没问题”的情况下直接提交,结果经常在一个改动后把捕捉判定弄坏而不自知。用QSignalSpy加上命令行里-test参数,五分钟能把战斗内核的主要路径过一遍,这个习惯帮我省了不少事。希望这篇路径能帮到你,让你少走我当年撞过的那些暗坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询