☰
基于Qt/C++的宠物小精灵人机对战课设:架构实战与避坑指南
2026/10/7 1:51:34 网站建设 项目流程

简介:基于QT+C++开发的宠物小精灵人机对战游戏项目,源自个人毕业设计,答辩评审得分98分,代码经调试测试可稳定运行。面向计算机、通信、人工智能、自动化等专业学生与从业者,项目同时提供服务端与客户端两个Qt工程,覆盖登录界面、战斗场景等模块,并配有详细文档说明,既可作课程设计、期末大作业或毕业设计参考,也能满足小白学习进阶或二次开发需求。压缩包为zip格式,共36个文件,主要包括11个C++源程序、11个头文件、2个UI界面文件、2个Qt资源文件、2个工程配置文件、Markdown文档及图片素材等,整包仅1.88MB,结构清晰,便于对照模块学习。目前已有99人学习/浏览,对于想快速上手QT/C++实战项目、学习人机对战逻辑与界面设计,或借鉴高分毕设方案的学习者,具有不错的参考价值。

1. 基于QT+C++的宠物小精灵人机对战:一门课设到底值不值得自己写

如果你翻到这行字,多半是在找一门能写进简历、能应付答辩、又不想纯抄代码的课设。标题里的“基于QT+C++开发的宠物小精灵人机对战游戏”,说白了就是用 Qt 做界面、用 C++ 写回合制战斗逻辑,再让电脑扮演对手跟你打一场。它解决的痛点很具体:命令行版的宠物对战太寒碜,Unity 又太重,而 Qt + C++ 恰好能把数据管理、战斗结算、AI 决策和 GUI 串成一条完整链路,是 C++ 方向最有性价比的图形化练手项目之一。这篇文章会从架构选型、核心战斗逻辑、界面实现,一路讲到 MSVC 编译报错和 AI 死循环排查,照着走完,你手里就是一套能答辩、能继续扩展的源码加文档。

我见过太多人一上来就往 MainWindow 里塞代码,几百行下来自己都找不到按钮回调在哪。要把这个项目做“高分”,关键不是把谁的源码抄明白,而是自己搞清楚每一层在干什么——宠物数据怎么存、回合怎么算、AI 怎么不犯蠢、界面怎么不高频刷新,这些拆开都不难,合起来就是完整项目。下面按落地顺序展开。

2. 开局先把 Qt 和 C++ 的架构选型定死:QWidget 还是 QML,回合怎么算

2.1 为什么选 QWidget 而不是 QML 做这种课设项目

Qt 做界面有两条路:QWidget 和 Qt Quick/QML。对于宠物小精灵这种界面元素固定、交互集中在几个按钮和状态显示上的项目,我一直推荐 QWidget。原因有三条:第一,QWidget 的信号槽是编译期检查的,参数类型不对直接报错,QML 里写错一个属性名往往是运行时黑匣子,报错信息还含含糊糊;第二,QWidget 的布局系统比 QML 的锚点更直观,四五个页面切换用 QStackedWidget 就能管住,不需要学 QML 的 State 和 Transition;第三,C++ 课设判分的老师大概率也是从 QWidget 年代过来的,读到代码里熟悉的 QPushButton、QLabel,比读一堆 QML 文件更有亲近感。

提示:如果项目名里没有明确要求 QML,就老老实实用 QWidget。QML 做花哨动画确实爽,但课设题目一般考核的是 C++ 逻辑和 Qt 基本功,不是动画帧率。

另一个要先定死的是 Qt 版本。我一般建议用 Qt 5.15.2 LTS 配 MSVC2019_64,这是目前课程设计里最常见的组合,网上的资料、镜像站的安装包、以及报错记录都最全。Qt 6 虽然也稳定,但很多教程和第三方组件还是按 5.15 写的,你卡壳时搜到的救命帖,大概率是 5.15 的。编译器跟着 Qt 版本走,MSVC 家的就用 MSVC 套件,别混用 MinGW 的 Qt 装 MSVC 的编译器,后面会专门讲这个坑。

