简介:北京大学《C++程序设计与算法-3》在线课程的最终大作业“魔兽世界三(开战)”的完整Qt实现,基于魔兽对战背景,用面向对象方式模拟角色招募、对战与胜负判定,适合正在学习C++类设计、Qt工程组织与调试的中级初学者参考。压缩包共33个文件,整体仅666KB,以.pro工程文件、.h头文件和.cpp源文件为核心——其中头文件10个、源文件8个——另有Makefile、.qmake.stash、debug/release目录及.exe可执行程序,既能看到源码如何组织,也能直接运行产物验证结果。目前已有2096人浏览学习。代码并未按常见做法堆成一个文件,而是按角色与职责拆分为dragon、iceman、lion、wolf、ninja等角色类,以及headquarter、battle等管理类,头文件与实现分离,各模块协同清晰;作者自述课程网站测试点全部通过,并自行补充随机测试用例;对大作业答辩、代码重构或二次扩展来说,这套结构本身就是很好的参考模板。
1. 魔兽世界三(开战)大作业:为什么用C++与QT做回合制战斗模拟
某高校的《C++程序设计与算法》课程每年都会出一道综合大作业,题目看似游戏向,实则把整个学期的知识点全部串进去。“魔兽世界三(开战)”就是这类题目里很典型的代表:要求用C++实现一个多阵营单位的战斗模拟系统,再用QT做一个可视化界面,把“开战”过程完整展示出来。难度不高,但覆盖面极广——类设计、继承多态、STL容器、智能指针、信号槽、事件循环,甚至算法里的状态机和随机策略都要用上。如果只是把功能跑通,交上去代码可读性和设计感不足,照样拿不到高分。这篇方案面向两类人:正在做同款或类似大作业的学生,想直接参考一套完整设计;以及需要给课程设计快速搭骨架的开发者。下面从架构、模块、调试到答辩,把整套方案讲透。
2. 总体架构设计:对象模型驱动战斗流程,四个模块的职责划分
2.1 从题目要求拆解需求:开战逻辑、单位属性、回合制规则、日志输出
拿到“魔兽世界三(开战)”这个题目,第一件事不是开IDE,而是把需求一条条拆出来。通常这类大作业在题目文档里会给出几个硬性要求:要有至少两个阵营、若干种职业或兵种单位、单位有生命值攻击力护甲等属性、战斗按回合推进、每回合双方做出行动决策,最后有一方战败结束。界面部分要求能显示单位状态、战斗过程和结果,最好还能回放或导出日志。
把这些翻译成技术需求就清晰了:单位属性对应一个基类和若干派生类;回合制推进需要一个战斗管理器来统一调度;行动决策可以是简单的策略函数,也可以用状态机来描述单位的“待机—技能—攻击—死亡”流转;日志输出则通过QT的信号槽把算法层的事件实时推到界面上。存数据用STL容器就够了,vector存单位列表,map存阵营和单位的映射,不需要上数据库。
这里有个容易走偏的点:很多人会把精力花在“画一个好看的魔兽界面”上,结果算法层写得一团糟。但课程设计评分的重心永远是C++本身的设计质量,QT只是展示手段。所以架构上必须让算法层完全不依赖QWidget,这样才能单独测试战斗逻辑——这也是后面能顺利验收的关键。
2.2 类层次与模块边界:基类、派生类、战斗管理器与界面的解耦
模块划分我一般会固定成四块:单位模型模块、技能模块、战斗调度模块、界面展示模块。单位模型模块负责定义角色基类和各职业派生类;技能模块做成独立类,挂在单位上,避免把技能逻辑写进角色类里变成大杂烩;战斗调度模块是核心,负责回合推进、行动顺序排序、伤害结算;界面展示模块只做两件事——把单位状态显示出来,接收用户操作指令回传给调度模块。
类设计上,基类通常长这样:单位有名称、阵营、最大生命、当前生命、攻击力、护甲值、速度。输出用虚函数来体现多态,比如每个单位都有攻击动画或技能名称,但具体行为由派生类决定。基类定义纯虚函数,派生类强制实现,这样调度器只管调用接口,不需要关心具体是哪种职业。
单位模型和界面之间通过信号槽通信。战斗调度模块每次状态变化直接发信号,界面槽函数接收后刷新显示。这样即使之后要把QT换成命令行版本,算法层一行不用改。开发者容易忽略的是事件循环的配合——战斗是同步逻辑,而QT界面是异步刷新,如果不做统一调度,界面会卡死或者状态显示错乱。
2.3 代码结构与编译组织:CMake管理工程,算法层与界面层分离编译
工程组织上不用Visual Studio的工程文件,直接用CMake更通用,也方便答辩时换环境演示。CMakeLists里至少分两个目标:战斗算法库和QT可执行程序。算法库不链接Qt,纯C++编译,这样可以用命令行单元测试;界面程序链库并启用Qt的AUTOMOC。
一个标准的目录结构大概是这样:src/algorithm存放单位类、战斗管理器、伤害计算;src/ui存放主窗口、单位状态面板、战斗日志控件;src/common放公共头文件。头文件里尽量前向声明,不要大面积include,编译速度会快很多,也避免循环依赖。
代码规范的细节会影响后期调试体验。比如所有单位对象的生命周期用shared_ptr管理,战斗管理器持有weak_ptr,避免循环引用导致内存泄漏。原始指针只在界面层传递,且不保存。这部分经验后面在避坑章节会展开。
3. 核心模块实现:角色系统、战斗调度与伤害算法的工程化落地
3.1 角色与技能系统:基类抽象、派生类扩展、技能组合的代码实现
角色系统的代码看起来简单,但设计差异会直接体现到答辩评分。下面这段是基类和两个派生类的核心骨架,省略了部分getter:
// unit.h #pragma once #include <string> #include <memory> #include <vector> class Skill; // 前向声明 enum class Camp { Alliance, Horde }; class Unit : public std::enable_shared_from_this<Unit> { public: Unit(const std::string& name, Camp camp, int hp, int attack, int armor, int speed); virtual ~Unit() = default; virtual std::string skillName() const = 0; virtual int skillDamage() const = 0; void takeDamage(int rawDamage); bool isAlive() const { return hp_ > 0; } void addSkill(std::shared_ptr<Skill> skill); // 公共属性访问接口 std::string name() const { return name_; } Camp camp() const { return camp_; } int hp() const { return hp_; } int attack() const { return attack_; } int armor() const { return armor_; } int speed() const { return speed_; } bool hasSkill(const std::string& key) const; protected: std::string name_; Camp camp_; int hp_; int maxHp_; int attack_; int armor_; int speed_; std::vector<std::shared_ptr<Skill>> skills_; };// warrior.cpp #include "unit.h" #include <algorithm> class Warrior : public Unit { public: Warrior(const std::string& name, Camp camp) : Unit(name, camp, 120, 18, 8, 10) {} std::string skillName() const override { return "致死打击"; } int skillDamage() const override { // 基础攻击力 + 技能额外加成 return attack_ + 15; } };这段代码设计的要点在三个地方。第一,基类构造函数统一初始化属性,派生类只需要传入名称和阵营,数值在初始化列表中定死,这样批量生成单位不会有属性漂移。第二,纯虚函数skillName和skillDamage强制每个职业都有技能,调度器在战斗时调这两个接口即可,不需要写if判断职业类型——这就是策略模式的核心价值。第三,takeDamage函数在基类实现,里面会统一扣护甲减伤,派生类不需要关心受伤逻辑。
技能系统的挂载方式也值得一提。用shared_ptr 存进vector,Skill可以是一个基类,每种技能做成一个派生类。这样单位可以在运行时获得新技能,而不是写死在类定义里。“开战”题目如果要求某些单位有控制技能或治疗技能,这种设计天然支持,不至于后期大改。
3.2 战斗调度与回合状态机:用有限状态机管理“开战”全流程
战斗调度是整个程序的发动机。朴素的实现是一个大while循环,里面轮流让每个单位行动一次,直到一方全灭。但作为课程设计,用状态机描述战斗阶段更能体现“算法”含量。战斗状态大体分为:初始化、行动排序、单位行动、死亡清理、胜负判定、战斗结束。
状态机实现不需要引入额外库,用枚举加switch就足够清晰。关键代码片段如下:
// battle_manager.cpp enum class BattleState { Initializing, Sorting, Action, Cleanup, VictoryCheck, Finished }; void BattleManager::tick() { switch (state_) { case BattleState::Initializing: initUnits(); state_ = BattleState::Sorting; break; case BattleState::Sorting: sortBySpeed(); currentIndex_ = 0; state_ = BattleState::Action; break; case BattleState::Action: if (currentIndex_ >= units_.size()) { state_ = BattleState::Cleanup; } else { auto& u = units_[currentIndex_]; if (u->isAlive()) { performAction(u); } ++currentIndex_; } break; case BattleState::Cleanup: removeDeadUnits(); state_ = BattleState::VictoryCheck; break; case BattleState::VictoryCheck: if (checkVictory()) { state_ = BattleState::Finished; emit battleFinished(winnerCamp_); } else { state_ = BattleState::Sorting; } break; case BattleState::Finished: default: break; } }这个状态机的思路是:每个tick推进一个状态,而不是一轮while把所有事情做完。好处是界面可以每帧调用tick,然后刷新,单位行动之间有时间间隔,视觉效果就是“一个一个在打”,而不是瞬间出结果。另一个好处是调试时可以单步追踪状态,挂断点看每个状态间的数据流转,比一个大循环好定位问题。
performAction里只做三件事:选目标、计算伤害、调用takeDamage。选目标用最简单的策略——“优先打对面血量最少的”,这个策略是写在独立函数里的,答辩时能讲清楚这是贪心策略。如果时间充裕,可以把策略函数换成策略模式,让不同阵营有不同的打法。
3.3 伤害计算与平衡策划:护甲公式、暴击概率、随机种子控制
伤害计算是这种游戏模拟题目的灵魂。公式不能拍脑袋,要有据可循。这里采用经典公式:实际伤害 = max(技能基础伤害 × 暴击倍率 − 护甲减伤, 最小伤害)。护甲减伤不是简单的减法,而是用护甲值除以护甲值加常数的方式算百分比,魔兽类游戏通常这么做。
用表格把这套参数定死,答辩时直接拿这张表讲:
| 参数 | 值 | 说明 |
|---|---|---|
| 暴击概率 | 15% | 命中时判定 |
| 暴击倍率 | 1.8 | 暴击后伤害放大 |
| 护甲减伤公式 | armor / (armor + 30) | 30为衰减常数 |
| 最小伤害 | 1 | 保底伤害,防止0伤回合 |
| 速度属性 | 10~20 | 决定行动顺序 |
随机数生成是这里最大的坑。如果直接用rand()而忘记设置种子,每次开战结果的随机序列完全一样,看起来非常假。正确做法是用C++11的 库,并且用std::random_device配合std::mt19937,每次程序启动生成不同序列。代码写法:
#include <random> std::mt19937 rng(std::random_device{}()); std::uniform_int_distribution<int> critRoll(1, 100); int BattleManager::calcDamage(const Unit& attacker, const Unit& defender) { int base = attacker.attack(); if (!attacker.skills_.empty()) { // 取第一个技能作为主动技能 base = attacker.skills_.front()->skillDamage(); } bool isCrit = critRoll(rng) <= 15; int raw = isCrit ? static_cast<int>(base * 1.8) : base; int reduction = defender.armor() * 100 / (defender.armor() + 30); int finalDamage = raw * (100 - reduction) / 100; return std::max(finalDamage, 1); }参数说明:critRoll用uniform_int_distribution保证1到100等概率,15%概率映射为数值15。护甲减伤用百分比计算而非直接减区间,这样护甲堆得再高也不可能出现无敌。代码里只暴露了计算公式,实际工程里会把这几个参数抽成结构体,放在一个配置文件里,方便答辩时现场调参演示平衡性。这个细节比写一堆算法更让人眼前一亮——至少能证明你理解“数值策划是独立于代码的”。
3.4 QT界面刷新与战斗日志:用信号槽把算法事件推到前端
界面层的主要任务是把战斗调度器发出的信号转成可视反馈。主窗口持有BattleManager的shared_ptr,启动战斗后用一个QTimer定期调用tick,每次tick结束后刷新。这里选择QTimer而不是开线程跑死循环,是很多新手最容易翻车的地方——直接在UI线程里跑while,界面直接无响应,看起来像程序卡死。
界面刷新的代码骨架如下:
// mainwindow.cpp void MainWindow::onBattleTick() { manager_->tick(); refreshUnitPanels(); appendLog(manager_->lastActionDescription()); if (manager_->state() == BattleState::Finished) { timer_->stop(); QMessageBox::information(this, "战斗结束", QString::fromStdString(manager_->winnerName())); } } void MainWindow::onStartButtonClicked() { manager_->reset(); timer_->start(800); // 每800毫秒推进一个tick }逻辑说明:timer每次超时触发一次tick,战斗状态推进一格,界面刷新一帧,日志框追加一行。这个设计天然支持“回放战斗”这个加分需求——把每次tick的lastActionDescription存进QList,回放时只需要重新走一遍列表,而不需要重跑随机数。关于这一点,我一般会在答辩时强调:“我将战斗过程与随机数序列解耦,回放只依赖日志记录,这也方便后续做测试断言。”这句话对拿高分很有帮助。
日志系统也是界面层的一个重要模块。战斗中的每次攻击、技能、死亡,都应该产生一条结构化描述,比如“阿尔萨斯对伊利丹造成23点伤害,暴击!”。不要把字符串硬编码在算法层,而是让调度器返回一个包含actor、target、skill、damage、isCrit的结构体,界面层负责拼接展示。这样算法层可以供命令行模式复用,界面层也可以随时改成log文件输出。
4. 调试与避坑:五个最容易翻车的点,现象、原因、解决全记录
4.1 界面卡死或闪退,日志无输出
现象:点击开始战斗后窗口无响应,几秒后系统提示程序未响应,强制关闭。
原因:最常见的情况是在UI线程的槽函数里写了死循环。BattleManager的tick如果在一个while里被反复调用而不是通过QTimer驱动,事件循环被阻塞,界面自然卡死。还有一种可能性是递归调用——某个信号不断触发同一个槽函数,导致栈溢出。
解决:严格按照“QTimer驱动tick、每tick一个状态推进”的设计。如果确实需要在后台跑复杂计算,用QtConcurrent::run异步执行,再通过信号回传结果。我一般调试时会在tick函数第一行输出当前状态值,确认状态在推进而不是原地转圈。
4.2 内存泄漏检测:对象循环引用导致析构函数不被调用
现象:程序关闭时,析构日志没有打印,或者用VS的诊断工具看到Unit对象数量没归零。
原因:战斗管理器持有单位,单位通过shared_ptr持有技能,技能如果反向持有单位指针,就会形成循环引用。特别是用shared_ptr存了this指针而不小心用了enable_shared_from_this,容易踩这个坑。
解决:所有反向引用一律用weak_ptr。单位不需要持有其他单位的强引用,战斗管理器才是唯一的所有者。析构函数里打印一条日志,跑完一个完整流程后看控制台输出,确认每个单位都被回收。这是答辩时能讲的很扎实的工程细节。
4.3 随机数序列固定,每次开战结果完全一样
现象:重启程序后战斗过程和结果和上一次完全相同,看起来就像假随机。
原因:调了rand()但没有设置srand(time(nullptr)),或者用了QRandomGenerator但没有指定全局种子。很多编译器在Debug模式下会默认固定种子,导致处处“一样”。
解决:使用 库,种子从std::random_device获取。std::mt19937的序列质量对于这种模拟足够。注意不要在每次伤害计算时都重新构造随机引擎——引擎应当作为BattleManager的成员复用,否则重置会导致序列退化。
4.4 数值溢出:暴击叠加护甲减伤后伤害变成负数
现象:某个单位技能伤害特别高,暴击后乘以1.8再叠护甲减伤,结果计算出来的值出现负值,单位不掉血反而回血,界面显示异常。
原因:int类型直接参与计算时,如果中间结果超过int范围会溢出,或者减伤百分比计算时除数过大得到0。另一个常见原因是公式里没有做下限保护。
解决:所有计算公式统一走calcDamage函数,在返回值处用std::max(finalDamage, 1)保证下限。中间计算用double,最后转int。同样重要的是把护甲减伤比例控制在合理范围,参数表里衰减常数定死,防止护甲数值无限堆叠。这个坑如果现场演示时翻车,直接解释“我在这里做了防御性下限保护,因此输出永远是合法值”,也能圆回来。
4.5 CMake链接QT模块失败或找不到Qt头文件
现象:编译时大量报错找不到QApplication头文件,或者链接时提示undefined reference to vtable。
原因:CMakeLists缺少find_package(Qt6 COMPONENTS Widgets)或者忘记设置AUTOMOC ON。另一个常见问题是QT版本不匹配,Qt5的项目用了Qt6的库。
解决:CMakeLists里核心几行固定写:
cmake_minimum_required(VERSION 3.16) project(WarcraftBattleSimulator) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_library(BattleCore STATIC src/algorithm/...) add_executable(WarcraftSim src/ui/main.cpp src/ui/mainwindow.cpp) target_link_libraries(WarcraftSim PRIVATE BattleCore Qt6::Widgets)这里有一个容易被忽略的点:只有包含Q_OBJECT宏的头文件才需要AUTOMOC,但开启全局AUTOMOC后Qt会自动处理,不需要手动添加moc文件。这个配置在答辩换机器演示时价值非常大,避免现场配置工程的尴尬。
5. 验收与答辩:从“跑通”到“讲出设计”,实战演练的完整路径
5.1 功能验收清单:按需求一条条演示,不自乱阵脚
答辩的演示顺序要有讲究。不要一开场就点开始看打斗,而是先展示代码结构、再演示功能、最后讲设计取舍。功能验收我习惯按以下顺序来:单位初始化日志、战斗开始的行动顺序排序、单回合内多次攻击的日志输出、单位死亡后被清理出战场、胜负判定与界面提示。每一步都用界面上的实际内容来佐证,而不是口头“我测试过了”。
准备一页验收表打印出来,自己演示时逐项打勾:
| 验收项 | 操作方式 | 预期结果 |
|---|---|---|
| 行动顺序 | 开战前显示速度排序列表 | 速度高的单位先行动 |
| 暴击判定 | 连续开战多局 | 伤害数值有波动,存在暴击 |
| 死亡清理 | 单方只剩1单位时攻击致死 | 战斗结束,弹出胜利方 |
| 日志回放 | 点击回放按钮 | 战斗过程逐条重现 |
5.2 边界与异常演示:空阵容、重开关战、连续点击
评分老师最喜欢做的操作就是乱点。连续多次点击开始战斗、在战斗中途点击重置、甚至杀掉全部单位后再开战,这些场景必须提前跑过。我的做法是在所有界面按钮的槽函数最前面加一个guard判断:如果BattleManager状态不是Finished,重置函数直接返回。这个防御式写法的答辩话术是:“我考虑到用户可能进行非法操作,因此在状态机层面做了合法性校验。”这话一出口,基本能堵住大多数Random测试问题。
边界测试还包括:单个单位面对多个单位时是否一轮内被秒杀、最小伤害是否为1而不是0、日志窗口无限增长导致内存膨胀——这个可以用QTextEdit的document()->setMaximumBlockCount(500)来限制,也是一个能讲的工程细节。
5.3 答辩时的三个回答套路:权衡、取舍、可扩展性
答辩中最怕的不是被问“怎么实现的”,而是被问“为什么这么设计”。准备三个层面的回答框架:第一,为什么用状态机而不是大循环——可单步调试、界面刷新自然、扩展动作阶段方便;第二,为什么用shared_ptr管理单位——避免手动delete遗漏、配合QT对象树更安全、引用计数明确;第三,为什么把随机数和公式抽离——因为要支持日志回放和数值调整,强行耦合会让测试形同虚设。
我自己的习惯是:在答辩演示前,把战斗管理器切到命令行模式跑一遍回归测试。令所有单位属性固定、随机数种子固定,输出一个黄金结果文件。再跑一遍,diff结果一致。这个“回归测试”的动作虽然简单,但在课程设计答辩里很少见,能让评委确信你的系统不是“演示时刚好能跑”。从那以后,我每次做类似的模拟系统,都会强制自己先定一套固定种子的基线用例,再做新功能——这个习惯帮我避免了好几次“改坏老功能而不自知”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取