本文最后修改于 2026 年 1 月 12 日。
摘要
该系列文章基于 Unity 官方电子书《在 Unity 中使用 ScriptableObjects 创建模块化游戏架构(Unity 6 版)》,深入学习 ScriptableObject 的核心优势和实践应用,包括数据容器、可扩展枚举,观察者模式等。
电子书资源
在 Unity 中使用 ScriptableObjects 创建模块化游戏架构(Unity 6 版)
https://unity.com/cn/resources/create-modular-game-architecture-scriptableobjects-unity-6
社区系列文章
Unity 电子书|ScriptableObject 模块化架构|一|SO介绍
https://developer.unity.cn/projects/694c94adedbc2a04b15d1732
Unity 电子书|ScriptableObject 模块化架构|二|SO变量
https://developer.unity.cn/projects/6953aebfedbc2aabd609730d
使用场景三:可扩展的枚举
枚举是什么
枚举的本质
枚举(Enum)是 C# 中的一种特殊值类型,它本质上是对一组命名整数常量的封装。在底层实现中,枚举值会被编译器转换为对应的整数值(默认为 int 类型),但它提供了更具可读性和类型安全性的方式来表示这些值。
例如,当你定义 Direction.North 时,编译器实际上会将其转换为数字 0,但在代码中你可以使用更有意义的名称,而不是直接使用数字。
枚举的核心特性
1. 单一状态表示
默认情况下,枚举变量在任意时刻只能存储一个枚举值。这与 bool 类型类似,但 bool 只有两个选择(true/false),而枚举可以定义任意多个命名值。
// 枚举变量同一时间只能是其中一个值
Direction currentDirection = Direction.North;
// 不能同时是 North 和 South
2. 类型安全优势
与使用裸整数或字符串相比,枚举提供了编译时类型检查:
// 使用整数 - 不安全,容易出错
int direction = 5; // 5 代表什么?没有约束
SetDirection(direction); // 可能传入无效值
// 使用枚举 - 类型安全
Direction direction = Direction.North;
SetDirection(direction); // 编译器确保传入有效值
Unity/C# 中的常见应用
枚举在游戏开发中有广泛应用:
状态机管理
publicenum EnemyState
{
Idle,
Patrol,
Chase,
Attack
}
事件类型定义
publicenum GameEvent
{
PlayerDied,
LevelCompleted,
BossFightStarted
}
UI 界面标识
publicenum UIPanel
{
MainMenu,
Settings,
Inventory,
Shop
}
难度等级
publicenum Difficulty
{
Easy,
Normal,
Hard,
Nightmare
}
Flags 特性:多状态共存
位运算原理
使用 [Flags] 特性可以让枚举支持多个值同时存在。其原理基于二进制位运算,每个枚举值占用一个独立的二进制位:
[Flags]
publicenum Permission
{
None = 0, // 0000
Read = 1, // 0001
Write = 2, // 0010
Execute = 4, // 0100
Delete = 8// 1000
}
// 组合多个权限
Permission userPerm = Permission.Read | Permission.Write;
// 结果: 0011 (同时拥有读和写权限)
位运算操作
// 添加权限
userPerm |= Permission.Execute;
// 移除权限
userPerm &= ~Permission.Write;
// 检查权限
bool canRead = (userPerm & Permission.Read) == Permission.Read;
bool hasFlag = userPerm.HasFlag(Permission.Read); // 推荐方式
实际应用场景
在 Unity 中,[Flags] 枚举常用于:
Layer Mask:选择多个渲染层
能力系统:角色同时拥有多种技能
物理材质属性:物体同时具有多种物理特性
权限控制:用户同时拥有多种操作权限
枚举编写方式
普通枚举(单一状态)
publicenum GameState
{
Menu,
Playing,
Paused,
GameOver
}
这种枚举在任意时刻只能表示一种状态,例如游戏只能处于菜单、游玩、暂停或结束中的某一个状态。
带有 [Flags] 的枚举(多状态)
[Flags]
publicenum PlayerAbility
{
None = 0,
Jump = 1,
Dash = 2,
DoubleJump = 4,
WallClimb = 8
}
使用 [Flags] 特性后,可以同时拥有多个状态,例如玩家可以同时拥有跳跃和冲刺能力:PlayerAbility.Jump | PlayerAbility.Dash。
可扩展的枚举是指什么
传统枚举的局限性在于它们被硬编码在代码中。每次添加新的枚举值都需要修改代码并重新编译,这对于非程序人员来说极不友好。同时,枚举值的含义和用途也不够直观,必须打开代码编辑器才能查看。
可扩展枚举的核心思想是使用 ScriptableObject 资源来代替传统枚举值。每个 SO 实例代表一个"枚举值",这些资源文件直接存在于项目中,可以在 Unity 编辑器中直观地查看和管理。
这种方式的优势包括:
可视化管理:在项目窗口中直接看到所有"枚举值",无需打开代码
设计师友好:非程序人员可以通过创建新的 SO 资源来扩展内容
无需重新编译:添加新值只需创建新资源,不用修改代码
可扩展行为:每个 SO 可以包含额外的数据和方法,实现比传统枚举更丰富的功能
简单应用案例
电子书中以经典的"石头剪刀布"游戏为例,展示了可扩展枚举的应用。
传统枚举实现
publicenum HandShape
{
Rock,
Paper,
Scissors
}
传统方式需要在代码中硬编码判断逻辑,添加新的手势(如"蜥蜴"、“史波克”)需要修改多处代码。
使用 ScriptableObject 的实现
首先创建一个 ScriptableObject 基类:
[CreateAssetMenu(menuName = "Game/HandShape")]
publicclassHandShape : ScriptableObject
{
public Sprite icon;
public List<handshape> winsAgainst;
publicboolDefeats(HandShape other)
{
return winsAgainst.Contains(other);
}
}
</handshape>
然后在项目中创建三个 SO 资源:Rock、Paper、Scissors。每个资源通过 Inspector 配置自己能战胜的对手:
Rock 的 winsAgainst 列表包含 Scissors
Paper 的 winsAgainst 列表包含 Rock
Scissors 的 winsAgainst 列表包含 Paper
可扩展行为的优势
这种设计带来了极大的灵活性:
轻松扩展:想添加新手势?只需创建新的 SO 资源并配置关系,无需修改任何代码
数据驱动:游戏逻辑通过配置数据驱动,设计师可以自由调整规则
额外属性:每个手势可以包含图标、音效、特效等额外数据
复杂规则:可以为每个手势定义不同的行为方法,实现更复杂的游戏机制
例如,可以扩展 HandShape 类添加更多功能:
publicclassHandShape : ScriptableObject
{
public Sprite icon;
public AudioClip soundEffect;
public List<handshape> winsAgainst;
publicint damageMultiplier = 1;
publicboolDefeats(HandShape other)
{
return winsAgainst.Contains(other);
}
publicintCalculateDamage(HandShape opponent)
{
return Defeats(opponent) ? damageMultiplier * 10 : 0;
}
}
</handshape>
这样每个手势不仅能判断胜负,还能计算伤害、播放特定音效,实现更丰富的游戏体验。
问题思考
为什么 [Flags] 枚举要使用二进制位运算?
避免 1+4 == 2+3 这种歧义是原因之一,但更深层的原因是确保每个标志占据独立的二进制位,从而实现精确的状态识别和操作。
问题的本质
让我们用具体例子来说明。假设我们不使用 2 的幂次方,而是用连续整数:
// 错误的做法
[Flags]
publicenum Permission
{
Read = 1, // 0001
Write = 2, // 0010
Execute = 3, // 0011 <- 问题!
Delete = 4// 0100
}
现在看看会发生什么:
// Read + Write = 1 + 2 = 3
Permission combined = Permission.Read | Permission.Write;
// 结果是 3,但 3 同时也是 Execute!
// 如何判断 3 到底代表什么?
// 是 Read + Write?还是单独的 Execute?无法区分!
正确的做法
使用 2 的幂次方(1, 2, 4, 8…),每个值在二进制中只占一位:
[Flags]
publicenum Permission
{
None = 0, // 0000
Read = 1, // 0001
Write = 2, // 0010
Execute = 4, // 0100
Delete = 8// 1000
}
// Read + Write = 0001 | 0010 = 0011 (二进制)
Permission combined = Permission.Read | Permission.Write;
// 结果是 3 (0011)
// 现在可以精确检测:
bool hasRead = (combined & Permission.Read) != 0; // true
bool hasWrite = (combined & Permission.Write) != 0; // true
bool hasExecute = (combined & Permission.Execute) != 0; // false
核心优势
独立性:每个标志位独立存在,不会相互干扰
可检测性:可以用位与运算 & 精确检测是否包含某个特定标志
可操作性:可以用位或 | 添加、用位非加位与 &~ 移除特定标志
所以,虽然从数值角度 1+4 == 2+3 都等于 5,但在二进制表示中:
1+4 = 0101(代表第 0 位和第 2 位)
2+3 = 0101(如果 3 = 0011,则含义完全不同)
使用 2 的幂次方,每个标志都有自己"独占"的一位,这才是位运算的精髓所在。
开源项目
Odin Toolkits:Odin Inspector 的扩展工具集。
Odin Toolkits - GitHub Repository
https://github.com/yuumixcode/OdinToolkits-For-Unity
Odin Toolkits 的 Unity 中国资源商店页面
https://assetstore.u3d.cn/packages/tools/utilities/assetstore-package-20000181
注:Unity 中国资源商店仅为付费支持,Odin Toolkits 可以在 GitHub 免费下载使用。
写在最后
如果你在使用 SO 的过程中有更好的实践经验,或者遇到了有趣的问题,欢迎在评论区分享交流!让我们一起探索更多可能性。