拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

用QT和C++做宝可梦小游戏:主循环、绘图与发布全攻略

用QT和C++做宝可梦小游戏:主循环、绘图与发布全攻略 简介面向初次接触 Qt 与 C 游戏开发的读者这份压缩包提供一款仿《宝可梦》玩法的二维角色扮演游戏源码。项目将功能拆成游戏世界、宝可梦、战斗、玩家四个系统俯视角地图、角色移动与碰撞检测由游戏世界模块承载宝可梦属性相克、技能与进化逻辑独立成模块回合制战斗包含技能选择与战斗动画玩家系统涵盖角色管理和背包道具。渲染基于 QGraphicsView 与 QGraphicsScene场景切换与角色控制均有可参考写法地图使用 TMX 格式加载工程还附带完整的战斗逻辑与培养流程。压缩包共 20 个文件大小仅 19KB以 9 个 cpp、8 个 h 为主另含 tmx 地图、qrc 资源及 pro 工程文件目录模块清晰方便对照阅读。已有 103 人学习下载适合课程设计、毕业设计或入门练手也可继续扩展新宝可梦、新地图和剧情任务。1. 从启动器到精灵球这款QT小游戏到底在做什么如果你以为“基于QTC开发的宝可梦小游戏”只是又一个套壳的像素demo那就错了。QT在这里不只是画个窗口而是承担了从主循环调度、信号槽事件链到绘图引擎的整套运行时骨架**C**则负责精灵数值、对战公式和地图碰撞这些不能卡顿的核心逻辑。整套东西跑下来是一个能独立编译、直接双击启动的桌面小游戏而不是依赖网页或脚本解释器的玩具。这篇文章写给两类人一类是刚学完C语法、想用QT把“面向对象”真正落到一个完整项目上的新手另一类是做过QT工具软件、但没碰过游戏循环和实时绘图的开发者——你会看到定时器驱动、双缓冲绘图、QPainter的变换栈和QSS换肤在游戏里是怎么协同的。我默认你装好了QT 5.15.2或6.x的MinGW套件VS Code或QT Creator都行下面所有代码都按这两个环境兼容着写。我打算按最稳妥的路径拆解先搭出架构和主循环再逐层实现地图、精灵、战斗和存档最后把QT里最容易翻车的几个坑单独摘出来说。你照着敲完收获的不是“会复制”而是“知道每行代码在QT里为什么这么写”。2. 把QT的“事件驱动”改造成“游戏驱动”先让窗口听你的话QT的原生编程模型是事件驱动的——用户点了按钮、拖了窗口才触发消息循环。但游戏是每帧都在变化的哪怕玩家不动手指头动画也要播放、草丛也要摇摆、野生精灵也要随机出现。所以第一步不是画画面而是把QT的被动事件循环接上一条主动的帧循环。2.1 为什么不能用QWidget的paintEvent硬刷双缓冲的物理边界很多初学者第一反应是重写paintEvent()在里面画所有的宝可梦和地图。这个思路在QT里能跑但跑不出游戏感。原因很直接paintEvent是QT在收到绘制请求时才调用的它不保证每秒调用次数。你哪怕用update()疯狂触发也会把绘制请求全部合并在事件循环的一次tick里帧率变成“随缘”。真正的游戏主循环需要三个硬指标固定步长的逻辑更新、独立的渲染频率、输入事件不被绘制阻塞。QT里最省事且可靠的做法是用QTimer把时间精度设为Qt::PreciseTimer以16ms为周期触发tick。这样一个100帧的游戏循环就出来了而且它天然是单线程的省去加锁的麻烦。我自己在这个项目里用16ms的timer做逻辑帧用update()请求绘制帧逻辑速率和渲染速率解耦地图移动和战斗动画的节奏都好调。// GameLoop.h 核心用QTimer把QT事件循环改造成游戏帧循环 class GameLoop : public QObject { Q_OBJECT public: explicit GameLoop(QObject *parent nullptr) { timer new QTimer(this); timer-setInterval(16); // 约60 FPS的逻辑帧 timer-setTimerType(Qt::PreciseTimer); // 高精度定时器避免系统省电策略影响 connect(timer, QTimer::timeout, this, GameLoop::onTick); } void start() { timer-start(); } private slots: void onTick() { emit tick(); // 发给各个系统玩家移动、精灵动画、战斗倒计时 } signals: void tick(); private: QTimer *timer; };逻辑说明onTick是每一个逻辑帧的入口通过信号tick广播给所有关心帧更新的子系统。这种做法的好处是你不需要在游戏循环类里手动管理一长串系统指针谁订阅谁接收新增一个天气系统也不会动现有代码。参数上setInterval(16)是经验值PreciseTimer在Windows和Linux下都能避开常规定时器15ms左右的最小粒度偏差。2.2 用QGraphicsView当游戏画布精灵、图块和碰撞的容器有了心跳之后画面往哪画QPainter在QWidget上画是裸奔所有精灵的坐标、层级、碰撞体都要自己算代码很快变成一团乱麻。我推荐直接用QGraphicsViewQGraphicsScene这套框架它本身就是为“画布上有大量可独立移动的对象”设计的。宝可梦游戏里的人物、NPC、草丛图块、对话框都是QGraphicsObject的子类对象丢进scene里由QT统一管理。每个QGraphicsObject可以独立响应鼠标、键盘能设置z轴层级还能用boundingRect()返回自己的碰撞范围——这正好替代手写的碰撞检测。// PlayerItem.h 玩家精灵同时是绘图对象、碰撞体、行走动画 #include QGraphicsObject class PlayerItem : public QGraphicsObject { Q_OBJECT public: PlayerItem() : frameIndex(0) {} QRectF boundingRect() const override { return QRectF(-16, -24, 32, 32); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { Q_UNUSED(option); Q_UNUSED(widget); // 从精灵图集的第frameIndex帧裁剪出玩家朝向的32x32贴图 painter-drawPixmap(-16, -24, spriteSheet.copy(frameIndex * 32, direction * 32, 32, 32)); } void setDirection(int d) { direction d; update(); } void nextFrame() { frameIndex (frameIndex 1) % 4; update(); } private: int frameIndex; int direction; // 0下 1左 2右 3上对应图集行顺序 QPixmap spriteSheet; };逻辑说明boundingRect()返回的矩形既是QT绘制时计算重绘区域的依据也是后续碰撞检测的默认形状。我把碰撞体设为中心点偏移后的32x32而不是整个图块这样角色左右走的时候不会因为贴图边缘的透明像素而卡墙。paint()里用source.copy(起始x, 起始y, 宽, 高)裁剪精灵图集是常见做法同时也是最省内存的动画方案——一套图集4帧8朝向才一张PNG。进阶时还可以给PlayerItem加一个QGraphicsObject::shape()在碰撞盒基础上收窄成矩形甚至椭圆我一般把宽收2像素避免两个角色擦肩而过时被判定撞到。3. 让地图和摄像机都动起来坐标、图块、滚动视图的三角关系窗口永远只有那么大但地图要比窗口大很多。这引出游戏开发里最经典的两个问题摄像机跟随玩家和地图只绘制可见部分。QT的QGraphicsView自带setSceneRect和centerOn两个API能省掉一大半手写摄像机的工作但用起来有几个参数坑。3.1 用Tiled编辑器导出的CSV地图从二维数组到可走的格子手工在代码里写地图数组是写不长的。我一般的做法是用Tiled地图编辑器画好图块层export as CSV导出每个地图层的二维数组然后在C里按行读取拼成QVectorQVectorint。每个数字对应图集里的一个图块ID碰到1就画草地碰到2画树。// MapLoader.h 把Tiled导出的CSV文件加载成二维图块索引数组 bool loadMap(const QString csvPath, QVectorQVectorint outMap, QSetint collisionIds) { QFile file(csvPath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false; while (!file.atEnd()) { const QString line QString::fromUtf8(file.readLine()).trimmed(); if (line.isEmpty()) continue; const QStringList cells line.split(,); QVectorint row; for (const QString cell : cells) { int id cell.toInt(); row.append(id); if (id 0 (id 1 || id 2)) { // 1和2在元数据里设定为障碍 collisionIds.insert(id); } } outMap.append(row); } return true; }逻辑说明Tiled导出的CSV每行是一整条逗号分隔的字符串用split(,)拆开再toInt()就能还原成二维数组。collisionIds用QSetint存原因是碰撞判定只需要查哈希集合“在不在”比遍历QVector快得多——虽然地图格子总共就几千个差别不大但养成用集合查碰撞的习惯以后做大规模地图不吃亏。这里有个隐性参数CSV文件里的-1代表空块我在后续使用时跳过不参与绘制和碰撞。3.2 摄像机跟随的边界要还要centerOn但得自己限制地图边缘用QGraphicsView::centerOn(playerItem)能让视图自动滚动跟随玩家但如果玩家走到地图边缘视图中央会露出地图以外的空白区域——那是QT默认的scene背景色看着像游戏出了bug。解决办法是手动把centerOn的坐标钳制在地图范围内。// GameView.cpp 摄像机跟随玩家同时钳制在地图矩形内 void GameView::followPlayer() { QPointF center player-pos(); const QRectF mapRect(0, 0, mapWidthPx, mapHeightPx); const QRectF viewRect viewport()-rect(); // 半视图宽高钳制中心点坐标使视图不越界 qreal halfW viewRect.width() / 2.0; qreal halfH viewRect.height() / 2.0; qreal cx qBound(halfW, center.x(), mapRect.width() - halfW); qreal cy qBound(halfH, center.y(), mapRect.height() - halfH); centerOn(cx, cy); }逻辑说明qBound(min, val, max)是QT的钳制函数把cam坐标限制在[halfW, 地图宽-halfW]区间。注意边界条件如果地图本身比窗口还小halfW会大于地图一半此时公式失效。我处理这种情况的方式是在初始化时检测mapRect.width() viewport()-width()就禁用摄像机跟随直接居中整张地图。这个钳制的计算频率是每帧一次开销微乎其微但如果没有它玩家走到右下角时看到的背景会直接变成QT窗口父级的灰色非常出戏。4. 让战斗跑起来像宝可梦数值系统、回合判定和对话框的QT实现地图和人物只是壳子宝可梦游戏真正留住人的是对战系统。回合制的核心在于玩家指令和对手行为之间要有一个可预期的时序。QT的QTimer单次触发模式setSingleShot(true)非常适合做回合序列的调度。4.1 先让属性克制表跑起来二维数组比if-else优雅十倍宝可梦的18种属性互相克制用if-else写会造成至少几十行重复且容易出错的逻辑。用一个18x18的二维数组存放克制倍率是最经典的实现。索引即属性ID值即伤害乘数。// TypeChart.h 属性克制表查询 enum Type { NORMAL 0, FIRE, WATER, ELECTRIC, GRASS, ... }; static const float TYPE_CHART[][TYPE_COUNT] { // NORMAL FIRE WATER ELECTRIC GRASS ... { 1.0f, 1.0f, 1.0f, 1.0f, 0.5f ... }, // 攻击方 NORMAL { 1.0f, 0.5f, 0.5f, 1.0f, 2.0f ... }, // 攻击方 FIRE // ...其余行省略 }; float typeMultiplier(int atkType, int defType) { return TYPE_CHART[atkType][defType]; }逻辑说明查表法的时间复杂度是O(1)比遍历链表判断快得多而且表本身可以通过代码自动生成校验——写个脚本检查每一行和每一列的对称性避免手误。这里我在代码里把TYPE_COUNT当宏或枚举常量用保证二维数组的宽度固定这样编译器能在编译期就给出数组越界的警告。你如果新增属性只改枚举和表减法伤害的倍率会作为分母参与计算我专门留了注释说明原始数组里的数值必须全为正数否则会出现“打人回血”的诡异bug。4.2 回合处理器用QTimer的“单次触发”代替复杂状态机战斗流程是玩家选招式→检查速度决定先手→播放攻击动画→计算伤害→检查战败→返回地图。这个小状态机用枚举加switch能写但会随着技能效果催眠、中毒、麻痹膨胀得很难维护。我建议把每个回合步骤拆成一个QTimer::singleShot调用链每一步结束后延迟一定毫秒回调下一步视觉上会有“出招→受击→飘字”的节奏感。// BattleController.cpp 回合处理器核心骨架 void BattleController::executeTurn(TurnData playerTurn, TurnData enemyTurn) { // 根据速度值判断先手顺序 bool playerFirst playerTurn.speed enemyTurn.speed; TurnData first playerFirst ? playerTurn : enemyTurn; TurnData second playerFirst ? enemyTurn : playerTurn; QTimer::singleShot(0, this, []() { playMoveAnimation(first); // 播放先手方招式动画 QTimer::singleShot(600, this, []() { applyDamage(first, second); // 结算伤害 QTimer::singleShot(400, this, []() { if (second.hp 0) { finishBattle(); return; } playMoveAnimation(second); // 后手方回合 QTimer::singleShot(600, this, []() { applyDamage(second, first); if (first.hp 0) finishBattle(); }); }); }); }); }逻辑说明QTimer::singleShot(0, ...)用处是“把这段代码丢到下一个事件循环再执行”实际目的是让主线程先把这个函数栈释放掉避免嵌套调用过深。600ms和400ms是我试出来的节奏参数动画播放600ms伤害飘字停留400ms这两个值压在“不墨迹”和“看得清”的平衡点。如果你做的是快节奏的网战可以把两个时间都压缩200ms手感会截然不同。这套写法唯一的代价是lambda嵌套多了以后读起来像回调地狱。我的缓解手段是把每个lambda块抽成命名成员函数lambda里只做“调用函数设定下一步”这样每个回调的真实逻辑还能在头文件里直接看到。5. 绕开QT自带的拦路虎六个高频踩坑现场还原运行这个项目时你会被QT的环境和机制绊倒几次。我把碰到的坑按“现象→原因→解决”的格式记下来希望你能绕开而不是再踩一遍。坑1cannot mix incompatible Qt library (version ex50601)现象跑起来提示这个错误代码完全编译通过但就是一运行就崩或异常退出。 原因程序链接的QT动态库和声明版本不一致最常见的是装了两个QT套件编译用的头文件是5.15.2运行用的DLL/Qt5Core.dll却是6.x版本或者MinGW和MSVC的库混着用了。 解决打开QT Creator的项目构建套件页检查kit里选的编译器是MinGW还是MSVC确保编译器、QT库和构建目录三者一致。如果使用VS Code确认“QT_QPA_PLATFORM_PLUGIN_PATH”或PATH环境变量指向版本对应的bin目录窗口起来之前先运行windeployqt拷贝运行时依赖。坑2could not find the Qt platform plugin linuxfb现象在树莓派或嵌入式Linux上用-platform linuxfb跑程序QT不认。 原因对应平台的插件没有被打进发布目录或者QT安装时没勾选该平台支持包。 解决开发环境装全平台插件并不划算我的做法是始终优先用xcb或eglfs这类和硬件配套的插件只有确定目标设备只有framebuffer时才回去装linuxfb并手动把libqlinuxfb.so拷到platforms目录。坑3fatal: unknown module(s) in qt: webenginewidgets现象编译时QT报未知模块但代码里明明只用了QWidget和QGraphicsView。 原因你自己没写但某个公用头文件间接include了它或者是CMake配置里find_package(Qt5 COMPONENTS WebEngineWidgets)被混进来。 解决把CMakeLists的find_package和target_link_libraries里显示的模块和代码直接include的头文件对齐多余的一行都不要留。我一般加一个QT模块依赖清单注释块在CMake头部防止自己下次手贱多引。坑4cannot find -lpublic现象编译链接阶段报找不到名为public的库。 原因在.pro文件里写了LIBS -lpublic但这只是一个变量名被当成了真正的库名传给链接器。常见的做法是新建静态库会起这个名或者把lib名错写进去。 解决检查.pro正确的写法应是LIBS -L$$PWD/lib -lMyShared前面是库目录后面才是库名。我建议统一用$$PWD相对写法不要写绝对路径否则换台机器就编不过。坑5C#调用C的程序突然access violation c0000005现象你的QT库或DLL被外部程序调用时要么闪退要么报C0000005无法访问。 原因QT版本和调用方的运行时库不匹配典型的如QT5.15.2基于VS2019的MSVC库而调用方用VS2015编译导致内存布局冲突。也可能是DLL导出接口忘了加extern C和__declspec(dllexport)C#侧拿到的函数签名错位。 解决导出库统一用extern C包裹别在头文件里用C的mangling。调用前先单独写一个最小C#测试程序只调一个空白函数确认能通再往上叠功能。坑6QT的MingW装完之后MSVC编译工具链装不上现象装了QT的MinGW工具包再用VS Code配环境想转MSVC编译器各种报错。 原因MSVC和MinGW是两套完全独立的东西QT Creator需要单独下载MSVC编译套件VS Code里也不是通过同一个插件配置。 解决我的选择是直接用QT Creator官方下载器勾上MSVC插件版本即可或者干脆放弃混用在VS Code里只用MinGW编译。两个编译器并存时一定要在项目配置里强制指定kit否则QT Creator自己也会乱。6. 从单机到可发布性能优化与打包验证的几个硬手段游戏做出来能跑是第一步发布给别人能跑才是完整的交付。最后这章讲三个我在收尾阶段固定做的操作它们都直接决定了这个项目能不能称得上“可用”。先做性能剖析。QT Creator自带的Analyzer一拍CPU火焰图你马上能看出瓶颈在绘制还是逻辑。我这套小游戏里遇到的最大瓶颈是QPixmap的重复绘制——每帧从PNG里copy()裁剪但每次渲染时都再读一次源图。优化手段很直接启动时把每个朝向的每一帧先裁剪好存进QVectorQPixmap里绘制时只做一次drawPixmap把一个约8ms的绘制时间压到1ms以内。// SpriteCache.cpp 预裁剪精灵帧避免运行时反复copy源图 class SpriteCache { public: static const QVectorQPixmap frames(int dir) { static QVectorQVectorQPixmap allCache buildAllFrames(); // 8个朝向 × 4帧 return allCache[dir]; } private: static QVectorQVectorQPixmap buildAllFrames() { QVectorQVectorQPixmap cache(8); QPixmap sheet(:/sprites/player.png); for (int d 0; d 8; d) { for (int f 0; f 4; f) { cache[d].append(sheet.copy(f * 32, d * 32, 32, 32)); } } return cache; } };逻辑说明这里用了static局部变量存放构建完成的缓存buildAllFrames()只执行一次QT的资源系统:/sprites/player.png从编译进二进制的qt资源包里读取比外部文件路径快也避免了路径不存在导致的启动崩溃。这也是这个游戏首屏比一般naive版本快一截的原因——绘制工作全部变成内存贴图。再验证发布环境的依赖完整性。QT的发布必须带上运行库和插件。用QT自带的windeployqt工具最省事在项目构建目录下执行一条命令它会自动把QT的DLL、platforms插件和样式插件拷到可执行文件旁。# 发布前在Release构建目录内执行 windeployqt --no-translations --release PokemonGame.exe逻辑说明--no-translations跳过语言翻译文件能省掉几MB体积--release指定发布模式。另外还有个参数叫--compiler-runtime在MSVC环境下它会自动帮你带上vcruntime140.dll和msvcp140.dll。新手常犯的错是直接双击release里的exe才发现“缺少QT5Core.dll”然后手动去QT安装目录翻库其实一条windeployqt全解决了。发布包做好之后我习惯再做一次“绿色测试”把整个发布目录拷贝到一台没装过QT的虚拟机或干净Windows机器上双击运行如果地图、对话框、战斗都正常说明依赖没漏。这套测试做完项目才算有底气交出去。最后说个我自己的习惯每个QT游戏我会在退出时把存档文件写到QStandardPaths::AppLocalDataLocation而不是可执行文件旁边的相对路径——后者在用户把游戏放进“Program Files”目录时机密问题直接报权限错误。写到AppData下虽然路径长但哪个系统都稳。这个方案也是宝可梦这类带成长养成系统的小游戏必须考虑的毕竟谁都不想玩家辛辛苦苦喂过的皮卡丘因为一次移动文件夹就全部清零。希望帮到你。本文还有配套的精品资源点击获取
返回列表