news 2026/10/4 6:32:41

C++面向对象实战:从继承多态到智能指针的战斗系统设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++面向对象实战:从继承多态到智能指针的战斗系统设计

这篇大作业做到第三部,题目叫“开战”,说实话看到这个标题我先是松了口气,因为前两步的地图和资源系统已经基本定下来了;接着又捏了把汗,因为战争系统是整个大作业里最容易暴露设计问题的环节,也是一道“类设计能力分水岭”。很多人前两步写得很顺,一到开战逻辑就变成一长串 if-else 堆出来的脚本,表面上“能跑”,但程序一复杂就失控。这篇就把我做“魔兽世界(三):开战”时的完整思路写出来,覆盖类层次设计、战斗主循环、多态调用、容器使用和调试经验。无论你是正在赶大作业,还是想复习 C++ 面向对象,都能从里面捞到点干货。

1. 战斗单元的继承树:先定 Unit 这个根,再谈别的

1.1 为什么必须做继承体系,而不是各写各的

“魔兽世界”这个题材天然适合用继承来建模:步兵、弓箭手、狼骑兵、英雄,属性上有大量重叠——都有名字、血量、攻击力、防御力、射程;行为上也有重叠——都要能被攻击、会攻击别人、可能放技能。如果每个兵种单独写一个类,代码能膨胀到难以维护。当时大作业的前两步里已经出现了类似的重复代码,比如步兵和弓箭手各有一份受伤函数,函数体几乎一致,唯一的差异是减伤逻辑不同。到“开战”这一步,必须抽出一个Unit基类,把这些公共的部分收编进去。

抽出基类的过程,其实就是面向对象里“共性抽取”的实践。我在设计时把属性分成两组:

  • 所有单位都有:名称、最大血量、当前血量、攻击力、防御力、射程、速度。
  • 部分单位才有:技能类型、经验值、武器类型、暴击率。

第一组直接放进基类,作为protected成员。第二组里,如果某个属性只服务于某一个派生类,就不放到基类里,比如经验值只属于英雄,武器类型只属于士兵,怪物不需要。这个决定最初有点犹豫,因为把经验值放进基类,后续加逻辑更方便,但那样会让怪物和普通士兵都背上“升级”的概念,从建模角度属于“为了省事制造错位概念”。保持基类精简,是后面能顺畅扩展的前提。

1.2 Unit 基类的接口设计:哪些该虚,哪些该普通

基类的接口决定了整个战斗系统能实现到什么程度。我当时是这样设计的:

class Unit { protected: std::string m_name; int m_maxHp; int m_hp; int m_attack; int m_defense; int m_range; int m_speed; public: Unit(const std::string& name, int hp, int atk, int def, int range, int speed) : m_name(name), m_maxHp(hp), m_hp(hp), m_attack(atk), m_defense(def), m_range(range), m_speed(speed) {} virtual ~Unit() = default; bool isAlive() const { return m_hp > 0; } const std::string& getName() const { return m_name; } int getHp() const { return m_hp; } // 纯虚函数:每种单位的攻击行为不同 virtual void attack(Unit& target) = 0; // 虚函数:默认的受击逻辑,派生类可覆写 virtual int takeDamage(int damage) { int realDamage = std::max(1, damage - m_defense); m_hp = std::max(0, m_hp - realDamage); return realDamage; } // 虚函数:技能,默认什么都不做 virtual void useSkill(BattleContext& ctx) {} // 非虚接口,留给外部统一调用 void heal(int hp) { m_hp = std::min(m_maxHp, m_hp + hp); } };

这里有个很重要的设计决策:attack为什么是纯虚函数,而takeDamage只是普通虚函数?因为“攻击”这件事在不同兵种之间差异太大,步兵就是贴脸砍,弓箭手要判断距离,英雄可能附带技能效果,让基类给一个“默认实现”毫无意义,所以用= 0强制每个派生类自己实现。而受伤逻辑虽然也有差异,但大部分兵种都是“伤害减去防御”,这个默认逻辑是成立的,个别特殊单位比如圣骑士可以覆写它实现伤害减免。

当时犹豫过要不要把useSkill也做成纯虚函数,后来想想还是给了一个空的默认实现更方便。因为普通步兵根本没有技能,如果做成纯虚,每个步兵类都得写一个空函数体,纯属噪音。空实现还能让调用方无脑调用unit->useSkill(ctx),不用每次判断“这个单位是不是英雄”。

还有一个容易被忽略但非常关键的点:析构函数一定要写成虚函数。只要基类指针指向派生类对象,并且在删除这个指针时析构函数不是虚函数,就属于未定义行为。大作业阶段可能看不出问题,但程序跑到释放内存时就会神秘崩溃。养成习惯,基类析构函数一律virtual ~Unit() = default;。

1.3 从 Unit 派生:Soldier、Hero、Monster 三类

我最终只做了三个派生类,不多但足够表达设计意图:

