ARTICLE DETAIL

资讯详情

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

基于Qt/C++的宠物小精灵人机对战游戏:战斗引擎与AI实战

基于Qt/C++的宠物小精灵人机对战游戏:战斗引擎与AI实战 简介基于QT与C实现的宠物小精灵人机对战游戏项目定位于高校计算机、通信、人工智能等相关专业的课程设计与毕业设计面向需要快速上手Qt开发与C编程的初学者或进阶者。压缩包内包含完整可运行的工程源码采用服务器与客户端双端结构涵盖11个cpp、11个头文件以及UI、pro、qrc等工程配置文件共36个文件整体仅1.88MB轻量但模块划分清晰便于逐模块阅读与二次开发。项目内置登录界面、对战场景截图与说明文档可直观了解游戏交互逻辑代码均经过调试测试答辩评审分达98分具备较强的学习借鉴价值。已有99人学习下载适合用作期末大作业或毕设项目也可在此基础上扩展宠物类型、技能效果与对战规则实现个性化功能改造。1. 基于QTC开发的宠物小精灵人机对战游戏这个高分项目到底在做什么“基于QTC开发的宠物小精灵人机对战游戏项目源码文档说明高分项目”这类题目在课程设计里出现的频率极高但多数人拿到的源码要么只有界面、战斗逻辑一碰就崩要么AI只会随机出招答辩时一问就露馅。这个方向真正值钱的地方是三点回合制战斗引擎是否完整、人机AI是否具备决策能力、文档能不能让答辩老师快速看懂全貌。它适合三类人正在找C课设题目的在校生、想从控制台练习转到Qt桌面开发的初学者、以及想验证类宝可梦玩法数值手感的设计者。下面按我做这类项目的落地习惯把架构、战斗引擎、AI、避坑和答辩验证完整过一遍。2. 为什么这份源码能拿高分QtC的项目架构与模块划分2.1 Qt 5.15.2在类宝可梦游戏里的定位界面归界面逻辑归逻辑拿高分的前提不是把界面画得多花哨而是让老师看到你懂分层。Qt 5.15.2配合Qt Creator是当前课程设计最稳的组合MinGW 64位编译器对新手最友好MSVC在处理中文编码时坑更多。这个项目里Qt只负责三件事窗口与控件的生命周期、信号槽把界面事件转给引擎、QSS把默认控件打扮成游戏面板。战斗计算、AI决策、状态判定这些核心逻辑必须写成与Qt无关的纯C类这样既能用控制台批量测试AI强度又能避免界面崩溃拖着整个逻辑下水。我一般会把项目拆成三层呈现层放MainWindow和战斗界面Widget引擎层放BattleEngine、Pokemon、Skill数据层放精灵图鉴和技能表。呈现层引用引擎层引擎层不反向依赖任何Qt界面头文件——这条依赖规则决定了你后期改界面时逻辑不用重写。2.2 数据层宠物、技能、属性三个基础类数据层是整套代码的地基。宠物对象至少要有名字、属性、等级、五项基础能力值HP、攻击、防御、速度再加一个可选的命中、当前血量、技能列表。技能对象要有名字、属性、威力、PP值使用次数、是否为回复技能。属性这里先用火、水、草、普通四系够支撑克制关系演示又不至于像正统宝可梦十八系那么复杂。类名关键成员职责边界Pokemonname, element, level, baseHp, baseAttack, baseDefense, baseSpeed, currentHp, skills保存精灵状态计算衍生数值Skillname, element, power, maxPp, currentPp, isRecovery, recoveryRate描述技能行为参数BattleEngineplayerActive, opponentActive, turnCount, battleState推进回合、结算伤害、判定胜负AIDecidergetAction(pokemon, opponent)根据双方状态选择技能索引注意衍生数值不要写死在构造函数里。等级变化时攻击力、防御力、最大HP都要跟着变所以用成员函数动态计算更合理例如int maxHp() const { return baseHp level * 5; }。这个公式纯属设计者定你完全可以根据手感调系数但要把系数提炼成常量别埋在公式里。2.3 引擎层与界面层如何用信号槽连接BattleEngine继承QObject通过信号把战斗结果抛给界面界面通过槽函数响应双方不直接持有对方指针。伤害结算、经验变化、胜负判定这些事件都定义成信号界面只需要连接自己关心的那几路。// BattleEngine.h 关键信号定义 class BattleEngine : public QObject { Q_OBJECT public: explicit BattleEngine(QObject* parent nullptr); void startBattle(Pokemon* player, Pokemon* opponent); void playerChooseSkill(int skillIndex); bool isFinished() const; signals: void damageDealt(int targetId, int damage, bool isCritical); void hpChanged(int targetId, int currentHp, int maxHp); void roundFinished(int roundNumber); void battleEnded(bool playerWin); };信号设计要粗粒度还是细粒度取决于界面要做什么动画。如果只做血条和日志hpChanged和roundFinished就够了。如果想让精灵做出受击抖动就得把damageDealt的targetId和damage带全。参数越少越易维护但也不能少到界面还得自己猜是谁打了谁。这段代码的逻辑说明所有通信都走信号界面层在MainWindow构造函数里用connect建立连接引擎内部结束一回合就emit对应信号哪怕界面最小化也不影响战斗推进——这就是解耦的直接收益。3. 把战斗引擎写出来属性克制表、伤害公式与回合主循环3.1 属性克制关系表火、水、草三系相克属性克制是宠物小精灵对战的灵魂。火克草、草克水、水克火普通系对所有属性都造成100%伤害被所有属性攻击也承受100%伤害。这个克制表用一个函数返回倍率比用二维数组更好读也方便以后扩展新属性。// type_table.cpp enum class ElementType { Fire, Water, Grass, Normal }; double typeEffectiveness(ElementType attackType, ElementType defendType) { if (attackType ElementType::Fire defendType ElementType::Grass) return 2.0; if (attackType ElementType::Water defendType ElementType::Fire) return 2.0; if (attackType ElementType::Grass defendType ElementType::Water) return 2.0; if (attackType ElementType::Fire defendType ElementType::Water) return 0.5; if (attackType ElementType::Water defendType ElementType::Grass) return 0.5; if (attackType ElementType::Grass defendType ElementType::Fire) return 0.5; return 1.0; }这段代码的判定顺序是先返回2.0的克制关系再返回0.5的微弱关系最后兜底返回1.0。参数说明返回值是伤害倍率克制翻倍、被克减半普通系天然就是1.0。注意这里不要写成(attackType Fire defendType Grass) ? 2.0 : 1.0这种单行嵌套条目一多就难排查。3.2 伤害公式两个参数决定游戏平衡性伤害公式直接抄正统宝可梦的框架会出问题——正统公式里精灵防御和HP数值巨大缩到课程设计的小数值里会造成“互挠几十回合不掉血”的尴尬。我用的是简化版经验公式// battle_formula.cpp int calculateDamage(const Pokemon attacker, const Pokemon defender, const Skill skill) { double effectiveness typeEffectiveness(skill.element, defender.element); int atk attacker.attack(); int def defender.defense(); double base (2.0 * attacker.level / 5 2) * skill.power * atk / def / 50 2; QRandomGenerator* rng QRandomGenerator::global(); double variation rng-bounded(85, 101) / 100.0; int damage static_castint(base * effectiveness * variation); return qMax(1, damage); }逻辑说明先按等级项(2.0 * level / 5 2)算出等级系数再乘以技能威力、攻击力除以防御力整体除以50并加2得到基础伤害。然后乘属性克制倍率和一个85%到100%的随机浮动最终伤害至少为1避免出现“0伤害”这种让玩家困惑的场面。参数说明level / 5里的5是等级缩放系数等级越高每级带来的增益越小/ 50是数值缩放分母它和技能威力共同决定了单发伤害占最大HP的比例。按我的经验把等级控制在1到15级最大HP用baseHp level * 5技能威力定在60到110之间单发克制伤害约占HP的20%到35%回合数在5到7局内结束演示节奏最舒服。觉得自己游戏打得太快就调大分母或调小威力太慢就反过来这两个参数是平衡性玄学的核心。3.3 先手判定与回合结算速度值决定出招顺序速度快的精灵先出手这是宠物小精灵对战的经典规则。但要注意速度快不意味着永远先手给一点随机浮动会让对战更有悬念。我的实现方式是按速度差计算概率而不是严格排序。// turn_order.cpp bool isFirstMove(const Pokemon left, const Pokemon right) { int speedDiff left.speed() - right.speed(); double probability 0.5 speedDiff * 0.03; probability qBound(0.25, probability, 0.75); QRandomGenerator* rng QRandomGenerator::global(); return rng-bounded(100) static_castint(probability * 100); }逻辑说明速度差每差1点先手概率改变3个百分点但整体限制在25%到75%之间。这样速度值接近的两只精灵几乎看运气速度碾压时也不会出现永远先手。参数说明0.03是速度差权重qBound两端的0.25和0.75是概率上下限。把下限调高会让先手优势更稳调低会让结果更随机。课程设计里不建议做绝对先手判定因为那会把数值设计推向“无脑加速度”的极端。3.4 回合主循环与界面解耦引擎不感知按钮点击回合主循环放在BattleEngine内部界面只负责把玩家的技能索引传进来。每回合按“先手判定→双方依次行动→回合结束信号”的顺序推进。// battle_engine.cpp void BattleEngine::processRound(int playerSkillIndex, int opponentSkillIndex) { bool playerFirst isFirstMove(*m_player, *m_opponent); if (playerFirst) { executeMove(m_player, m_opponent, playerSkillIndex); if (m_opponent-currentHp 0) executeMove(m_opponent, m_player, opponentSkillIndex); } else { executeMove(m_opponent, m_player, opponentSkillIndex); if (m_player-currentHp 0) executeMove(m_player, m_opponent, playerSkillIndex); } emit roundFinished(m_turnCount); if (m_player-currentHp 0) emit battleEnded(false); else if (m_opponent-currentHp 0) emit battleEnded(true); } void BattleEngine::executeMove(Pokemon* attacker, Pokemon* defender, int skillIndex) { const Skill skill attacker-skills[skillIndex]; int damage calculateDamage(*attacker, *defender, skill); defender-currentHp qMax(0, defender-currentHp - damage); skill.currentPp--; emit damageDealt(attacker-id(), defender-id(), damage); }逻辑说明executeMove里先取技能、再算伤害、扣血、扣PP、发信号顺序不能乱。processRound的关键是第二行动者要先检查第一行动者是否已经把对方打死了血量不大于0就不必再行动避免出现“尸体反击”的怪象。参数说明playerSkillIndex和opponentSkillIndex是技能在技能列表里的下标不是技能ID这样引擎不需要维护技能表。如果以后要支持换宠在此处增加一个switchPokemon分支即可不影响现有流程。4. 人机对战AI的三个档位从随机出招到带博弈的决策4.1 第一档随机AI用c随机数实现的最基础版本很多“高分项目”源码里所谓AI就是rand() % 4这是答辩时最容易被打穿的短板。但随机AI并不是没有价值——它是你验证战斗引擎是否完整的最快路径。先让两个随机AI对打一百场观察有无卡死、有无负数伤害、速度判定是否合理再往上加策略。// ai_random.cpp int randomPick(const Pokemon self) { QVectorint usableIndices; for (int i 0; i self.skills.size(); i) { if (self.skills[i].currentPp 0) usableIndices.push_back(i); } if (usableIndices.isEmpty()) return -1; // 所有技能PP耗尽战斗引擎需单独处理 return usableIndices[QRandomGenerator::global()-bounded(usableIndices.size())]; }逻辑说明先过滤掉PP耗尽的技能再从可用技能里随机选一个。返回-1表示无技能可用这时候战斗引擎应该让这只精灵只能“挣扎”或者强制换宠否则会下标越界崩溃。参数说明bounded(n)返回0到n-1的随机整数这是Qt推荐的方式比C的rand() % n分布更均匀。这里踩过的坑是% n在n很小时会出现明显的偏向性虽然对AI强度影响不大但答辩老师若较真会让你难堪。4.2 第二档克制AI用期望伤害替代随机选择随机AI之上第一层思考就是利用属性克制表。做法很直接遍历所有可用技能用“技能威力 × 克制倍率”作为评分选评分最高的。这一档AI已经能稳定打赢随机AI因为它每次出手都是数学期望上的最优解。// ai_type_aware.cpp int typeAwarePick(const Pokemon self, const Pokemon opponent) { int bestIndex -1; double bestScore -1.0; for (int i 0; i self.skills.size(); i) { const Skill skill self.skills[i]; if (skill.currentPp 0) continue; double score skill.power * typeEffectiveness(skill.element, opponent.element); if (score bestScore) { bestScore score; bestIndex i; } } return bestIndex; }逻辑说明评分只考虑威力和克制倍率没考虑攻击力、防御力和随机浮动。这其实是合理的简化——同一只精灵使用不同技能时攻防数值不变真正影响技能间优劣的就只有威力和属性关系这两项。参数说明这里刻意没有乘命中率因为课程设计里技能命中率一般是100%。若想引入“威力高但命中低”的招式评分公式要改成power * effectiveness * accuracy否则那个高威力技能会成为永远的最优解反而抹掉了决策多样性。4.3 第三档战术AI血量阈值、PP管理与回复技能博弈克制AI虽然聪明但它不懂防守。真正让项目有“人机对战”味道的是让它知道残血时回血、PP低时省着用。这一档AI的决策树按优先级排列血量危险且回复收益高→选择回复否则选期望伤害最高但附带PP惩罚的技能。// ai_tactical.cpp int tacticalPick(const Pokemon self, const Pokemon opponent) { int maxHp self.maxHp(); int recoveryIndex -1; int maxDamage estimateMaxDamage(opponent, self); for (int i 0; i self.skills.size(); i) { if (self.skills[i].isRecovery self.skills[i].currentPp 0) recoveryIndex i; } if (recoveryIndex 0 self.currentHp maxHp * 0.3) { int recoverAmount maxHp * self.skills[recoveryIndex].recoveryRate / 100; if (self.currentHp recoverAmount maxDamage * 2) return recoveryIndex; } int bestIndex -1; double bestScore -1e9; for (int i 0; i self.skills.size(); i) { const Skill skill self.skills[i]; if (skill.currentPp 0) continue; double effectiveness typeEffectiveness(skill.element, opponent.element); double ppPenalty (skill.currentPp skill.maxPp / 4) ? 0.7 : 1.0; double recoveryPenalty skill.isRecovery ? 0.5 : 1.0; double score skill.power * effectiveness * ppPenalty * recoveryPenalty; if (score bestScore) { bestScore score; bestIndex i; } } return bestIndex; }逻辑说明第一段判断残血时是否值得回血条件是“回复后能扛住对方两发伤害”才回否则回血就是白送一回合第二段正常选技能PP低于四分之一就打七折回复技能平时打五折避免AI血多时也疯狂回血。这两条规则不算复杂但已经能让AI表现出“它会保命”的观感。参数说明0.3是血量阈值recoveryRate是技能定义里的回复百分比maxDamage * 2里的2是扛击打次数。这套参数不是一个绝对最优解但它提供了一个可解释的决策逻辑——答辩时老师问“AI为什么这回合回血了”你可以指着这行条件给出明确答复这比“我随便写的”强太多了。4.4 用统计测试验证AI强度写个无GUI的批量对战器AI强不强不能靠“感觉”要跑数据。写一个不依赖Qt界面的批量对战器让三档AI两两对战统计胜率和平均回合数。这一步既是测试手段也是答辩时最能展示工程能力的素材。// ai_stats.cpp #include QCoreApplication #include QDebug int main(int argc, char* argv[]) { QCoreApplication app(argc, argv); constexpr int battleCount 10000; int tacticalWins 0; int totalRounds 0; for (int i 0; i battleCount; i) { BattleEngine engine; engine.setPlayerAI(AILevel::Tactical); engine.setOpponentAI(AILevel::TypeAware); engine.startBattle(); while (!engine.isFinished()) { engine.processRound(engine.playerDecision(), engine.opponentDecision()); } bool playerWin engine.playerWon(); tacticalWins playerWin ? 1 : 0; totalRounds engine.totalRounds(); } double winRate tacticalWins * 100.0 / battleCount; double avgRounds static_castdouble(totalRounds) / battleCount; qInfo() win rate: winRate avg rounds: avgRounds; return 0; }逻辑说明每一局都新建BattleEngine实例避免上一次对战的状态残留。AI决策是独立函数所以引擎无需知道自己面对的是哪个档位的对手。totalRounds累计所有对局回合数最后除以总局数得到平均回合数。参数说明battleCount设为10000是为了让胜率波动小于1%1000局数据噪点会很大。平均回合数和胜率要一起看如果某档AI胜率很高但平均回合数也高说明它是靠拖时间耗赢的这样的AI设计在答辩里容易被追问。5. 避坑让代码跑起来并交付的5个常见问题5.1 QT版本混用报错cannot mix incompatible Qt library现象编译通过一运行就崩控制台输出fatal: cannot mix incompatible Qt library (version ex50601) with this librar之类的错误。原因Qt库版本与编译时使用的头文件版本不一致或者同一工程里混用了MinGW和MSVC两个工具链编译出来的Qt库。最常见的触发点是换电脑后没有清理旧构建目录或者Qt Creator自动选了新装的Qt版本。解决先删掉build目录里的所有临时文件在Qt Creator里执行“清理项目 → 重新构建”。确认工具链匹配MinGW编译器必须配MinGW版QtMSVC编译器必须配MSVC版Qt三者前缀对不上就是翻车现场。5.2 MSVC下中文乱码源文件编码与编译器参数现象界面上所有中文按钮文字变成乱码或者编译报C4819警告。原因MSVC默认按系统ANSI代码页读取源文件而Qt Creator默认用UTF-8保存源文件。两者对中文的字节解释不一致就出现了乱码。解决有两种可靠做法。一是在.pro文件里加msvc { QMAKE_CXXFLAGS /utf-8 }强制MSVC按UTF-8读取源文件二是所有中文字符串都写成QStringLiteral(开始对战)让编译器按UTF-8处理字面量。这两种方式我建议都做前者解决编译期问题后者解决运行期问题。注意在Qt 5里用QString::fromUtf8处理外部导入的中文数据文件别直接依赖字符串拼接。5.3 动画期间界面卡死主线程Sleep的翻车现场现象点“攻击”按钮后整个窗口像死了一样等几秒才恢复期间窗口不能拖动、按钮无响应。原因在按钮的槽函数里写了QThread::sleep(1)或者循环里QThread::msleep(100)主线程被堵死了事件循环跑不动界面自然冻结。解决战斗结算立刻做完动画交给QTimer或QPropertyAnimation。常见做法是让槽函数只做“记录玩家选择并交给引擎”然后启动一个QTimer每200毫秒拉取一次引擎状态驱动血条变化。如果想让精灵有受击抖动效果用QPropertyAnimation对pos属性做短时位移比如30毫秒内从x0震动到x±8再归位。5.4 界面缩放被布局器反噬固定窗口尺寸不如布局约束现象拖动窗口边缘后按钮挤成一团或者精灵信息面板被截断。原因窗口用了绝对定位直接setGeometry摆放控件没有给布局器足够的约束信息。很多课设为了省事会把窗口设为固定大小但这在演示时一旦换上分辨率差异较大的电脑就露馅。解决主窗口用QGridLayout布局左右两侧放精灵面板、中间放战斗区域每列设置伸缩因子setColumnStretch。需要固定最小尺寸的控件用setMinimumSize而不是setFixedSize。给QLabel的文本开启换行避免长技能名把布局撑爆。打包前在Windows显示设置里把缩放调到125%或150%各跑一遍这能提前暴露大部分布局问题。5.5 换电脑跑不起来windeployqt与运行库现象在自己机器上好好的把exe拷到室友电脑上双击没反应或者报缺少DLL。原因没有部署Qt运行时库。Qt程序不是单个exe就能跑的它有几十个DLL依赖必须用windeployqt工具把依赖收集齐。解决用MinGW构建时在构建目录执行windeployqt.exe release\你的程序名.exe它会自动复制platforms插件目录和相关库。MSVC构建同理还要注意带上VC运行库或用静态编译。最稳的交付方式是把整个release目录打包zip里面包含exe、DLL和platforms文件夹。另外程序里如果有相对路径的资源文件比如精灵图片换目录运行前记得确认工作目录正确。6. 答辩验证与两个进阶方向让“高分”名副其实6.1 用Qt Test给战斗引擎做回归验证答辩时最加分的动作是展示自带自动化测试。Qt Test框架可以写纯逻辑测试覆盖属性克制、伤害下限、回合判定几个关键点。// test_battle.cpp #include QtTest class TestBattle : public QObject { Q_OBJECT private slots: void testTypeEffectiveness(); void testDamageMinimum(); }; void TestBattle::testTypeEffectiveness() { QCOMPARE(typeEffectiveness(ElementType::Fire, ElementType::Grass), 2.0); QCOMPARE(typeEffectiveness(ElementType::Water, ElementType::Grass), 0.5); } void TestBattle::testDamageMinimum() { Pokemon weakAttacker; weakAttacker.level 1; Pokemon tankDefender; tankDefender.level 15; Skill weakSkill{撞击, ElementType::Normal, 10, 10, 10, false, 0}; int damage calculateDamage(weakAttacker, tankDefender, weakSkill); QVERIFY(damage 1); } QTEST_MAIN(TestBattle) #include test_battle.moc逻辑说明第一个用例锁死克制表防止以后扩展属性时把原始倍率改坏第二个用例验证伤害下限为1防止数值调优时出现0伤害。两个用例都不涉及任何界面编译运行秒级完成演示时直接跑测试窗口给老师看。6.2 两个进阶方向方向一是给战斗动画加QGraphicsView舞台。把精灵形象做成QGraphicsPixmapItem攻击时用QPropertyAnimation控制item从一端滑到另一端。方向二是引入qmake的CONFIG选项做不同AI档位切换比如CONFIG ai_demo时启动一个无UI的自动对战窗口用于调试胜率。这两个进阶点都能在答辩演示里形成差异化记忆比堆砌更多精灵种类更能体现工程能力。做这个项目我自己最大的习惯是先把BattleEngine和AI写成不依赖Qt界面的纯逻辑模块用控制台批量对战把胜率调到合理区间再套Qt界面做呈现。反过来先花两周画界面再补战斗逻辑的几乎都会在联调阶段推翻重来。这套流程我踩过不止一次希望你不用再踩希望帮到你。本文还有配套的精品资源点击获取
返回列表