2.2 五层结构的拆法:从 Pet 数据到 BattleService

拿到标题后不要急着建工程,先在草稿纸上把结构拆出来。做 QT+C++ 的宠物对战项目,我惯用的分法是这样五层:

分层核心类职责
资源层pokedex.json、skill.json、qrc存宠物图鉴、技能参数、图片资源
数据层PetData、SkillData读取配置,提供结构体给上层
逻辑层BattleSystem回合制状态机、伤害结算、属性克制
AI层AIController出招决策、换宠决策、难度分档
界面层MainWindow、BattleView、StartMenu页面切换、信号槽绑定、动画刷新

这张表就是你写文档时的架构图雏形。数据层和界面层严格解耦:BattleSystem 里不出现任何 QLabel、QPushButton,界面层通过信号槽把玩家“点了攻击按钮”这个动作传给逻辑层,逻辑层算完结果再通过信号传回界面层。这样写的好处是,即使到时候 UI 崩了,你还能写几个命令行用例把 BattleSystem 单独调通——这是答辩时区分“抄的”和“自己写的”最好证据。

2.3 先约定回合制时序:谁先手、怎么转状态

宠物小精灵人对战规则里,最核心的就是回合制时序。我见过翻车最多的地方是“先手判定”和“状态转移”混在一起,代码里一堆 if 嵌套,最后 AI 和玩家同归于尽时状态乱套。约定要这样定:

每个回合分四步:第一步判定先手,按速度属性比较,快的先出招;第二步先手方选技能并结算伤害;第三步后手方如果还活着,选技能并结算;第四步结算持续状态(中毒、灼伤等),检查胜负,进入下一个回合。这里的核心是“回合内状态快照”——回合开始时就把双方的速度、血量存下来,本回合内不管中间发生了什么,先手顺序不再变。否则会出现“A 打死了 B,B 的回合还继续结算”这种逻辑 bug。

在代码上,我建议用一个枚举状态机来表达:RoundState { START, FIRST_ACT, SECOND_ACT, END_ROUND },每次只允许向前推进,不允许回退。这个约定既能让 AI 决策变简单,也能让你在文档里画状态转移图时站得住脚。有了这个地基,下面两章分别处理“数据怎么存”和“界面怎么刷”。

3. 把后台对战逻辑写干净:Pet 数据、技能结算、AI 决策怎么做

3.1 用结构体还是用类?PET 数据的 C++ 表示

这是新手第一个纠结的点。我的答案是:字段全是公开的、没有私有方法去维护内部状态的,就用 struct,不要硬上 class 封装。PetData 在这里更像是一种带类型的记录(record),而不是一个严格意义上的对象——它的属性由外部配置和 BattleSystem 修改,自己不该有“回血”这种方法,否则就把逻辑层和数据层揉在一起了。

常见做法是配合枚举定义宠物属性和技能类型:

// PetType 枚举顺序与克制表二维数组的索引保持一致 enum class PetType { Grass = 0, Fire, Water, Electric, Normal, Count }; // 技能数据结构 struct SkillData { int id; QString name; PetType type; int power; // 威力 int maxPP; // 最大使用次数 int accuracy; // 命中率,0~100 int priority; // 先制度,先制技能用 }; // 宠物图鉴条目 struct PetData { int id; QString name; PetType type; int baseHP, baseAttack, baseDefense, baseSpeed; QVector<int> skillIds; // 最多四个技能 };

逻辑说明:这里把属性和技能定义为“值语义”的 struct,而不是 QObject 派生类,是为了让 BattleSystem 能直接拷贝、比较、存容器,不用管内存释放。效率上 Qt 隐式共享(implicit sharing)对 QVector、QString 的拷贝几乎是零成本,传给 AI 做策略计算时大胆按值传就可以。

参数说明:accuracy用整数存而不是浮点,是为了避免 0.85 和 0.8500001 这种精度比较问题;priority字段留给先制技能(比如电光石火),在 3.3 节 AI 决策时要把它纳入评分,否则 AI 永远不知道“我方速度慢但可以靠先制技能抢出手权”。另外技能 id 在 SkillData 里也用 int,而不用字符串名字,因为技能在战斗日志里要检索名字,而伤害公式里只关心它的数值属性。

3.2 伤害公式与属性克制表:一个二维数组顶过十层 if

伤害公式我推荐直接用经典修正公式,简单而且平衡性好:

伤害 = (攻击方攻击力 * 技能威力 / 防御方防御力) / 50 * 克制系数 * 随机浮动 + 2

这个公式来自《宝可梦》系列的底层数值模型,经过二十多年的对战验证,参数规模正好匹配课设级别的数值。克制系数查表得到:属性相克关系做一个PetType::Count x PetType::Count的二维数组,行是攻击方属性,列是防御方属性,值只有三种:2.0(克制)、0.5(抵抗)、1.0(普通)。不要在逻辑代码里写“if 你是水我就 2 倍”这种硬编码,把克制表抽成静态资源,后期扩展新属性(比如加一个 Fairy 妖精属性)只需要改表、加枚举值,不用动战斗逻辑。

// 克制表静态数组,按枚举索引排列 // 行:攻击方类型;列:防御方类型 static const float typeChart[static_cast<int>(PetType::Count)][static_cast<int>(PetType::Count)] = { // 防御方: Grass Fire Water Electric Normal { 1.0f, 0.5f, 2.0f, 1.0f, 1.0f }, // 攻击方 Grass { 2.0f, 0.5f, 0.5f, 1.0f, 1.0f }, // 攻击方 Fire { 0.5f, 2.0f, 0.5f, 1.0f, 1.0f }, // 攻击方 Water { 1.0f, 1.0f, 2.0f, 0.5f, 1.0f }, // 攻击方 Electric { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f }, // 攻击方 Normal }; float BattleSystem::calcTypeMultiplier(PetType attackType, PetType defenseType) { return typeChart[static_cast<int>(attackType)][static_cast<int>(defenseType)]; }

逻辑说明:这种静态二维数组的访问时间复杂度是 O(1),比用QMap<QPair<PetType, PetType>, float>要快,关键是它没有哈希开销,而且一眼能看出表格全貌,文档里画属性表时直接抄数组值就行。注意static_cast<int>(PetType::Count)作为数组维度,这样以后 Count 变大数组自动扩容,绝对不会越界访问。

参数说明:随机浮动我控制在 0.85 到 1.0 之间,用QSrand+QRandomGenerator::global()->generateDouble()生成,与 C 风格rand()混用的坑是:rand()是进程级共享的,如果你在 AI 线程和 UI 线程同时调用,会互相干扰序列,Qt 官方推荐用QRandomGenerator。命中判定单独做——如果随机数大于 accuracy,直接返回 null 并显示“miss”,不进伤害公式,保证 PP 消耗和 miss 显示是正确配对。

3.3 让 AI 不那么蠢:从随机选招到预判四回合

“人机对战”的分数高低,一半看 AI 决策质量。最简单的随机出招策略只能拿及格分,因为老师可能会跟你的 AI 打三局,发现它每局都在用同一个低威力技能打你高防御宠物,体验分就降了。我一般会给 AIController 做三档难度,默认中档使用“贪心 + 预判”的混合策略。

贪心部分:遍历当前宠物全部可用技能,对每个技能计算“去掉随机浮动后的期望伤害”,再根据自身剩余 PP 做折扣——PP 只剩 1 的技能降到 0.5 权重。选择期望伤害最高的技能。预判部分:记录玩家最近三回合的出手记录,如果玩家连续三次用同一系技能,AI 就有 50% 概率换出克制该系的宠物(如果背包里还有存活的话)。这就是经典的“读操作”套路,代码实现并不复杂:

