QT+C++宠物小精灵人机对战:状态机、AI与课设实战源码
简介一款基于QTC开发的宠物小精灵人机对战游戏源码整体工程分为服务端与客户端模块主打人机对战玩法非常适合作为高校学生的毕业设计、课程设计或项目开发参考。项目核心设计了精灵基类通过虚函数实现各子类的攻击方法包含小火龙力量型、妙蛙种子肉盾型、杰尼龟防御型、皮卡丘敏捷型四类精灵属性涵盖种类、名字、等级、经验值、攻击力、防御力、生命值和攻击间隔初始等级为1级、满级15级每次升级都会带来属性增长主属性加成更高。整套源码共36个文件资源配置完整主要由11个cpp源文件、11个h头文件、ui界面文件、pro工程文件、png截图、qrc资源文件及readme文档构成压缩包约1.88MB解压后可直接用Qt Creator打开工程文件运行体验。源码已经过严格测试逻辑清晰可以直接沿用并在此基础上进行二次开发。目前已有281人学习下载是快速上手Qt游戏开发与C面向对象实战的优质参考。1. 一份QTC宠物小精灵人机对战源码能帮你把课设交付到哪一步期末课设里“图形界面交互数据管理”是最常见的三项硬指标而宠物小精灵人机对战正好把它们全压在一道题里玩家和AI各持一只精灵通过QTC的界面选技能、看血条、读日志直到一方倒下。这类项目做完C对象设计、容器选择、事件驱动和游戏AI都被串成一条完整链路课设答辩时几乎每个模块都能展开讲。这篇笔记按我调通同类项目的顺序来写先讲为什么用QT、战斗逻辑该怎么拆再给环境搭建和可复制的C代码然后是AI决策与数值调平参数最后是四类让源码跑不起来的环境坑和答辩前的打磨。适合正在做毕业设计、课程设计或者想用C小游戏练手、需要一个能提交完整项目的人。2. 拆开宠物小精灵人机对战数据、状态机与为什么非QT不可2.1 为什么用QTC控制台跑得动逻辑跑不动交互我见过不少人先搜qt mvvm框架想按MVVM把界面和逻辑拆干净。课设项目真心不建议这么干——MVVM需要额外绑定层一周时间多半耗在框架本身。QT自带的信号槽已经把“界面变化驱动逻辑响应”这件事做完了QGraphicsScene负责场景对象管理QPushButton自带点击事件事件循环本身就是战斗主循环的骨架。你要补的只是把战斗逻辑放进一个不依赖UI的类里例如BattleManager。这样以后加测试、换界面、做存档都很容易。换成控制台行不行逻辑上完全可以但课设评审看的是“界面友好、交互合理”。QGraphicsScene本质是qt绘图场景你在里面addRect、addPixmap、移动精灵成本比控制台字符动画低得多而且每个操作都能被鼠标事件直接驱动。再配合QTimer做延迟回合之间肉眼可见有节奏感这正是“人机对战”该有的观感。2.2 先写精灵和技能的数据结构C对象设计从来不是画完界面再补拿到任何自称“宠物小精灵对战源码”的包先检查里面有没有专门的数据结构文件。没有PetData、Skill这种实体类只靠一堆按钮槽函数硬撑的项目后期根本没法扩展换宠、存档、属性克制。数据结构先行是这类项目最划算的起步方式。struct Skill { QString name; // 技能名界面按钮上直接显示 int power; // 技能基础威力 int type; // 技能属性与PetData.type共用一套枚举 int pp; // 剩余使用次数 }; struct PetData { QString name; // 精灵名 int maxHp; // 血量上限 int currentHp; // 当前血量战斗只改这个值 int attack; // 攻击力 int defense; // 防御力 float speed; // 决定先手用float方便平衡微调 std::vectorSkill skills; // 最多4个技能 int type; // 0火 1水 2草 3电 };这里的几个选择都有原因QString处理中文显示比char*省心不会出现数组越界或编码问题currentHp和maxHp分开存战斗过程中反复修改currentHp而存档、读档、换宠都拿maxHp当上限做校验speed用float是因为做数值平衡时经常出现81和81.5这种微小差距int反而调不动。skills用vector而不是C数组因为技能数量可变整个PetData作为值类型拷贝时vector会自动复制不用手管内存。属性克制关系不要写进PetData里单独放一张类型表。宠物精灵只需要知道自己是什么属性至于火打草是2倍还是0.5倍那是战斗系统的事不归精灵管。这种“数据与规则分离”的做法答辩时可以直接讲成“低耦合设计”。2.3 回合制状态机一套能让AI不抢跑的流程骨架宠物小精灵对战的核心是一个回合制状态机。最常翻车的写法是把逻辑直接散在技能按钮的槽函数里点一下打一下、再点又打一下结果玩家可以一回合连续出手。真正的做法是让整个战斗过程只认状态每个时刻只能处于一个状态点击事件只有在“等待玩家行动”这个状态里才生效。enum class BattleState { PlayerAction, // 等待玩家选技能 PlayerAnim, // 玩家技能动画播放中 EnemyAction, // AI正在决策 EnemyAnim, // AI技能动画播放中 GameOver // 战斗结束 }; class BattleManager : public QObject { Q_OBJECT public: void startBattle(PetData player, PetData enemy); void onPlayerSkill(int skillIndex); void onEnemyAction(); // 内部调用AI决策 BattleState state() const { return m_state; } int winner() const { return m_winner; } private: BattleState m_state BattleState::PlayerAction; PetData m_player, m_enemy; int m_winner -1; };状态跳转关系可以画成一张表写代码前先在纸上过一遍当前状态触发来源下一状态PlayerAction玩家点击技能按钮PlayerAnimPlayerAnim动画播放结束信号EnemyActionEnemyActionAI决策完成EnemyAnimEnemyAnim动画播放结束信号PlayerAction任意状态任意一方HP≤0GameOver回合延迟用QTimer::singleShot实现千万不要用sleep。sleep会阻塞UI线程窗口直接假死鼠标点击事件全部卡住。我用QTimer::singleShot(400, ...)把AI行动延后400ms界面期间玩家技能按钮保持禁用状态视觉顺序就是“我出手→对方挨打→对方出手→我挨打”。这套流程骨架建立之后后面加属性克制、加技能动画、加存档都只是往状态机里挂新事件不需要推翻重来。3. 用QT Creator搭出对战界面场景、血条、技能按钮与一条完整战斗链路3.1 环境准备QT 5.15.2与MinGW Kit怎么选别用vscode硬编QT下载安装是很多源码跑不起来的第一个原因。我一般用QT 5.15.2它是长支持版本在qt官网下载对应安装包安装时勾选QT 5.15.2下的MinGW 8.1 64-bit组件再装配套的QT Creator。装完在QT Creator里添加一个KitDesktop Qt 5.15.2 MinGW 64-bit。之后所有构建都走这个Kit不要跨Kit混用。如果你先花了一下午配置vscode的c/c环境再想用QT跑pro工程会发现qmake、编译器、调试器之间的环境变量够你折腾好几天。QT Creator把编译器、QT库、调试器整合成一个Kit选对Kit等于环境就绪。Linux桌面环境下最省事的装法是apt直接装sudo apt install qtbase5-dev qt5-qmake qt5-qmake qtcreator qmake --version qmake -query QT_VERSIONqmake输出正常就说明基础环境没坏。Windows上如果觉得在线安装器慢离线包选maintenance tool也可以核心是版本和编译器必须配套。新建工程时选择Qt Widgets Application模板会自动生成一个MainWindow和空的pro文件。pro文件里最常见的是缺模块导致编译报错对照检查这四行QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET petfight TEMPLATE appTARGET不要写中文、不要带空格。工程路径也尽量不要出现中文否则后边编译链接阶段会有一些莫名其妙的中文路径编码问题这部分留到第5章展开。3.2 QGraphicsScene搭对战场景qt绘图从占位矩形到精灵图场景搭建我用QGraphicsScene而不是直接往QWidget上贴图片后者移动精灵、做碰撞、做动画都很别扭。QGraphicsScene把每个可见对象当作独立的QGraphicsItem管理血条、精灵、特效都能单独移动和重绘。// 战斗场景初始化放在MainWindow或自定义Scene类的构造函数里 m_scene new QGraphicsScene(this); m_scene-setSceneRect(0, 0, 800, 600); // 用占位矩形先把两端站位画出来逻辑跑通后再换成精灵图片 QGraphicsRectItem *playerBase m_scene-addRect(150, 380, 160, 120); playerBase-setBrush(QBrush(QColor(60, 160, 220))); QGraphicsRectItem *enemyBase m_scene-addRect(500, 120, 160, 120); enemyBase-setBrush(QBrush(QColor(230, 90, 70))); // 敌方血条用矩形宽度表示当前血量百分比 m_hpBar m_scene-addRect(480, 100, 200, 18); m_hpBar-setBrush(QBrush(QColor(80, 220, 90))); m_hpBar-setPen(QPen(Qt::black));占位矩形是最高效的起步方式先确认坐标和血条位置是否符合预期再替换资源。替换成精灵图时用QGraphicsPixmapItemQGraphicsPixmapItem *playerSprite m_scene-addPixmap(QPixmap(:/res/player.png)); playerSprite-setOffset(-playerSprite-boundingRect().width() / 2.0, 0); playerSprite-setPos(220, 380);图片通过QT资源系统加载路径里的:/前缀对应qrc文件。在QT Creator里右键项目新增Qt Resource File把res目录下的图片加进去编译时会自动打包进exe答辩时换机器演示不用额外带图片文件。血条刷新是个高频操作战斗每次结算都要调一次void BattleManager::refreshHpBar(QGraphicsRectItem *bar, int cur, int max) { int w 200 * cur / max; // 按百分比换算成像素宽度 bar-setRect(bar-rect().x(), bar-rect().y(), w, bar-rect().height()); }注意这里用整数乘法200乘完再除避免直接用cur/max得到0。血量低于30%可以把血条刷成红色就是加一个if判断。这个细节看起来小答辩演示时很出效果。3.3 技能按钮与鼠标点击事件把一个点击变成一次出手界面布局可以用qt designer拖按钮也可以纯代码写。qt designer适合先排主界面框架比如四个技能按钮和日志框的位置但游戏场景这块还是得回代码里操作QGraphicsScene。如果你用qt designer生成了ui_mainwindow.hQPushButton可以直接在界面里排好代码里只做connect。// 在MainWindow构造函数里按玩家精灵的技能列表动态生成按钮 for (int i 0; i 4 i playerPet.skills.size(); i) { QPushButton *btn new QPushButton(playerPet.skills[i].name, this); ui-skillLayout-addWidget(btn); connect(btn, QPushButton::clicked, this, [this, i]() { battleManager-onPlayerSkill(i); }); skillButtons.push_back(btn); }lambda里用[this, i]同时捕获this和技能下标这样每个按钮点击时会带上自己的技能索引。onPlayerSkill内部第一步就判断当前状态是不是PlayerAction不是就直接return。按钮的禁用状态也要跟状态机同步进入动画状态时循环遍历skillButtons逐个setEnabled(false)回到PlayerAction再setEnabled(true)。这一步就是在落实2.3里说的“不让玩家一回合打两下”。调试时不想手动点按钮QT里可以用QTest模拟鼠标点击事件#include QtTest/QtTest QTest::mouseClick(skillButtons[2], Qt::LeftButton);这个调用会完整走一遍clicked信号→onPlayerSkill→状态机流转比直接调onPlayerSkill更能验证界面层是否正常。3.4 用战斗日志验证最小闭环一局对战从开始到弹出胜负战斗做到一半最怕的是不知道流程走到哪。我给BattleManager加一个battleLog信号所有关键节点都emit一行文字MainWindow里把信号接到日志框上每回合发生了什么都看得见。class BattleManager : public QObject { Q_OBJECT signals: void battleLog(const QString line); }; // MainWindow构造函数里 connect(battleManager, BattleManager::battleLog, this, [this](const QString s) { ui-logView-append(s); });日志信号把战斗逻辑和界面完全解耦BattleManager不需要include MainWindow的头文件纯逻辑层可以在没有界面的情况下独立测试。这一步做完验收标准就变得很明确开局双方血条满四个技能按钮可用点击技能后按钮立刻禁用日志输出“玩家使用撞击造成42点伤害”400ms后敌方血条变化日志输出AI行动回到玩家回合按钮重新可用任一方血量归零弹出QMessageBox显示胜负按钮全部禁用按这个清单逐项打勾第一版最小闭环就跑通了。此时你已经拥有一个可以演示的完整对局剩下的工作才是让AI更聪明、画面更好看。4. 让AI打得不蠢随机策略、属性克制与胜率调平参数4.1 AI决策的三种策略从随机到属性克制人机对战最容易翻车的是AI像个只会普攻的木桩。课设里我不要求AI多智能但至少让它有“血量告急换策略、知道属性克制”的感觉。常见做法是三级决策血量健康时随机出招血量低于30%时拼命输出技能列表里有克制玩家当前精灵属性的技能时优先使用。int AIController::chooseSkill(const PetData aiPet, const PetData playerPet) { // 血量低于30%时选威力最大的技能拼一波 if (aiPet.currentHp * 10 aiPet.maxHp * 3) { return bestPowerIndex(aiPet); } // 克制优先找到能克制玩家精灵的技能 for (int i 0; i aiPet.skills.size(); i) { if (typeMul(aiPet.skills[i].type, playerPet.type) 1.5) { return i; } } // 兜底随机让对局有不确定性 return randomIndex(aiPet.skills.size()); }30%这条血线不要写死在判断条件里我用一个const int常量方便后续调参。bestPowerIndex遍历skills找出power最大的下标与typeMul配合后AI的行为就不再是机械的“随机打一下”而是能表现出策略性。玩家换宠后下一回合AI重新走一遍chooseSkill克制判断自然更新。4.2 用C随机数做不确定性QRandomGenerator比rand()稳很多入门教程里AI随机都是用srand(time(0))配合rand()%n在QT的单线程demo里勉强能跑但rand的全局状态可能被其他库污染而且无法复现一场对局。调试时你想固定随机序列都做不到。我在QT项目里默认用QRandomGenerator它没有全局状态污染bounded(n)直接生成[0, n-1]区间的整数正好对应技能下标。#include QRandomGenerator int AIController::randomIndex(int count) { return QRandomGenerator::global()-bounded(count); } // 需要固定种子复现对局时先生成一个局部实例 QRandomGenerator rng(9527); int idx rng.bounded(4); // 每次程序启动跑出来的序列完全一致固定种子这个功能在答辩演示时极其有用。你想让前面两回合打出理想效果就先用9527或者其他种子反复试找到一条“技能不miss、伤害不拉胯”的随机序列然后把种子固定在main函数入口演示时每次跑都是同一套结果。想在演示里展示随机性就把种子换成QRandomGenerator::securelySeeded()。调出一个对局精彩的种子是个玄学过程我的做法是准备三个种子演示现场翻车马上换下一个。4.3 数值平衡参数表伤害公式、属性克制与1000场模拟验证代码写完只是第一步数值不平衡会让AI要么被秒杀要么永远打不死玩家。先定一个简单可调的伤害公式int damage(const PetData atk, const PetData def, const Skill skill, double randomFactor) { int base skill.power * atk.attack / (def.defense 0 ? def.defense : 1); double mul typeMul(skill.type, def.type); randomFactor 0.90 QRandomGenerator::global()-generateDouble() * 0.25; return int(base * mul * randomFactor); // 0.90~1.15浮动 }属性克制表单独放在typeMul函数里用静态二维数组查表比一堆if else清晰double typeMul(int skillType, int petType) { static const double kTable[4][4] { // 火 水 草 电 {1.0, 0.5, 2.0, 1.0}, // 火系技能 {2.0, 1.0, 0.5, 1.0}, // 水系技能 {0.5, 2.0, 1.0, 1.0}, // 草系技能 {1.0, 2.0, 1.0, 1.0} // 电系技能 }; return kTable[skillType][petType]; }这套表里0.5是抵抗1.0是普通2.0是克制。以电系技能打水系精灵为例kTable[3][1]返回2.0一个本应该被水系克制的精灵用电系技能反而能打出双倍伤害这种属性循环正是宠物小精灵类游戏的核心乐趣。数值参数建议全部集中放在一个图鉴表里不要散落在各技能槽函数里static const QVectorPetData kPokedex { { QStringLiteral(小火龙), 120, 120, 40, 25, 60.0f, { {QStringLiteral(撞击), 40, 0, 30}, {QStringLiteral(火花), 65, 0, 20} }, 0 // 火属性 }, // 继续添加水、草、电对应的精灵 };这样所有精灵和技能的调整都集中在一个文件里。光靠手感调平衡不靠谱我一般会写一个无界面模拟循环跑1000场战斗统计胜率// headless模拟不弹窗、不依赖UI线程纯逻辑跑完整对局 int playerWin 0, enemyWin 0; for (int i 0; i 1000; i) { PetData p kPokedex[0]; // 值类型直接拷贝不会污染图鉴 PetData e kPokedex[1]; BattleManager bm; bm.startBattle(p, e); while (bm.state() ! BattleState::GameOver) { bm.onPlayerSkill(QRandomGenerator::global()-bounded(4)); if (bm.state() BattleState::GameOver) break; bm.onEnemyAction(); } bm.winner() 0 ? playerWin : enemyWin; } qDebug() 玩家胜率: playerWin / 1000.0;这个循环直接把BattleManager当作纯逻辑类来用不需要启动窗口。1000场样本足以压掉随机浮动如果玩家胜率高于70%就把玩家精灵的攻击或速度调低10%低于40%就提高技能power或者给玩家速度加成。调参的依据从“我觉得”变成“数据说”答辩时评委问平衡性怎么验证直接把这份统计结果指给他看。5. QT源码跑前必查的4个坑环境报错、插件缺失、中文乱码与链接失败5.1 一启动就崩cannot mix incompatible qt library (version ex50601)现象编译链接都通过一运行exe就弹出类似“cannot mix incompatible qt library (version ex50601)”的报错或者程序直接崩溃。原因最常见的是QT库和编译器不匹配。比如用MinGW编译器编出来的代码链接了MSVC版本编译的QT库或者debug和release的库混用又或者电脑里装了好几套QT环境变量PATH把不同版本的qmake混在一起。Qt库编译时会把自己的版本和工具链信息写进二进制链接器发现两者不一致就拒绝执行。解决把QT Creator的构建套件清理干净。先关闭QT Creator删除工程目录下的build文件夹重新打开确认当前Kit是“Desktop Qt 5.15.2 MinGW 64-bit”右键工程执行Rebuild而不是Build。如果电脑上存在多个QT版本检查环境变量PATH里是否混入了不该有的qt bin目录。命令行下执行where qmake可以看当前到底命中哪个目录多版本并存时把不用的路径从PATH里去掉。5.2 无桌面环境报错qt.qpa.plugin: could not find the qt platform plugin linuxfb现象在树莓派或纯Linux帧缓冲设备上运行程序报错qt.qpa.plugin: could not find the qt platform plugin linuxfb程序起不来。原因QT启动时需要加载平台插件桌面环境默认加载xcb插件无桌面环境指定linuxfb。编译时可执行文件里带了插件搜索路径但部署到板子上路径不存在插件自然找不到。树莓派4交叉编译qt时这个问题尤其高频因为你本地编出来的QT插件目录和板子上的实际目录往往不一致。解决确认插件目录里有libqlinuxfb.so然后用环境变量显式指定export QT_QPA_PLATFORMlinuxfb export QT_QPA_PLATFORM_PLUGIN_PATH/path/to/qt5/plugins/platforms桌面Linux如果报的是找不到xcb缺的是系统库常见处理是安装libqt5gui5或libxcb-xinerama0。交叉编译时更省事的做法是把整个QT运行库目录复制到板子上保持相对结构不变再让QT_QPA_PLATFORM_PLUGIN_PATH指到对应位置。5.3 中文乱码与C4819源码编码在Windows上必须统一现象源码里写了中文注释和中文技能名MSVC编译时报C4819警告运行后按钮文本和日志显示成“????”或者一片乱码。原因源文件保存成UTF-8无BOM格式而MSVC默认按本地代码页解析两者不一致时中文字符被解读错位。QT Creator默认UTF-8Visual Studio或某些编辑器默认GBK混用保存后字符串就废了。解决所有源码统一用UTF-8保存QT Creator的“编辑-文本编辑器”里把默认编码设为UTF-8。MSVC工具链下在pro文件里统一加编译选项msvc { QMAKE_CXXFLAGS /utf-8 }字符串显示也尽量用QStringLiteral或u8前缀避免在运行期做隐式编码转换。这个坑的隐蔽之处在于有时候只报警告不出错但在波表文件、JSON存档和界面显示之间来回转换时中文会以完全不可控的方式失真。一劳永逸的办法只有一条源文件编码、编译器解析编码、运行时字符串编码三者全统一。5.4 链接失败cannot find -lpublic 到底是谁在找public库现象编译到链接阶段报错cannot find -lpublic看起来像是在找一个名字叫public的库文件。原因pro文件里写了或误带了一个不存在的库名。-lpublic的意思是让链接器去找libpublic.a或public.lib工程里压根没有这个库链接器自然报错。常见来源有两种一种是有人为了修复某个undefined reference顺手加了LIBS -lpublic结果库名根本不对另一种是把QT模块名写错比如QT printsupport手滑成QT publicqmake就会把它解析成链接库。解决先定位是谁在找public库grep -rn lpublic . grep -rn QT *.pro第一行查LIBS里的错误库名第二行查QT模块声明。把-lpublic改成实际需要的库比如Windows下多线程需要的是-lws2_32Linux下线程是-lpthread如果模块名写错就改回正确的模块。改完Reclean项目再Rebuild链接错误通常就消失。6. 答辩前再打磨存档、动画与一场“必赢”演示6.1 用QSettings做回合存档比手写json更省事数据持久化是课设评分里高频出现的问题点QSettings三五行代码就能把战斗状态存下来。它在Windows上写ini文件在Linux上写配置文件不需要引入第三方库。void saveGame(const QString slot, const PetData pet) { QSettings s(MyStudio, PetFight); s.beginGroup(slot); s.setValue(name, pet.name); s.setValue(hp, pet.currentHp); s.setValue(maxHp, pet.maxHp); s.endGroup(); } PetData loadGame(const QString slot) { QSettings s(MyStudio, PetFight); s.beginGroup(slot); PetData pet; pet.name s.value(name).toString(); pet.currentHp s.value(hp).toInt(); pet.maxHp s.value(maxHp).toInt(); s.endGroup(); return pet; }存档读档配合状态机的GameOver状态能让游戏具备“中途退出后继续战斗”的完整流程。答辩演示时先打半场关掉程序重开读档回到半场状态这个交互比从头打一遍更能体现工程完整性。6.2 攻击动画与固定种子的演示技巧给精灵加简单攻击动画用QPropertyAnimation就够了让精灵的x坐标瞬移出去再弹回来QPropertyAnimation *anim new QPropertyAnimation(playerSprite, pos); anim-setDuration(150); anim-setKeyValueAt(0, playerSprite-pos()); anim-setKeyValueAt(0.5, playerSprite-pos() QPointF(-25, 0)); anim-setKeyValueAt(1, playerSprite-pos()); anim-start(QAbstractAnimation::DeleteWhenStopped);动画播放期间状态机处于PlayerAnim或EnemyAnim技能按钮保持禁用动画结束后发出finished信号再推进到下一状态。答辩演示的核心技巧是把随机种子固定住找一个你已经验证过、过程精彩且结局必胜的种子写在main函数入口。现场演示前用headless模拟跑1000场胜率统计结果打印出来摆在项目说明里评委问平衡性就指数据说话。这些年带课设最大的感受是凡是先把状态转移图画在纸上再写代码的人基本没在战斗流程上返工一上来就写按钮槽函数的总会有一天半夜发现玩家可以一回合打两下。我自己的习惯始终是数据结构先行状态机画图数值用模拟说话。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →