☰
C++与Qt实现即时战略对战引擎:架构、AI与实战
2026/10/21 9:13:05 网站建设 项目流程

简介:魔兽世界三(开战)是北京大学《C++程序设计与算法-3》在中国大学MOOC平台亮相的最终大作业,专为正在学习C++面向对象编程、算法设计与Qt界面开发的读者提供。作者在倍速学完课程后使用Qt框架从零编写,没有沿袭网上常见的单文件写法,而是按多类架构将角色、阵营、战斗逻辑分别抽离成独立模块,源码结构清晰易读。压缩包共包含33个文件,既有类声明头文件(.h)和实现源文件(.cpp),也有编译中间目标(.o)、Qt工程配置(.pro)、makefile以及可直接运行的exe程序,整体体积仅666KB,基本涵盖完整工程的构建与运行所需。代码已通过课程网站给定的测试点,同时作者自行补充随机测试也未见异常,可帮助读者理解C++大作业如何拆分模块、设计类层次和搭建Qt工程。目前已有2096人学习下载,若正在完成同类作业或希望借鉴成熟工程组织方式,这份资源值得参考。

1. 一份大作业凭什么叫“魔兽世界三(开战)”:这是用 C++ 和 QT 重建的对战引擎

拿到这份“魔兽世界三(开战)”源码包的第一反应,是这不像学生大作业,更像一个能直接跑的游戏 Demo。解开压缩包后,里面是完整的 C++ 工程:有逻辑层、有界面层、有配置文件,编译起来就能看到双方 AI 在地图上造兵、走位、放技能、结算伤害。这是某高校《C++程序设计与算法》课程的大作业,用 C++ 配合 QT 把魔兽争霸三“开战”阶段的核心玩法做成了可运行的桌面程序,题材是经典即时战略,落点却是课程里最能拿分的算法设计——继承多态、事件驱动、AI 决策、数据驱动配置。

这个项目适合三类人。一类是想在期末前找一份“能讲清楚”的 C++ 综合项目做参考;一类是刚学完 QT 基础、想看 QGraphicsView 如何应用在真实游戏场景里的;还有一类是已经写了千行纯控制台代码、想看看“大作业天花板”长什么样。我会从工程结构一路拆到编译运行、常见坑和二次改造,保证你看完能跑起来,也知道从哪儿下手改成自己的东西。

2. 工程结构与架构设计:从 main() 到单位工场,一张图看清模块边界

2.1 技术选型复盘:为什么课程项目要押在 C++ 和 QT 上

这类课程大作业通常有两条路线。纯控制台路线用 ASCII 字符画战场,逻辑能跑,但视觉效果差,答辩时很难展示。另一条就是本项目走的 QT 路线:课程限定 C++,QT 又是 C++ 生态里做桌面端最快的框架,QGraphicsView 这个组件天生适合二维游戏场景——单位是图元(Item),地图是场景(Scene),鼠标事件、动画刷新都有现成机制,比直接用 OpenGL 或裸 Win32 API 写省几个量级的功夫。

我一般拿到这种工程包,不会急着读代码,先看构建文件确认技术栈。这个项目根目录同时存在 CMakeLists.txt 和 .pro 文件,说明作者在开发时用过 CMake 也兼容 qmake。实际编译时用 CMake 更舒服,因为 AUTOMOC 和 UIC 这些 QT 编译步骤都被 CMake 自动托管,下面第五节我会专门讲这个坑。逻辑上用 C++11 就够了,项目里最复杂的继承体系也只在单继承内展开,没有模板元编程的炫技,符合课程要求,也让后来人好维护。

2.2 目录结构与模块职责:core 与 ui 分离是最大的亮点

工程目录结构如下,我按“先看结构、再读代码”的顺序展开:

war3_open/ ├── CMakeLists.txt ├── conf/ │ ├── units.json │ ├── heroes.json │ └── map.json ├── src/ │ ├── main.cpp │ ├── core/ // 纯逻辑层,不依赖 QT │ │ ├── Unit.h / Unit.cpp │ │ ├── Hero.h / Hero.cpp │ │ ├── BattleEngine.h / BattleEngine.cpp │ │ ├── AiController.h / AiController.cpp │ │ └── GameLoop.h / GameLoop.cpp │ ├── ui/ // QT 渲染与交互层 │ │ ├── MainWindow.h / MainWindow.cpp │ │ ├── BattleScene.h / BattleScene.cpp │ │ └── UnitItem.h / UnitItem.cpp │ └── util/ │ └── ConfigLoader.h / ConfigLoader.cpp ├── assets/ │ └── icons/ └── docs/ └── 课程设计报告.md

这个目录结构最值得抄的是 core 和 ui 的分层。core 文件夹里所有头文件都不 include 任何 QT 模块,Unit、BattleEngine 这些类只负责游戏规则;ui 文件夹才依赖 QGraphicsScene、QPainter 这些 GUI 类型。这么分的好处非常实际:逻辑层可以脱离界面单独跑单元测试,战斗结算对不对、AI 决策合不合理,直接用控制台测试就能验证,不用每次点开窗口手操。main.cpp 的工作也很轻——加载配置、创建 MainWindow、把 BattleEngine 和 BattleScene 通过信号槽接起来,然后进入事件循环。

2.3 数据与逻辑分离:一改 JSON 就能调兵种平衡

项目里所有兵种数值都在 conf/units.json 里,而不是硬编码在 C++ 类里。单位配置长这样:

{ "units": [ { "id": "footman", "name": "步兵", "hp": 420, "atk": 21, "armor": 5, "armorType": "heavy", "atkType": "normal", "moveSpeed": 270 }, { "id": "archer", "name": "弓箭手", "hp": 245, "atk": 18, "armor": 0, "armorType": "light", "atkType": "pierce", "moveSpeed": 300 } ] }

ConfigLoader 启动时把这个文件读成一个std::unordered_map<std::string, UnitConfig>,Unit 构造函数拿到 UnitConfig 直接初始化每个字段。armorType 和 atkType 在 JSON 里是字符串,加载时统一映射成枚举,避免配置写错类型导致硬编码魔法数字。这样设计的好处显而易:想平衡种族,只改数值不用动代码;想验证特定场景,临时改一个单位攻击力然后重新开局就行。比赛类游戏项目最忌讳把数值散落在几十个类里,这属于“战术上勤奋、战略上偷懒”,二次开发时改起来想哭。

3. 核心玩法拆解:一个攻击命令从下发到伤害结算的完整链路

3.1 单位基类 Unit 与派生体系:步兵和英雄的差异怎么收敛

战斗逻辑的起点是 Unit 基类。它把血量、攻击力、护甲、移动速度这些公共属性收敛到一处,所有具体单位都从它派生。核心头文件长这样:

#ifndef UNIT_H #define UNIT_H #include <string> #include <vector> enum class ArmorType { Light, Heavy, Fortified, Hero }; enum class DamageType { Normal, Pierce, Magic, Chaos }; struct UnitConfig { std::string id; std::string name; int hp = 0; int atk = 0; int armor = 0; ArmorType armorType = ArmorType::Light; DamageType atkType = DamageType::Normal; float moveSpeed = 0.0f; }; class Unit { public: explicit Unit(const UnitConfig& cfg); virtual ~Unit() = default; virtual void TakeDamage(int dmg); virtual void Update(float dt); int Hp() const { return hp_; } bool IsAlive() const { return hp_ > 0; } void SetPos(float x, float y); float X() const { return x_; } float Y() const { return y_; } protected: int hp_; int maxHp_; int atk_; int armor_; ArmorType armorType_; DamageType atkType_; float moveSpeed_; float x_ = 0.0f; float y_ = 0.0f; }; #endif

这个类有两个设计细节值得琢磨。一是构造函数只接受 UnitConfig,不接收一堆散乱参数,这样新增单位的时候只要在 JSON 里加一段,不需要改任何调用点的构造代码。二是 Update 和 TakeDamage 都被声明为 virtual,给派生类留了扩展钩子——比如英雄类可以重写 Update 实现技能冷却计时,重写 TakeDamage 加入魔法护盾抵消伤害。IsAlive()用hp_ > 0判断而不是hp_ == 0,能容忍伤害结算的微小溢出误差。

