资讯动态

C++设计模式实战:从怪物生成到战斗逻辑的演进与取舍

发布时间:2026/9/16 18:06:28 来源:尧图企业网站定制
这些年面试C开发遇到一个很典型的现象十个人里至少有六七个能把设计模式背得滚瓜烂熟。你问“简单工厂和工厂方法有什么区别”他能给你背出定义再问“你最近维护的模块里用到了哪个模式”很多人愣住了。我在一个游戏后端项目里做了三年战斗系统期间几乎把工厂、策略、观察者、组合这几个经典模式都用了一遍踩了不少坑也推翻过不少“教科书式”的写法。这篇文章我打算直接用一段会持续演进的怪物生成与战斗逻辑当主线把设计模式从需求变化里“长”出来的过程讲清楚每个模式对应什么场景、解决什么痛点、现代C下又该怎么写。如果你正在准备C面试的八股题或者维护着一个改起来越来越痛苦的旧模块这篇文章应该能给你一些参考。1. 为什么背熟了设计模式一写真实需求还是懵1.1 设计模式是“解”不是“题”大多数人学设计模式的方式是把它当成考试科目来学先记类图再背适用场景和优缺点。这种方式最大的问题在于设计模式是无数人从类似代码坏味道里提炼出来的“解法”而不是一个可以脱离场景独立存在的东西。就像药房里的药你得先确诊自己生了什么病再谈吃什么药。很多人在写代码时强行套模式属于典型的“没病乱吃药”写完之后不但代码没变清爽反而多了一层抽象读起来更难懂。我见过不少新人把策略模式和状态模式的类图一画就在一个本来只有三种情况的小功能里硬塞了五个类。结果代码量翻倍可读性下降面试官问他为什么这么做他也只能答“这样符合开闭原则”。这就是模式被过度使用的典型症状。设计模式本身是有代价的它引入了类型抽象、调用间接性和对象数量这些都是成本不是免费的。1.2 真正的第一步是找到“变化点”实战项目里你不可能一眼看出“这里应该用桥接模式”更常见的路径是先看见代码在反复改动然后才意识到某个位置需要引入抽象。我一般会这样找这一段代码里哪个需求最容易变变了以后要连带改多少地方每加一个新东西有没有一个函数被反复打开比如怪物类型一直在增加那变化点在“创建流程”技能效果经常调来调去那变化点在“算法逻辑”怪物死亡后要触发任务、成就、掉落、UI飘字那变化点在“通知关系”。每个变化点都对应一个候选的模式创建流程膨胀用工厂算法反复变动用策略通知关系混乱用观察者。先有变化后有模式这个顺序不能反过来。我后来带新人时都会强调“不要为了用模式而用模式要为了挡住变化而用模式。”2. 一个失控的怪物生成代码长什么样子2.1 第一版很爽三行switch解决一切很多游戏项目初期都是一个样怪物少规则简单随便写一个创建函数就够了。类似这样Monster* createMonster(const std::string type) { if (type slime) { return new Slime(); } else if (type goblin) { return new Goblin(); } else if (type dragon) { return new Dragon(); } return nullptr; }这个版本在怪物只有三种时是舒服的代码直白任何一个新人都能看懂。问题在于它把“创建逻辑”和“类型判断”绑死在一个函数里。你每加一种怪物就要打开这个函数加一个分支。当时我觉得没什么三个月后这个函数长到了两三百行里面塞满了各种初始化参数、掉落列表、AI脚本绑定改一次就提心吊胆。随后新需求陆续来了策划在配置表里新增一个怪物类型要求游戏不重新编译就能读出来同一种怪物在不同副本里皮肤和数值不一样怪物创建时还要挂一堆附属技能。switch方式的弊病全暴露了。2.2 三个失控信号出现一个就该警觉我把这段经历总结成三个信号供你对照自己的代码类型一多函数越来越长每加一个分支都是复制黏贴。代码里到处是 if-else 或 switch修改时容易漏掉某个分支。怪物类不只是简单的构造还需要初始化参数、掉落、AI脚本if 分支内部变得臃肿不堪需要很多代码来组装对象。类型从代码写死变成数据驱动。配置文件里写了“mythic_dragon”代码里却没有对应的 case必须改代码重编译才能生效。此时问题已经不只是不优雅而是影响到了开发的响应速度。出现这些信号就该考虑用工厂类把“变化的创建点”隔离起来。注意这里有一个细节早期代码用裸指针 new在现代C里应该返回 unique_ptr 而不是裸指针。先把这个改掉再谈模式也不迟。3. 从简单工厂到抽象工厂怪物工厂的三种演进3.1 简单工厂把所有switch集中关押最简单的一步是把那些散落的 switch 集中到一个地方。这样即使还是要改你只需要改一个类不用在业务代码里到处找分支。写出来是这个样子class MonsterFactory { public: static std::unique_ptrMonster create(const std::string type) { if (type slime) { return std::make_uniqueSlime(); } if (type goblin) { return std::make_uniqueGoblin(); } if (type dragon) { return std::make_uniqueDragon(); } return nullptr; } };注意这里我已经把返回类型换成了 unique_ptr调用方不需要再手动 delete这是一个和设计模式无关但非常重要的现代C实践。简单工厂严格来说不是 GoF 的 23 种模式之一但它极其实用适合怪物类型不多、变化不频繁的小项目。它的缺点是每一次加类型还是要改这个工厂类没有彻底根治开闭原则的问题。它只是把混乱关进了一个笼子里笼子里的东西还是会越堆越多。3.2 工厂方法把创建行为交给子类当你的创建入口不只一个而且每个入口都需要创建不同的怪物时简单工厂就开始不够用了。比如森林副本和地下城副本它们各自有一套怪物风格一个大厅里同时有多个创建点每个创建点需要由不同逻辑决定创建哪类怪物。此时可以把“创建怪物”这个动作抽象成接口让子类决定具体创建一个什么样的怪物class MonsterCreator { public: virtual ~MonsterCreator() default; virtual std::unique_ptrMonster createMonster() const 0; }; class ForestMonsterCreator : public MonsterCreator { public: std::unique_ptrMonster createMonster() const override { return std::make_uniqueForestSlime(); } }; class DungeonMonsterCreator : public MonsterCreator { public: std::unique_ptrMonster createMonster() const override { return std::make_uniqueDungeonGoblin(); } };这就是工厂方法模式定义一个创建对象的接口让子类决定实例化哪个具体类。它不再需要在创建函数里写一堆 if-else新增一种风格的怪物时只需要新增一个 Creator 子类对已有代码保持不动。但代价也很明显每个怪物类都要配一个 Creator 类类数量几乎翻倍。如果你的项目只有一个创建点或者怪物种类的变化方向很单一强行上工厂方法反而会把简单问题复杂化。我在实战里遇到过同事花大力气给五种怪物写了五个 Creator后来发现配置表驱动加一个简单工厂的映射表就够了那些类纯属多余。3.3 抽象工厂保证“一族对象”风格统一还有一个场景是简单工厂和工厂方法解决不了的你不只要创建怪物还要创建配套的装备、技能特效、掉落物并且要求它们属于同一个阵营风格。比如亡灵族副本里的怪是骷髅兵、装备是暗属性、特效是灰紫色机械族副本里的怪是机器人、装备是电气属性、特效是蓝白色。如果分开创建很容易出现“亡灵怪拿着光明系装备”这种风格错乱的bug。抽象工厂模式就是为这种“一整套对象需要配套创建”的场景设计的class EnemyThemeFactory { public: virtual ~EnemyThemeFactory() default; virtual std::unique_ptrMonster createMonster() 0; virtual std::unique_ptrEquipment createEquipment() 0; virtual std::unique_ptrSkillEffect createSkillEffect() 0; };配置表里写“undead”程序拿到一个 UndeadThemeFactory用这个工厂创建出的怪物、装备、特效天然匹配。以后要加“海底副本”只需要新增一个海底风格的 ThemeFactory 子类不用去修改其它主题的创建逻辑。在游戏开发里抽象工厂尤其适合门派、阵营、种族这类强相关的配置体系。3.4 三个工厂该怎么选我这些年总结的对照表模式解决的核心问题是否符合开闭原则适用场景典型坏味道简单工厂把类型判断集中隔离否加类型仍需改工厂怪物类型少、变化不频繁switch 无限膨胀工厂方法把创建延迟到子类是对扩展方向开放多个创建入口、多风格类数量爆炸抽象工厂一族对象的配套创建是对扩展方向开放强相关的对象族接口过胖难以扩展实战里没有哪个模式是“最正确”的只有“最能把变化关在门外”的。选工厂之前先问一个问题你的变化点是单个物种还是一整个风格体系单个物种用简单工厂加注册表足够一整套风格体系才需要抽象工厂。另外我再提一句如果可能的话把类型从字符串映射到工厂的过程交给配置表游戏里常见做法是启动时从 JSON 里读取宏把字符串 key 和工厂对象填进 unordered_map这样连 switch 都不用写了。4. 伤害计算和战斗事件策略与观察者的落地写法4.1 不用策略模式时的战斗代码真的是灾难工厂解决的是对象创建问题接下来进入战斗逻辑本身。很多项目初期伤害计算是直接写在战斗主流程里的我见过最夸张的版本长这样void applySkill(CombatContext ctx, SkillId skillId) { if (skillId SKILL_FIREBALL) { ctx.target.takeDamage(30 ctx.target.magicDefense() / 2); ctx.target.applyBuff(BurnBuff(3, 5.0f)); } else if (skillId SKILL_ICE_NOVA) { ctx.target.takeDamage(12); ctx.target.applyBuff(SlowBuff(0.4f, 2.0f)); } else if (skillId SKILL_POISON_BLADE) { // 又是一长串逻辑... } }这段代码在只有两三个技能时问题不大一旦加到十几个技能这个函数就会膨胀到一个文件放不下。更糟糕的是技能逻辑属于玩法团队经常调整的部分结果每次调技能数值和效果都要去修改战斗核心文件。我们团队当时三个人同时改这一个函数合代码天天冲突改错一个括号就把整个战斗模块搞崩压力非常大。4.2 策略模式把技能算法从主流程里拆出去策略模式在这个场景里几乎是标准答案。核心思想是定义一组算法把它们封装起来在运行时按需替换。技能效果就是典型的算法族火球术是一种算法冰霜新星是一种算法毒刃又是一种。把它们都做成策略对象后主流程再也不用关心某个技能的细节。class SkillEffect { public: virtual ~SkillEffect() default; virtual void apply(CombatContext ctx) const 0; }; class FireballEffect : public SkillEffect { public: void apply(CombatContext ctx) const override { ctx.target.takeDamage(30 ctx.target.magicDefense() / 2); ctx.target.applyBuff(BurnBuff(3, 5.0f)); } }; class IceNovaEffect : public SkillEffect { public: void apply(CombatContext ctx) const override { ctx.target.takeDamage(12); ctx.target.applyBuff(SlowBuff(0.4f, 2.0f)); } };战斗主流程的代码变得非常简短std::unique_ptrSkillEffect effect skillEffectFactory.create(ctx.skillId); effect-apply(ctx);以后加一个新技能就是新增一个 SkillEffect 子类然后在工厂里登记一下战斗核心文件完全不用动。这个结构的另一个好处是策划调整技能数值时可以在配置表里配不需要每次都让程序改代码。我后来把这套逻辑接到 JSON 配置表上技能 ID 到策略对象的映射从配置文件加载程序员加技能的工作量彻底降下来了。4.3 观察者模式战斗事件解耦多个无关模块技能伤害搞定了下一个痛点是事件广播。怪物死了要触发任务系统判断“击杀几只哥布林”、成就系统弹出“屠龙者”徽章、掉落系统生成战利品、UI界面播放击杀特效。如果战斗逻辑直接调用这些模块战斗系统就得依赖任务、成就、掉落、UI耦合程度高到改一个技能都要小心翼翼。观察者模式解决的就是这种“多个互不相关的模块都对同一事件感兴趣”的问题。定义一个观察者接口战斗系统只维护一个订阅列表其它模块自己决定是否订阅。class ICombatObserver { public: virtual ~ICombatObserver() default; virtual void onMonsterKilled(const Monster monster) 0; }; class CombatEventBus { public: void subscribe(std::shared_ptrICombatObserver observer) { observers_.push_back(std::move(observer)); } void notifyMonsterKilled(const Monster monster) { for (const auto observer : observers_) { observer-onMonsterKilled(monster); } } private: std::vectorstd::shared_ptrICombatObserver observers_; };战斗模块只需要在自己的逻辑里调用 eventBus_.notifyMonsterKilled(monster)剩下的事情谁关心谁去订阅。这样战斗逻辑不依赖任何具体业务模块。后来我们新增了“死亡慢动作回放”功能只需要写一个订阅者然后 subscribe 进来战斗系统一行代码没改。这里有一个非常关键的 C 实战细节观察者模式最坑的地方是生命周期管理。如果用裸指针存订阅者订阅者析构后事件总线还持有它就出现悬空指针崩溃现场极其难查。我们内部规定所有观察者必须用 shared_ptr 存储如果观察者需要反注册订阅时返回一个 token析构时通过 token 移除自己。另外注意不要在事件回调里直接销毁订阅者否则遍历列表时迭代器失效这种问题很难调试。我自己踩过一次后来被迫改成“先收集销毁事件再统一处理”的策略。4.4 其实不需要每次都写接口类如果你觉得上面那套观察者接口写起来太啰嗦有个更轻量的现代写法订阅列表里直接存 std::function订阅方用 lambda 注册事件。战斗事件总线可以简化成这样class CombatEventBus { public: using MonsterKilledHandler std::functionvoid(const Monster); void registerMonsterKilledHandler(MonsterKilledHandler handler) { handlers_.push_back(std::move(handler)); } void notifyMonsterKilled(const Monster monster) { for (const auto handler : handlers_) { handler(monster); } } private: std::vectorMonsterKilledHandler handlers_; };这么写的好处是代码量小对简单项目非常友好坏处是没有接口约束订阅方可以塞任意 lambda时间久了容易变成“一锅粥”。我的建议是内部工具或者原型阶段用 std::function 方案大型项目多人协作时尽量用传统的观察者接口让约束更明确。这两种写法我都实际用过没有绝对的对错只有维护成本的取舍。5. 组合模式当“单个”和“一组”在调用方眼里不该有区别5.1 一个真实需求技能效果树观察者模式解决了事件通知战斗逻辑里还有一个隐蔽的痛点技能效果经常会组合。比如“毒爆”技能既造成伤害又上一个中毒 DOT目标死亡时还能再炸一次。你当然可以把它写成一个大类但下次做一个类似的“冰雷”技能又是三四个效果组合在一起复制黏贴会让人崩溃。组合模式的思想是把单个效果和组合效果放在同一个抽象下让调用方用一致的方式对待它们。对我而言最直观的建模方式是让组合效果内部持有多个子效果对外的接口和单片效果完全一致。5.2 组合模式的C实现关键在递归apply一个简单的技能效果树实现可以这样写class Effect { public: virtual ~Effect() default; virtual void apply(CombatContext ctx) const 0; }; class DamageEffect : public Effect { public: void apply(CombatContext ctx) const override { ctx.target.takeDamage(20); } }; class PoisonEffect : public Effect { public: void apply(CombatContext ctx) const override { ctx.target.applyBuff(PoisonBuff(3, 2.0f)); } }; class CompositeEffect : public Effect { public: void add(std::unique_ptrEffect effect) { effects_.push_back(std::move(effect)); } void apply(CombatContext ctx) const override { for (const auto effect : effects_) { effect-apply(ctx); } } private: std::vectorstd::unique_ptrEffect effects_; };调用方拿到一个 Effect 指针根本不需要关心它内部是一个技能还是六个技能的组合直接调 apply 就行。组合模式最适合这种树状结构一个容器节点下面挂多个叶子节点叶子下面还可以继续挂叶子最终呈现的是一棵完整的技能树。如果不用组合模式你会在调用方那里写出一堆if (effect 是组合) else ...的判断每加一层嵌套都让代码丑一分。5.3 组合模式的代价和现代C的替代方案组合模式也不是没有缺点。最明显的问题基类接口会被“撑胖”。叶子节点不需要 add 方法容器节点需要但你通过基类指针操作时不知道它到底是叶子还是容器有时会想去加一个isComposite()判断这就又回到类型判断的老路上。更符合现代C口味的替代方案是放弃继承直接使用std::variant来表示效果类型把组合能力放在数据里而不是类型里using Effect std::variantDamageEffect, PoisonEffect, CompositeEffectData;配合std::visit做操作分发代码依然清晰而且不需要虚函数表。我自己倾向于如果效果类型不多、组合深度有限用组合模式很自然如果效果类型特别多、操作复杂variant visit可能是更好的选择。组合模式的价值在于它提出了“部分-整体一致对待”的建模视角这一点无论是老式继承还是现代variant都适用。6. 现代C如何冲击传统设计模式6.1 std::function 一出场半个策略/命令/模板方法都被简化了以前实现策略模式你必须写一个接口类、两个实现类还要处理虚析构。现在很多场景下策略其实就是“一个可调用的函数”直接用 std::function 就行using DamageStrategy std::functionfloat(const CombatContext); DamageStrategy strategy [](const CombatContext ctx) { return ctx.baseDamage * 1.5f; };这种写法省去了大量类定义。命令模式的核心是“把要执行的动作封装成对象以便延迟或者排队执行”本质上也可以用 std::function 实现把函数存进队列到点了调用。模板方法模式的变化部分同样可以变成传入 lambda而不是在子类里重写虚函数。这不是说 GoF 的设计模式没用了而是现代C的语法让我们能以更低的成本表达同样的结构。6.2 RAII 和智能指针让很多模式变轻了工厂模式以前最大的痛点是返回裸指针谁负责 delete 经常说不清楚。现在返回 unique_ptr 以后调用方天然获得所有权无需手动释放这个模式的安全性提升了一个量级。观察者模式以前要格外小心悬空指针现在用 weak_ptr 或者 shared_ptr 管理回调对象生命周期的问题显著减少。RAII 本身就替代了很多代理类和装饰器类的作用——资源释放不再需要你写一个专门的对象来包装局部对象析构时自动完成。单例模式在现代C里也有标准写法了。函数内 static 局部变量的初始化在 C11 之后是线程安全的所以直接用局部静态单例就是最佳实践class Config { public: static Config instance() { static Config config; return config; } Config(const Config) delete; Config operator(const Config) delete; private: Config() default; };不过我要多说一句单例模式本身很容易被滥用。它能隐藏依赖关系让模块之间通过全局访问点偷偷耦合。在我实际经验里真正适合单例的只有配置、日志、事件总线这类客观上只需要一份的资源业务状态塞进单例后期连测试都写不了。6.3 传统模式与现代写法的快速对照传统GoF模式现代C简化写法我的使用建议策略模式std::function成员或参数简单算法用lambda复杂多步骤策略保留类命令模式std::function存队列延迟执行命令需要支持撤销重做时还是用类更清晰模板方法传入lambda替换钩子函数钩子只有一个时用lambda钩子一串时用虚函数观察者std::functionshared_ptr回调列表大型项目用接口约束原型阶段用lambda工厂unique_ptrmake_unique所有现代C工厂都应该返回智能指针不要把现代C和设计模式对立起来。模式提供的是“场景与结构的抽象认知”语法只是实现层面的工具。你越熟悉 std::function、unique_ptr、variant 这些现代特性实现同一个模式就越顺手代码越短坑也越少。7. 面试中聊设计模式被追问时才见真章7.1 面试官想听的不是定义而是取舍我自己面试别人时最常问的一句话是“你给我讲一个你真正用过的设计模式不用讲类图讲当时的代码是什么样以及你为什么要改成模式。”很多人一上来就背“工厂方法定义一个创建对象的接口让子类决定实例化哪个类”这听起来很流利但无法证明你真正在工作里用过。真正有区分度的回答是讲清楚那段业务代码经历了什么变化最终为什么选这个模式以及付出什么代价。一个比较通用的回答框架是需求背景 - 不用模式前代码的坏味道 - 为什么选这个模式 - 改动后的效果 - 带来的代价或权衡。比如“我们项目的技能逻辑全写在战斗核心函数里每加一个技能都要动这个文件三个人同时改同一个函数导致频繁冲突。我引入了策略模式把每个技能实现拆成独立策略对象通过配置表映射。之后加新技能只需要新增一个策略类并登记。代价是类数量变多编译时间变长所以我们只对频繁变化的技能效果使用策略简单的一次性功能仍然用普通函数。”7.2 高频追问点和得分答法这里整理了几个面试中大概率被追问的点我给出的都是踩过坑之后的体会简单工厂、工厂方法、抽象工厂三者的区别简单工厂是做集中判断工厂方法是把创建延迟到子类抽象工厂是配套创建一族对象。关键在判断你的变化点是单个物种还是一整个风格体系。策略模式和状态模式的区别策略模式是外部换算法对象不知道自己用哪个策略状态模式是对象内部状态转移导致行为变化对象自己管状态。两者结构上像意图完全不同。观察者模式的内存问题回调列表里的裸指针悬空是最常见的坑用 shared_ptr 或 weak_ptr 管理订阅方析构时必须反注册。C11后单例如何线程安全函数内局部 static 变量初始化由编译器保证线程安全不要自己搞 double-checked locking除非你对内存序非常熟悉。工厂模式返回裸指针还是智能指针一定要返回 unique_ptr。这个点能看出候选人写现代C的熟练度我听到回答“裸指针 文档约定谁释放”时通常会扣分。写在最后回到题目本身设计模式从来不是靠背定义能掌握的也不是靠套类图能用对的。它本质上是对“变化”的预判和应对。你看到代码里哪个地方反复在改、改一次连带一堆文件那个地方就是模式该出现的位置。我在战斗系统里经历了从 switch 泛滥到工厂收敛、从主流程堆逻辑到策略拆分、从模块互相依赖到观察者解耦的完整过程回头去看每一步推动我改代码的都不是“我想用模式”而是“这里改得太痛了”。痛过才知道模式解决什么用过才明白模式也有代价。这句话比任何类图都重要。

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

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

免费获取报价