简介:基于Qt与C++实现的人机对战宠物小精灵游戏,是一款可直接运行的完整项目,适合作为计算机专业毕业设计、课程设计或进阶练手。游戏围绕小火龙、妙蛙种子、杰尼龟、皮卡丘四种精灵展开,按力量、肉盾、防御、敏捷划分种族特性,每种精灵拥有等级、经验、攻击力、防御力、生命值和攻击间隔等属性,升级时主属性成长更多,策略差异明显。项目设计了精灵基类与虚函数攻击方法,并在子类中分别实现,能帮助读者深入理解面向对象多态、继承,以及Qt界面与游戏逻辑的整合技巧。压缩包共36个文件,主体为11个cpp源文件和11个h头文件,另有2个ui界面、2个pro工程配置、2个qrc资源、少量说明文档和截图,整体约1.88MB,工程结构清晰,可快速导入Qt Creator编译运行。源码经过严格测试,注释完整,适合直接参考,并能在此基础上扩展联机对战、存档系统、技能特效等功能。目前已有281人学习下载,特别适合需要完整可运行项目作为课设或毕设起点的学习者。
1. 基于QT+C++开发的宠物小精灵人机对战游戏:为什么它是毕业设计里少见的“性价比之王”
把“基于QT+C++开发的宠物小精灵人机对战游戏+源码”这个题目交给学生,第一反应往往是被“游戏”两个字带偏,直接打开Qt Creator拖按钮。其实它真正考的是三件事:把回合制规则转成C++数据结构的能力、用Qt把数据呈现到界面的能力、以及给电脑编写行为可调的AI逻辑的能力。这三件事恰好覆盖了C++面向对象、Qt信号槽、自定义绘图、状态机和基本算法,做出的成品又能现场演示,比管理系统和商城类题目更容易讲清楚工作量。
我经常把这类项目推荐给两类人:一类是C++刚入门、想找一个“做完能跑、跑起来能玩”的项目练手的新手;另一类是毕业设计题目被分到桌面应用方向、想避开CRUD增删改查的同学。它的核心难度并不在“宠物小精灵”这个题材本身,而在于你愿不愿意把战斗规则、宠物属性和界面交互拆成独立的模块。拆得好,这个项目四到六周能收工;拆得不好,光是在按钮回调里改bug就能改到交作业前一晚。
2. 数据模型先行:宠物小精灵的属性、技能与回合结算怎么设计
2.1 先定战斗规则再写界面:为什么不能上来就画窗口
见过不少同学拿到题目第一件事是打开Qt Designer,把宠物小精灵的窗口轮廓拖出来,然后发现按钮有了、界面有了,却不知道该点哪里。原因很简单:还没定义“一只宠物小精灵”在程序里是什么,也没定义“一次攻击”会产生什么效果。界面是数据的投影,数据没想清楚,投影自然空转。
我一般建议先花一晚上把游戏规则写成文字。常见做法是模仿回合制RPG那一套:己方和对手各持有若干只宠物小精灵,每回合选择技能或道具,按速度高低决定谁先出手,结算伤害和状态,血量归零则换下一只,全部阵亡的一方输。规则里有三个可以被C++直接翻译的东西:属性、宠物个体、技能。属性是一个枚举,宠物个体是一个结构体,技能是结构体里的一个数组。先把这个模型定下来,后面的窗口绘制、AI逻辑和存档功能就都有了地基。
2.2 用struct和enum把“一只宠物小精灵”变成可计算的C++对象
Qt开发里最容易被初学者忽略的,是Qt本身并没有替代C++的类型系统,它只是提供了QString、QVector这些容器。宠物小精灵的建模还是得用原生C++语法。下面这个头文件是我在类似项目里常用的最小集合:
// pet.h #ifndef PET_H #define PET_H #include <QString> #include <QVector> enum class Element { None, Fire, // 火 Water, // 水 Grass, // 草 Electric // 电 }; struct Skill { QString name; Element type; int basePower; // 基础威力 int accuracy; // 命中率 0~100 }; struct Pet { QString name; // 宠物小精灵名称 Element element; // 属性 int maxHp; int hp; // 当前血量 int atk; int def; int speed; QVector<Skill> skills; // 最多带 4 个技能 }; #endif // PET_H这段代码没有用到任何Qt界面类,所以可以直接用纯C++单元测试去验证。struct Pet里我特意让当前血量和最大血量分开,是因为UI上需要显示“23/50”这样的血量条,如果只存一个hp,回血和扣血时还要自己记上限。技能用QVector而不是固定数组,是因为换技能、学新技能时不需要手工维护数组长度,这也是Qt容器在游戏数据层最舒服的用法。
出场宠物小精灵的数值我一般控制在50到100之间,技能威力控制在10到40之间。这个范围不是随意定的:数字太小,属性克制系数乘上去之后看不出差距;数字太大,一场战斗两三招就结束,玩家还没来得及体验策略就已经赢了。
2.3 把回合流程做成状态机:给战斗逻辑一个可以先脱离界面的测试环境
宠物小精灵类游戏最容易写乱的是战斗流程。如果用按钮回调里的一串if层层嵌套来表达,会出现一个到处都能改状态、却没人说得清当前处于哪个阶段的情况。我习惯把回合流程收敛成一个状态机,每个阶段用一个枚举值表示,框架大致如下:
// battle_manager.h #ifndef BATTLE_MANAGER_H #define BATTLE_MANAGER_H #include "pet.h" #include <QObject> class BattleManager : public QObject { Q_OBJECT public: enum class BattleState { SelectAction, // 等待玩家选择技能 ResolveTurn, // 结算本回合行动 CheckEnd, // 检查胜负 BattleOver // 战斗结束 }; Q_ENUM(BattleState) explicit BattleManager(QObject* parent = nullptr); void setParty(const QVector<Pet>& playerPets, const QVector<Pet>& enemyPets); void playerChooseSkill(int petIndex, int skillIndex); void enemyChooseAction(); signals: void stateChanged(BattleState newState); void petHpChanged(int petIndex, bool isEnemy, int newHp); void battleFinished(bool playerWin); private: void resolveTurn(); void checkEnd(); QVector<Pet> m_playerPets; QVector<Pet> m_enemyPets; BattleState m_state = BattleState::SelectAction; }; #endif // BATTLE_MANAGER_H这个类继承了QObject,是因为我想让界面层能订阅它的stateChanged信号,这样UI只在状态变化时刷新,而不是每帧都重绘。把战斗核心放到一个独立类里还有个额外好处:在界面做出来之前,我可以先写一段不依赖窗口的测试代码,手动调用playerChooseSkill和enemyChooseAction,用qDebug打印血量变化。
你会注意到这里已经出现了一点Qt MVVM框架的味道:BattleManager是模型层,它不知道界面上有什么按钮,只负责状态和数据的更新;界面通过信号订阅来同步显示。如果后续想把项目升级成QML版,或者引入更完整的Qt MVVM框架分层,这个核心类可以原封不动地搬过去,只需要重新写界面层。
3. Qt界面设计:从工程配置到用QPainter和信号槽把对战跑起来
3.1 Qt工程该用qmake还是CMake?MSVC和MinGW怎么选
新人在创建Qt工程时最先遇到的不是写代码,而是工具链选择。Qt Creator打开新建项目向导,会看到“构建系统”和“工具包”两个选择。构建系统我建议直接用qmake,除非老师明确要求CMake。qmake在Qt 5.15.2里配置最快,添加资源文件、链接模块都自动完成,适合课程设计和毕业设计这种需要快速出成果的场景。CMake的优势只在大型跨平台工程里才体现得出来,为一个宠物小精灵对战项目引入CMake等于给自己加戏。
工具包的选择要看你最终在哪台机器上演示。如果全程用自己的Windows电脑,我推荐MSVC 2019 64位版本;如果要在老师机器或机房电脑上跑,MinGW版本反而更省心,因为MinGW运行时是静态链接方案,出问题少。下载Qt时常见做法是选官方在线安装器,也可以直接找国内镜像站下载离线包,注意离线包通常把MSVC和MinGW分开打包,装错了会出现在第5章里讲的那个经典报错。
VS Code配置C/C++环境同样是能写这个项目的,配合Qt官方插件也能编译调试,但我不建议新手这么做。Qt Creator的调试器集成、UI设计师、帮助文档快捷键这几样东西,在独立开发一个带窗口项目时比轻量化编辑器实用得多。
3.2 自定义绘图控件:用QPainter把精灵球和战斗面板画出来
宠物小精灵项目里最提气的一块是战斗界面,谁都不想用一排默认按钮糊弄。但放一张图片做背景再叠几个QLabel,又显得交互单薄。我通常会做一个自定义QWidget子类,负责画精灵球图标、宠物小精灵的Q版轮廓和血量条。核心是重写paintEvent,用QPainter画线条和色块。
// pet_panel.h #ifndef PET_PANEL_H #define PET_PANEL_H #include <QWidget> #include <QPainter> #include <QColor> #include "pet.h" class PetPanel : public QWidget { Q_OBJECT public: explicit PetPanel(QWidget* parent = nullptr); void setPet(const Pet& pet); void setHpRatio(double ratio); protected: void paintEvent(QPaintEvent* event) override; private: Pet m_pet; double m_hpRatio = 1.0; }; #endif // PET_PANEL_HsetPet和setHpRatio是两个对外入口,界面其他部分只需要调它们,不需要关心怎么画。paintEvent里的绘制逻辑按顺序是:画一个精灵球圆形底色,画中间的横带,画血量条背景,再按m_hpRatio画前景色条。血量低于30%时把前景色从绿色切换成红色,这是一个能让演示效果立刻变得“像游戏”的小细节。
注意自定义控件一定要在构造函数里设置setMinimumSize,否则窗口布局会把控件压缩成一个小点,出现“画面缺失”的翻车现场。如果绘制内容复杂,第一帧可能空白,这时可以在构造时调用update()强制触发第一次重绘,或者直接把setPet放在构造函数参数里。
3.3 信号槽串起“出招→结算→刷新界面”:一套完整的交互链路
当数据和界面都准备就绪,就可以把按钮、下拉框和战斗管理器用信号槽连起来。这个链路我建议统一采用“界面点击 → 调用控制器接口 → 战斗管理器发信号 → 界面刷新”的顺序,不要让界面直接修改战斗管理器的私有数据。
// 在 MainWindow 构造函数中 connect(ui->btnFire, &QPushButton::clicked, this, [this]() { int petIndex = ui->comboPet->currentIndex(); int skillIndex = 0; // 0 对应火系技能 m_battle->playerChooseSkill(petIndex, skillIndex); m_battle->enemyChooseAction(); m_battle->resolveTurn(); refreshAllPanels(); });上面这段代码里,界面不读取任何结构体内部字段,只通过返回值或信号刷新。refreshAllPanels内部再去BattleManager读取当前血量、胜负状态,然后调用PetPanel的setHpRatio。这符合Qt界面设计里“视图不碰模型核心”的惯例,代码看起来多了一层调用,但实际上把出招、AI行动、结算三个阶段分开了,调试时可以很清楚地知道是哪一步出了问题。
如果觉得每次手动调用三个步骤太啰嗦,可以把这三步合并成BattleManager::startTurn()这样一个槽函数。按钮只管发信号,管理器内部决定执行顺序。这样后续增加“进入下一回合倒计时”之类的动画时,只需要在startTurn里加延时处理,不需要改按钮的lambda。
4. 人机AI与数值平衡:让电脑不是纯随机出招
4.1 基于效用函数的人机AI:从“闭眼乱按”到“会换宠、会补血”
宠物小精灵对战的对手如果只会随机出招,玩家玩几局就会觉得无聊。但要写成“必胜AI”又没意义,因为课程设计需要展示的是算法思想,不是做一个不可能打赢的电脑。我常用的是效用函数加随机扰动:给每个可选技能打分,分数高者更可能被选中,但保留一定概率选第二高分,这样AI既聪明又不至于被玩家摸透。
// ai_agent.cpp #include "ai_agent.h" #include <QtGlobal> #include <cmath> int AIController::chooseActionIndex(const Pet& actor, const Pet& foe, const QVector<Skill>& skills) { QVector<double> scores; for (const Skill& skill : skills) { double typeFactor = typeEffect(skill.type, foe.element); double damage = estimateDamage(actor, foe, skill, typeFactor); double score = damage * (0.7 + 0.6 * typeFactor); // 当自己残血时,把高威力技能评分压低,减小无脑硬拼概率 double hpRatio = double(actor.hp) / actor.maxHp; if (hpRatio < 0.3) { score *= 0.6; } scores.append(score); } // 先按分数排序,再在 top2 里随机选 QVector<int> order; for (int i = 0; i < scores.size(); ++i) order.append(i); std::sort(order.begin(), order.end(), [&scores](int a, int b) { return scores[a] > scores[b]; }); int bestIdx = order[0]; if (scores.size() > 1 && (qrand() % 100) < 35) { bestIdx = order[1]; } return bestIdx; }typeEffect返回属性克制系数,比如火打草是2.0,草打火是0.5。estimateDamage这里不需要精确模拟实际伤害,只需要一个和真实伤害成比例的估算值,因为AI决策不关心个位数的血量误差。
随机那一步用qrand() % 100的写法在Qt 5.15里没问题,注意在使用前调用qsrand(QTime::currentTime().msecsSinceStartOfDay())做一次种子初始化,否则每次启动AI行为都完全相同,容易被看出是伪随机。这个“35%概率选第二优技能”的参数,我推荐保留在配置文件或宏定义里,方便调节难度。改成10%就是困难AI,改成70%就是娱乐AI。
4.2 伤害公式和速度先手:数值参数是宠物小精灵对战手感的关键
伤害公式如果不讲究,容易出现“一拳秒杀”或者“双方互相刮痧”。我尽量让伤害期望落在目标最大血量的15%到25%之间,这样一场战斗大约持续5到8个回合,有足够空间展示换宠、残血反杀这些策略。
下面是简化版本的核心公式:
int estimateDamage(const Pet& attacker, const Pet& defender, const Skill& skill, double typeFactor) { double base = 2.0 * attacker.atk * skill.basePower / qMax(1, defender.def); base = base / 10.0 + 2.0; // 随机浮动 0.85 ~ 1.15 double random = 0.85 + (qrand() % 31) / 100.0; return qMax(1, int(base * typeFactor * random)); }basePower是技能威力,范围30到40时,一个攻击力50的宠物小精灵打防御力40的对手,基础伤害大约落在10到20点,正好是50点血量的20%到40%。防御力对这个结果的影响是线性的,不会出现极端值。typeFactor直接乘在最后,属性克制成了胜负手,这就逼着玩家去了解和记忆属性关系,游戏策略性就出来了。
速度先手规则我放在resolveTurn里处理:比较双方speed,快的那一方先执行伤害,如果先手已经打死了对手,后手不再反击。这个规则会带来一个问题,速度快的宠物小精灵会同时享受“先手”和“攻击机会”两个优势,导致某些高速高攻角色过于强势。我的解决办法是把攻击力调低一点,或者给技能增加5%的miss率。miss率在命中率accuracy里体现,这个参数记得在UI的提示文本里显示,否则玩家会觉得“我明明打中了怎么没伤害”。
5. 避坑:能编译通过不等于能交作业,这五条我基本每次都会踩
5.1 现象:Qt Creator在MSVC工具包下直接报“-1: error: dependent ...\qt\5.15.2\msvc2019_64\include\qtwidgets”之类的依赖错误
这是我见过最频繁的Qt环境问题。报错本身的共同点是:错误信息带一长串绝对路径,路径里能看到qt、5.15.2、msvc2019_64、include这些关键词,而且往往是在新装Qt之后第一次编译就出现。原因一般是两个:第一种是MSVC工具包安装不完整,库路径没被正确添加;第二种是打开了另一个Qt版本的工程,项目经理里选错了工具包,导致头文件路径、库路径和正在使用的编译器不匹配。解决起来,先检查“工具 → 选项 → Kits → 构建套件”里当前Kit对应的Qt版本和编译器是否高亮显示为绿色,若带黄色感叹号就重新选择Qt版本路径;再把项目构建目录整体删除,在“构建”菜单里执行“清理全部”,最后重新构建。如果仍然报错,查看“Qt版本”标签里是否出现了“无”字样的路径,八成是Qt安装目录被移动过,重新指定到真正安装位置即可。
5.2 现象:界面上的宠物名字显示成乱码,或者源文件里的中文注释在MSVC下直接报错
这个问题在Windows上很典型。Qt 5默认源码格式是UTF-8,而MSVC编译器在没有BOM的情况下会按本地代码页去读,结果就是中文字面量变成“鈥滃”之类的内容,严重时直接编译失败。原因不是Qt问题,而是编码不匹配。解决有两个方向:要么把所有源文件另存为UTF-8 with BOM,让MSVC自动识别;要么在代码里全用QStringLiteral("皮卡丘")这样的宏,Qt官方建议是后者。但QStringLiteral也不能彻底解决注释乱码,所以我个人会给整个工程统一设置编码,并在pro文件里加上msvc: QMAKE_CXXFLAGS += /utf-8,这样源代码和注释都能安心用中文写。
5.3 现象:生成exe后拷到别的电脑,双击运行直接弹窗说缺MSVCP140.dll、VCRUNTIME140.dll,或者干脆“应用程序无法正常启动”
这是典型的发布时没有带上运行库。调试时Qt Creator会同时设置Qt的bin目录和编译器运行库路径,程序能跑是环境托了底;拷贝exe出去后这些路径全部失效。原因有两个:一是Qt动态库没带上,二是MSVC运行库没装上。解决方法是使用windeployqt工具,在Qt命令行工具里执行“windeployqt 你的exe路径”,它会自动把需要的Qt模块、插件、样式表复制到exe同级目录。MSVC运行库方面,带上vc_redist.x64.exe让目标机器安装,或者更省事的是改用MinGW工具包构建,MinGW的运行时不会出现MSVCP140.dll这种依赖。一个血泪经验是:别只拷Debug版exe,Debug版体积大且额外依赖调试运行库,发给老师前记得构建一次Release版。
5.4 现象:自定义绘图控件在窗口切换后一片空白,放大缩小时图形撕裂或闪烁
这个坑出现在我写PetPanel的第三版,当时为了省事,paintEvent里只画了当前状态没有处理更新信号。原因通常有三种:控件没有收到update调用、绘制依赖的背景图还没加载完、或者没有启用Qt的自动双缓冲。Qt的QWidget默认就开了双缓冲,但自定义控件里如果调用了QPainter::begin(this)之后再画,结束后忘了end,也会出现异常。解决上先看刷新入口,是不是所有修改宠物血量、切换宠物的路径都触发了update();再看是不是把耗时操作放在了paintEvent里,比如在paintEvent里读文件,第一次绘制会卡到界面假死;最后在构造函数里加一句setAttribute(Qt::WA_OpaquePaintEvent),能减少一部分重绘开销。
5.5 现象:qrc资源文件里的图片设成了路径却显示不出来,换成绝对路径也无效
很多人在资源管理器里把图片拖进qrc,然后写“:/images/pikachu.png”,运行时就是找不到资源。原因在于qrc里的“前缀”和文件路径是两回事,默认前缀是“/”,文件在qrc里显示为“/images/pikachu.png”没错,但代码里常常写成了“images/pikachu.png”,少了开头的冒号。还有一种是文件路径被加到了“/images”前缀下,调用时写的却是另一个前缀。解决方法是点开qrc编辑器,看清前缀列写的是什么,再对照资源浏览器里的完整路径去复制。更稳的做法是在资源文件上右键选择“复制资源路径”,直接把Qt生成的路径粘贴到源码里,不要手敲,手敲就会踩坑。
6. 收尾:交作业前的一小时,我具体做哪五件事来保证演示不出丑
如果明天就要演示,我不再改任何战斗公式,而是按固定顺序做五件事。第一件是把整个项目重新构建一次Release版,在Qt Creator的“项目”模式里把构建套件切到Release,缓存目录也指到新文件夹,避免Debug和Release的中间文件互相污染。第二件是打开Qt命令行工具,执行windeployqt把运行库打全,然后把整个文件夹拷到一台没有装Qt的机器上试跑,重点看血量条、技能图标、背景音乐资源能不能加载。第三件是过一遍流程:连续打完三场战斗,故意拖到残血再换宠,确认不会出现血量先归零然后还能出招的状态错误;同时把窗口拉大缩到各个尺寸,看布局有没有崩坏。
第四件是给源码做一次“减脂”。我会检查头文件里有没有一堆用不到的#include,把整个工程里的调试qDebug删掉或注释,把没有注释的函数补齐说明。这步看起来不占工作量,但答辩时老师会翻源码,清爽的代码观感能抵掉十页PPT。第五件也很重要:确保退出程序时能正确保存战斗数据。如果宠物小精灵有养成系统,就用QSettings保存当前队伍、血量、等级,没有养成系统就直接跳过,但一定保证点击关闭按钮时进程不会卡死在后台,任务管理器里看不到残留在运行的exe。
做完这五件事我还有十分钟,我会把QPainter绘制战斗面板那段代码单独截出来看一遍,确认它没有在窗口初始化的第一帧就触发了空指针读取。曾经有一次我在构造函数里setPet传入了临时对象,临时对象析构后PetPanel里保存的引用失效,程序一启动就qt崩溃,后来改成传值拷贝才算解决。从那以后,自定义控件里的数据我都用值类型保存,不在QWidget里存指针引用,这是我在宠物小精灵项目上最深刻的一条教训。
如果你也打算拿这个题目做毕业设计或者课程设计,我的建议是从第2章的数据模型开始,别从界面开始。界面会让你产生“我做了很多”的错觉,数据模型才会让后面每一步都有迹可循。把BattleManager写成一个不依赖界面也能跑的类,你会省掉至少一整个星期的联调时间。希望这篇笔记帮到你,祝你也能写出一只能打能画的宠物小精灵。
本文还有配套的精品资源,点击获取