// AIController 核心决策入口 int AIController::chooseSkill(const PetRuntime& mine, const PetRuntime& opponent, const QVector<BattleLog>& recentLogs) { // 1. 先算出每个技能的打分 QVector<QPair<int, float>> candidates; // (skillId, score) for (int sid : mine.skillIds) { const SkillData& sk = skillRepo[sid]; if (mine.ppLeft(sid) <= 0) continue; // 已经耗尽 PP 的技能直接跳过 float baseScore = sk.power * typeChart[(int)sk.type][(int)opponent.type]; // 克制时给额外加分,抵抗时降权 if (typeChart[(int)sk.type][(int)opponent.type] > 1.0f) baseScore *= 1.2f; if (typeChart[(int)sk.type][(int)opponent.type] < 1.0f) baseScore *= 0.6f; // 先制技能在速度劣势时加分 if (mine.speed < opponent.speed && sk.priority > 0) baseScore *= 1.3f; // PP 越少,越不想用(保留后备) float ppFactor = 0.5f + 0.5f * (sk.maxPP - mine.ppLeft(sid)) / sk.maxPP; candidates.append({sid, baseScore * ppFactor}); } // 2. 预判:玩家最近三回合都使用同一属性攻击,则选择抵抗该属性的技能(优先换宠) if (recentLogs.size() >= 3) { bool sameType = true; for (int i = recentLogs.size() - 2; i >= 0; --i) { if (recentLogs.last().type != recentLogs[i].type) { sameType = false; break; } } if (sameType) { // 实际工程里这里会交给换宠逻辑,课设里简化为给同类技能降权 for (auto& cand : candidates) { if (skillRepo[cand.first].type == recentLogs.last().type) cand.second *= 0.4f; } } } // 3. 排序取最高分 std::sort(candidates.begin(), candidates.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); return candidates.isEmpty() ? -1 : candidates.first().first; }

逻辑说明:整个决策是基于评分的贪心,没有用蒙特卡洛树搜索(MCTS)那种重武器,因为课设对响应时间有要求——AI 思考不能超过 100ms,否则玩家会觉得“卡了”。评分函数就是几行乘除,不需要机器学习,但已经能做到“知道克制关系、知道保 PP、知道抢先手”这三个基本素养,正常答辩打三局至少能赢一局,不会出现一碰就碎。

参数说明:recentLogs用QVector<BattleLog>而不是std::vector,因为 QVector 可以配合 Qt 的元类型机制,在信号槽里发射给 UI 日志窗口。降权系数 0.4 是经验值,如果玩家常用技能被防住,AI 会主动换招,形成“你变我也变”的博弈感觉。这里有个隐含的前提:chooseSkill必须在 BattleSystem 的回合状态机里调用,不要在 UI 线程里直接调用,否则你将收获第一课崩溃——AI 算完结果,界面已经销毁了。

4. Qt 界面层怎么做才不拖后腿:页面切换、信号槽与血条动画

4.1 QStackedWidget 管住三个页面:菜单、选宠、战斗

界面层最常犯的错是“一个 MainWindow 里全堆上”,三个页面放三个 QWidget 互相隐藏显示,代码混乱度直线上升。正确做法是把页面拆成 StartMenu、TeamSelectView、BattleView 三个 QWidget,用 QStackedWidget 当容器,切换时按索引跳。这样做的好处是每个页面独立处理自己的信号,MainWindow 只负责“切换”这个动作。

// MainWindow 构造函数中的页面装配 stack = new QStackedWidget(this); startMenu = new StartMenu(this); teamSelect = new TeamSelectView(this); battleView = new BattleView(this); stack->addWidget(startMenu); // index 0 stack->addWidget(teamSelect); // index 1 stack->addWidget(battleView); // index 2 connect(startMenu, &StartMenu::startGameRequested, this, [this]() { stack->setCurrentIndex(1); // 主菜单点“开始”后进选宠页 }); connect(teamSelect, &TeamSelectView::teamConfirmed, this, [this]() { stack->setCurrentIndex(2); // 选完六只宠进战斗页 battleView->startNewBattle(teamSelector->selectedTeam()); });

