ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

使用Qt和C++开发宝可梦回合制小游戏:从状态机到信号槽实践

使用Qt和C++开发宝可梦回合制小游戏:从状态机到信号槽实践 简介一份基于 Qt 框架的 C 2D 角色扮演游戏源码面向初学 Qt 和游戏开发的人群以《宝可梦》核心玩法为蓝本完整实现了世界探索、宝可梦养成、回合制战斗与玩家管理四大系统。资源共 20 个文件以 9 个 cpp 与 8 个 h 源文件为主体并包括 TMX 地图文件、pro 工程配置和 qrc 资源管理文件压缩包仅 19KB结构紧凑适合直接阅读。游戏基于 QGraphicsView/QGraphicsScene 搭建渲染框架覆盖角色移动碰撞检测、属性克制、技能与进化机制、战斗动画和背包系统等关键模块能帮助学习者理解 2D 游戏对象组织与 Qt 图形视图框架的配合方式同时 TMX 地图加载和回合制战斗流程展示了从地图解析到状态管理的完整思路。已有 102 人学习下载适合作为课堂练习或课程设计参考在现有骨架上还可继续扩展宝可梦种类、地图场景与剧情任务也便于二次开发时熟悉 Qt 事件循环与场景切换机制。1. 基于QTC开发的宝可梦小游戏不是玩票而是把C对象、信号槽和事件循环一次摸透很多人一听说用Qt做游戏第一反应是“那不如用Unity”。但对于一个宝可梦题材的回合制小游戏QWidget/QML 的界面层级和交互方式恰好和回合制节奏很搭。基于QTC开发的宝可梦小游戏核心挑战不是3D渲染和物理引擎而是数据结构、状态机和UI的同步——这正是C和Qt最擅长的领域。把这个题目做一遍你会遇到QString、QList、指针、随机数、JSON读写、信号槽和事件循环比写“学生管理系统”能学到的C知识多得多。适合正在学C但想用具体c游戏来练手的开发者也适合拿这个选题做课程设计或毕设的在校生。这里就按一条实际落地路线带你把项目从零搭到能玩并避开我当年踩过的那些坑。2. 搭出游戏骨架Qt Widgets还是QML先让窗口和场景切换跑起来做宝可梦小游戏的第一步不是写战斗而是决定用什么界面技术。很多人在这一步就开始犹豫Qt有QWidget和QML两套体系选错了后面全是坑。我的建议很直白除非你打算把同一套界面以后搬到手机端否则在C刚入门的阶段用 Qt Widgets QGraphicsScene 是最稳的。它不需要你额外掌握QML/JavaScript所有逻辑都能在C里打断点遇到问题看一眼调用栈就明白。QML虽然动画写起来漂亮但调试时要不断往返C和JS黑匣子太多了。2.1 选型理由Widgets QGraphicsScene vs QML如果你以后想进游戏公司搞UI动效QML值得学但如果目标是靠这个宝可梦项目练熟C就别让QML的学习曲线挡住你。下面是两组技术在“关卡/菜单回合制战斗”场景下的对比对比维度Widgets QGraphicsSceneQML Qt Quick学习曲线有Qt Designer拖控件熟悉C就能上手要额外学QML/JS绑定语法排错路径长动画性能普通小游戏足够配合QPropertyAnimation能做出弹性、位移效果内置动画和粒子系统更强适合复杂转场调试难度逻辑全在C断点、调用栈清晰C/JS来回切报错信息有时很抽象部署体积只需要Widgets运行时需要Qt Quick运行库体积更大所以我在项目里用的是 Qt 5.15.2 QWidget QGraphicsScene。这个版本是Qt官方长期支持版教程和社区讨论最多遇到问题好搜索。新版本功能多但很多教材和第三方库还停在5.15用了新版本反而容易遇到“代码照抄却编译不过”的情况。2.2 用国内镜像装好Qt 5.15.2并搭一个最小工程安装Qt时别去官网慢速源国内镜像站会把Qt 5.15.2下载速度拉满。安装时选择“msvc2019_64”和“mingw81_64”至少其中一个不需要勾选所有组件否则编译起来会慢得让人想放弃。装好之后先用Qt Creator打开命令行工具确认qmake和编译器在同一个套件里这能免掉后面大量莫名其妙的qt崩溃。在工程目录下建一个.pro文件作为最小项目入口QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET PokemonQt TEMPLATE app SOURCES main.cpp MainWindow.cpp HEADERS MainWindow.h RESOURCES resources.qrc这里QT widgets是Qt5以后必须的否则Qt Creator会报“找不到QWidget”。TARGET是最终可执行文件的名称我一般按项目名起。RESOURCES声明文本资源清单后面精灵图片和JSON配置都靠它打包。在命令行直接用qmake mingw32-make或nmake就能编译但需要先在Qt命令行环境里执行。如果你习惯用VS记得把Qt的bin目录加进PATH否则运行时会报找不到平台插件。2.3 用QStackedWidget管理标题、地图和战斗场景新手最爱把多个窗口轮流show/hide结果焦点和事件到处乱跑。我一般用QStackedWidget把标题界面、地图界面、战斗界面各自做成一个QWidget放进栈里切换时只需setCurrentIndex。这比新建窗口轻量而且能保持各场景的状态。MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_stack new QStackedWidget(this); m_titlePage createTitlePage(); m_battlePage createBattlePage(); m_stack-addWidget(m_titlePage); // index 0 m_stack-addWidget(m_battlePage); // index 1 setCentralWidget(m_stack); setWindowTitle(宝可梦 - Qt/C); connect(m_startButton, QPushButton::clicked, this, [this]() { m_stack-setCurrentIndex(1); }); }解释createTitlePage里创建一个带“开始”按钮的页面createBattlePage里放一个QLabel显示敌方精灵。点击按钮后lambda里切换页面。注意在构造函数里把当前栈内容设置好否则打开就是黑屏。setCurrentIndex的参数从0开始也可以用setCurrentWidget传页面指针。如果你需要更复杂的场景转场可以给QStackedWidget设置动画但需要自己继承写这里先不展开。先把结构跑通后面往战斗页面塞逻辑就顺手了。这一步是“qt界面设计”的基础很多教程不在这一步下功夫直接进入战斗代码最后连场景切换都做成一团乱麻。3. 把宝可梦数据装进C结构体、JSON配置与随机遇敌宝可梦小游戏的核心是“精灵数据”。我这里说的数据不是数据库里的字段而是结构体和容器。先定义好数据结构后面战斗代码写起来才不会像一团乱麻。最常见的方法是直接写struct而不是上来就建一堆QObject类除非你要用信号槽在线程间传递精灵否则没必要。3.1 用结构体描述精灵、技能和属性我先定义基础类型和结构体enum class PetType { Fire, Water, Grass, Electric, None }; struct Skill { QString name; PetType type; int power; // 技能威力 int maxUses; // 使用次数上限 int currentUses; // 当前剩余次数 }; struct Pet { QString name; PetType type; int maxHp; int hp; int attack; int defense; int speed; QListSkill skills; };解释把hp和maxHp分开因为战斗过程中hp会变。攻击、防御、速度都直接用int方便计算伤害公式。QListSkill在C里是Qt的容器能直接配合QJson读写也可以放进QVariant里。为什么不用std::vector因为后续要和UI里的model交互QList更顺手而且我们后面读JSON时可以直接遍历。注意别把技能定义成二维数组否则读代码的时候脑子要转好几圈。如果你以后要加“捕捉率”“个体值”直接在Pet里继续加字段就行不影响已有代码。3.2 用QJsonDocument读取图鉴文件资源路径别写死我习惯把精灵图鉴放在JSON文件里而不是用C硬编码。这样想加一只精灵只改JSON不动代码方便测试也方便别人二次开发。用QJsonDocument读取的代码如下#include QFile #include QJsonDocument #include QJsonArray #include QJsonObject bool loadPets(const QString jsonPath, QListPet *out) { QFile file(jsonPath); if (!file.open(QIODevice::ReadOnly)) return false; QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(file.readAll(), err); if (err.error ! QJsonParseError::NoError) { qWarning() JSON parse error: err.errorString(); return false; } QJsonArray array doc.array(); for (const QJsonValue val : array) { QJsonObject obj val.toObject(); Pet p; p.name obj.value(name).toString(); p.maxHp obj.value(hp).toInt(); p.attack obj.value(attack).toInt(); p.defense obj.value(defense).toInt(); p.speed obj.value(speed).toInt(); QString typeStr obj.value(type).toString(); p.type (typeStr Fire) ? PetType::Fire : PetType::Water; QJsonArray skillArray obj.value(skills).toArray(); for (const QJsonValue s : skillArray) { Skill skill; skill.name s.toObject().value(name).toString(); skill.power s.toObject().value(power).toInt(); skill.maxUses skill.currentUses s.toObject().value(uses).toInt(); p.skills.append(skill); } out-append(p); } return true; }解释第一个参数用const QString jsonPath因为Qt会把qrc路径“:/pets.json”当作字符串传入。里面先判文件是否打开再判JSON语法这两个错误必须分开记录否则后面调试时不知道是文件问题还是内容问题。注意obj.value(...)比obj[...]更安全当键不存在时返回默认值而不是触发警告。类型映射那里我用三目只是为了示例类型多了就写一个strToType函数每个case都要覆盖漏掉会得到默认值。JSON文件可以长这样[ { name: 喷火龙, type: Fire, hp: 120, attack: 85, defense: 70, speed: 100, skills: [ { name: 火焰拳, power: 75, uses: 15 }, { name: 抓, power: 40, uses: 35 } ] } ]在.pro里把json文件放进RESOURCESqrc文件里前缀设为/那么实际路径为:/pets.json。如果你把json放在工程目录外比如D:/data那发布后路径变了就会加载失败。更稳妥的方式是把静态图鉴作为qrc资源打包存档类数据才写到用户可写目录。3.3 随机数的坑别用rand()用std::mt19937遇敌需要随机很多教程还在用rand() % 10这在现代C里已经是坑了。rand()的实现在不同编译环境不一样而且多线程共享状态容易遇到连续相同结果。我在Qt开发里遇到最典型的例子连续三场遇到同一只精灵看起来就像随机数没有工作。换掉rand()后就正常了。#include random static std::mt19937 rng{std::random_device{}()}; int randInt(int min, int max) { std::uniform_int_distributionint dist(min, max); return dist(rng); } Pet makeWildPet(const QListPet table) { int idx randInt(0, table.size() - 1); Pet wild table.at(idx); wild.maxHp randInt(0, 9); wild.attack randInt(0, 5); wild.hp wild.maxHp; return wild; }解释std::mt19937是梅森旋转算法生成质量比rand()好得多uniform_int_distribution帮你把随机值均匀映射到闭区间。random_device{}()作为种子在Windows和Linux上都能取到系统熵足够游戏使用。如果你希望某局对战可复现比如调试可以把rng保存起来下次沿用同一个种子。这点在存档功能里特别有用。注意randInt传参时别把min和max传反我之前把min传成max结果数组越界。后来统一在函数里写成dist(std::min(a,b), std::max(a,b))虽然多几行但再也没越界。4. 回合制战斗状态机、信号槽与血条动画的配合战斗系统是宝可梦项目的灵魂。你当然可以一口气把所有逻辑堆在窗口类的槽函数里但那样写出来的代码运行没问题扩展时想加个新技能就手忙脚乱。我习惯把战斗拆成状态机 两个独立类BattleCore负责数值BattlePage负责界面两者通过信号槽通信。4.1 战斗流程状态机用枚举简化逻辑先定义状态枚举和一个简化的状态推进函数。注意这里说的不是那种QStateMachine框架而是自己手写的枚举分支因为战斗状态不算多手写更直观。enum class BattleState { Menu, // 显示“攻击/背包/逃跑” ChooseSkill, // 选择技能 PlayerMove, // 玩家技能结算 EnemyMove, // 敌人技能结算 CheckEnd, // 检查胜负 Win, Lose }; BattleState stepState(BattleState cur, const BattleCore core) { switch (cur) { case BattleState::Menu: return BattleState::ChooseSkill; case BattleState::ChooseSkill: return BattleState::PlayerMove; case BattleState::PlayerMove: return BattleState::EnemyMove; case BattleState::EnemyMove: return BattleState::CheckEnd; case BattleState::CheckEnd: if (core.enemyHp() 0) return BattleState::Win; if (core.playerHp() 0) return BattleState::Lose; return BattleState::Menu; } return cur; }解释状态机的核心是“当前状态 事件 → 下一状态”。这里用返回值形成推进主窗口拿到新状态后再决定显示什么。注意不要真把这个函数放在while循环里否则动画播不出来。每个状态之间应该由用户点击或动画完成信号驱动而不是无限循环。参数里的const BattleCore core是只读访问这样函数不会把状态变更和数值变更混在一起。补充一句为什么不用QStateMachine因为它对简单回合制来说有点重而且状态转移的触发条件写在哪些信号里不如手写枚举来得直观。等你的游戏做到“思考中、读档中、战斗后对话”这种复杂状态再考虑换框架。4.2 用信号槽解耦战斗逻辑与界面直接写BattleCoreclass BattleCore : public QObject { Q_OBJECT public: void playerChooseSkill(int idx) { Skill s player.skills[idx]; int damage s.power * player.attack / enemy.defense; enemy.hp - qMax(0, damage - 5); // 简单随机修正 emit hpChanged(enemy.name, enemy.hp); if (enemy.hp 0) emit battleFinished(true); } void enemyTurn() { int damage enemy.skills[0].power * enemy.attack / player.defense; player.hp - qMax(0, damage - 3); emit hpChanged(player.name, player.hp); if (player.hp 0) emit battleFinished(false); } signals: void hpChanged(QString petName, int newHp); void battleFinished(bool won); private: Pet player, enemy; };解释BattleCore不持有任何UI控件只维护player和enemy两个Pet并且通过emit把变化广播出去。BattlePage在构造时connect这些信号收到后更新血条、文字和动画。这样做的好处是以后你写AI、写联机对战只要换一个core界面逻辑完全复用。注意emit本身是Qt宏后面跟信号名信号参数的类型和个数要和connect里的槽函数匹配。这里的伤害公式只是示例你可以在里面加上randInt和属性相克。但关键点是无论公式多复杂所有数据修改必须集中在BattleCore内界面只读不写否则会出现血条显示和实际血量不一致的玄学问题。4.3 用QPropertyAnimation让精灵动起来用QProgressBar显示血条界面层最核心的两个控件是QLabel显示精灵图片和QProgressBar显示血量。动画我一般用QPropertyAnimation它能让任何值为int或QRect的属性做插值不需要自己开定时器。void BattlePage::playAttackAnim(QLabel *petLabel) { QPropertyAnimation *anim new QPropertyAnimation(petLabel, geometry); anim-setDuration(250); anim-setEasingCurve(QEasingCurve::OutBounce); anim-setStartValue(QRect(petLabel-x(), petLabel-y(), petLabel-width(), petLabel-height())); anim-setEndValue(QRect(petLabel-x() 40, petLabel-y(), petLabel-width(), petLabel-height())); connect(anim, QPropertyAnimation::finished, anim, QObject::deleteLater); anim-start(); }解释QPropertyAnimation通过修改QLabel的geometry属性来触发重绘所以动画过程中QLabel位置渐变。setEasingCurve(OutBounce)会让它像弹出一样最后有点反弹。注意start()是异步的不会阻塞主线程如果你在动画之后立刻更新数据界面可能看到“先跳结果后播放动画”的错位。我的习惯是在connect中把动画finished信号接到数据更新上顺序就对了。参数duration单位毫秒图片尺寸不变只位移如果要缩放直接改QRect的宽高即可。血条更新更简单ui-hpBar-setRange(0, pet.maxHp); ui-hpBar-setValue(pet.hp);在播放动画期间你需要在窗口里设置一个bool isAnimating防止用户连续点技能导致动画重叠。在finished信号里把它置回false同时把状态机状态从PlayerMove推进到EnemyMove。这个小细节能避免“技能释放两次”的怪异现象。另外如果用QPainter直接绘图也能实现更复杂的特效但需要自己管理重绘区域和图片缓存。对于这个项目用QLabel 动画足够还不用额外处理QGraphicsScene的坐标变换。5. Qt与C开发宝可梦的常见问题排查崩溃、找不到插件、按键无响应这一章直接上踩坑清单。我把这几年做Qt小游戏遇到最多的问题列出来每一条都是“现象→原因→解决”的结构照着排查能省下很多时间。5.1 qt崩溃cannot mix incompatible Qt library (version ex50601) with this library现象程序编译通过一启动就在控制台输出cannot mix incompatible Qt library (version ex50601) with this library随后退出。原因Qt的二进制库和当前编译器的ABI不匹配。比如你下载的是msvc2019的Qt库却在Qt Creator里用了MinGW编译器后者用C异常和RTTI的方式不同运行时检测到版本差异就会拒绝执行。解决打开“工具→Kits”把活动Kit切换成与Qt库对应的套件。如果你和我一样装了Qt 5.15.2的两个版本建议在Kit名称里写明“MinGW”和“MSVC”避免选错。如果是在命令行用qmake先执行qmake -v确认路径再执行编译器g -v或者cl确认是同一套。这个坑我在换电脑重装环境时又踩了一次后来养成了一个习惯新建项目后第一件事就是编译一个空窗口确认基础环境没问题再写业务代码。5.2 qt.qpa.plugin: could not find the qt platform plugin “linuxfb”现象在嵌入式Linux或者ARM国产板子上部署时启动程序报错qt.qpa.plugin: could not find the qt platform plugin linuxfb。原因Qt的GUI运行时依赖platform plugin默认是xcb或者linuxfb等。目标板上没有安装对应的Qt平台插件或者环境变量QT_QPA_PLATFORM没有指明。解决在Qt安装目录的plugins/platforms下找到libqlinuxfb.so或libqxcb.so把它复制到可执行文件旁的plugins/platforms目录然后用export QT_QPA_PLATFORMlinuxfb启动。在x86桌面本机开发不需要这个插件只有交叉编译部署时才需要。另外在Qt Creator里运行时它会自动带上宿主机的插件所以你本机能跑、板子不能跑多半就是忘了复制platforms目录。5.3 图片不显示qrc资源路径与磁盘路径搞混现象用QPixmap(../images/pet.png)加载图片结果窗口上一个像素都没有也不报错。原因程序的工作目录不一定是工程目录。在Qt Creator里默认是构建目录所以相对路径除非你自己拼接否则找不到而qrc资源路径可以保证在不同平台都有效。解决把图片文件加进项目里的.qrc文件然后在代码中用:/images/pet.png。写路径时注意大小写Qt在Linux下对大小写敏感不是Windows那样宽容。调试时可以在代码里加一行qWarning() QFile::exists(:/images/pet.png);如果为false说明qrc内容没编进去或前缀不对。这里最常犯的错误是在qrc文件里把prefix写成/img实际代码里却写:/images/那就白搭。5.4 按键无响应焦点策略和事件过滤现象在战斗界面按键盘“A”没有反应但鼠标点击按钮工作正常。原因QWidget默认没有键盘焦点必须显式调用setFocusPolicy(Qt::StrongFocus)并setFocus()另外如果你在子控件上处理按键事件需要把焦点给到那个子控件而不是给窗口。解决在需要响应键盘的场景页构造函数里写setFocusPolicy(Qt::StrongFocus); setFocus();然后重写keyPressEvent。如果你想全局响应快捷键可以给qApp安装事件过滤器在eventFilter里拦截QEvent::KeyPress。测试时可以用QTest::keyClick(window, Qt::Key_A)来模拟鼠标点击事件不过要注意QTest需要工程链接QtTest模块。这个习惯能帮你快速定位是事件没发出来还是焦点没给到。5.5 内存泄漏和悬空指针父对象与QPointer现象游戏连续玩一小时内存从100M涨到300M或者某个动画结束后指针再访问就崩溃。原因频繁new的控件没有指定parent也没有在合适时机delete或者父窗口销毁后子对象的地址还留在某个临时变量里形成悬空指针。解决在创建QLabel、QPropertyAnimation这类QObject时尽量把this或实际父窗口传进去动画这种临时对象则用deleteLater挂到finished信号上。对于需要长期引用的对象用QPointer包裹它在对象销毁时会自动置空。比如QPointerQLabel petLabel new QLabel(this); // 后续某处宠物被移出队伍 if (!petLabel.isNull()) petLabel-hide();解释QPointer是Qt提供的智能监护指针但它只能用于QObject及其子类。它不会帮你析构对象却能在对象死亡后避免访问非法内存。配合Qt的父子对象机制大部分内存问题都能在开发阶段暴露出来。6. 进阶存档、随机种子与性能验证把游戏从“能跑”变成“能玩”6.1 用QDataStream做存档与读档宝可梦游戏最关键的体验是存档。用QDataStream把玩家队伍和进度写进文件比手写文本解析靠谱很多因为所有QString、QList都能被它的操作符直接序列化QFile saveFile(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) /save.dat); if (saveFile.open(QIODevice::WriteOnly)) { QDataStream out(saveFile); out.setVersion(QDataStream::Qt_5_15); out playerName playerTeam currentSceneIndex; saveFile.close(); }解释QDataStream版本最好和Qt版本一致因为不同版本序列化格式可能变化。文件放到AppDataLocation避免在Windows的“Program Files”下没有写权限导致保存失败。读档时用QDataStream的operator按同样的顺序读回来即可。6.2 用QElapsedTimer测量单帧耗时如果你发现战斗动画卡顿不要靠眼睛猜。QElapsedTimer比QTime精准得多逻辑如下QElapsedTimer timer; timer.start(); // 一帧逻辑状态推进 动画刷新 qDebug() frame cost(ms): timer.nsecsElapsed() / 1000000.0;解释如果单帧超过16ms就说明逻辑里有阻塞或过度重绘。常见原因是循环里频繁创建QBrush或者图片缩放这时应该预处理图片尺寸。这个参数在调整精灵图大小时特别好用。6.3 让QTimer驱动游戏主循环别用while循环很多新手在槽函数里写while (true)等待动画这会把Qt事件循环卡死界面直接变成白屏。我习惯的做法是QTimer *timer new QTimer(this); timer-setInterval(50); connect(timer, QTimer::timeout, this, [this]() { if (battleCore-isWaitingAnimation()) return; battlePage-updateUI(); }); timer-start();这样的主循环不会阻塞动画信号也能及时处理。把QTimer间隔设为50ms就是每秒20帧的状态更新对回合制游戏足够。这几条技巧都是我在做了几个版本的Qt宝可梦之后沉淀下来的。特别是“把状态机和界面通过信号槽拆开”这个习惯让我以后写Qt工具都变得清晰。希望帮到你。本文还有配套的精品资源点击获取
返回列表