  • Soldier:普通单位,攻击方式是造成攻击力 - 目标防御的伤害。它代表最通用的行为。
  • Hero:在Soldier的基础上增加了经验值、等级、技能系统,攻击时附带额外的英雄技能效果。
  • Monster:野怪,可以有特殊的被 attack 行为,比如反伤、潜行,或者被打死后掉落奖励。

这个三级层次在写战斗逻辑时,用基类指针就能统一操作。下面是 Soldier 的简单实现:

class Soldier : public Unit { public: Soldier(const std::string& name, int hp, int atk, int def, int range, int speed) : Unit(name, hp, atk, def, range, speed) {} void attack(Unit& target) override { target.takeDamage(m_attack); } };

就这么简单,一个普通兵种的攻击行为已经完整了。弓箭手如果想实现“射程外不能攻击”,可以在自己的attack里加距离判断,或者覆写一个canAttack函数。Hero 则会这样:

class Hero : public Soldier { private: int m_level; int m_exp; public: Hero(const std::string& name, int hp, int atk, int def, int range, int speed) : Soldier(name, hp, atk, def, range, speed), m_level(1), m_exp(0) {} void useSkill(BattleContext& ctx) override { // 英雄特有:比如造成范围伤害或治疗 } void gainExp(int exp) { m_exp += exp; if (m_exp >= 100) { m_level++; m_exp -= 100; m_maxHp += 20; m_hp += 20; m_attack += 5; } } };

这里 Hero 从 Soldier 继承,是“士兵也能做到的事,英雄基本也能做到,只是英雄更强一些”的逻辑推演。如果从 Unit 直接派生出 Hero,也可以,但是从代码复用角度会重复 Soldier 中的通用攻击逻辑。三层继承结构,在展示大作业时也能给老师一个信号:你理解继承并不只是一层的概念。

2. BattleEngine 与伤害结算:把回合制战斗的主循环写稳

2.1 为什么单独抽一个 BattleEngine,而不是把战斗逻辑塞进 Unit

战斗逻辑是整个系统里最复杂的一部分:它要管理双方的所有单位、判断回合、处理攻击顺序、结算伤害、处理单位死亡、判断胜负。如果把这些代码写进 Unit 类里,一个单位将不得不知道整个战场的所有细节,这显然违背了单一职责原则。我一开始也犯过这个毛病,把“某个单位对另一个单位发起攻击”理解成“这个单位自己执行攻击”,结果 Unit 里塞满了对战场状态的引用,类之间耦合得一塌糊涂。

后来想通了:Unit 只负责“单个单位的属性变化和行为”,BattleEngine 负责“规则调度”。换句话说,Unit 是一个棋子,BattleEngine 才是棋盘和裁判。这个划分让双方的关注点完全分离——设计 Unit 时我只需要考虑“一个单位怎么伤害/被伤害”,设计 BattleEngine 时我只需要考虑“场面如何按轮次推进”。

一个简化的 BattleEngine 骨架如下:

enum class Camp { Red, Blue }; class BattleEngine { private: std::vector<std::unique_ptr<Unit>> m_redArmy; std::vector<std::unique_ptr<Unit>> m_blueArmy; int m_turn; public: BattleEngine() : m_turn(1) {} void addUnit(Camp camp, std::unique_ptr<Unit> unit) { if (camp == Camp::Red) m_redArmy.push_back(std::move(unit)); else m_blueArmy.push_back(std::move(unit)); } void run() { while (!isGameOver()) { executeTurn(); m_turn++; } announceWinner(); } private: bool isGameOver() const { return allDead(m_redArmy) || allDead(m_blueArmy); } void executeTurn() { std::cout << "=== 第 " << m_turn << " 回合 ===" << std::endl; // 红方行动 for (auto& unit : m_redArmy) { if (!unit->isAlive()) continue; chooseAndAttack(unit, m_blueArmy); } // 蓝方行动 for (auto& unit : m_blueArmy) { if (!unit->isAlive()) continue; chooseAndAttack(unit, m_redArmy); } } void chooseAndAttack(std::unique_ptr<Unit>& attacker, std::vector<std::unique_ptr<Unit>>& enemies) { // 找到一个存活目标 for (auto& enemy : enemies) { if (enemy->isAlive()) { attacker->attack(*enemy); break; } } } bool allDead(const std::vector<std::unique_ptr<Unit>>& army) const { return std::all_of(army.begin(), army.end(), [](const std::unique_ptr<Unit>& u) { return !u->isAlive(); }); } };

这里有个细节:executeTurn每一回合先让红方全体行动,再让蓝方全体行动。这种“先后手”的判定影响很大,后面我会讲它的问题和改进。大作业阶段可以先这样写,逻辑清楚,演示也不会出大乱子。

2.2 攻击速度与行动排序:为步兵和弓箭手设计合理的先手权

如果每个单位都按照队形依次行动,会出现一个奇怪的局面:无论是红方还是蓝方,处在数组前面的单位永远先攻击,与速度无关。这个时候如果有一个速度极快的刺客型单位,但被放在数组末尾,它反而会成为最后出手的人,完全不合理。

我做法是引入行动顺序数组,在每回合开始时按速度降序排序:

void executeTurn() { // 构建一个包含所有活着的单位的行动序列 std::vector<Unit*> actionOrder; for (auto& unit : m_redArmy) { if (unit->isAlive()) actionOrder.push_back(unit.get()); } for (auto& unit : m_blueArmy) { if (unit->isAlive()) actionOrder.push_back(unit.get()); } std::sort(actionOrder.begin(), actionOrder.end(), [](const Unit* a, const Unit* b) { return a->getSpeed() > b->getSpeed(); }); for (Unit* actor : actionOrder) { if (!actor->isAlive()) continue; // 可能被前面的单位打死了 ... } }

这里用到了std::sort和algorithm头文件,顺便解决了“快单位后手”的问题。还有一个细节:排序后,如果前面的单位把后面的单位打死了,后面单位在轮到它时应该检测一次存活,否则会出现“一个尸体又跳起来打人”的笑话。

2.3 伤害结算与“保底伤害”:别让防御高到无敌

伤害结算公式是max(1, attack - defense),也就是说,无论对面防御多高,至少也会受到 1 点伤害。这个保底机制在日常游戏中很常见,不然会出现“满防御的城墙型单位磨光所有回合也打不死”的死锁局面。

但光有保底还不够。我在测试时发现一个问题:假设一个单位攻击力 30,防御力 28,每次只造成 2 点伤害,就算有保底,战斗也会拖成一场漫长的消耗战。为了让开战节奏看起来“爽”一点,我参考了一些常见游戏的做法,把伤害公式改成带有“波动比例”的形式,比如:

int realDamage = std::max(1, damage - m_defense);

这是最基础的版本,适合大作业演示。如果想让数值更有层次,可以把攻击力拆成“基础攻击 + 随机浮动”,再减去防御。我最终在代码里保留了最简的max(1, damage - defense),因为大作业的评分重点是类和逻辑结构,不是数值系统,越简单越好解释。

2.4 死亡处理与“尸体障碍”:坑了我一整个晚上的 bug

死亡和非死亡单位的处理点位特别容易出问题。我第一版代码写的是:

for (auto& unit : army) { if (!unit->isAlive()) { // 尝试删除 } unit->attack(...); }

在遍历一个vector的同时删除元素,这造成迭代器失效,整个循环行为变成未定义。很多同学一跑程序发现“随机崩溃”,十有八九就是这类问题。

我当时采用的解决方法是先标记,后清理。executeTurn里只负责让活人打人,不删除任何单位;等整个回合结束后,再统一把死亡单位移除。这样做还有一个额外的好处:死亡动画、尸体的“占位”效果(如果游戏逻辑需要)都不会因为删除太早就丢失。

void removeDead(std::vector<std::unique_ptr<Unit>>& army) { army.erase( std::remove_if(army.begin(), army.end(), [](const std::unique_ptr<Unit>& u) { return !u->isAlive(); }), army.end()); }

std::remove_if配合erase是 C++ 里清理容器的标准操作。原因是remove_if会把所有不满足条件的元素移到容器前面,然后返回新逻辑末端的迭代器,再配合erase把后面那截彻底删掉。这个方法我在项目里用了无数遍,大作业里也很值得用上。

3. 英雄技能与多态:把“差异性”交给虚函数,而不是 if-else

3.1 技能系统的最初设计:一个让人头疼的 switch

战斗系统里最容易膨胀的是技能逻辑。最开始我做了一个极冗余的版本,战斗里出现“技能释放”时先判断单位类型,再根据类型调用不同的处理:

// 这种做法不推荐,只是反面教材 void castSkill(Unit* caster) { if (dynamic_cast<Hero*>(caster)) { // 英雄释放雷霆一击 } else if (dynamic_cast<Monster*>(caster)) { // 怪物释放毒液喷射 } }

第二种写法的危害是很明显的。每加一个新技能,castSkill就得改一次,类型判断列表越来越长,dynamic_cast本身就说明你没有好好利用多态。更危险的是,每次检查都依赖运行时类型信息,会拖慢程序速度,而且一旦继承层次调整,这些代码可能直接编译报错。如果有人问你“什么是面向对象的反模式”,这个就是。

3.2 用虚函数解放技能系统

正确的思路是:让每个单位自己知道怎么释放技能,外部不需要关心对象的具体类型。在Unit基类里声明:

virtual void useSkill(BattleContext& ctx) {}

然后Hero类覆写它:

class Hero : public Soldier { public: void useSkill(BattleContext& ctx) override { // 对敌方全体造成 10 点伤害 } };

BattleContext是自定义的战斗上下文结构,里面可以包含当前回合数、双方军队的引用、施法者自身等。这样设计之后,调用方可以统一处理:

void BattleEngine::executeHeroSkills(Camp camp) { auto& army = (camp == Camp::Red) ? m_redArmy : m_blueArmy; for (auto& unit : army) { unit->useSkill(ctx); } }

不管以后新增“死亡骑士”“熊猫酒仙”还是“恶魔猎手”,调用方代码一行都不用改,这是多态带来的真正收益。写大作业的时候,如果能在答辩时讲出这一层“新增扩展点而不用改调用方”的好处,得分会比单纯罗列代码高很多。

3.3 地形、Buff 与 BattleContext 的边界

为了让上下文对象不至于变成“万能口袋”,我控制它只包含战斗过程中各单位都需要读的数据,比如:当前回合、双方兵力引用、一个简单的随机数生成器。这样设计后,技能函数在释放过程中如果需要查询敌方血量,可以从上下文里拿;如果需要造成伤害,可以直接操作目标对象。

还有一个细节值得提:BattleContext里的军队容器是引用还是拷贝?必须是引用。如果拷贝一份,技能对敌人造成伤害,实际上打的是副本,正主安然无恙,整个战斗逻辑就白写了。

struct BattleContext { std::vector<std::unique_ptr<Unit>>& redArmy; std::vector<std::unique_ptr<Unit>>& blueArmy; int turn; BattleContext(std::vector<std::unique_ptr<Unit>>& red, std::vector<std::unique_ptr<Unit>>& blue, int turn) : redArmy(red), blueArmy(blue), turn(turn) {} };

这个上下文类的存在,让技能系统有了一个干净的数据通道。以后想加“地形让火焰技能伤害翻倍”这类功能,只需要扩展BattleContext的字段,技能函数内部按需读取即可,不需要一路改调用链。

4. vector<unique_ptr > 的三个坑:切片、裸指针、内存管理

4.1 对象切片的坑:为什么不能用 vector 存兵种

用std::vector存对象是个很自然的想法,但如果你写std::vector<Unit> army;然后把Hero或Soldier往里面放,C++ 会执行对象切片(object slicing)。也就是派生类对象被“切”掉派生部分,只拷贝基类部分进去,结果就是:英雄被存进去后,等级、经验、技能这些属性全部丢失,变成了一个“披着英雄外表的普通士兵”。而且由于多态依赖对象的动态类型,Unit对象的动态类型就是Unit本身,虚函数调用也找不到派生类的实现了。

正确做法是存指针,让对象留在堆上,容器保存指向对象的指针,这样派生类信息不会被切掉。

4.2 unique_ptr 还是裸指针:这里不仅是风格问题

裸指针存在内存泄漏的风险,因为需要手动delete。大作业虽然规模小,但养成手动内存管理的习惯会埋下隐患。使用std::unique_ptr后,智能指针在容器销毁时会自动释放所管理的对象,异常发生时也会安全释放内存,天然具备异常安全性。

m_redArmy.push_back(std::make_unique<Hero>("阿尔萨斯", 600, 80, 50, 1, 5)); m_redArmy.push_back(std::make_unique<Soldier>("步兵甲", 200, 30, 20, 1, 3)); m_blueArmy.push_back(std::make_unique<Monster>("野狼", 150, 25, 15, 2, 4));

std::make_unique在 C++14 里可用,如果你的课程还停留在老标准,可以用std::unique_ptr<Hero>(new Hero(...))。它有几个好处:创建对象和分配内存一步完成,安全性更高;配合容器使用,可以完全避免手动 delete。

4.3 unique_ptr 的移动语义与容器的配合

std::unique_ptr是只可移动不可拷贝的类型。这意味着你不能把一个unique_ptr复制到另一个容器,但是可以std::move过去。这个特性在战斗系统中有一个实际好处:它强制你明确“所有权”的概念。一个单位在任意时刻只能属于一个容器,要么是红方军队,要么是蓝方军队,不能同时存在于两个地方。用裸指针的话,很难保证这点。

当初做addUnit时,参数我直接传std::unique_ptr<Unit>,调用方必须显式用std::move转移所有权:

engine.addUnit(Camp::Red, std::make_unique<Hero>(...));

这看起来比传裸指针麻烦一点,但让代码的意图非常明确:一旦英雄被addUnit进去,外部就不再持有它的裸指针,后续要操作它,必须通过BattleEngine提供的接口。这对防止“手一抖在某个地方 delete 了一个还在容器里的对象”非常有效。

4.4 善用 get():需要裸指针时怎么办

unique_ptr虽然方便,但有时你必须传裸指针给某些接口(比如std::sort里要对unique_ptr解引用)。这时候用.get()获取原始指针,而不是.release()。release()会放弃对指针的所有权,之后容器不再负责 delete,非常容易泄漏。我在项目里只在需要传给传统 C 接口且明确生命周期时才用release(),其他场景一律.get()。

5. 覆盖、隐藏与重载:为什么弓箭手的攻击总是“失灵”

5.1 一次几乎让我崩溃的“失灵”现场

在写完弓箭手这个类之后,我遇到一个怪象:明明弓箭手在攻击时应该先判断距离、再造成远程伤害,但实际战斗里它总是使用基类的“近战攻击”逻辑。原因就是我在派生类里写了这样一个函数:

class Archer : public Soldier { public: void attack(Unit& target) { // 这里没有写 override 关键字,而且基类对应函数签名一致 ... } };

如果基类里的attack是虚函数,派生类中参数完全相同的一个函数也算覆写,编译器在大多数情况下还能正常动态绑定。但如果你把基类的attack从虚函数改成了非虚函数,那么派生类里的同名函数就变成“隐藏”而不是“覆写”,调用时看指针类型决定走哪个版本。当时我的Soldier基类里attack少写了一个virtual,于是Archer的所谓“覆写”实际上只是隐藏了基类版本,而BattleEngine总是通过Unit*或Soldier*指针调用,所以永远调到了基类版本。

5.2 override 和 virtual:让编译器帮你改 bug

为了避免这类问题,C++11 之后提供了override关键字。在派生类中,如果这个函数确实覆写了基类虚函数,加上override编译器会检查签名是否一致,如果写错就报编译错误;如果基类不是虚函数,编译器也会报错。这个检查帮我省下了大量调试时间。

// 基类需要时 virtual void attack(Unit& target); // 派生类中 void attack(Unit& target) override; // 编译器强制检查

相比之下,Soldier类的attack函数我一开始没有加override,导致弓箭手类在继承时把基类的攻击逻辑覆盖成了“隐藏”版本。加上override之后,编译器立刻提示我签名不一致,问题直接暴露。

5.3 重载、覆写、隐藏的区别:一张表讲清

这三类概念经常被搞混。重载是同一个作用域内多个同名函数参数不同;覆写是基类虚函数在派生类中重新实现,签名一致;隐藏则是派生类中定义了与基类同名的函数,基类的同名函数被屏蔽,参数可以相同也可以不同。

概念触发条件调用方式动态绑定?常见问题
函数重载同作用域,同名但参数不同根据实参类型选择否容易被误认为覆写
函数覆写基类有 virtual,派生类重写同名同参数函数通过基类指针/引用调用是签名不一致导致不是覆写
函数隐藏派生类定义同名函数,参数的基类函数被遮挡根据指针静态类型调用否调用非虚成员函数时意外隐藏基类版本

还有一个坑:覆写时基类函数如果是const成员函数,派生类也必须加const,否则只是重载甚至隐藏。我在给isAlive()加const时,发现派生类想覆写一个getHp差点因为const的问题失败,编译器检查override字面量才看出来。

5.4 推荐在派生类中统一使用 override,而不是 virtual

在派生类中写不写virtual都不改变它是虚函数的事实。但写override比写virtual更能表达意图。我在整个项目里规定:基类用virtual,所有派生类使用override。这个习惯让代码可读性提高不少,后来老师抽查代码时也夸了一句“这习惯像搬砖多年的老手”。

6. 从“能跑”到“能演示”:数据平衡、防御性检查和现场防炸

6.1 演示数据的重要性:开局设置不能太随意

大作业最终要演示。我第一版尝试时,随便设置了双方兵力,结果红方平均攻击力 80,蓝方平均防御力 90,打了十几个回合双方都死不了。后来调整数据时发现,演示的最佳长度是 5 到 8 回合,双方你来我往,场面热闹,但不会拖到观众走神。为了让演示可控,我把出兵数据硬编码成一个固定的“表演剧本”,不开放随机生成,这样每次演示都能预期走向,不会突然双方全灭结束。

6.2 防御性检查:不要相信任何外部数据

战斗系统中几乎所有函数的输入都可能出现异常情况:攻击目标是空指针、目标已经死亡、越界索引。我一开始认为“自己的代码自己最清楚”,结果因为在chooseAndAttack里没检查目标是否存活,出现过“攻击尸体”的尴尬。后来在每个关键入口都加上了防御性检查:

if (target == nullptr || !target->isAlive()) { return; }

虽然这让代码看起来有些啰嗦,但在演示时非常有用,程序不容易闪退。答辩现场一旦崩溃,印象分直接跌到谷底。

6.3 输出战报:让战斗过程“看得懂”

如果代码只是在后台计算数字,演示效果会很差,因为评委看不到发生了什么。我加了一个简单的战报输出,把每一次攻击都变成一行文本:

第 3 回合 [红方] 阿尔萨斯 对 [蓝方] 野狼 造成 62 点伤害。 野狼 的生命值从 150 降到 88。 [蓝方] 野狼 对 [红方] 步兵甲 造成 21 点伤害。 步兵甲 的生命值从 200 降到 179。

这种类似“战斗日志”的输出,代码不复杂,但对演示效果提升是肉眼可见的。相比一长串数字表格,“阿尔萨斯对野狼造成了 62 点伤害”更有代入感,也让评委能快速理解程序在做什么。

6.4 环境与编译问题:vscode 和 Visual C++ 的环境排查思路

周围不少同学用的是 vscode 配 C/C++ 插件,或者直接用 Visual Studio。环境问题几乎是每一届大作业的必经坑,比如编译器版本过低不支持 C++14/17,导致make_unique、override报错,或者缺少运行库报出“Microsoft Visual C++ 14.0 or greater is required”。这类问题的排查思路其实很统一:先看编译器版本,再看是否缺少组件。vscode 里可以打开终端执行g++ --version或cl检查版本;如果安装了 VS 但命令行编不过,多半是环境变量没配置好。运行库缺失的话,去官方下载对应版本的 Redistributable 安装一遍基本能解决。

我自己的经验是,用 vscode 写大作业需要自己维护tasks.json和launch.json,对新手不太友好;如果课程要求不苛刻,Visual Studio Community 版开箱即用,创建空项目直接跑,省掉不少环境折腾时间。但如果你是命令行爱好者,那 vscode 配好之后也挺顺手,关键是编译标准要调到 C++17。

6.5 代码结构与类拆分:让老师一眼看出“面向对象”

最后再提一下代码组织。把类拆到独立文件中是大作业的基本要求,我在项目里按“一个类一对 .h/.cpp”来组织:

Unit.h / Unit.cpp Soldier.h / Soldier.cpp Hero.h / Hero.cpp Monster.h / Monster.cpp BattleEngine.h / BattleEngine.cpp main.cpp

这么做的好处不只是“好看”,更重要的是每个文件不会太长,编译时间短,改一个类不会导致全项目重编。如果时间充裕,还可以把BattleContext单独放一个文件,它虽然不是类,但作为结构体占一个头文件,命名更清晰。

6.6 自测清单:演示前跑三遍

我在最终演示前给自己列了一个自测清单:

  • 双方英雄能正常释放技能,技能名称能正确出现在战报里。
  • 胜利条件能正确触发:某一方全灭时程序结束并宣布胜者。
  • 连续跑三局,没有出现一次内存泄漏或崩溃。

前两个好检查,第三个需要留意输出里是否有“Access violation”之类的异常情况。如果你在写代码时遵循了前文的几个原则(智能指针、虚析构、迭代器安全),第三项通常不会出问题。

从我这次做“开战”的全过程来看,最深的体会是:战斗系统真正的难点不在于“让两边打起来”,而在于“打起来之后,代码还能不能保持清晰、能不能继续扩展”。继承、多态、智能指针、容器操作这些知识点,单独拎出来都不难,但凑到一起,特别考验对对象生命周期的理解。如果你在写的过程中发现到处是dynamic_cast、大量 if-else 判断单位类型或者手动 new/delete,那大概率是设计上走错了方向。回到基类和派生类的边界上重新想一想,往往比硬着头皮继续堆代码更省时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 6:28:29

小吃培训合同怎么看:长沙曾食坊小吃培训走访提示

本篇要点&#xff1a;合同先看课时与品类是否写清&#xff1b;费用与材料条款逐条确认&#xff1b;退费调整要落进书面约定。搜"培训合同怎么看"的人&#xff0c;多半是担心签完才发现权责不清。合同本身不是用来规避什么&#xff0c;而是把双方说好的内容固定下来。…

作者头像 李华
网站建设 2026/10/4 6:25:36

做纸袋热封,你踩过胶的哪些坑?

在纸袋热封的过程中、存在一些常见的问题可能会影响到最终的封口质量和生产效率。胶水选择不当往往是一个主要的“坑”胶水、以确保密封强度。另外、热封温度等设定也重要&#xff0c;过高或过低都会导致封口不牢或材料损坏。在操作技巧方面、无经验的操作人员可能会犯一些失误…

作者头像 李华
网站建设 2026/10/4 6:25:28

国内高校学生论文季必用的AI论文网站是哪款?

国内高校学生在论文写作中越来越依赖AI工具&#xff0c;以本土化全流程服务为核心&#xff0c;结合通用大模型与专业辅助功能&#xff0c;覆盖选题构思、框架搭建、初稿撰写、语言润色、降重处理、查重检测及格式排版等关键环节&#xff0c;以下是当前主流工具的深度解析与对比…

作者头像 李华
网站建设 2026/10/4 6:22:48

OpenShell:跨平台终端工作流统一配置方案

1. OpenShell 是什么&#xff1f;它不是 Shell&#xff0c;而是一套跨平台终端体验重构方案OpenShell 这个名字乍一听容易让人联想到“开源的 Shell”——比如 bash、zsh 或 fish 的某个分支。但实际查遍 GitHub、GitLab 和主流包管理器&#xff08;Homebrew、apt、choco&#…

作者头像 李华
网站建设 2026/10/4 6:22:16

插件加载与激活机制详解:从web boot报错到IAR与MusicFree排查实践

“plugins”这个词&#xff0c;最近算是把我包围了。后台连着收到好几类提问&#xff1a;有人问“IAR 里的 plugins 到底是干什么的”&#xff0c;有人研究 MusicFree 插件导入到一半卡住&#xff0c;还有更直接的&#xff0c;把控制台一屏红字甩过来&#xff0c;开头第一句就是…

作者头像 李华
网站建设 2026/10/4 6:22:12

数据中心云架构落地指南:BGP与SRv6 Policy实战

简介&#xff1a;这是一份面向数据中心运维、云计算架构及智慧城市相关从业者的PPT解决方案&#xff0c;围绕高效数据中心与基础架构云展开。内容涵盖动态基础架构管理&#xff08;AIM&#xff09;的一次上架、一次连线机制&#xff0c;以及服务器在Windows集群与Linux网页系统…

作者头像 李华