逻辑说明:连个 lambda 就能完成页面路由,关键点是信号槽连接都在 MainWindow 的构造函数里完成,页面之间不互相引用。teamConfirmed信号里带一个QVector<PetData>参数,需要在 PetData 上声明Q_DECLARE_METATYPE才能跨信号槽传递,否则 Qt 会提示 “Cannot queue arguments of type 'PetData'”。

参数说明:选宠页面我用的是一个 QListWidget 加右侧详情 QLabel,数据源是静态的图鉴配置,不需要复杂的委托。如果项目想加分,这里可以加一个“随机选宠”按钮,在 C++ 里直接用QRandomGenerator::global()->bounded(pokedex.size())选一只,演示时比手动选更有节目效果。

4.2 信号槽分离:战斗按钮不直接改血条

战斗过程中,玩家点击“技能 1”按钮,如果直接在按钮回调里改 UI 血条,那 BattleSystem 就没法单独测试了。正确姿势是:按钮只发“我点了技能 1”的信号,BattleSystem 算完结果发“伤害结算”的信号,界面层收到信号才刷新 UI。这套机制是 Qt 最值钱的资产,也是答辩老师最喜欢问的“你哪里用了信号槽”。

// BattleView 中按钮的回调,不直接做逻辑 void BattleView::onSkillButtonClicked(int skillIndex) { if (!myTurn()) return; // 不是己方回合直接忽略 emit playerActionSelected(m_currentPetId, skillIndex); setControlsEnabled(false); // 锁按钮,等待 BattleSystem 回传 } // BattleView 中连接的战斗结果处理 void BattleView::onDamageDealt(const QString& attackerName, const QString& targetName, int damage, bool isCrit) { m_battleLog->appendPlainText(QString("%1 对 %2 造成了 %3 点伤害%4") .arg(attackerName, targetName).arg(damage) .arg(isCrit ? "(会心一击!)" : "")); // 血量动画由该函数内部再用定时器异步执行,不等 UI 卡顿 m_animator->animateHpChange(targetName, damage); }

逻辑说明:onSkillButtonClicked里emit playerActionSelected之前先判断myTurn(),这是防止玩家在 AI 回合狂点按钮把状态机搞乱。setControlsEnabled(false)是“锁界面”的简单实现,等 BattleSystem 发出roundFinished信号再解锁,这是一个完整的“信任但验证”模型:界面相信逻辑层会给出正确结果,但它不自己在中间做判断。

参数说明:这里的信号连接要加Qt::QueuedConnection吗?不需要。因为所有对象都在主线程,用默认的AutoConnection就行。如果你的 AI 是独立线程算的,那 BattleSystem 那个对象在主线程,AI 线程回传结果必须用跨线程信号——把 AI 决策包成QRunnable丢QtConcurrent::run,跑完 emit 结果信号,这是另一个加分点,新手阶段不建议碰。

4.3 血条动画别直接 setValue:用 QVariantAnimation 平滑过渡

老手看课设代码,一眼就能看出“是仿的游戏还是真游戏”:真游戏的血条是丝滑变化的,仿的课设是“啪”地一下掉到底。QProgressBar 默认的 setValue 是瞬间跳变,游戏质感很差。解决方案是加一个中间动画层,用一个 QVariantAnimation 在 300ms 内把数值从旧值线性过渡到新值,每次动画帧更新时再 setValue。

// 血条平滑动画的实现要点 QVariantAnimation *hpAnim = new QVariantAnimation(this); hpAnim->setDuration(300); // 300 毫秒走完,太长影响出招节奏 hpAnim->setStartValue(m_oldHp); // 改为从旧血量开始 hpAnim->setEndValue(newHp); connect(hpAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { ui->hpBar->setValue(v.toInt()); // 动画帧更新进度条数值 updateHpLabel(v.toInt()); // 同步数字文本 "96 / 120" }); connect(hpAnim, &QVariantAnimation::finished, this, [this]() { ui->hpBar->setValue(m_currentHp); // 最后校正一次,防止亚像素误差 }); hpAnim->start();

