【游戏架构】【笔记】设计模式——《游戏编程模式》学习笔记001
该笔记中有AI辅助内容。
命令模式
“将一个请求(request)封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同时支持请求操作的撤销与恢复。”
更精简来说:命令就是一个对象化(实例化)的方法调用(A command is a reified method call)。
有时,一个命令代表了一个可重用的对象,表示一件可完成的事情(a thing that can be done);有时,命令也可以表示一些可在特定时间点完成的事情。
什么时候使用呢?
- 当某个接口中仅剩一个返回值为空的方法时,命令模式便很可能适用。
- 想要实现可撤销的行为。
用例
命令模式有两种常见的具体写法:
- 动态创建型:每次操作
new一个Command实例,内部持有被操作的Actor和原始状态(适合带有Undo的推箱子、解密游戏)。 - 单例无状态型:Command 实例全局唯一(如
JumpCommand),在执行时传入目标对象execute(Actor& actor),仅用于纯粹的按键输入映射绑定。
基类模板
class Command{
public:
virtual ~Command() {}
virtual void execute() = 0; // 执行命令
virtual void undo() = 0; // 撤回操作
};
在玩家的移动、跳跃等功能,需要绑定按钮,我们可以把具体的移动、跳跃实现,实现在一个继承于命令的类中。
该类可以记录一些必要参数,如跳跃的实体,撤回需要的原始数据等,然后在execute方法中执行详细操作。
最后在输入处理处,实例化该类,并在对应输入时执行即可。
而要多次撤销的话,只要维护一个命令列表和一个对“当前”(current)命令的一个引用(维护一个undo栈)。当玩家执行了一个命令,我们将这个命令添加到列表中,并将“current”指向它:
- 当玩家选择“撤销”时,我们撤销当前的命令并且将当前的指针移回去。
- 当玩家选择“重做”时,我们将指针前移然后执行它所指向的命令。
- 当玩家在撤销之后选择了一个新的命令,那么列表中位于当前命令之后的所有命令都被舍弃掉。
在 C++ 实现撤销栈时,推荐使用
std::vector<std::unique_ptr<Command>>存储历史命令,利用智能指针的生命周期管理,在舍弃后续历史或栈销毁时自动释放内存。
在实现时,可以使用类风格化或函数风格化。在一些语言中,只需要闭包函数或直接定义一个函数就可实现命令模式。
但是,定义一个实际的附带字段的实体类也有助于读者分辨该命令中包含哪些数据。
闭包自动包装一些状态的方式是比较简洁,但它们太过于自动化了以至于很难分辨出它们实际上持有的状态。
享元模式
“使用共享以高效地支持大量的细粒度对象。”
享元模式就是 UE 中的 ISM/HISM、Unity 中的 GPU Instancing(或 API 中的 Graphics.RenderMeshInstanced)的实现方式——只发送一次共享数据。然后,再单独地将每个模型实例的特有数据——位置、颜色和缩放比推送到GPU。最后,我们告诉GPU,“使用那个共享的模型来渲染每个实例”。
事实上,显卡可以直接实现API,这意味着享元模式可能是GoF的设计模式中唯一需要硬件支持的模式。
在这两种API中,你都需要提供两组数据。第一组是要被渲染多次的通用数据——比如上面例子中树的网格和纹理。第二组就是实例列表以及它们每次被绘制时用来在第一组数据的基础上产生差异化的那些参数。进行一次绘制调用,即可将整片森林绘制出来
应用场景:
- 一般来说,当有太多对象并考虑对其进行轻量化时它便能派上用场。
- 如果你发现自己正在创建一个枚举,并且做了大量的switch,那么可考虑用这个模式来替代。如果你在担心性能,那么在将代码修改成难以维护的风格之前,你至少要先做一下性能分析。
替换switch的详细方法:
用配置表对象(如 Unity 的 ScriptableObject 或 C# 数据单例,UE 中的DataTable)替代枚举类型,把写在 switch 里的各种条件数值直接作为数据字段存在共享对象里,从而消灭繁琐的分支判断。
详细来说,享元模式通过将对象数据切分成两种类型来解决问题。
- 第一种类型数据是那些不属于单一实例对象并且能够被所有对象共享的数据。即与上下文无关的状态。
- 其他数据便是外部状态(the extrinsic state),对于每一个实例它们都是唯一的。
这个模式通过在每一个对象实例之间共享内部状态数据来节省内存。
享元模式不仅用于显卡渲染,在纯 Gameplay 逻辑上极其常用,例如:
- 背包/装备系统:100 把同名“铁剑”,共享一份 ItemData(内部状态:图标、基础伤害、描述),而玩家背包里的每个物品对象 ItemInstance 只记录外部状态(当前耐久度、强化等级、指向 ItemData 的引用)。
- 地图网格/地形系统:网格地图中几万个“草地格子”,共享一个 GrassTileData(物理摩擦力、通行属性),每个格子自身只存坐标位置。
观察者模式(MVC——Model-View-Controller)
“在对象间定义一种一对多的依赖关系,以便当某对象的状态改变时,与它存在依赖关系的所有对象都能收到通知并自动进行更新。”
观察者模式使得代码能够发出一个消息,并通知对消息感兴趣的对象,而不用关心具体是谁接收到了通知。
详细拆解
- 观察者
- 观察者是接收通知的对象,定义一个观察者接口,只要实现该接口的具体类都可以成为观察者。
- 被观察者
- 被观察者会调用方法,发送信息。
- 被观察者拥有观察者的一个列表,这些观察者在随时候命接收各种各样的通知。
- 被观察者对象暴露一个用来修改观察者列表的公有API。
- 可被观察的模块
- 可被观察的模块继承被观察者类,就可以让我们把notify()方法变成被保护的方法。这样,派生的模块类就可以调用它来发送通知,但是,在模块外部的代码是不行的。
实际代码,本着合成复用原则,我们尽可能不使用继承,而是使用组合的方式。将可被观察的模块中,添加被观察者实例,然后此实例再发送消息。
观察者模式和事件模式的区别
观察者模式:你观察一个事情,它做了一些你感兴趣的事。
事件模式:你观察一个对象,这个对象代表了已经发生的有趣的事情。
在一个高度线程化的引擎中,最好使用事件队列来处理异步通信问题。
可能的问题
1. 动态分配内存
由于观察者列表总是一个动态分配的集合,当添加或者删除观察者的时候,该集合会动态地扩展或者收缩。这种内存的分配有时候会令人头疼不已。
链式观察者
这里不是让被观察者类拥有一系列观察者的集合,而是让观察者们变成链式列表的一个节点。
我们把被观察者类作为一个友元类。被观察者类拥有添加和删除观察者的接口,但是,现在我们想在观察者类中来维护这个列表。
- 在添加观察者时,只需要把它插入到这个列表中就可以了,在头部或尾部都可。
- 在删除观察者时,使用双向链表就可以将删除保存在常量时间内。
- 发送消息时,遍历整个链表进行发送即可。
但是,有缺点:
因为我们的观察者对象本身也是链表的一个节点,所以,这意味着我们的观察者必须是被观察者对象的观察链表的一部分。换句话说,一个观察者在任意时刻只可以观察一个被观察者对象。在一些更一般的实现中,每一个被观察者对象都维护一个独立的观察者链表,那样一个观察者就可以同时观察多个被观察者对象了。
这里有一个原则,如果两个观察者观察同一个被观察者对象,则它们两个不会因为注册顺序而受到影响。
所以改成这个链表里面的节点包含一个指向观察者对象的指针和一个指向下一个节点的指针。多个链表节点可以指向同一个观察者,这意味着一个观察者可以同时观察多个被观察者对象,这样一来,我们又可以同时观察多个被观察者对象了。
我们避免动态内存分配的方法很简单:由于所有的节点都是同样的大小和类型,因此你可以预先分配一个内存对象池。这样你就有了一个固定大小的链表节点池,并且可以根据需要去重用而不用自己处理一个内存分配器。
2. 销毁观察者或被观察者
当一个观察者对象被删除时,观察者本身应该负责把它自己从被观察者对象中移除。通常情况下,观察者都知道它在观察着哪些被观察者,所以需要做的只是在析构器中添加一个removeObserver()方法,将自己从被观察者中移除。
当一个被观察者对象被删除时,如果我们不想让观察者来处理问题,我们只需要在被观察者对象被删除之前,给所有的观察者发送一个“死亡通知”就可以了。这样,所有已注册的观察者都可以收到通知并进行相应的处理。
为了不会发生野指针的问题,我们可以在观察者和被观察者中都维护一个双向指针:
- 绑定时(双向登记):
- 当你用
subject.addObserver(observer)时,不仅Subject把Observer加到自己的列表里; Observer内部也偷偷把这个Subject加到自己的subjects_列表里。
- 当你用
- 销毁时(双向注销):
- 当
Subject析构时:它遍历自己的Observers列表,告诉大家:“我要死了,你们在各自的subjects_列表里把我的名字删掉。” - 当
Observer析构时:它遍历自己的Subjects列表,告诉大家:“我要死了,你们在各自的observers_列表里把我的名字删掉。”
- 当
3. 失效观察者
由于被观察者对象持有它们的侦听者对象的引用,因此最后会导致一些僵尸UI对象留在内存中。我们学到的经验就是要及时删除观察者。
观察者模式非常适合于一些不相关的模块之间的通信问题。它不适合于单个紧凑的模块内部的通信。
拓展:函数式实现
把“基于类”的观察者模式重构成“基于函数”的函数式设计,核心思想就是:用“回调函数(Lambda/Delegate)”替代 IObserver 接口,用“函数列表”替代“对象列表”。
在函数式视角下,观察者不再是一个复杂的类实例,而是一个符合特定签名的纯粹动作;取消订阅也不再拿着 this 去寻找被观察者,而是直接执行一个返回的“取消句柄(Token/Disposer)”。
基于类 VS 函数式 核心对比
| 维度 | 基于类(OOP 传统模式) | 函数式(Functional 模式) |
|---|---|---|
| 观察者身份 | 必须实现 IObserver 接口的对象实例 |
任何符合参数签名的函数 / 静态方法 / Lambda |
| 存储容器 | List<IObserver> |
List<Action<T>> 或 Dictionary<Token, Action<T>> |
| 耦合度 | 观察者必须知道被观察者的类型以进行注销 | 零类型耦合,观察者甚至不需要知道是谁发出的通知 |
| 取消订阅 | subject.RemoveObserver(this) |
执行订阅时返回的 Unsubscribe() 闭包函数 |
C# / Unity 实现:Token 模式(现代函数式解耦)
为了解决匿名 Lambda 表达式“无法手写 -= 取消订阅”的问题,最优雅的函数式设计是在订阅时返回一个“取消订阅”的闭包函数:
using System;
using System.Collections.Generic;
public class EventStream<T>
{
private readonly Dictionary<int, Action<T>> _handlers = new();
private int _nextId = 0;
// 订阅:传入一个函数,返回一个“取消订阅”的 Action 闭包
public Action Subscribe(Action<T> handler)
{
int id = _nextId++;
_handlers[id] = handler;
// 返回一个闭包,调用该函数即可自动将自己从字典中移除
return () => _handlers.Remove(id);
}
// 广播:直接执行所有函数
public void Publish(T value)
{
// 倒序或复制一份遍历,防止回调内部取消订阅破坏迭代器
var targets = new List<Action<T>>(_handlers.Values);
foreach (var handler in targets)
{
handler?.Invoke(value);
}
}
}
实际使用(在业务代码里):
public class PlayerHealth
{
// 实例化一个血量变化事件流
public EventStream<int> OnHpChanged = new();
}
public class UIHealthBar
{
private Action _unsubscribe;
public void Bind(PlayerHealth player)
{
// 函数式写法:直接传 Lambda,无需实现任何接口!
_unsubscribe = player.OnHpChanged.Subscribe(hp =>
{
Console.WriteLine($"[UI] 当前血量更新为: {hp}");
});
}
public void OnDestroy()
{
// 注销时无需知道 PlayerHealth 是谁,直接执行闭包一键注销!
_unsubscribe?.Invoke();
}
}
C++ 函数式实现(std::function + 句柄)
如果在 C++ 中,可以通过 std::function 配合自增 ID 句柄来实现完全相同的函数式体验:
#include <functional>
#include <unordered_map>
template <typename... Args>
class Signal {
public:
using Callback = std::function<void(Args...)>;
using ConnectionToken = int;
// 注册回调,返回 Token
ConnectionToken connect(Callback cb) {
ConnectionToken id = current_id_++;
slots_[id] = cb;
return id;
}
// 通过 Token 取消注册
void disconnect(ConnectionToken token) {
slots_.erase(token);
}
// 触发信号
void emit(Args... args) {
for (const auto& [id, callback] : slots_) {
if (callback) callback(args...);
}
}
private:
std::unordered_map<ConnectionToken, Callback> slots_;
ConnectionToken current_id_ = 0;
};
函数式观察者的两大优势与坑点
优势
- 彻底解耦接口:像 UI、音效、成就系统等,再也不需要继承同一个
IObserver,任何组件随手写个方法就能监听。 - 支持生命周期闭包:订阅时返回
Action取消函数,使得“注销动作”可以在声明订阅时就内联写好,极大地减少了遗忘注销导致的野指针/内存泄漏问题。
坑点与注意事项
- GC 压力量(C#):如果在 Update 等高频帧中频繁
Subscribe并在 Lambda 中捕获局部变量,会产生额外的堆内存分配(Closure Block),游戏开发中应尽量在Awake/Start初始化阶段完成Subscribe。 - 生命周期绑定:如果 Lambda 表达式捕获了
this(比如hp => this.UpdateUI(hp)),当this被销毁但未执行Unsubscribe()时,触发事件仍可能报空指针异常,因此组件销毁时必须调用保存的Unsubscribe句柄。
原型模式
“使用特定原型实例来创建特定种类的对象,并且通过拷贝原型来创建新的对象。”
原型模式其核心思想,是一个对象可以生成与自身相似的其他对象,即通过拷贝现有的对象实例(原型)来创建新对象,而不是通过类重新实例化。
以 Unity 为例子,把一个 Prefab 拖到 Inspector 槽位里,然后调用 Instantiate(prefab) 时;以 UE 为例,就是SpawnActor<T>(Class)。
引擎就是在做“原型克隆”:不通过 new 具体的类,而是拿一个现成的对象实例作为模板,全盘复制它的网格、组件与当前状态。
-
解决的核心痛点:
- 避免为每一种怪物/物件都写一个专门的生成器类(彻底淘汰
GoblinSpawner、OrcSpawner、DragonSpawner这种类爆炸)。 - 只需要一个通用的
Spawner类,内部持有任何实现了clone()的原型对象(或 Prefab 引用)即可生成任意单位。
- 避免为每一种怪物/物件都写一个专门的生成器类(彻底淘汰
-
核心优势:状态克隆:
- 普通
new只能创建初始化的默认对象; - 原型克隆不仅能复制类型,还能直接继承原型当前的运行状态(比如一个被强化过、戴着特定装备的哥布林原型,克隆出来的新哥布林直接自带这些状态)。
- 普通
-
游戏引擎的工业落地:
- Unity Prefab:Prefab 就是挂在磁盘上的原型。
Instantiate(prefab)就是引擎底层的Clone()实现。 - 区别认知:经典 OOP 是“根据设计图纸(Class)造房子”;原型模式是“拿一栋已经建好的房子(Prefab)直接 3D 打印出相同的副本”。
- Unity Prefab:Prefab 就是挂在磁盘上的原型。
拓展:JS 早期没有传统的类继承,而是通过对象的原型链(
__proto__)实现属性共享与委托。在 C# / Unity 语境下,理解 Prefab 的克隆机制就足够了。
单例模式
“确保一个类只有一个实例,并为其提供一个全局访问入口。”
很神奇,我感觉我以前经常应用单例模式,但参考书居然写说这是介绍如何避免使用这一模式。
单例模式特点
-
确保一个类只有一个实例
- 应用:这个类与一个维持着自身全局状态的外部系统进行交互的情况。
-
提供一个全局指针以访问唯一实例
instance_这个静态成员保存着这个类的一个实例,私有的构造函数确保它是唯一的。公有的静态函数instance()为整个代码库提供了一个获取该实例的方法。它也负责在第一次访问的时候初始化这个实例,也就是延迟初始化(lazy initialization)。 -
如果我们不使用它,就不会创建实例
既然单例只在第一次被访问的时候初始化,那么如果我们的游戏始终不使用它,它就不会初始化。 -
它在运行时初始化
包含静态成员的类是单例最常见的替代品。但是静态类有一个局限:自动初始化。编译器早在main()函数调用之前就初始化静态数据了。这意味着它不能利用那些只有游戏运行起来才能知道的信息(比如,从文件中载入的配置)。它还意味着它们之间不能相互依赖——鉴于静态数据之间初始化的关联性,编译器不能保证它们之间的初始化的顺序。
延迟初始化解决了以上所有问题。单例会尽可能地将初始化延后,所以到那时它们需要的信息都应该是可以得到的。只要不是循环依赖,一个单例甚至可以在其初始化时引用另一个单例。 -
你可以继承单例
比如多平台时,给文件封装类实现为一个抽象接口,并由它的子类提供各个平台上的实现。该文件封装类是单例。使用一个简单的编译跳转,我们就可以将文件封装绑定到正确的具体类型上。我们的整个代码库都可以通过FileSystem::instance()来访问文件系统,而不必和任何平台相关的代码发生耦合。这部分耦合的代码封装在FileSystem类的实现文件之中了。
缺点
-
它是一个全局变量
- 它们令代码晦涩难懂
我们需要检查整个代码库来看是哪些部分访问了全局状态。
- 它们令代码晦涩难懂
-
全局变量促进了耦合
“限制全局访问,是为了防止有人走捷径破门而入。”
全局单例(如 AudioPlayer::Instance)就像把家里的钥匙挂在门外大路上。虽然你自己用着方便,但任何不熟悉规则的人都能随时进来把房间搞乱。不给全局实例,就是用架构机制强制约束大家走正规的“事件通知”管道。 -
并发不友好
- 竞态条件(Race Condition:把数据写乱)
- 原理:修改一个变量在 CPU 底层分三步:读取旧值 \(\rightarrow\) 计算新值 \(\rightarrow\) 写入新值。
- 危机:假设全局金币 GlobalCoins = 10。线程 A(玩家捡金币)和线程 B(完成任务奖励)同时触发 GlobalCoins++。如果它们在同一微秒读取,都读到了 10,各自加 1 后写回,最终金币变成了 11 而不是正确的 12。这类 Bug 在测试时极难复现,因为取决于 CPU 的微妙调度。
- 死锁与性能卡顿(Deadlock & Lock Overhead)
- 原理:为了防止上面数据写乱,开发者被迫给全局变量上锁(Lock / Mutex)。
- 危机:一旦上锁,其他线程就必须挂起等待,原本用来提升性能的多线程直接退化为单线程排队。如果线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1,游戏就会彻底无响应(死锁卡死)。
- 引擎视角下的并发约束
- Unity (C# Job System / DOTS)官方强制限制:
Unity 的 C# Job System 为了保证多线程安全,直接在编译器层面禁止在 Job worker 线程里访问任何 static 全局变量或单例(如 PlayerManager.Instance)。后果:一旦你试图在并行 Job 里访问单例,Unity 会直接抛出编译错误。你必须把数据打散通过原生数组(NativeArray)逐份传递给 Job。 - Unreal Engine (Task Graph / C++ Threads)随机崩溃风险:
在 UE 中使用 AsyncTask 或 ParallelFor 执行异步并行计算(例如并行解算 1000 个怪物的 AI 路径)时,如果你在并行任务内部直接访问了全局 Actor 或单例且未做 FCriticalSection 互斥锁保护,游戏在发布版(Shipping Build)中会随机报内存读写冲突而直接崩溃(Crash)。
- Unity (C# Job System / DOTS)官方强制限制:
- 竞态条件(Race Condition:把数据写乱)
替换方法
-
显式传参(Dependency Injection / 依赖注入)
-
思路:谁需要用这个对象,就在它的构造函数或初始化函数里,把对象作为参数传进去。
-
优点:代码依赖关系完全透明,编译期就能发现缺少哪个模块,杜绝“偷偷调用”。
-
引擎落地:
-
Unity:在组件的
Init(AudioManager audio)中显式传入,或使用 Zenject/VContainer 等 DI 框架。 -
UE C++:在 Actor 的
BeginPlay()或组件初始化函数中传入指针依赖Init(AAudioManager* InAudio)。
-
-
-
存放在基类中(Base Class Access)
-
思路:如果不希望每个函数都传参,就把共享系统的引用存放在所有游戏对象的公共基类(Protected 变量)里。
-
优点:所有继承该基类的游戏对象都可以直接访问,但外部类完全看不到该实例,将访问权限严格限制在子类内部。
-
引擎落地:
-
Unity:继承自
BaseEntity : MonoBehaviour,基类在中提供protected AudioManager Audio。 -
UE C++:在自定义的
AGameCharacter : ACharacter基类中暴露 protected 属性或 Getter 方法。
-
-
-
收拢到已有的“大全局对象”中(Aggregated World / Game Instance)
-
思路:与其创建 10 个分散的全局单例(AudioMgr、UIMgr、PhysicsMgr),不如只保留 1 个大环境对象,将其他系统作为它的成员变量。
-
优点:全局入口从 10 个减少为 1 个,方便统一控制各个子系统的初始化顺序和生命周期。
-
引擎落地:
-
Unity:挂载在全局持续存在的
GameManager节点上,统一调度各 System。 -
UE C++:利用 UE 原生的
UGameInstance或UWorldSubsystem,由引擎统一托管生命周期。
-
-
-
服务定位器模式(Service Locator)
-
思路:建立一个“服务注册表”,只暴露抽象接口(如 IAudio)。具体的音频实现类向注册表登记,需要使用的对象去注册表里按接口索取。
-
优点:彻底解耦了具体实现类。即使未来将 Unity/UE 的原生音频替换为 FMOD 或 Wwise,调用方的代码一行都不用改。
-
引擎落地:
-
Unity:
ServiceLocator.Register<IAudio>(new FMODAudioService())。 -
UE C++:利用 ModularFeatures 或引擎底层的 Subsystem 动态注册接口。
-
-
-
仅限制实例数量,取消全局访问入口(Single-Instance Assertion)
-
思路:在类的构造函数里添加断言 assert(numInstances == 0),如果试图创建第二个实例就直接报错。但不提供 GetInstance() 静态方法,逼迫开发者只能通过传参来使用它。
-
优点:既保证了“全局有且仅有一个”的物理唯一性,又掐断了任何人在角落里“偷偷访问”的捷径。
-
拓展:称不访问或者不修改全局状态的函数为“纯函数”(pure function)。纯函数易于理解,利于编译器优化,并令你能够使用诸如记忆缓存、重用之前调用结果的技巧。
状态模式
“允许一个对象在其内部状态改变时改变自身的行为。对象看起来好像是在修改自身类。”
有限状态机

核心
- 你拥有一组状态,并且可以在这组状态之间进行切换
- 状态机同一时刻只能处于一种状态
- 状态机会接收一组输入或者事件
- 每一个状态有一组转换,每一个转换都关联着一个输入并指向另一个状态
简而言之,整个状态机可以分为:状态、输入和转换。
实现方式
-
最简单的方法:枚举和分支
将每一个状态用一个枚举来表示,先判断状态,再进行操作的判断 -
普通实现方式:
- 为状态定义一个接口
首先,我们为状态定义一个接口。每一个与状态相关的行为都定义成虚函数。 - 为每一个状态定义一个类
对于每一个状态,我们定义了一个类并继承此状态接口。它的方法定义主角对应此状态的行为。换句话说,把之前的switch语句里面的每一个case语句里的内容放置到它们对应的状态类里面去。 - 状态委托
我们在主角类中定义一个指针变量,让它指向当前的状态。我们把之前那个很大的switch语句去掉,并让它去调用状态接口的虚函数,最终这些虚方法就会动态地调用具体子状态的相应函数。 - 状态对象的放置
-
静态状态
如果一个状态对象没有任何数据成员,那么它的唯一数据成员便是虚表指针了。那样的话,我们就没有必要创建此状态的多个实例了,因为它们的每一个实例都是相同的。
在那种情况下,我们可以定义一个静态实例。即使你有一系列的FSM在同时运转,所有的状态机也能同时指向这一个唯一的实例。
一般来说,静态方法放置到基类状态类中。如果你的状态类没有任何数据成员,并且只有一个虚函数方法。那么我们还可以进一步简化此模式。我们可以使用一个普通的状态函数来替换状态类。这样的话,我们的state_变量就变成一个状态函数指针。
此时就变换为享元模式了。 -
实例化状态
在切换状态时 new State() 或 FSM 初始化时独立创建。在数据上,可随意定义 timer、speed 等私有字段,并且支持带参构造,如FSM.ChangeState(new AttackState(comboID))。
但若频繁new/delete会产生 GC 压力或堆内存碎片。工业界常用的两种优化GC方式:
- FSM 预加载模式(最推荐):状态机在
Init时为当前 Actor 创建好所有可能用到的 State 实例并保存在字典中。状态切换时只做指针引用切换,既保留了独立状态数据,又实现了零动态内存分配。 - 状态对象池(State Pool):对于带复杂动态初始化参数的状态(如带有不同技能参数的 CastSpellState),通过对象池(Object Pool)进行复用。
引擎落地实现:
- Unity (C#):
通常定义非MonoBehaviour的普通 C# 类实现状态接口,或者将State做成ScriptableObject(切换时利用Instantiate(soState)复制一份独立的动态实例)。 - Unreal Engine (C++):
若状态轻量,可采用继承自 UObject 的 C++ 类,在UFSMComponent初始化时通过NewObject<UStateBase>(this)动态创建并托管给垃圾回收(UPROPERTY);或者使用纯 C++ 结构体与std::unique_ptr进行生命周期管理。
- FSM 预加载模式(最推荐):状态机在
-
- 状态的进出
通过给每一个状态添加一个enter和exit函数。
- 为状态定义一个接口
状态委托、策略模式、类型对象模式
- 策略模式的目标是将主类与它的部分行为进行解耦。
- 类型对象模式的目标是使得多个对象通过共享相同类型对象的引用来表现出相似性。
- 状态模式的目标是通过改变主对象代理的对象来改变主对象的行为。
并发状态机
如果我们需要为主角定义n种状态和m种它能够携带的武器状态,如果使用一个状态机来表示,那么我们需要n×m个状态。而如果使用两个状态机,那么状态组合仅是n+m。
将不同的状态类型,拆分成不同的状态机来进行组合。这样每一个状态机都可以响应输入事件并以此切换状态而不用考虑其他状态机的实现细节。当两个状态没什么关系的时候,这种方法工作得很好。
功能更加完备的系统可能会只让一个状态机来处理输入,以便另外一个状态机不会接收到输入。这样将能防止两个状态机对同一输入进行错误的响应。
不过,实践中,可以会遇到一些需要自己添加简单判断的干预进行特殊处理。
层次状态机
定义:
一个状态有一个父状态。
当有一个事件进来的时候,如果子状态不处理它,那么沿着继承链传给它的父状态来处理。换句话说,它有点像覆盖继承的方法。
也可以在基类中使用状态栈而不是单单一个状态的方法来更加明确地表示父状态的状态链——我们当前的状态总是处于栈顶,栈顶下面的第一个元素是它的父状态,再下一个状态则是它的父状态的父状态,以此类推。如果你要进行一些与状态相关的行为操作,那么首先从栈顶状态开始。如果它不处理,则往下寻找直到找到一个能处理此事件的状态为止(如果找遍整个栈了,还是没能被处理,则将此事件被忽略掉)。
下推自动机
使用状态栈记录了历史记录。
本来,有限状态机有一个指向当前状态的指针。而下推自动机则有一个状态栈。在一个有限状态机里面,当有一个状态切进来时,则替换掉之前的状态。下推自动机可以让你这样做,同时它还提供其他选择:
- 你可以把这个新的状态放入栈里面。当前的状态永远存在栈顶,所以你总能转换到当前状态。但是当前状态会将前一个状态压在栈中自身的下面而不是抛弃掉它。
- 你可以弹出栈顶的状态,该状态将被抛弃。与此同时,上一个状态就变成了新的栈顶状态了。
有限状态机的应用场景:
- 你有一个游戏实体,它的行为基于它的内部状态而改变。
- 这些状态被严格划分为相对数目较少的小集合。
- 游戏实体随着时间的变化会响应用户输入和一些游戏事件。
[1] Robert Nystrom. 游戏编程模式[M]. 微信读书版

浙公网安备 33010602011771号