英雄的派生类在 core/Hero.h 里,结构也很清爽:

class Hero : public Unit { public: Hero(const UnitConfig& cfg, int maxMp); void CastSkill(const std::string& skillId, Unit* target); void Update(float dt) override; private: int mp_; int maxMp_; std::vector<Skill> skills_; };

Hero 在 Unit 基础上多了蓝量、技能列表,CastSkill 内部会检查 mp_ 是否足够、冷却是否转好。技能的伤害是固定数值还是百分比,每种技能用单独的子类实现,典型的策略模式。这个套路在魔兽题材项目里极其常见:基类管公共状态,虚函数留行为差异,上层代码永远只跟 Unit* 打交道。

3.2 伤害结算:用“攻击类型 × 护甲类型”二维表替代一堆 if else

战斗规则的核心在 BattleEngine::CalcDamage。魔兽争霸三原版的伤害系统是攻击类型和护甲类型的二维克制表,这个项目完整复刻了那张表。计算函数如下:

int BattleEngine::CalcDamage(const Unit& attacker, const Unit& defender) { // 从静态二维表里取克制系数 const float rate = damageTable_[static_cast<int>(attacker.AtkType())] [static_cast<int>(defender.ArmorType())]; // 攻击力乘以克制系数,再加 10% 随机浮动 float dmg = attacker.Atk() * rate * (0.9f + 0.1f * (rand() % 3)); // 护甲值按线性方式减免 dmg -= defender.Armor() * 0.25f; // 保底伤害 1,避免出现攻击力不破防 return std::max(1, static_cast<int>(dmg)); }

damageTable_ 是个 4×4 静态数组,在构造 BattleEngine 时初始化。克制系数参考了原版设定,但做了课程项目常见的简化:

攻击类型 \ 护甲类型轻型重型城甲英雄
普通 Normal1.01.00.50.75
穿刺 Pierce1.50.750.51.0
魔法 Magic1.01.50.751.0
混乱 Chaos1.01.01.01.0

参数设计上有个细节值得一说:护甲减伤这里用的是“固定减伤”,每点护甲减 0.25 点伤害,而不是原版的百分比减伤。这么做的原因是课程项目里单位血量普遍几百,固定减伤的数值感知更强,战斗结算更直观,也方便课堂答辩时解释“为什么这个弓箭手打重甲兵这么刮痧”。随机浮动控制在 ±10%,既保留了真实战斗的偶然性,又不会出现“满血步兵被一刀秒”的离谱结果。保底 1 点伤害是这类系统的血泪经验——不做保底的话,弓箭手打城甲会出现伤害为负反而回血的闹剧。

3.3 AI 决策模块:电脑对手靠“评分”而不是固定剧本

另一个硬核模块是 AiController。项目里的电脑对手不是写死的攻击脚本,而是每帧对战场态势做评估,根据双方兵力分决定进攻、骚扰还是撤退。套路是经典的势态评分:

AiDecision AiController::Decide(const BattleState& state) { float myScore = EvaluateArmy(state, Side::My); float enemyScore = EvaluateArmy(state, Side::Enemy); // 兵力优势超过 30% 就进攻 if (myScore > enemyScore * 1.3f) { return { Action::Attack, FindNearestEnemy(state) }; } // 兵力劣势超过 30% 就拉扯收缩 if (myScore < enemyScore * 0.7f) { return { Action::Retreat, GetTownPos(state) }; } // 均势时骚扰对方最弱单位 return { Action::Harass, FindWeakestEnemy(state) }; } float AiController::EvaluateArmy(const BattleState& state, Side side) { float score = 0.0f; for (const Unit* u : state.UnitsOfSide(side)) { score += u->Hp() * 0.5f; // 血量占半个权重 score += u->Atk() * 1.0f; // 攻击力占一个权重 if (u->IsHero()) score += 200.0f; // 英雄价值单独加权 } return score; }

这套评分体系用同一套公式评估双方实力,避免“看心情打人”的机械感。血量乘 0.5 而不是 1,是为了让残血满编部队的评分不至于虚高,避免 AI 带着一群残兵去送。英雄权重 200 是反复试出来的阈值:太高会让 AI 无限保护残血英雄导致畏战,太低又会让英雄毫无存在感。FindNearestEnemy、FindWeakestEnemy 这两个函数内部是遍历单位列表找骑士距离或者血量最低值,算法简单直接,核心的“决策阈值”反而跟 EvaluateArmy 紧密耦合,调参时要一起动。

3.4 事件驱动:用 QT 信号槽在逻辑层和 UI 层之间解耦

core 层虽然不依赖 QT 控件,但 BattleEngine 继承了 QObject 并声明了信号。这是整个项目最巧妙的折衷:逻辑层保持纯 C++ 的算法实现,但在状态变化的关键节点向外发信号,UI 层订阅信号做刷新。信号定义如下:

class BattleEngine : public QObject { Q_OBJECT public: explicit BattleEngine(QObject* parent = nullptr); void Update(float dt); signals: void UnitHpChanged(Unit* unit, int previous, int current); void UnitDied(Unit* unit); void BattleFinished(int winnerSide); void UnitSpawned(int side, Unit* unit); };

UI 层的 UnitItem 在构造时连接 UnitHpChanged,回调里拿到新旧血量直接更新血条:

connect(engine, &BattleEngine::UnitHpChanged, this, [this](Unit* unit, int prev, int cur) { if (unit == unit_) { update(); // 触发 paint,重绘血条 } });

这种“逻辑层发信号、界面层收信号”的写法是 QT 项目的惯用姿势,也是课程答辩的加分项——它展示了事件驱动编程的理解,而不是把 GUI 调用写在战斗循环里。战斗循环的 Update 只管状态推进,不需要知道屏幕上谁被选中了,UI 也只做渲染。两边各自演进,互不干扰。

4. 画面与交互:QGraphicsView 场景里单位是怎么动起来的

4.1 场景搭建与单位图元:UnitItem 把逻辑单位包装成可绘制对象

游戏界面用 QGraphicsView + QGraphicsScene 搭场景,每个逻辑 Unit 被包装成一个 UnitItem,UnitItem 继承自 QGraphicsObject。这样做的直接好处是框选、碰撞、坐标变换这些 Qt 已经帮你实现好的功能能直接复用。类声明如下:

class UnitItem : public QGraphicsObject { Q_OBJECT public: explicit UnitItem(Unit* unit); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* opt, QWidget* widget) override; private: Unit* unit_; // 指向逻辑层单位,不持有所有权 QPixmap icon_; // 单位头像 QGraphicsTextItem* hpLabel_; // 头顶血条数值 };

paint 函数是整个 Item 的视觉核心,绘制逻辑非常直白:

void UnitItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) { painter->drawPixmap(boundingRect().topLeft(), icon_); // 黑色底条 painter->fillRect(5, -10, 40, 5, QColor(60, 60, 60)); // 绿色血条,宽度按血量比例 int hpW = static_cast<int>(40.0f * unit_->Hp() / unit_->MaxHp()); painter->fillRect(5, -10, hpW, 5, QColor(0, 200, 0)); if (unit_->IsAlive() == false) { setVisible(false); } }

注意 hpW 用了整数除法后再强转,避免浮点到 int 的隐式截断告警。血条的颜色逻辑做了最简单的红绿两段处理,实际项目里可以按血量百分比三分段(绿、黄、红),那就是一行 lambda 的事。painter 的 fillRect 以 Item 本地坐标为基准,所以单位移动到哪,血条自动跟着走,不需要额外计算屏幕偏移。

4.2 渲染刷新节奏:逻辑帧和 UI 帧用同一个定时器驱动

游戏主循环没有用 QThread,而是用了 QTimer 驱动 GameLoop::Tick,这是 QT 客户端的标准做法:

void GameLoop::Start() { timer_ = new QTimer(this); connect(timer_, &QTimer::timeout, this, &GameLoop::Tick); timer_->start(16); // 约 60 FPS } void GameLoop::Tick() { const float dt = 0.016f; engine_->Update(dt); // 1. 推进战斗逻辑 scene_->RefreshItems(); // 2. 把逻辑坐标同步给 UI }

逻辑帧和渲染帧共用 16 毫秒的节奏,因为房间内对战规模不到 50 个单位,不存在性能瓶颈。dt 是写死的 0.016,而不是用真实间隔计算,这是大作业项目的常见取舍——写死能保证每帧行为完全一致,便于调试;缺点是帧率波动时游戏速度会忽快忽慢。我一般会先按写死的方式跑通,再改成elapsed / 1000.0f的动态间隔,二三十行就能做完。

RefreshItems 里做的事情是遍历 scene 所有 UnitItem,调用 setPos(unit->X(), unit->Y()) 同步坐标,让图元跟着逻辑层跑。这个步骤如果漏掉,你会看到单位在逻辑上已经走远、画面上却全挤在原点,属于这个模块最经典的翻车姿势。

4.3 鼠标框选与右键命令:BattleScene 的事件处理

交互事件落在 BattleScene 的 mousePressEvent 和 mouseMoveEvent 里,核心思路是用一个 QRubberBand 记录选择矩形:

void BattleScene::mousePressEvent(QGraphicsSceneMouseEvent* e) { if (e->button() == Qt::LeftButton) { // 记录框选起点 selectionStart_ = e->scenePos(); isSelecting_ = true; } else if (e->button() == Qt::RightButton && selectedUnits_.size() > 0) { // 右键地板:发移动命令 emit MoveCommandIssued(e->scenePos()); } QGraphicsScene::mousePressEvent(e); } void BattleScene::mouseMoveEvent(QGraphicsSceneMouseEvent* e) { if (isSelecting_) { QRectF selectRect(selectionStart_, e->scenePos()); for (auto* item : items(selectRect)) { if (auto* unitItem = dynamic_cast<UnitItem*>(item)) { unitItem->SetSelected(true); selectedUnits_.insert(unitItem); } } update(); } QGraphicsScene::mouseMoveEvent(e); }

这里有个关键细节:items(selectRect)本身会做 Qt 的碰撞检测,返回场景内所有与矩形相交的 Item,所以框选逻辑不用自己算“哪个点在框内”,这是 QGraphicsView 相比裸写坐标轮询的巨大优势。右键发的 MoveCommandIssued 信号最终会走进 core 层的命令队列,让每个选中单位按 A* 寻路结果逐步移动——寻路模块不在本文展开,但你只要知道 Unit::Update 每帧从路径队列里取一个路点,移动到附近再取下个,就能理解移动动画的节奏。

5. 编译、运行与排坑:从拿到源码到窗口弹出的完整步骤

5.1 环境配置与构建命令:先跑通再改代码

这个项目在 Windows 和 Linux 上都能构建,前提是装了 QT 5.15 或更高版本,以及对应平台上的 C++ 编译器。最快路径是用 CMake 命令构建:

mkdir -p build && cd build cmake .. -DCMAKE_PREFIX_PATH=/opt/Qt/5.15.2/gcc_64 cmake --build . --parallel 4 ./war3_open

CMAKE_PREFIX_PATH必须指向 QT 安装目录,它会帮助 CMake 找到 Qt5Config.cmake 等配置文件,路径不对会报“Could not find a package configuration file named Qt5Widgets”。Windows 上如果装了 Qt Creator,更省事的方式是直接打开 CMakeLists.txt,让 IDE 自动解析环境,然后点构建按钮。构建完成后先从命令行跑一遍,确认能弹窗,再进 IDE 里调试——这个顺序能避免把“环境问题”和“代码问题”混在一起排查。

5.2 编译期颠簸记录:三条高频报错逐一说透

坑 1:中文乱码。现象是控制台或界面上所有中文都变成问号方块。原因是源码文件用 UTF-8 编码,而 Windows 上 MSVC 默认用本地代码页 GBK 解析源文件。解决方法是给 CMakeLists 加上/utf-8编译选项,或者在每个含中文的 .cpp 文件头部写#pragma execution_character_set("utf-8")。我更推荐前者,一次性覆盖整个工程。

坑 2:链接错误undefined reference to vtable for UnitItem。现象是编译能过,链接时一大片 vtable 相关报错。原因是项目里的类声明了 Q_OBJECT,触发了 MOC(Meta-Object Compiler),但 CMakeLists 没打开 AUTOMOC。解决方法是设置set(CMAKE_AUTOMOC ON)。这是所有 QT + CMake 项目新手都会踩的坑,它跟代码逻辑无关,纯属构建系统配置遗漏。

坑 3:Debug 版本正常运行,Release 版本直接崩溃。现象是同一个操作,debug 构建没事,release 一进战斗界面就没有响应。这类问题最常见的根源是成员变量未初始化——debug 模式下编译器会把变量初始化成固定值,release 模式下不知道是什么野值,随机崩。解决方法是给 Unit 的所有字段写初始化值,比如int hp_ = 0;而不是int hp_;。建议全局排查所有裸变量,用列表初始化或初始化式收口。

5.3 运行期排查:动画闪烁与单位错位

运行期最头疼的是画面闪烁。现象是单位移动时整个场景像老电视一样抖动。原因是 QGraphicsView 默认视口更新模式是全画面重绘,单位一多就触发大面积刷新。解决方法是加一行代码:

view->setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);

这个模式只重绘图元变化区域,能把重绘开销压低一个数量级。还有一个常见运行期问题是单位在地图上“瞬移”错位,现象是右键点地之后单位直接跳到目标位置,中间没有移动过程。这种通常不是渲染问题,而是逻辑层的移动命令在 Unit::Update 里被直接赋值给了坐标,正确做法是每帧只移动 moveSpeed * dt 个像素,走到路点再取下一点。

内存泄漏在课程项目里不太显眼,但如果退出时崩溃,检查一下所有 QObject 子类时是否传了父对象。QGraphicsScene 负责管理 UnitItem 生命周期,UnitItem 是用new UnitItem(unit)创建后通过scene->addItem(item)交给场景的——这条链路没问题。真正容易漏的是 BattleEngine 如果在堆上 new 出来,务必把 MainWindow 作为 parent 传入,否则窗口关闭时引擎析构顺序不可控,信号还在飞,界面已经没了。

6. 二次开发:把 AI 互殴改成玩家直控单位

项目默认是红蓝两方 AI 对战,想改成玩家直控单位,不需要动战斗结算和渲染层,核心是换掉“命令来源”。这套改造非常适合用来展示你对工程的理解,也是一个高性价比的课程答辩题目。

第一步,新增一个 PlayerController 类,作为玩家鼠标输入的缓冲层:

class PlayerController { public: void OnRightClick(float x, float y) { MoveCommand cmd; cmd.targetX = x; cmd.targetY = y; cmd.unitIds = selectedUnits_; queue_.push(cmd); } std::queue<MoveCommand> TakeCommands() { std::queue<MoveCommand> out = std::move(queue_); return out; } private: std::queue<MoveCommand> queue_; };

第二步,把 BattleEngine 创建单位时绑定的 AiController 替换成 PlayerController。AiController 的 Decide 是每帧主动决策,PlayerController 是等待外部喂命令,两者都输出 MoveCommand,往下游 BattleEngine 消费命令的逻辑完全不用改。第三步,在 BattleScene 的右键事件里调playerController_->OnRightClick(e->scenePos()),然后在 GameLoop::Tick 的 engine_->Update 之前把队列里的命令灌进去。这样改完后,原来 AI 用的寻路、伤害、动画代码 80% 不用动。

一个最直观的验证方式是:开局后鼠标框选一个步兵,右键点地图,观察它按照路径规划逐步走位,再右键点敌方单位,确认能进入攻击循环并且血条按伤害公式下降。如果发现单位不动,先查命令队列有没有被消费;如果动了但血条不涨,再查信号连接是否绑定到了正确的引擎实例。

这次改造让我重新理解了一件事:所谓“二次开发”,最重要的不是写新功能,而是识别哪些模块可以替换、哪些模块要保持稳定。从那以后我每次接手一份新的大作业代码,都会强制自己在动手前画一遍“输入 — 决策 — 执行 — 表现”的数据流向图,然后只替换其中一环。存量逻辑能不动就不动,这样改起来快,出了问题也容易回头排查。希望这篇拆解能帮你把“魔兽世界三(开战)”这份 C++ 项目真正跑起来、改明白,少走我当初绕过的弯路。

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

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

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

立即咨询