逻辑说明:用 QVariantAnimation 而不是自己写 QTimer 的好处是,它内部基于 QAbstractAnimation 的时间轴,窗口最小化、拖动缩放时动画会自动暂停恢复,不会出现“切出去再切回来血条瞬满”的诡异现象。setValue放在valueChanged里,每帧都会收到新值,但 Qt 的元对象系统会合并同一帧的多余更新,不会造成界面抖动。

参数说明:300ms 是手感调试结果。太短 (100ms) 玩家还没看清掉多少血就结束了;太长 (800ms) 会让连续伤害结算(比如中毒结算 + 技能伤害叠加)排队把人急死。另外这里有个隐藏坑:QVariantAnimation的 startValue 必须和你上一次动画的 endValue 一致,否则连续两次伤害时血条会从“上一次的终值”跳到“这次的初值”,视觉上会看到一次不自然的闪跳。正确做法是让 BattleSystem 维护一份权威血量数值,动画层只做表现,它自己永远不知道真正的血量是多少。

4.4 战斗日志窗口:用 QPlainTextEdit 而不是 QTextEdit

日志窗口显示“你使用了技能 xxx,造成 xx 伤害”这类滚动信息。很多人选 QTextEdit,但对高频追加文本来说,QTextEdit 会随着内容变多越来越卡,因为它在内部维护富文本格式。正确选择是 QPlainTextEdit,它的纯文本渲染速度快得多,配合appendPlainText追加日志,即使打满 200 回合也不会有明显的输入延迟。

// 在构造函数里设置日志窗口为只读并调整性能参数 ui->battleLog->setReadOnly(true); ui->battleLog->setMaximumBlockCount(200); // 超过 200 行自动丢弃最早的行 ui->battleLog->document()->setDefaultStyleSheet(QString()); // 不启用富文本样式表

参数说明:setMaximumBlockCount(200)是防止无限增长的黑科技。QPlainTextEdit 的底层是个 QTextDocument,行数无限增长时每次 append 都会触发一次全文重排,游戏打到第 500 回合时,输入一个字符都卡。限制区块数后,Qt 会自动从头部删除旧行,日志性能恒定。如果你需要每行带颜色(技能名蓝色、暴击红色),用 QTextEdit 加 QTextCharFormat 也行,但记得 50 回合后手动clear()防止拖慢。

5. 避坑集合:Qt 5.15 的 MSVC 编译、中文乱码、崩溃与卡死排查

5.1 “:-1: error: dependent '..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwid...” 头文件路径爆炸

现象:在 Qt Creator 里打开别人的项目,一编译就报错,错误信息里出现一长串以dependent开头的包含路径错误,后半截经常被截断,看起来像 Qt 头文件找到不。

原因:这个项目原来是在别的机器上用绝对路径配置的 Qt 套件,拷贝到本地后.pro文件里或者C/C++ 常规 - 附加包含目录里残留了原来的全局路径。Qt 头文件的解析是分层依赖的,一个头文件没找到就会带崩一串。

解决:先检查两处。一是 Qt Creator 里“构建 - 运行 qmake”的构建目录路径不能有空格和中文;二是如果项目是.pro格式,直接选中项目点右键“清除项目”,再“执行 qmake”,然后重新构建。如果还报,手动检查.pro.creator.local或.pro.user文件里的CONFIG变量,把INCLUDEPATH里手写的绝对路径删掉,改成+= $$PWD这种相对写法。最暴力的办法是删除.pro.user文件后重新打开项目,Qt Creator 会基于当前套件重新生成完整的配置。这一步我把它排在所有坑的第一位,因为它拉黑的项目比代码 bug 多得多。

5.2 MSVC 下中文字符串全变乱码

现象:源码注释里写中文没问题,运行起来界面上弹出的技能名、宠物名全是“杩烽樋”这种天书,或者直接编译报错 “C4819” 警告。

原因:MSVC 默认把源文件按本地代码页(中文系统是 GBK)解析,但你的 .cpp 文件如果是以 UTF-8 保存的,那中文字符串在编译时被按 GBK 读取,字节错位,运行时 QString 拿到的是错的字节串。

