【游戏架构】【笔记】行为型模式——《游戏编程模式》学习笔记003
该笔记中有AI辅助内容。
字节码
“通过将行为编码成虚拟机指令,而使其具备数据的灵活性。”
看完后就发现,这一章本质上就是在揭开 UE 蓝图(Blueprint)、Unity 脚本热更新(XLua/uLua)以及游戏引擎内嵌脚本(如 Lua/Python)底层的真正运行机制。
我们平时在 UE 连线时创建的蓝图节点,在点击 Compile(编译) 的瞬间,并没有直接变成 C++ 源码,而是被编译成了一串连续的 字节码(Unreal / Kismet Bytecode)。当游戏运行时,UE 底层的虚拟机(UnrealVM)以一个巨大的循环逐条读取这些字节码并执行对应的 C++ 底层函数。
核心定义:将复杂的行为逻辑编译为紧凑、连续的二进制指令集(Opcodes),并在游戏内置的虚拟机(VM)中顺序解释执行。实现“既有数据驱动的灵活性,又兼具高密度与沙箱安全性”。
前提知识
解释器模式
- 首先,定义一个所有表达式都必须实现的基础接口。
- 然后为你的语言中的每个语法定义类来实现这个接口。
缺点
一个字:慢!
- 从磁盘加载它需要进行实例化并串联成堆的小对象。
- 这些对象和它们之间的指针占用大量内存。在32位机上,即使不考虑内存对齐,这个小小的表达式也要占用68字节(4字节/指针*17个指针)。
- 从每个指针遍历子表达式都会大量消耗数据缓存,而虚函数调用也会对指令缓存造成很大压力。
虚拟机器码
为什么要使用机器码呢:
- 高密度。它是坚实连续的二进制数据块,不浪费任何一个字节。
- 线性。指令被打包在一起顺序执行。不会在内存中跳跃访问(当然了,除非你确实编写了控制流)。
- 底层。每个单独的指令仅仅完成一小个动作,各种有趣行为都是这些小动作的组合。
- 迅速。以上几点让机器码疾行如风(当然还得算上机器码由硬件实现这一点了)。
字节码模式
我们在游戏中实现一个执行它们的模拟器。这些虚拟机器码与机器码相似(高密度、线性、相对底层)同时它完全受到游戏本身的安全管理。
我们将这个小型模拟器称为虚拟机(VM),这个虚拟机所执行的语义上的“二进制机器码”称为字节码。它具备在数据内定义对象的灵活性和易用性,同时也比解释器模式这种高级呈现方式更高效。
这也可以提高对Lua的了解。
指令集定义了一套可以执行的底层操作。一系列指令被编码为字节序列。虚拟机逐条执行指令栈上这些指令。通过组合指令,即可完成很多高级行为。
什么情况使用
- 编程语言太底层了,编写起来繁琐易错。
- 因编译时间太长或工具问题,导致迭代缓慢。
- 它的安全性太依赖编码者。你想确保定义的行为不会让程序崩溃,就得把它们从代码库转移至安全沙箱中。
架构的核心三要素
- 指令集 (Instruction Set / Opcodes):定义虚拟机支持的基础操作(如
MOVE,ADD,JUMP,CALL_FUNC),通常每一个指令占用 1 字节(uint8_t)。 - 字节码流 (Bytecode Stream):把编写好的逻辑编译并打包成一块连续的二进制数组。相比于由无数指针构成的语法树,它对 CPU 缓存极其友好(Cache-Friendly)。
- 虚拟机 (Virtual Machine):内部维护一个数据栈(Stack-based)或寄存器(Register-based),通过
while(true) switch(*ip++)循环解码并按顺序执行指令。
传统解释器 vs 字节码虚拟机
| 维度 | 传统语法树解释器 (AST Interpreter) | 字节码虚拟机 (Bytecode VM) |
|---|---|---|
| 内存结构 | 离散的节点对象与大量的指针链接 | 紧凑连续的 uint8_t 字节数组 |
| 性能表现 | 慢(频繁的指针跳转导致大量的 CPU Cache Miss) | 快(指令连续读取,高度利用 L1/L2 缓存) |
| 典型应用 | 早期的简单脚本、简单的配置文件解析 | UE 蓝图、Lua 引擎、JVM、C# CIL |
引擎中的工业级落地
-
Unreal Engine (UE C++):
- 蓝图编译:蓝图图表会被编译成由
EExprToken(如EX_CallMath,EX_JumpIfNot,EX_Context)组成的 Kismet 字节码。 - UnrealVM:运行时通过
UObject::ProcessInternal内核函数中的switch(*Code)循环解析这些字节码。 - 蓝图原生化 (Nativization):引擎将字节码直接“翻译”为原生 C++ 代码编译,避开虚拟机解释开销,从而提升性能。
- 蓝图编译:蓝图图表会被编译成由
-
Unity (C# & 热更新体系):
- C# 编译:写好的 C# 代码首先被编译为微软的 CIL (MSIL) 字节码(
.dll),运行时由 Mono 虚拟机或 IL2CPP 解释/编译运行。 - 热更新方案 (XLua / HybridCLR):iOS 平台禁止运行时动态生成 CPU 机器码(JIT),因此热更方案通过在 C# 内部自己写一个字节码解释器(VM),去读取并执行打进包内的热更字节码。
- C# 编译:写好的 C# 代码首先被编译为微软的 CIL (MSIL) 字节码(
子类沙盒
“使用基类提供的操作集合来定义子类中的行为。”
看完后,我感觉我之前的在父类中的方法,父类实现通用内容,然后按实现顺序写几个空方法,供给子类自己进行实现不一样的效果,看起来好像就和子类沙盒差不多。——但是事实上面那个是模板方法模式。
子类沙盒模式经常以模板方法作为入口函数。两者的侧重点不同:
- 模板方法关注“控制执行流与生命周期”,
- 而子类沙盒关注“提供受保护的工具集,隔离外部依赖”。
1. 核心定义
在基类中定义一个抽象/虚函数的“沙盒方法”(Sandbox Method),同时在基类中提供一系列受保护(protected)的辅助工具函数。子类在实现沙盒方法时,只使用基类提供的工具函数组合出具体的业务逻辑。
2. 核心三要素
- 沙盒方法(Sandbox Method): 派生类必须重写的虚函数(如
activate()、update()),代表派生类的独有行为。 - 工具函数(Provided Operations): 基类提供的
protected非虚函数(如playSound()、spawnParticles()、moveTo()),作为子类构建逻辑的“积木”。 - 依赖隔离(Dependency Encapsulation): 外部系统(音效引擎、特效系统、物理系统)的引用被隐藏在基类内部,子类无需直接依赖全局变量或单例。
3. 子类沙盒 vs. 模板方法
| 维度 | 模板方法模式 (Template Method) | 子类沙盒模式 (Subclass Sandbox) |
|---|---|---|
| 核心关注点 | 何时做(控制算法步骤与执行顺序) | 用什么做(提供安全工具集,解耦外部依赖) |
| 关键机制 | 基类写死主流程,调用子类重写的步骤/钩子 | 基类提供大量 protected 辅助函数,子类自由组合 |
| 控制权归属 | 控制权在基类(基类主动调用子类) | 控制权在子类(子类在沙盒内主动调用基类工具) |
| 典型应用 | UI 生命周期函数(如 Init -> Show -> Close) |
游戏中的技能(Skill)、Buff、成就(Achievement) |
结合使用: 实际开发中,基类常常用模板方法控制沙盒方法的触发时机(例如
Show()内部触发execute()),而子类在execute()内部使用子类沙盒提供的工具函数。
4. 典型代码结构(C++ 示例)
// 1. 基类:封装外部依赖,提供沙盒工具
class Superpower {
public:
virtual ~Superpower() = default;
// 入口函数(结合模板方法模式)
void activate() {
startCooldown();
execute(); // 执行子类的沙盒逻辑
}
protected:
// === 待子类实现的沙盒方法 ===
virtual void execute() = 0;
// === 受保护的工具函数(沙盒积木) ===
void playSound(const std::string& soundName) {
// 内部封装了 AudioSystem::Get().Play(soundName);
}
void spawnParticles(const std::string& fxName) {
// 内部封装了 FXManager::Get().Spawn(fxName);
}
void moveOwner(float distance) {
// 移动控制逻辑
}
private:
void startCooldown() { /* 冷却逻辑 */ }
};
// 2. 子类:只在沙盒内部组合工具函数,不直接引用外部 Manager
class SkyLaunch : public Superpower {
protected:
void execute() override {
// 纯粹使用基类工具组合业务逻辑
playSound("whoosh.wav");
spawnParticles("cloud_puff");
moveOwner(100.0f);
}
};
5. 适用场景与优势
- 大量同类衍生对象: 游戏中存在数十甚至上百种平级派生类(如各种法术、怪物技能、任务条件)。
- 降低头文件与代码耦合: 如果不使用沙盒,每个技能子类都需要
#include音效、特效、动画、物理等头文件;使用沙盒后,这些依赖全被收拢到基类,子类头文件极为干净。 - 修改成本低: 如果底层音效引擎接口发生变更,只需要改动基类的工具函数实现,所有子类无需任何变动。
6. 隐患与应对方案(基类臃肿问题)
- 痛点: 随着子类需求增加,基类中的
protected辅助函数会越来越多,导致基类变成巨大的“上帝类”(God Object)。 - 应对策略(辅助对象分组):
当工具函数过多时,不要全部塞在基类直属方法里,可以将其按功能分组为服务对象(Services),基类只暴露这些服务对象的引用:
class Superpower {
protected:
// 将工具分组,基类仅提供受保护的服务引用
SoundService& getSound() { return soundService_; }
ParticleService& getParticles() { return particleService_; }
private:
SoundService soundService_;
ParticleService particleService_;
};
// 子类中使用:
void execute() override {
getSound().play("whoosh.wav");
getParticles().spawn("cloud_puff");
}
引擎中的应用
两大引擎在很多涉及“衍生变体极多、需要暴露给策划或 Gameplay 程序员编写”的子系统中,都深度使用了子类沙盒模式。
引擎通过沙盒基类把底层复杂的网络同步、内存管理或底层 API 封装成安全、易用的辅助方法(Protected Operations),使用者只需继承基类并在重写的“沙盒方法”里用这些积木拼装逻辑。
一、 Unreal Engine (UE C++) 中的应用
UE 的很多Gameplay框架和蓝图系统是子类沙盒模式的典型代表:
1. Gameplay 技能系统(GAS - UGameplayAbility)—— 最经典的教科书案例
- 沙盒方法:
ActivateAbility() - 辅助工具集:
CommitAbility(),ApplyGameplayEffectToOwner(),EndAbility(),MakeOutgoingSpec() - 运作机制:
在 GAS 中,每一种技能(如火球术、闪现)都是UGameplayAbility的子类。程序员或策划在重写ActivateAbility()时,不需要直接操作复杂的属性组件(AttributeSet)或网络同步内核,只需调用基类提供的辅助函数(如CommitAbility扣减消耗,ApplyGameplayEffect...施加 Buff)。
2. 行为树任务节点(UBTTaskNode / UBTTask_BlueprintBase)
- 沙盒方法:
ExecuteTask(),TickTask() - 辅助工具集:
FinishExecute(),GetBlackboardValueAsVector(),SetBlackboardValueAsObject() - 运作机制:
编写自定义 AI 任务节点时,只需继承UBTTask_BlueprintBase并重写ExecuteTask。子类不需要知道行为树底层的调度机制和节点轮询细节,逻辑完成后调用FinishExecute(true/false)即可向基类汇报执行结果。
3. 角色基础控制(ACharacter)
- 沙盒方法:
SetupPlayerInputComponent(),OnJumped() - 辅助工具集:
Jump(),StopJumping(),AddMovementInput(),LaunchCharacter() - 运作机制:
ACharacter帮子类屏蔽了复杂的UCharacterMovementComponent物理解算和网络预测。你在重写输入绑定或移动逻辑时,只需调用AddMovementInput()或Jump()这类受保护的工具方法,而无需直接处理速度向量叠加或碰撞修正。
二、 Unity (C#) 中的应用
Unity 虽然提倡组合模式(MonoBehaviour Component),但在许多框架级的扩展基类中,同样采用了子类沙盒的思想:
1. 动画状态机行为(StateMachineBehaviour)
- 沙盒方法:
OnStateEnter(),OnStateUpdate(),OnStateExit() - 辅助工具集:通过参数传入的
Animator上下文对象,以及基类提供的状态检测方法。 - 运作机制:
当需要为 Mecanim 状态机上的某个 State 编写特定逻辑(如“进入攻击帧时播放音效”)时,只需继承StateMachineBehaviour。基类定义好了触发时机,派生类在OnStateEnter中利用基类暴露的上下文轻松完成逻辑,不需要自己去写针对动画层的轮询监测。
2. Timeline 扩展(PlayableBehaviour)
- 沙盒方法:
ProcessFrame(),OnGraphStart(),OnBehaviourPlay() - 辅助工具集:
playable.GetTime(),playable.SetSpeed() - 运作机制:
在扩展 Unity Timeline 自定义轨道时,继承PlayableBehaviour并重写ProcessFrame。派生类在这个沙盒方法内使用Playable结构体提供的 API 操作当前帧的混合权重与时间,而不必理会底层PlayableGraph的节点调度和计算链路。
3. 自定义编辑器窗口与属性绘制(EditorWindow / PropertyDrawer)
- 沙盒方法:
OnGUI() - 辅助工具集:
EditorGUILayout.PropertyField(),EditorGUI.BeginChangeCheck(),ShowNotification() - 运作机制:
编写 Unity 编辑器工具时,子类继承EditorWindow并重写OnGUI()。子类并不需要手写底层的 DirectX/OpenGL 绘制指令,所有的控件生成、排版布局、撤销重做(Undo)都直接调用基类提供的 GUI 工具集。
总结:为什么游戏引擎偏爱子类沙盒?
| 引擎设计痛点 | 子类沙盒提供的解法 |
|---|---|
| 隔离底层复杂性 | 将网络同步、物理解算、内存管理封装在基类内部,Gameplay 开发者只在“沙盒”内写逻辑。 |
| 暴露给蓝图/脚本 | 蓝图节点的底层实现本质上就是把 C++ 基类的 protected 工具函数暴露给可视化节点。 |
| 安全防御(防崩溃) | 限制派生类直接操作风险极高的指针或全局单例,强制通过基类校验过的工具函数进行操作。 |
类型对象
“通过创建一个类来支持新类型的灵活创建,其每个实例都代表一个不同的对象类型。”
这个看起来和读取表格数据的感觉很像呢,所以...
“类型对象模式”在现代游戏工业中的落地形式,本质上就是配表(Excel/CSV/JSON/DataTables)与数据驱动架构。
配表中的每一行数据(如 Goblin_Archer_Row),就是内存中被实例化出来的类型对象(Type Object);而场景中刷出来的具体怪物(如 Goblin_Archer_102),就是持有类型对象的实体(Typed Object)。
1. 类型对象模式的定义
- 类型对象类
- 表示一个不同的逻辑类型
- 存储同概念类型所共享的数据;
- 持有类型对象类
- 实例引用一个描述其类型的类型对象;
- 存储实例特定数据;
引用同一个类型对象的对象之间能表现出“同类”的性状。这让我们可以在相似对象集合中共享数据和行为,这与类派生的作用有几分相似,但却无需硬编码出一批派生类。
2. 使用场景
- 你不知道将来会有什么类型(例如,我们的游戏是否需要支持包含怪物新种类的资料包下载?)。
- 你需要在不重新编译或修改代码的情况下,修改或添加新的类型。
- 运行时热重载(Hot Reloading)与热更新
将类型从编译期(C++/C# 代码类)抽离为运行期(类型对象/数据配置)后,游戏可以在不重启、不重新编译的情况下,直接在运行时重新读取配表并覆盖内存中的类型对象,实现策划立竿见影的“调数值/调机制”体验。
3. 注意事项
- 我们要注意对应的持有类型对象类存在时,对应的类型对象一定要被实例化并驻留于内存
使用类型对象模式,我们现在不但要负责管理内存中的怪物,还要管理它们的类型,我们得保证只要有怪物存在,其对应的种族对象就应该被实例化并驻留于内存。
一旦创建新的怪物,我们就必须确保它是以一个有效种族实例的引用来进行正确的初始化。 - 不能获得更多变的效果
此时,我们用成员变量替代了方法重写。所以不能有更多样的行为。
有几种方法可以跨越这个限制:- 创建一个固定的预定义行为集合,让类型对象中的数据从中选一个
结合策略模式(Strategy Pattern)或组件模式(Component):- 类型对象里不写死亡代码,而是配置一个“行为策略 ID”或“事件响应组件”。
- 例如:类型对象里存
DeathBehaviorType = "ExplodeOnDeath",实体死亡时调用行为工厂去执行对应的爆炸逻辑。
- 支持在数据中定义行为。
- 创建一个固定的预定义行为集合,让类型对象中的数据从中选一个
4. 使用类型对象模式的好处
- 无需派生很多子类
例如,构造一个怪物时,我们给它一个种族对象的引用。由此定义怪物的种族,取代之前的类派生关系。在构造函数中,怪物使用种族来确定它的初始生命值。要获得攻击字符串,怪物只需调用它所属种族的相应方法。 - 在类型对象类中定义一个拥有类型对象类的构造函数——让类型对象更加像类型
把此逻辑放进唯一能创建怪物的Breed里,就保证了所有的怪物都由我们预想的内存管理体系经手。 - 通过继承共享数据
我们可以让种族之间也能共享特性。
当我们构造一个种族时,先为它传入一个基种族。我们可以传入NULL来表示它没有祖先。
为使其更实用,子种族需要明确哪些特性从父类继承,哪些特性由自己重写和特化。打比方,子种族只继承基种族中的非零生命值以及非NULL的攻击字符串。
实现方法:- 在属性每次被请求时执行代理调用
- 好处:在运行时修改了种类、去掉种类继承、去掉某个特性继承,也能正常运行。
- 坏处:占用更多的内存,更慢。
- 在构造时采用继承——复制代理
在构造时,将父类的属性拷贝下了,这时只需要读取字段即可。- 好处:快
- 坏处:不可变
- 在属性每次被请求时执行代理调用
5. 设计时的考虑
-
类型对象应该封装还是暴露
- 如果被封装
- 安全度高。类型对象模式的复杂性对代码库的其他部分不可见。它成为了持有类型对象才需关心的实现细节。
- 普通的灵活性。持有类型对象的类可以有选择性地重写类型对象的行为。
- 公开方法很麻烦。我们得给类型对象暴露的所有内容提供转发函数。
- 如果被公开
- 灵活性高,安全度低。外部代码在没有持有类型对象类实例的情况下就能访问类型对象。
- 不用写一堆公开方法。类型对象现在是对象公共API的一部分。
- 如果被封装
-
如何创建持有类型对象
- 构造对象并传入类型对象
- 外部代码可以控制内存分配
- 在类型对象上调用“构造”函数
- 类型对象控制内存分配
- 构造对象并传入类型对象
-
类型是否可以被改变
- 类型不可被改变
- 更易读更好理解
- 易于调试
- 类型可变
- 减少了对象的创建
- 做约束时要更加小心。
当我们修改类型时,我们可能会需要执行一些验证代码来保证对象现在的状态对新类型来说有意义。
- 类型不可被改变
-
支持何种类型的派生
- 没有派生
- 简单
- 但可以导致重复劳动
- 单继承
- 相对简单
- 属性查找会更慢
- 多重派生
- 能避免绝大多数的数据重复
- 但复杂
- 没有派生
6. 引擎落地对标(Unity vs Unreal Engine)
| 维度 | Unity (C#) 实现 | Unreal Engine (C++) 实现 |
|---|---|---|
| 基础类型对象载体 | ScriptableObject |
UDataAsset |
| 配表/表格驱动 | TextAsset + JsonUtility / ExcelImporter |
UDataTable (结合 FTableRowBase 结构体) |
| 类型实例化方式 | Monobehaviour 暴露 [SerializeField] EnemyDataSO data; |
AActor 暴露 UPROPERTY() UEnemyDataAsset* DataAsset; |
| 变种/继承支持 | 嵌套 ScriptableObject 或 C# 数据结构继承 |
UPrimaryDataAsset 搭配蓝图继承/数据表继承 |
1. Unity (C#) 模式展示:ScriptableObject
在 Unity 中,最标准的类型对象模式实现就是 ScriptableObject(数据资产)搭配 MonoBehaviour(实体):
// 1. 类型对象 (Type Object):充当“种族/模板”
[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Game/Enemy Data")]
public class EnemyDataSO : ScriptableObject
{
public string enemyName;
public int maxHp;
public float moveSpeed;
public GameObject prefab; // 表现层预制体
}
// 2. 持有类型对象的实体 (Typed Object)
public class EnemyController : MonoBehaviour
{
[SerializeField] private EnemyDataSO _enemyData; // 引用类型对象
private int _currentHp; // 实体专有的当前数据
public void Init(EnemyDataSO data)
{
_enemyData = data;
_currentHp = _enemyData.maxHp; // 从类型对象提取共享初始值
}
}
2. Unreal Engine (C++) 模式展示:UDataAsset 与 UDataTable
在 UE 中,如果为每种怪物都创建一个 AActor C++ 子类,会导致编译极其缓慢且内存膨胀。UE 推荐使用 UDataAsset 或 UDataTable 结合通用的 AActor:
// 1. 类型对象结构体 (Type Object Data)
USTRUCT(BlueprintType)
struct FEnemyBreedData : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText BreedName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float BaseHealth = 100.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
USkeletalMesh* CharacterMesh;
};
// 2. 持有类型对象的通用 Actor (Typed Object)
UCLASS()
class AGameEnemy : public AActor
{
GENERATED_BODY()
public:
// 持有数据表中的某一行作为类型引用
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "TypeObject")
FDataTableRowHandle EnemyTypeHandle;
// 实体独有的动态状态
float CurrentHealth;
virtual void BeginPlay() override
{
Super::BeginPlay();
// 从类型对象(Data Table Row)中提取共享配置
if (FEnemyBreedData* Data = EnemyTypeHandle.GetRow<FEnemyBreedData>(TEXT("")))
{
CurrentHealth = Data->BaseHealth;
}
}
};
7. 额外内容
与享元模式(Flyweight)的关系
享元模式倾向于节约内存,并且共享的数据可能不会以实际的“类型”呈现。类型对象模式的重点在于组织性和灵活性。
- 本质:类型对象模式可以看作是享元模式在“对象类型”维度的特化应用。
- 分工:类型对象存储内在状态(Intrinsic State,如基础攻击力、音效路径、模型 Mesh),实体存储外在状态(Extrinsic State,如当前坐标、剩余血量、仇恨目标)。
[1] Robert Nystrom. 游戏编程模式[M]. 微信读书版

浙公网安备 33010602011771号