资讯动态

C++ Qt实现红蓝武士模拟:面向对象与事件驱动设计

发布时间:2026/10/21 18:58:31 来源:尧图企业网站定制
简介魔兽世界三(开战)是北京大学《C程序设计与算法-3》期末大作业的完整代码实现。作者在倍速学习课程后独立开发采用C与Qt编写将角色、战斗、司令部等逻辑拆分为多个类文件细化到龙、忍者、狮子、狼等不同兵种类并配有武器装备、战士等基础模块整体结构比常见的单文件实现更接近工程化写法适合正在学习面向对象设计或准备课程大作业的同学参照。资源以zip压缩包发布共33个文件大小约666KB内含10个.h头文件、8个.cpp源文件、8个.o编译中间文件、可执行exe和.pro工程文件等既可直接查看源码也可用Qt Creator或Makefile构建运行。代码已通过MOOC网站给出的测试点作者又自行补充了随机测试整体完成度较高。目前已有2096人学习对于希望了解多文件类结构、Qt项目组织以及魔兽世界三(开战)题目解法的人来说是一份很有参考价值的小型工程。1. “开战”不是游戏是一次离散事件模拟训练把“魔兽世界三(开战)”当成游戏来写是这门 C 课程大作业最常见的翻车起点。题面通常只有一句话红蓝双方各有总部按固定顺序生产武士武士沿城市推进相遇后按攻击力和生命值结算直到一方总部生命值归零。真正要你练的不是“做游戏”而是怎么用 C 设计对象、推进时间、处理边界状态。换到 Qt 上做可视化之后又多了一重考验界面线程不能被模拟循环卡住必须用事件驱动重写主流程。这份方案按“需求拆解—总体架构—关键实现—可视化—避坑—验收”的顺序展开适合正在做 C 程序设计与算法课设、并且想交一份可演示 Qt 项目的同学参考。2. 总体架构设计三层结构与五个核心类的职责划分2.1 从题面拆对象营、城、武士、时间、事件题目文本很短但名词密度很高。营总部负责按固定顺序生产武士同时维护剩余生命值城是地图上的节点同一时间可能驻留多个武士武士是战斗的最小实体携带类型、编号、生命值、攻击力时间由一个计数器控制每一跳触发生产、移动、战斗三类动作事件则是所有动作的结果最终转成一行战报。别小看“时间”和“事件”这两个抽象概念很多同学把tick和log混在同一个循环里导致日志顺序乱、调试困难。我在代码里会让 SimCore 统一持有当前时间Warrior、City 不自己维护时钟只接收外部传入的回合信息。下面这张对象职责表可以作为类设计的第一版目录对象核心职责关键成员设计Camp生成武士、记录营生命值、暴露剩余可生成序列campId、owner、hp、produceOrder、produceIndexCity作为地图节点、暂时托管武士、记录归属状态cityId、warriorId、状态位Warrior承载类型差异、执行攻击/反击/死亡钩子type、id、campId、hp、attack、EventCallbackSimCore推进时间、调度各对象、保存统一日志currentTime、warriors、cities、logsRuleEngine判定战斗流程、输出 CombatResult、不持有状态resolveCombat()、applyReward()这张表虽然简单却是我每次做模拟类项目的第一张草图。把表填完后再决定哪些字段放 private、哪些通过接口暴露。营的produceIndex容易被忽略营的生成顺序是一个环形队列每生产一个武士就推进一下索引缺了它会导致生产永远停在第一种武士。如果题面要求红蓝双方使用不同的生产顺序那么produceOrder必须按阵营分别初始化不能在 Camp 的构造函数里写死。2.2 三层架构把规则与界面解耦命令行版本可以把所有逻辑堆在 main 里但一旦决定做 Qt 可视化就必须分层。我把代码切成三层SimCore 是状态与调度层它只关心“当前到了第几分钟、哪些武士在哪个城”RuleEngine 是纯规则层它接受两个武士和一个城市返回一个战斗结果MainWindow 是展示层只订阅 SimCore 的通知来刷新控件。分层最大的优势是测试不需要启动界面也能跑完整场战斗。课程设计验收时我会先展示命令行验证再展示 Qt 界面这样老师会觉得你确实理解了结构而不是只套了一个 QMainWindow。这里强调一个设计原则RuleEngine 不持有任何全局状态。为什么因为战斗规则是确定性的给定两个武士和城市状态就应该输出唯一结果。如果 RuleEngine 内部缓存了“上次战斗时间”之类的字段模拟一旦跳步结果就会和日志对不上。SimCore 只暴露只读的查询接口把内部容器全部设为 private外部只能通过tick()改变状态。SimCore 的类头可以这样写// core.h class RuleEngine; class SimCore { public: explicit SimCore(RuleEngine* rule); void tick(); int currentTime() const { return currentTime_; } int campHp(int campId) const; std::vectorstd::string recentLogs() const; private: std::vectorstd::unique_ptrWarrior warriors_; std::vectorCity cities_; std::dequestd::string logs_; int currentTime_ 0; RuleEngine* rule_; };把recentLogs()设计成返回副本是为了避免 UI 拿到内部容器的引用后直接改日志。rule_用普通指针即可因为 RuleEngine 的生命周期由 MainWindow 或测试桩持有与 SimCore 一致。这里的日志容器用std::deque而不是std::vector因为日志会在超过 2000 条时从头部删除deque的pop_front()比vector的erase更高效也不需要移动全部元素。如果你只用vector掉头部的复杂度是 O(n)模拟跑几万回合后整体性能会明显下降。2.3 数据结构选择固定城市用 vector查找关系用 map地图上的城市数量通常写死在题面里比如 12 个或 20 个城市容器用std::vectorCity就够了。武士数量随时间增长但也不会达到百万量级std::vectorstd::unique_ptrWarrior完全够用。需要记录“武士 id 到城市 id”的映射时我再加一个std::mapint, int warriorCity_每次移动或死亡就更新。营的生产顺序用环形下标不用链表因为操作只有“取下一个类型”链表在这里只会增加内存碎片和调试难度。有些同学喜欢把所有实体塞进一个std::listWarrior然后遍历查找代替 map。这个思路在小规模模拟里也跑得动但答辩时如果被问到复杂度用 map 是更稳的回答。战斗顺序也应该由“进入城市的时间先后”决定而不是武士数组的下标。所以我让 City 记录一个简单的状态位比如isDefender战斗前先检查谁先到。这个细节很容易踩坑后面避坑章会专门展开。数据结构选型不要追求炫技优先保证“模拟逻辑可复现、日志可对账”这才是这门课想要训练的能力。3. 关键实现武士生成、时间步进与战斗结算3.1 武士基类与派生类用虚函数收敛规则差异在题目给出的配置里几种武士的生命值和攻击力各不相同特殊规则集中在反击、死亡和攻击三个点上。如果战斗主流程里直接写if (type ninja)新增一种武士就要改三处。我把差异全部收进基类的虚函数class Warrior { public: Warrior(std::string type, int hp, int attack, std::functionvoid(const std::string) cb) : type_(std::move(type)), hp_(hp), attack_(attack), cb_(std::move(cb)) {} virtual ~Warrior() default; int hp() const { return hp_; } int attack() const { return attack_; } const std::string type() const { return type_; } virtual bool canCounterattack() const { return true; } virtual void onDamaged(int damage) { hp_ - damage; } virtual void onDeath(Warrior opponent) { cb_(type_ 死亡); } protected: std::string type_; int hp_; int attack_; std::functionvoid(const std::string) cb_; }; class Ninja : public Warrior { public: using Warrior::Warrior; bool canCounterattack() const override { return false; } };这里有一个设计细节onDeath通过回调cb_发日志而不是直接调 stdout 或 UI。这样 Warrior 仍然是纯数据加行为的类测试时可以传入空的 lambda 来静默运行。canCounterattack()返回 false 后主流程在反击判断处直接跳过不需要感知具体类型。如果后面要加“龙死亡时自爆”或“狼击杀后回血”就在对应派生类里覆盖onDeath不影响其他类。注意派生类构造函数里传类型名时不要再用默认参数否则日志里会出现空类型。3.2 工厂生成武士配置驱动代替硬编码营生产武士必须支持红蓝双方不同顺序而且要方便测试“极端参数”。所以我实现一个按字符串类型注册的工厂class WarriorFactory { public: using Builder std::functionstd::unique_ptrWarrior( int id, int campId, int hp, int attack); void registerType(const std::string type, Builder builder) { builders_[type] std::move(builder); } std::unique_ptrWarrior create( const std::string type, int id, int campId, int hp, int attack) const { auto it builders_.find(type); if (it builders_.end()) return nullptr; return it-second(id, campId, hp, attack); } private: std::mapstd::string, Builder builders_; };使用示例factory.registerType(ninja, [](int id, int cid, int hp, int atk) { return std::make_uniqueNinja(ninja, hp, atk, defaultCb); });hp、attack参数是从配置读出来的不在工厂内部写死。这样做的好处是验证特殊场景时可以临时把攻击力调成 0观察程序是否陷入死循环。生成顺序由营的produceOrder列表驱动每次 tick 判断当前营血量是否足够生产消耗够则取produceOrder[produceIndex % size]创建武士。生产消耗和武士生命值都要作为配置项防止题面改一个数字就得重新编译。如果工厂返回nullptr说明类型拼写错误我会在create里写一条 DEBUG 日志而不是静默失败。3.3 战斗结算固定四步避免“同归于尽但战利品发错”我见过的翻车事故九成出在攻击、反击、死亡的顺序上。最稳妥的是下面四步struct CombatResult { bool defenderDied false; bool attackerDied false; std::vectorstd::string steps; // 记录执行过程 }; CombatResult RuleEngine::resolveCombat( Warrior attacker, Warrior defender, City city) { CombatResult result; // 第一步攻击方出手 defender.onDamaged(attacker.attack()); result.steps.push_back(attacker hit defender); // 第二步防守方是否死亡 result.defenderDied defender.hp() 0; // 第三步防守方若存活且允许反击则反击 if (!result.defenderDied defender.canCounterattack()) { attacker.onDamaged(defender.attack()); result.attackerDied attacker.hp() 0; result.steps.push_back(defender counterattacked); } // 第四步处理死亡与战利品 if (result.defenderDied) { city.removeWarrior(defender.id()); attacker.onKill(defender); result.steps.push_back(defender died); } if (result.attackerDied) { city.removeWarrior(attacker.id()); defender.onKill(attacker); result.steps.push_back(attacker died); } return result; }这个函数的顺序不能在并发里随意调整。先造成伤害再判断死亡最后处理战利品避免了“对手已经死亡还拉起来反击”的经典 bug。另一个容易忽略的点是如果攻击方和防守方同时死亡日志要写两条而且战利品不能互相触发。steps字段是我用来做对账的它不属于业务逻辑但调试时打印出来能直接看到攻击、反击、死亡的实际执行顺序比拿生命值前后差值猜要快得多。若题面要求某个武士死亡后增加攻击方生命值在attacker.onKill(defender)里实现即可不要再修改 CombatResult。3.4 时间推进QTimer 驱动 tick替代 while 循环命令行版本用while (currentTime limit) loop()转到 Qt 后必须改成“按需触发”。原因很简单如果 while 循环跑在 UI 线程里界面永远不会刷新进度条和日志都会卡死。标准做法是用 QTimersimTimer_ new QTimer(this); simTimer_-setInterval(100); connect(simTimer_, QTimer::timeout, this, [this]() { if (core_.isFinished() || core_.currentTime() limit) { simTimer_-stop(); ui-logEdit-append(QString([SYSTEM] 战斗结束)); return; } core_.tick(); refreshView(); }); simTimer_-start();间隔 100ms 是兼顾流畅度和观察度的经验值太快看不清太慢又显得死板。isFinished()在营血量归零或时间超过 limit 时返回 true此时必须停表不能继续 tick。我还会保留一个“单步”按钮connect到core_.tick(); refreshView();这样演示时可以手动验证每一步战斗顺序。这个单步功能在调试和答辩时都很加分因为它证明你对每一步状态变化都有掌控。注意 lambda 里捕获了this如果 MainWindow 先被销毁定时器还在跑就会崩溃这个问题在第 5 章避坑里单独说。4. 数据可视化与参数配置让战报可读、可核查4.1 结构化日志一行一条事件按等级着色战报是模拟器的“证据链”也是验收时最容易快速建立信任的部分。我在 SimCore 里统一生成日志不在 UI 层临时拼字符串void SimCore::appendLog(const std::string level, const std::string text) { std::string line [ formatTime(currentTime_) ] [ level ] text; logs_.push_back(line); if (logs_.size() 2000) logs_.pop_front(); notifyLogAdded(line); // 绑定到 UI 的 append }日志等级分成 SYSTEM、MOVE、BATTLE、DEBUG 四种。UI 收到后按等级着色SYSTEM 灰色、MOVE 蓝色、BATTLE 红色。DEBUG 默认关掉只有调试时打开否则日志量会翻好几倍反而看不清战斗节奏。notifyLogAdded是一个std::functionvoid(const std::string)默认空实现在 Qt 项目里绑定 QTextEdit 的append。用回调而不是信号是为了让 SimCore 可以在没有事件循环的命令行测试里直接运行。时间戳格式统一为MM:SS比如[03:27]读起来比纯数字分钟数直观得多。4.2 界面刷新表格承载城市状态进度条显示营血量城市驻防用 QTableWidget 展示每次 tick 重填所有行。城市数量在 20 以内时性能可以接受如果是大地图就必须改成只更新变化行。我的做法是维护QStandardItemModel并缓存每个城市对应的行指针死亡时只更新那一行文本。营生命值用 QProgressBar最大值来自配置里的初始生命值这样进度条能直观反映双方差距。void MainWindow::refreshView() { ui-hpRedBar-setValue(core_.campHp(0)); ui-hpBlueBar-setValue(core_.campHp(1)); ui-cityTable-clearContents(); ui-cityTable-setRowCount(static_castint(core_.cities().size())); for (int i 0; i core_.cities().size(); i) { auto* name new QTableWidgetItem(QString(城市 %1).arg(core_.cities()[i].id())); auto* guard new QTableWidgetItem(QString::fromStdString(core_.cities()[i].guardName())); ui-cityTable-setItem(i, 0, name); ui-cityTable-setItem(i, 1, guard); } }这里cities()返回一个临时 vectorUI 层拿到后立即复制到表格控件不保存引用防止后续 tick 修改原始容器时导致悬空。如果觉得重填整个表格有闪烁可以让cityTable-setUpdatesEnabled(false)在循环前关闭刷新循环结束后再打开。进度条和表格的刷新放在同一个函数里保证画面与日志的时间戳同步。战斗日志用 QTextEdit 的append天然自动滚动不需要手动设置滚动条位置因为 QTextEdit 默认会在追加时把光标滚到底部。4.3 配置驱动JSON 文件承载生成顺序与数值我把红蓝顺序、城市数量、武士生命值都放到config.json这样演示时只需要修改 JSON不必重新编译。这也方便现场演示“不同生成顺序下的战况差异”。配置内容如下{ cityCount: 12, limitMinutes: 50, campInitialHp: 100, produceOrder: { red: [dragon, ninja, iceman, lion, wolf], blue: [wolf, lion, iceman, ninja, dragon] }, warriors: { dragon: { hp: 100, attack: 30 }, ninja: { hp: 80, attack: 20 } } }读取代码QFile file(config.json); if (!file.open(QIODevice::ReadOnly)) { qWarning() config.json not found, use default; return loadDefault(); } QJsonDocument doc QJsonDocument::fromJson(file.readAll()); QJsonObject root doc.object(); int cityCount root[cityCount].toInt(12); int initialHp root[campInitialHp].toInt(100);用toInt(default)可以让配置文件缺失时走默认值避免启动直接崩溃。生产顺序数组读成QStringList再转成std::vectorstd::string给 Camp 使用。注意 JSON 解析要放在主窗口构造之前因为营的生产队列和进度条上限都依赖这些值如果放在构造之后前面创建的营会拿到错误的初始值。我在课程设计里还会把“一次演示用的短时长参数”单独写成demo.json和默认config.json分离避免现场调试时改乱正式参数。5. 避坑指南C 模拟类大作业最容易翻车的 5 个点5.1 现象战斗结算结果和题目样例对不上“对不上”有三种典型表现攻击方伤害数值正确但目标残血变成 1反击多打了一次击杀后战利品没有生效。原因几乎都指向同一个点规则引擎的执行顺序和题目隐含的先后关系不一致。比如先判断反击再判断死亡就会让已经倒地的武士又回到战场。我的处理方式是把resolveCombat的顺序固化成代码注释里的四行然后在CombatResult里增加steps向量记录执行过程result.steps.push_back(hit std::to_string(defender.id())); if (result.defenderDied) result.steps.push_back(defender died);跑完样例后把steps和手写推导逐行对比。加这几行代码不会影响性能却能在答辩时拿出“每个战斗步骤都有据可查”的日志而不是对着控制台空口解释。5.2 现象界面假死CPU 占用率 100%原因通常是 QTimer 间隔设成了 0 或 1 毫秒tick()里又做了大量全量排序和字符串拼接少数情况是有人把旧的while循环直接塞进了定时器回调导致回调永远跑不完。解决思路是让每次 tick 保持轻量间隔不低于 50ms日志超过 2000 条就裁剪城市驻防刷新时只更新变化行而不是全表删除。如果模拟本身要跑很久可以把 SimCore 放进std::thread再用 Qt 的信号把日志线程安全地回传到 UI。但课设阶段我不建议引入线程先检查是否有人在循环里写了while(true)等时间推进十个假死里九个是这个原因。5.3 现象死亡武士继续出现在战斗报告里原因只修改了生命值但没有从城市容器移除下一轮遍历城市时又查到了它。解决定义一个统一的状态判断bool Warrior::isAlive() const { return hp_ 0; }所有寻找攻击对象的循环都先过滤isAlive()死亡时同时调用city.removeWarrior(id)和warriorCity_.erase(id)。还有一个隐蔽情况onDeath回调里写日志时读取了opponent的字段但opponent可能已经被外部销毁会出现 use-after-free。处理方法是把需要的信息先拼成字符串再传给回调不要在回调里继续访问对方对象。5.4 现象日志里出现两个相同编号的武士原因多半是std::vectorWarrior扩容时自动拷贝了对象拷贝出来的新对象和旧对象指向同一个城市导致同一个武士被处理两次。解决禁用 Warrior 的拷贝构造函数用std::unique_ptr持有。判断是否被拷贝可以临时在构造函数里打印一条create日志在析构函数里打印一条destroy日志如果构造次数大于实际生成次数就说明有隐藏拷贝。另外如果容器用std::vectorWarrior而不是指针还会发生对象切片派生类的canCounterattack()虚函数表丢失忍者一夜之间变成普通武士。5.5 现象关闭主窗口时崩溃原因QTimer 还在运行lambda 捕获的this已经失效或者 Warrior 的 EventCallback 绑定了 SimCore 的成员函数而 SimCore 先于 Warrior 销毁。解决在 MainWindow 析构函数里显式simTimer_-stop()并disconnect(simTimer_, nullptr, this, nullptr)。同时保证 SimCore 的生命周期覆盖到所有 Warrior 销毁之后比如把 SimCore 声明为 MainWindow 的成员变量并按“武士→城市→模拟核心→规则引擎”的逆序析构。这听起来反直觉但对象依赖方向必须和析构顺序相反。我习惯在 MainWindow 的析构函数第一行写stopSimulation()把所有定时器和线程收干净再让成员变量自然释放。6. 验收与答辩从“能跑”到“讲得清楚”6.1 验收清单五种必测场景演示前一定要跑五个场景。第一红蓝双方在极短回合内生产出不同类别武士确认工厂和顺序正确日志里没有重复编号。第二两只相同武士互相攻击验证反击顺序和一次战斗中的攻击次数确认只产生一条战斗日志且包含完整步骤。第三一个武士在生命值为 1 时被攻击确认不会出现负血量、也不会复活后再次参战。第四营生命值降到 0 后定时器立即停止界面出现胜负提示。第五把配置文件里攻击力改成 0程序必须正常跑完而不是死循环。每测一项随手截图记录日志首尾这比答辩时临时敲命令更有说服力。6.2 答辩主线从对象模型讲到状态转移老师问“你这个系统怎么组织的”不要从界面控件讲起。我会从这句话开头“整个模拟围绕三个对象——营、城、武士由 SimCore 统一调度。”然后画一条时间线生产→移动→战斗→日志→界面刷新。讲到多态时拿出Ninja::canCounterattack()和某个派生类的onDeath()覆盖哪个函数比背“封装继承多态”的定义更有说服力。如果被问“为什么用 map 而不用 list”就答“城市数量固定、需要按 id 随机查询map 的查找复杂度是 O(log n)list 是 O(n)”。这个回答虽然简短但能体现数据结构选型是思考过的不是随机选的。每次做完模拟类大作业我都会强制走一遍上面的验收清单尤其是双死和零攻击力这两个新手重灾区。这套从架构到避坑的流程已经帮我拿下了好几次课程设计的演示环节。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