ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Qt Widgets和C++写一个宝可梦风格回合制小游戏:状态机与实战详解

用Qt Widgets和C++写一个宝可梦风格回合制小游戏:状态机与实战详解 简介这是一款基于Qt框架与C语言开发的宝可梦风格2D角色扮演游戏工程源码面向Qt入门学习者及对游戏开发感兴趣的读者重点呈现图形视图框架、地图场景搭建与回合制战斗逻辑的完整项目实践。资源以zip压缩包发布整体仅19KB共20个文件包含9个cpp源文件与8个h头文件另附TMX地图文件、pro工程配置与qrc资源管理文件代码按游戏世界、宝可梦培养、战斗系统与玩家系统四大模块划分结构清晰便于分模块研读。目前已有103人学习浏览适合希望快速掌握Qt游戏框架、移动碰撞检测与状态管理的读者。通过该工程可系统学习QGraphicsView与QGraphicsScene渲染实现、TMX地图加载解析、宝可梦属性相克与进化逻辑、技能选择与战斗动画以及角色背包管理等完整玩法。项目预留了良好的扩展接口后续可增补更多宝可梦种类、地图场景与剧情任务兼具教学演示与二次开发价值是入门Qt游戏开发的优质参考。1. 基于QTC的宝可梦小游戏为什么值得自己写一遍我见过不少想入门 C 的开发者第一个项目就去啃引擎源码结果被构建系统和蓝图节点劝退。其实宝可梦这种回合制 RPG骨架刚好卡在 QTC最擅长的那条线上一个 QApplication 加状态机就能跑完地图、战斗、背包三条主线不依赖任何第三方引擎。这个练手方向能解决的核心问题是——用最小的库依赖把 C 对象、事件循环和绘图 API 串成一个完整游戏同时交付一个别人愿意点开玩两分钟的成品。它适合两类人一类是 Qt Widgets 只写过表单、想试试中型应用怎么划分模块的客户端开发者一类是刚啃完语法、想知道结构体、多态和信号槽在真实程序里怎么落地的初学者。下面按 Windows Qt 5.15.2 MSVC 2019 64位这套最常见的组合展开顺手把 Ubuntu 下搭 Qt 开发环境的差异也点一下。2. 架构先行场景状态机与 Qt Widgets / QML 的选型2.1 为什么是 Widgets 而不是 Qt QML按界面复杂度算账先回答一个绕不开的问题Qt 现在有 Widgets 和 QML 两套界面体系宝可梦小游戏该选哪套我的建议是 Qt Widgets除非你已经明确要用大量自定义动画。原因很简单回合制 RPG 的特点是场景之间切换频繁但单个场景内的界面复杂度不高。主菜单一个按钮列表战斗界面一组指令菜单加文本区Widgets 自带 QStackedWidget、QListWidget、QLabel全部是现成控件不需要像 QML 那样为了一个焦点导航去写状态绑定。QML 的优势在流畅的转场动画和粒子特效它和 Widgets 的差别有点像前端里声明式框架和传统操作 DOM 的区别有前端基础的人会觉得 QML 亲切纯 C 背景则走 Widgets 上手更快。至于搜索引擎里常看到的 qt mvvm 框架对小游戏是过度设计——你总共三个页面引入一层 ViewModel 反而要多维护一套映射关系。把精力留在游戏逻辑上不要被框架热词带偏。2.2 最小状态机四个状态加 QStackedWidget 切换我一般会把游戏主线拆成四个状态主菜单、野外地图、战斗、收服/暂停弹窗。背包和设置这类次级界面不单独占状态做成战斗内的模态弹窗更省事。切换统一收敛到一个函数里避免散落的setCurrentIndex调用把状态搅乱。// GameState.h #ifndef GAMESTATE_H #define GAMESTATE_H #include QMainWindow #include QStackedWidget // 游戏主线只保留三个页面弹窗类界面独立管理 enum class GameState { MainMenu, // 主菜单新游戏 / 读档 MapScene, // 野外地图角色移动、草丛遇敌 BattleScene // 回合制战斗招式 / 换宠 / 逃跑 }; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent nullptr); // 所有跨场景跳转都走这个统一入口 void switchTo(GameState newState); private: QStackedWidget *stack; QWidget *menuPage; QWidget *mapPage; QWidget *battlePage; GameState currentState GameState::MainMenu; }; #endif // GAMESTATE_H// MainWindow.cpp #include MainWindow.h MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { stack new QStackedWidget(this); // 三个页面都作为 stack 的子控件注册索引顺序与枚举顺序一致 menuPage new QWidget(this); mapPage new QWidget(this); battlePage new QWidget(this); stack-addWidget(menuPage); stack-addWidget(mapPage); stack-addWidget(battlePage); setCentralWidget(stack); switchTo(GameState::MainMenu); } void MainWindow::switchTo(GameState newState) { currentState newState; switch (newState) { case GameState::MainMenu: stack-setCurrentIndex(0); break; case GameState::MapScene: stack-setCurrentIndex(1); break; case GameState::BattleScene:stack-setCurrentIndex(2); break; } }这里有两个参数细节值得注意。第一状态枚举值必须和addWidget的调用顺序一一对应如果中间插入一个设置页后面的索引全部错位跳转就会跳到错误的页面这是新手最容易踩的坑。第二这个方案是同步切换页面不要和浏览器那种压栈出栈的导航混为一谈如果你后续想让角色从地图平滑过渡到战斗场景只需要在switchTo里先播一段动画再切换索引。提示用枚举而不是整数存状态编译器会在 switch 分支里帮你检查遗漏改状态名时全局搜索也方便。小项目可能感觉不到等状态加到六个以上时收益很明显。有没有更省事的方案如果你从 Qt 5.15 升级到 Qt 6这套代码基本不用改QStackedWidget的接口非常稳定。入口文件就更简单了直接构造MainWindow不需要额外包一层 QApplication 之外的容器。// main.cpp #include QApplication #include MainWindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.resize(960, 640); // 固定窗口大小避免地图场景被拉伸 w.show(); return app.exec(); }这段代码里QApplication必须存在它负责事件循环和资源管理argv 里以 -qt 开头的参数会被 Qt 自动解析其余参数原样保留这个特性在你以后做命令行调试时会用到。2.3 环境准备Qt Creator MSVC 2019还是 VS2022 Qt Solutions编译套件我推荐 MSVC 2019 64位而不是 MinGW。原因有两点第一MSVC 生成的调试信息在 Qt Creator 里和 Windows 的符号服务器配合更好崩溃时能直接定位到系统 DLL 的调用栈第二项目以后要接第三方库比如读取存档文件的加密库基本都是 MSVC 编译的二进制MinGW 的 ABI 不兼容到时候哭都来不及。习惯 Visual Studio 的人也可以装 vs2022 qt solutions 插件在 VS 里写 Qt 项目的体验同样成熟。提示如果只做跨平台小项目Ubuntu 下用apt install qt5-default qtcreator装好环境同一份代码直接切 kit 编译Widgets 项目基本不需要改文件。如果你之前是在 VS Code 里配置 C/C 环境写控制台程序迁到 Qt 项目后会多一层 .pro 文件的概念。qmake 和 CMake 二选一我默认用 qmake因为 Qt 5.15 的官方示例和文档仍然以 qmake 为主部署时用 Qt 自带的命令行工具windeployqt可以一键把依赖 DLL 收集到发布目录比手动拷贝靠谱得多。3. 地图与角色让宝可梦在屏幕上“走起来”3.1 地图绘制选型QLabel 拼贴 vs QPainter 自绘地图这块有个血泪经验不要用几百个 QLabel 拼瓦片。第一版我图省事每块草地放一个 QLabel角色走一步就改一堆 label 的 pixmap结果事件循环被大量控件拖慢切换场景时明显掉帧。正确的常见做法是继承 QWidget 重写paintEvent在事件里用 QPainter 一次性绘制可见区域的所有图块。// MapSceneWidget.h #ifndef MAPSCENEWIDGET_H #define MAPSCENEWIDGET_H #include QWidget #include QPixmap #include QKeyEvent class MapSceneWidget : public QWidget { Q_OBJECT public: explicit MapSceneWidget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: int playerRow 1; // 角色所在行 int playerCol 1; // 角色所在列 QPixmap grassTile; // 草地 QPixmap treeTile; // 树不可通行 }; #endif // MAPSCENEWIDGET_HpaintEvent里做的就是纯绘图先按地图宽高计算瓦片数再双层循环画背景最后把角色画在中心。这里所有图片都要提前用 QPixmap 加载好不能在paintEvent里做文件 IO否则每次重绘都会触发磁盘读取卡顿是必然的。如果你只是画一条线或者几个矩形QPainter 的drawLine、drawRect和画图块是同一套 APIQt 桌面画线、绘图这块的知识点可以完全复用不需要额外学渲染框架。3.2 角色帧动画QTimer 加图片序列切帧角色行走动画最简单的实现是帧动画一组走路图按固定间隔轮流显示。下面这个类核心成员就三样帧列表、定时器和当前帧下标。用 QTimer 驱动每到一个超时信号就切到下一帧并触发重绘。// SpriteWidget.h #ifndef SPRITEWIDGET_H #define SPRITEWIDGET_H #include QWidget #include QPixmap #include QTimer class SpriteWidget : public QWidget { Q_OBJECT public: explicit SpriteWidget(QWidget *parent nullptr); // 从文件路径列表加载帧intervalMs 是每帧显示时长 void setFrames(const QStringList paths, int intervalMs 100); protected: void paintEvent(QPaintEvent *event) override; private slots: void nextFrame(); // 定时切换帧 private: QListQPixmap frames; // 加载好的帧避免 paintEvent 做 IO QTimer animTimer; int frameIndex 0; }; #endif // SPRITEWIDGET_H// SpriteWidget.cpp #include SpriteWidget.h #include QPainter SpriteWidget::SpriteWidget(QWidget *parent) : QWidget(parent) { animTimer.setInterval(100); // 默认 10 帧/秒 } void SpriteWidget::setFrames(const QStringList paths, int intervalMs) { for (const QString path : paths) { QPixmap px(path); if (px.isNull()) continue; // 路径不对就跳过避免程序崩溃 frames.append(px); } if (!frames.isEmpty()) { frameIndex 0; setFixedSize(frames.first().size()); animTimer.setInterval(intervalMs); animTimer.start(); connect(animTimer, QTimer::timeout, this, SpriteWidget::nextFrame); } } void SpriteWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); if (!frames.isEmpty()) { painter.drawPixmap(0, 0, frames.at(frameIndex)); } QWidget::paintEvent(event); } void SpriteWidget::nextFrame() { if (frames.isEmpty()) return; frameIndex (frameIndex 1) % frames.size(); update(); // 请求重绘真正画图在 paintEvent 里做 }这里有两个关键点。第一update()是异步合并的连续调用多次只会触发一次paintEvent所以不要在nextFrame里直接调repaint那会强制同步刷新动画反而更卡。第二intervalMs取 100 毫秒是 10 帧每秒对于像素风格的角色足够想更平滑可以降到 80 毫秒但低于 50 毫秒后肉眼不太分得出差别反而加重 CPU 负担。帧动画的帧率并不是越高越好宝可梦这种低清素材用 812 帧最合适。3.3 键盘移动与碰撞重写 keyPressEvent 加查表角色移动我建议做成按格子走每次按键固定移动一个瓦片不做像素级平滑这样碰撞检测就从“矩形相交”退化成一个查表操作逻辑简单且稳定。地图用二维数组描述0 表示可走1 表示树、水这类障碍。void MapSceneWidget::keyPressEvent(QKeyEvent *event) { const int step 32; // 一个瓦片 32 像素 int dx 0, dy 0; switch (event-key()) { case Qt::Key_Up: dy -step; break; case Qt::Key_Down: dy step; break; case Qt::Key_Left: dx -step; break; case Qt::Key_Right: dx step; break; default: QWidget::keyPressEvent(event); return; } // 计算目标格子越界直接忽略 int newRow playerRow dy / step; int newCol playerCol dx / step; if (newRow 0 || newRow MAP_H) return; if (newCol 0 || newCol MAP_W) return; // 查表1 表示障碍物不做像素级碰撞 if (mapData[newRow][newCol] 1) return; playerRow newRow; playerCol newCol; update(); // 触发重绘角色整体移动一个瓦片 }这段代码里dy / step是整除方向键每次都产生 ±32 的位移所以结果一定是 ±1不用担心除出小数。要注意的是窗口必须设置焦点策略否则按键事件不会到达MapSceneWidget。我在构造时调用setFocusPolicy(Qt::StrongFocus)它同时接受 Tab 和鼠标点击聚焦。注意连按方向键时keyPressEvent会连续触发角色会快速连续走格这正是回合制 RPG 想要的“走一步触发一次遇敌检测”的行为。如果你做的是动作游戏反而要改成记录按下状态在keyReleaseEvent里清除。这个区分想清楚游戏手感就对了。4. 回合制战斗系统从数据结构到伤害计算4.1 用 C 结构体把宝可梦描述成数据宝可梦的核心不是画面是数据。一只宝可梦就是一组结构体名字、等级、六项能力、状态、最多四个招式。用结构体而不是 class是我反复权衡后的选择这个规模的数据只做读取和减法写一堆 private 加 getter 是自我感动反而是结构体让战斗回合计伤害的代码清晰很多。// data.h #ifndef DATA_H #define DATA_H #include QString #include QVector // 招式 struct MoveInfo { QString name; // 招式名 int power 0; // 威力0 表示变化类招式 int accuracy 100; // 命中率0~100 int pp 35; // 剩余使用次数 }; // 一只宝可梦 基础信息 能力值 招式列表 struct PokemonData { QString name; int level 5; int maxHp 20, hp 20; int atk 10, def 10, spd 10; // 攻击、防御、速度 QVectorMoveInfo moves; // 最多 4 个 }; #endif // DATA_H为什么招式列表用QVector不用固定数组MoveInfo moves[4]虽然宝可梦最多带四个招式但后续做招式学习、遗忘界面时动态数组的增删明显省事。Qt 的QVector在写入时是写时拷贝复制一只宝可梦传到战斗逻辑里不会立刻深拷贝底层数据性能完全没问题。C 结构体链表基本语法在这里也用得上如果你要给队伍排出战顺序std::sort按速度字段排一次就够了不需要手写链表。4.2 伤害公式经典物理/特殊二分战斗伤害公式我直接套经典回合制模板先算整数基准值最后乘一个 0.85 到 1.00 的随机浮动。这个公式的微妙之处在于中间的整除顺序先乘后除和先除后乘结果完全不同写的时候要按下面这段的括号顺序来。// battle_math.cpp #include QRandomGenerator #include QtMath // 物理伤害简化版不考虑属性克制和暴击 int calcPhysicalDamage(int level, int power, int atk, int def) { // 第一段经典伤害公式的整数部分 int base ((level * 2) / 5 2) * power * atk / def / 50 2; if (base 0) return 1; // 防御极高时至少打 1 点血 // 第二段随机浮动 0.85 ~ 1.00 double randFactor 0.85 QRandomGenerator::global()-generateDouble() * 0.15; return static_castint(base * randFactor); } // 命中判定返回 true 表示命中 bool isHit(int accuracy) { int roll QRandomGenerator::global()-bounded(100); // 0~99 return roll accuracy; }level * 2 / 5这一串整数运算必须放在最前面浮点数误差会在多次除法里被放大随机因子放在最后乘这样基准伤害可以单独做单元测试。QRandomGenerator::global()是 Qt 5.10 之后推荐的随机数入口它内部自己管理种子比qrand()和rand()都安全多线程调用也不会互相干扰。显示伤害数字时QString::number(damage, f, 2)可以把 double 转成保留两位小数的字符串这个 Qt double 转字符串的接口在做战斗日志时很常用。4.3 战斗 UI 导航和“打字机”文本战斗界面无非三块我方信息栏、对方信息栏、指令菜单。指令菜单用四个按钮或者一个 QListWidget 都能做我更推荐 QListWidget因为上下键选择天然支持鼠标点击也兼容。难点在战斗文本的呈现——一行“皮卡丘使出了电光一闪”如果瞬间全部显示玩家根本来不及看逐字显示才有回合制味道。// BattleTextPrinter.h #ifndef BATTLETEXTPRINTER_H #define BATTLETEXTPRINTER_H #include QLabel #include QTimer // 战斗文本打字机效果每 30ms 打一个字 class BattleTextPrinter : public QLabel { Q_OBJECT public: explicit BattleTextPrinter(QWidget *parent nullptr); // 开始打印一整段文本 void startPrint(const QString text); private slots: void tick(); private: QTimer printTimer; QString fullText; int currentLength 0; }; #endif // BATTLETEXTPRINTER_H// BattleTextPrinter.cpp #include BattleTextPrinter.h #include QtGlobal BattleTextPrinter::BattleTextPrinter(QWidget *parent) : QLabel(parent) { printTimer.setInterval(30); // 约 33 字/秒 } void BattleTextPrinter::startPrint(const QString text) { fullText text; currentLength 0; printTimer.start(); } void BattleTextPrinter::tick() { currentLength qMin(currentLength 1, fullText.size()); setText(fullText.left(currentLength)); if (currentLength fullText.size()) { printTimer.stop(); // 显示完毕停止定时器 } }注意tick里用qMin包了一下防止定时器槽函数里出现下标越界fullText.size()在 Qt 5 返回的是 UTF-16 编码的字符数中文字符按一个算不会出现半个字的乱码。这里的 30ms 间隔是我调过几次后的手感值太短糊成一团太长玩家会烦躁。如果你打算做玩家点击跳过文本在startPrint里存一个标志位鼠标点击时直接setText(fullText)并停掉定时器就够了。战斗结算阶段逻辑一定要先算完整再逐条显示结果而不是边算边显示否则撤回操作时会有一堆文本残留。提示战斗逻辑和 UI 显示分离是我在这个项目里最坚持的一条原则。伤害计算做成纯函数UI 只负责把结果打出来。这样以后加新招式、新特性只需要改数据结构不用碰界面层。5. 避坑与排查编译报错、中文乱码和动画翻车现场5.1 现象-1: error: dependent ..\..\qt\5.15.2\msvc2019_64\include\qtwidgetsinclude 找不到第一次用 Qt Creator 新建项目.pro 文件里只写了QT core编译直接报找不到 qtwidgets 头文件。原因不是路径错了而是 Qt 的模块机制MSVC 套件下 include 路径由 qmake 根据 .pro 文件声明的 QT 模块推导你没写QT widgetsqmake 就不会把 QWidget 相关的头文件目录加进编译参数。解决很简单.pro 里补上QT widgets然后执行 qmake 重新生成 Makefile。至于那一串..\..\..\的相对路径是 Qt Creator 为影子构建生成的不要去手动改。5.2 现象代码里全是中文编译没报错运行却是乱码MSVC 编译器默认按操作系统的本地代码页读取源文件中文 Windows 上是 GBK如果你用 VS Code 或 Qt Creator 保存成 UTF-8 无 BOM字符串字面量在编译期就被按 GBK 解析运行结果自然是乱码。两个解决办法第一源文件统一存成 UTF-8 with BOMMSVC 能自动识别 BOM第二在 .pro 里加一行msvc: QMAKE_CXXFLAGS /utf-8强制编译器按 UTF-8 处理源文件。我推荐第二种因为团队成员用不同编辑器存 BOM 的几率太高了。另外字符串拼接别用统一QString::arg否则中文字面量混合数字时最容易出幺蛾子。5.3 现象Debug 版本一进战斗就崩溃Release 却正常这类“崩和不崩看心情”的问题九成是数组越界或迭代器失效。Debug 下 Qt 的容器访问带有断言检查越界直接抛异常给你看Release 关闭断言越界读到的是旁边的垃圾数据运气好程序不崩但伤害值会变成负数或者宝可梦血量清零后还能继续战斗。解决方法是在战斗逻辑里对招式列表做边界检查。每只宝可梦最多四个招式玩家在选招界面连续按方向键下标很容易走到 4。我先是用qWarning打日志后来直接改成if (index 0 || index moves.size()) return;问题从根上断掉。5.4 现象图片总是加载失败日志显示file not found加载图片用的是相对路径../../images/grass.png在 Qt Creator 里能跑打包后就找不到。原因是 Qt Creator 运行时的当前工作目录是构建目录不是源码目录相对路径在不同环境下的表现完全不一样。解决是用 Qt 资源系统把图片加进 .qrc 文件加载路径写成:/images/grass.png这个路径只依赖资源文件内部结构与部署目录无关。判断文件是否存在用QFile::exists想拿文件大小、修改时间就用QFileInfo这些 Qt 获取文件信息的接口在存档功能里也大量用到。另外注意 qrc 内路径大小写敏感Windows 下平时不敏感到了 Linux 打包就会炸。5.5 现象QTimer 驱动的动画忽快忽慢切后台再回来跳帧QTimer 默认是粗粒度定时器误差在 5% 左右平时看不出来帧动画每秒切 10 次误差累积后就明显卡顿。更糟的是窗口最小化时定时器回调被事件循环挂起恢复后定时器不会补帧角色会瞬间跨过十几帧跳到动画末尾。处理办法分两层动画本身改成QPropertyAnimation驱动它按属性插值不受定时器误差影响非用 QTimer 不可的场景调用animTimer.setTimerType(Qt::PreciseTimer)切换到精确模式并在窗口hideEvent里暂停、showEvent里重启。这两招能解决 95% 的动画翻车现场。6. 扩展与验证存档、音效和可玩性打磨6.1 用 QSaveFile 做原子存档存档最大的风险是写一半崩溃原存档文件损坏。直接拿QFile::write当然也能写但不是原子操作。我推荐QSaveFile它先写临时文件全部写完再commit()一次性替换原文件失败时原文件完好无损这就是玩家的“后悔药”。// save_game.cpp #include QSaveFile #include QJsonDocument #include QJsonObject #include QJsonArray bool writeSave(int playerRow, int playerCol, const QVectorPokemonData party) { QJsonObject root; root[playerRow] playerRow; root[playerCol] playerCol; QJsonArray team; for (const PokemonData p : party) { QJsonObject obj; obj[name] p.name; obj[hp] p.hp; obj[maxHp] p.maxHp; team.append(obj); } root[team] team; QSaveFile file(save.json); if (!file.open(QIODevice::WriteOnly)) { return false; // 打开失败多半是目录权限问题 } file.write(QJsonDocument(root).toJson()); return file.commit(); // 这里才真正替换原文件 }为什么不选 QSettings 或者 SQLiteJSON 的可读性最好玩家可以直接打开存档文件改数值宝可梦类游戏的社区文化里这是重要一环。commit()返回 false 时要保留旧文件玩家能继续游戏而不是重新开档。6.2 一套够用的验证方法做完第一版后我用的是最小冒烟测试启动程序直接调switchTo(BattleScene)手动模拟一次攻击观察血量变化是否符合预期。伤害计算这类纯函数单独写成 QTest 用例验证calcPhysicalDamage(5, 40, 10, 10)的输出在 1 到 300 的区间内顺便检查防御为 0 时会不会除零崩溃。地图碰撞没有自动化让非程序员朋友走十分钟记录他们按错键的位置那些方向键连按导致越界的报错比我自己测一小时发现得都快。我的教训是——第一版把战斗逻辑全塞进槽函数里一放技能整个界面卡住后来把所有计算拆成纯函数才收敛。按这套做完你手里是一份能演示、能存档、能自由加新宝可梦的完整基础工程加音效用QSoundEffect加技能动画也是往里添砖。希望帮到你。本文还有配套的精品资源点击获取
返回列表