解决:统一在.pro文件里加一句QMAKE_CXXFLAGS += /utf-8,强制 MSVC 把源码当 UTF-8 解释,这是 Qt 5.15 + MSVC 的黄金组合。命令行编译的话,cl后面手动加/utf-8开关。另一个更彻底的办法是代码里的中文字符串全部包一层QStringLiteral()宏,QStringLiteral 在编译期就把 UTF-8 字面量转成 QString,比QString::fromLocal8Bit()更安全,而且零运行时开销。注意 QObject::tr() 里写的文本建议保持英文或拼音,中文留给翻译文件,否则 QML 里插值也可能踩同一坑。

5.3 程序跑着跑着闪退,停在“QWidget: Cannot create a QWidget without QApplication”

现象:Debug 模式下运行,点击“开始游戏”按钮时崩了,输出窗口打印一行带 Qt 内部断言的提示,定位到的函数在QWidget构造函数里。

原因:这是典型的“在非 GUI 线程创建了 QWidget 子类对象”。常见场景是你在一个std::thread或者 QtConcurrent 异步任务里,new 了一个 QLabel 或者 QDialog。QWidget 必须在主线程(Qt 的事件循环线程)里创建,因为 QWidget 的绘制依赖 QApplication 的事件循环。遇到这个问题时,代码逻辑上没有错,但线程模型错了。

解决:把新控件的创建移回主线程。如果是在 QtConcurrent 任务里拿到的数据想弹窗,用信号把数据发回主线程再创建窗口;如果是在服务类里想临时显示提示,不要自己 new QMessageBox,改用QMetaObject::invokeMethod(this, [this](){ QMessageBox::information(...); }, Qt::QueuedConnection)排队到主线程。另外检查你的main.cpp里是否忘了QApplication a(argc, argv)——每个人至少犯过一次这个错,因为只写了QCoreApplication,也能正常进事件循环,但一碰 GUI 就崩。

5.4 AI 思考时界面假死,鼠标转圈 10 秒

现象:点击“技能”按钮后,界面像死了一样,鼠标变成忙碌状态,CPU 占用率突然飙升到 100%,十秒钟后才恢复。

原因:AI 决策函数里写了死循环或者极端耗时逻辑。常见两种:一种是chooseSkill里对candidates排序的 lambda 捕获了外部大对象(比如整个 Pokedex)导致拷贝爆炸;另一种是在QSortedList里const引用返回后,把迭代器存下来,下一次 push_back 导致迭代器失效,死循环。

解决:先给 AI 决策加“保险丝”。在chooseSkill里加一个循环计数上限,比如检查candidates.size()做Q_ASSERT,Debug 模式下能立刻看到是哪里转圈。更稳妥的做法是直接给 AIController 的决策动作套超时护栏:用一个QElapsedTimer,决策超过 30ms 立刻返回当前最高分候补,不需要最优解。课程设计不比锦标赛引擎,稳定性优先级远高于最优策略。这个坑的核心教训是:不要信任 AI 里的任何一个循环,所有 while 都必须有出口条件和上限计数。

5.5 构建通过但运行时缺VCRUNTIME140.dll

现象:在装了 Qt 的机器上双击生成的 exe 能跑,复制到另一台没装开发环境的电脑上报错,缺VCRUNTIME140.dll或Qt5Core.dll。

原因:MSVC 编译的 Qt 程序动态链接到微软的 VC++ 运行库,而 Qt 自身的 DLL 也没打包进 exe 目录。这不是代码问题,是部署问题。很多学生把“在开发机跑通”和“发布可用”等同了,答辩时用的教室电脑没装 Qt,就现场翻车。

解决:用 windeployqt 工具把依赖补齐。打开 Qt 安装目录下的 “Qt 5.15.2 (MSVC 2019 64-bit)” 命令行,cd 到 release 构建目录,执行windeployqt.exe 你的程序名.exe,它会自动把需要的 Qt DLL 和插件目录拷到 exe 旁边。然后还要把C:\Qt\5.15.2\msvc2019_64\bin下的libstdc++-6.dll(MinGW 的不需要)和 VC 运行库vcruntime140.dll、msvcp140.dll一并拷过去。最后用 Dependencies 工具检查缺哪些依赖,别靠猜。打包体积 30MB 左右就对了,如果只有 1MB 那就是纯漏了 DLL。

6. 从能开到高分:文档怎么组织,验证怎么补,答辩前还能加分的习惯

6.1 文档结构先于代码写:让老师看见你的设计痕迹

高分项目的文档不是产品说明书,而是“决策记录册”。按这个顺序写:第一章写需求分析,画出人机对战的整体流程图(用文字描述:开始 → 选宠 → 回合循环 → 胜负结算),不需要抽奖图;第二章写架构设计,把第 2 节那层表放进去,每个类下面写三行职责;第三章写核心算法,伤害公式、克制表、AI 评分这三个必须给出推导过程,比如“为什么乘以 1.2 而不是 1.5”,给出你的测试数据;第四章写测试用例,列出你测过的十个边界情况:满 PP 连打、空 PP 跳过、毒伤致死、先制技能抢速、属性反克一倍、血量归零时选择技能、AI 预判换宠、玩家连点按钮锁状态、日志超 200 行自动清理、低分辨率窗口下 UI 是否遮挡。

最后一章写踩坑记录,就写 3 条最有含金量的,例如第 5 节的乱码和线程问题。老师看到这里会认为你有排错能力和工程记录习惯,这在评分维度里比代码多 200 行更值钱。

6.2 用断言脚本替代人工点击的冒烟测试

你不可能靠手点鼠标把 100 个回合的 AI 对战测完,所以答辩前必须有一个“无头模式”验证。我在项目里留了一个test_runner.cpp,编译成一个独立的命令行工具,它不进入 Qt 事件循环,直接构造 BattleSystem,喂两组固定宠物数据,跑 200 回合模拟,统计胜负、平均回合数、异常概率。其中核心断言就是期望伤害:

// test_runner.cpp 中一段核心测试断言 PetData attacker = makePet(...); // 攻击力 100 PetData defender = makePet(...); // 防御力 80 BattleLog result = battleSystem.simulateSkill(attacker, defender, skillId); Q_ASSERT_X(result.damage >= 2, "damage", "伤害下限必须为 2"); Q_ASSERT_X(result.damage <= 150, "damage", "伤害上限不能爆炸"); // 属性克制的核心断言:水打火应该是 2 倍基准伤害 Q_ASSERT_X(result.typeMultiplier == 2.0f, "typeChart", "水克火倍率错误");

这个跑一下就知道哪条逻辑挂了,而且演示的时候直接跑一遍“自动化测试通过”,比嘴硬说“我测了很多次”有力十倍。把test_runner的代码和输出截图塞进文档附录,又是一个加分项。

6.3 答辩前最后一天做哪三件事

第一,把项目路径和代码文件里所有中文路径清理干净,统一用英文目录,避免答辩教室电脑的 Qt 配置和你不一样时又复现 5.1 节头文件爆炸。第二,用 Release 模式重新构建一次,你会抓到一堆 Debug 模式下不报的 bug——MSVC Release 对未初始化变量更警觉(优化后暴露未定义行为),这一条靠的是血泪经验。第三,准备一份 800 字左右的口述稿:你是怎么拆解这个题目的、你用了什么设计模式、你的 AI 比朴素随机强在哪、你卡在哪里又怎么排掉。重点是“我踩过坑但我自己搞定了”,导师在评分表上写评语时这就是差异点。

做这套东西时我自己的一个小习惯是:移动端和桌面端逻辑分开写是假清高,真项目里测试才是保命符。别迷信“写一次就能跑”,Qt 项目程度越高,越要证明你控制住了不确定性和复杂度。希望这篇笔记里的架构、代码和避坑清单能帮你少熬两晚夜,更希望你在答辩时能理直气壮地说“这真的是我自己写的”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询