Unity ScriptableObject 学习笔记
作者:hzb
开始时间:2023.12.12
最后一次更新时间:2023.12.12
为什么MonoBehaviour
不好用
MonoBehaviour
的强大之处
- 大多数脚本都是继承自
MonoBehaviour
- 它们可以被挂载到
GameObject
上 - 存在于场景或者预制体中
- 可以接收来自Unity的回调
MonoBehaviour
的缺点
- 当退出运行模式的时候,会重置
- 当
Instantiating
物体时,会完全拷贝所有数据,消耗内存 - (实例以及实例包含的数据等)在不同场景中难以共享
- (实例以及实例包含的数据等)在不同项目中难以共享
- 多人开发时版本管理容易遇到冲突
- 每个实例上的脚本可以不一致的自定义,例:不小心把一群敌人中的一个属性意外地设置错误,很难找出问题所在,需要一个一个查找数据
C#的static
可以解决这个问题吗?
- 当退出Unity的播放模式(playmode)时,静态变量的值会被重置
- 如果你想要在Unity编辑器重启或退出播放模式后保留静态变量的值,你需要自己处理序列化和反序列化。
- 在序列化过程中,直接引用Unity对象(如游戏对象或组件)可能会比较困难,因为这些对象不能直接序列化到磁盘。
- 默认情况下,Unity的检视器(Inspector)不支持直接编辑静态变量。如果你想要在检视器中编辑静态变量,你需要编写自己的检视器GUI代码。
- 使用Unity引擎的一个好处是它提供了许多内建的工具和系统,比如序列化和检视器界面,所以通常不需要自己从头开始编写这些功能。如果决定使用纯静态变量,那么开发者可能会错过Unity提供的这些便利功能。
预制体可以解决吗
-
在使用预制件时可能会不小心犯错误,例如意外地实例化(Instantiate)一个预制件。在Unity中,如果你不小心多次实例化了预制件,可能会导致场景中出现许多不必要的副本,这可能会导致性能问题或场景管理上的混乱。
-
预制件可能包含一些不必要的额外组件。例如,如果你只是想使用预制件中的一部分数据或资源,但预制件本身包含了许多其他组件(如碰撞体、渲染器等),这可能会使得使用变得复杂,并增加资源的负担。
-
预制件可能在概念上并不完全适合。预制件在Unity中通常用于创建和配置可以在场景中复用的游戏对象模板。但是,如果你的目的是仅仅存储和管理数据,而不是实例化游戏对象,那么预制件可能不是最佳选择,因为它们的设计初衷是为了实例化对象,而不仅仅是作为数据容器。
ScriptableObjects
是什么以及如何做到
ScriptableObject
在Unity中作为数据管理和组织工具的优势
-
ScriptableObject
拥有类似于MonoBehaviour
的一些特性(比如可以序列化和在检视器中编辑),但它不是一个组件,这意味着它不能像 MonoBehaviour 那样附加到游戏对象上。- 创建
ScriptableObject
类非常简单,你只需要从ScriptableObject
类派生,而不是从MonoBehaviour
类派生。
- 创建
-
与
MonoBehaviour
不同,ScriptableObject
实例不能直接附加到游戏对象或预制件上。这使得它们更适用于作为数据的容器,而不是定义行为。 -
ScriptableObject
可以被序列化,这意味着它们的数据可以保存和加载。它们也可以在Unity检视器中编辑,就像MonoBehaviour
一样,这为数据管理提供了便利。 -
ScriptableObject
的实例可以被保存为.asset
文件,这些文件可以在Unity项目中管理和分发,从而使数据重用和共享变得非常方便。 -
ScriptableObject
可以帮助解决一些涉及多态性的问题。例如,你可以创建一个ScriptableObject
基类,并从它派生出具有不同数据和行为的子类。这样,你可以在运行时根据需要动态地引用不同的子类实例。 -
总体而言,使用
ScriptableObject
作为一种在Unity中管理复杂数据和避免某些常见编程问题的方法。通过利用ScriptableObject
,开发者可以更好地组织数据,减少依赖于场景的游戏对象,以及提高代码的灵活性和可维护性。
ScriptableObject
是如何解决这些问题的
ScriptableObject
存储为项目资源,这意味着当退出播放模式(playmode)时,其存储的数据不会被重置,与在播放模式期间使用静态变量不同。ScriptableObject
可以被引用,而不是像在实例化(Instantiate)过程中那样被复制。这有助于避免不必要的数据副本,并且可以在运行时更高效地访问这些数据。- 正如其他资源(如纹理、网格和音效)一样,
ScriptableObject
可以在任何场景中被引用。这使得跨场景共享数据变得容易。 ScriptableObject
的资源可以轻松地从一个项目传输到另一个项目。由于它们是独立的资源文件,这使得重用和共享设置或数据变得简单。- VCS(版本控制系统)粒度理想。因为
ScriptableObject
是单独的文件,它们可以被版本控制系统(如Git)更有效地管理,而且每个文件的改变都能被精确地追踪。
如何使用ScriptableObjects
创建ScriptableObject
using UnityEngine;
[CreateAssetMenu]
public class myScriptableObject : ScriptableObject {
public int someVariable;
}
- 把继承自
MonoBehaviour
改为ScriptableObject
[CreateAssetMenu]
显示在资产的创建菜单中- 或者
ScriptableObject.CreateInstance<myScriptableObject>();
- 或者
回调Callbacks
OnEnable
- 当创建时
- 当加载时
- 当脚本改动后回到编辑器时的
Reloading script
完成时
OnDisable
- 当准备进行脚本改动后回到编辑器时的
Reloading script
时 - 当准备销毁时
- 当准备进行脚本改动后回到编辑器时的
OnDestroy
- 当销毁时
ScriptableObject
的生命周期
- 与其他资源相同:
ScriptableObject
的生命周期与Unity中任何其他资源(如纹理、模型文件等)的生命周期相同。 - 当持久时(绑定到.asset文件,AssetBundle等):
- 可以通过资源垃圾回收(比如调用
Resources.UnloadUnusedAssets
)来卸载。 - 通过脚本引用保持加载状态。
- 在需要时再次重新加载。
- 可以通过资源垃圾回收(比如调用
- 当非持久时(使用
CreateInstance<>
创建,没有.asset文件):- 可以被资源垃圾回收销毁。
- 使用
HideFlags.HideAndDontSave
保持活跃状态。
几种ScriptableObjects
的使用模式
管理数据对象和数据表
- Plain-Old-Data (POD) 类绑定到
.asset
文件:- 提到“Plain-Old-Data”(POD),它指的是简单的数据结构,没有方法或复杂的继承结构。这样的类可以绑定到
.asset
文件,让数据能够以资源的形式存储在Unity项目中。
- 提到“Plain-Old-Data”(POD),它指的是简单的数据结构,没有方法或复杂的继承结构。这样的类可以绑定到
- 在检视器中编辑,作为单一文件提交到版本控制系统(VCS):
- 这些数据可以直接在Unity的检视器中编辑,便于查看和修改。编辑后的数据可以作为单一文件提交到版本控制系统,例如Git,便于管理版本和协作。
- 自定义编辑器使其更加友好:
- 通过创建自定义编辑器界面,可以进一步改善数据对象的用户编辑体验。自定义编辑器可以提供更清晰、更直观的数据操作界面。
- 每个条目一个对象与每个表一个对象:
- 这里提到了两种管理数据的方式:一种是每个条目创建一个
ScriptableObject
实例;另一种是一个ScriptableObject
实例包含了整个数据表。
- 这里提到了两种管理数据的方式:一种是每个条目创建一个
- 例子:本地化、库存项目、掉落表、敌人配置/模板
class EnemyInfo : ScriptableObject {
public int MaxHealth;
public int DamagePerMeleeHit;
}
- 注意这里存储的数据是共享的,不属于任何一个实例,所以不能作为每一个敌人当前的血量,只能作为它们血量的上限。
- 游戏物体可以保存这个数据的引用
class Enemy : MonoBehaviour {
public EnemyInfo info;
}
ScriptableObject
来创建可扩展枚举(Extendable Enums)
- 空的ScriptableObject绑定到.asset文件
- 仅用于等值比较:
- 这些
ScriptableObject
实例主要用于等值比较。这类似于传统的枚举类型,但是以一种可以在编辑器中创建和管理的方式提供。
- 这些
- 类似枚举,但不在代码中,可以由设计师创建:
- 这种方式类似于代码中的枚举,但它们不是硬编码的,而是可以由游戏设计师在Unity编辑器中直接创建和修改。
- 例子:库存物品、AI事件分类、伤害类型、锁/钥匙设置:
- 可扩展枚举可以用于各种游戏系统,比如管理库存物品、AI事件的分类、伤害类型或是锁和钥匙的逻辑。
- 如果需要,可以轻松扩展到数据表:
- 如果将来需要更多的功能或数据,这些
ScriptableObject
实例可以轻松地扩展为包含更多信息的数据表。
- 如果将来需要更多的功能或数据,这些
class AmmoType : ScriptableObject {}
if(inventory[Weapon.ammotype] == 0) {
PlayOutOfAmmoSount();
return;
}
inventory[weapon.ammoType] -= 1;
上面为Unity官方给出的简短例子,下面我将该例子补充完整。
-
创建一个空的
ScriptableObject
绑定到.asset文件using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; [CreateAssetMenu] public class AmmoType : ScriptableObject {}
-
定义
Weapon
类using System.Collections; using System.Collections.Generic; using UnityEngine; public class Weapon : MonoBehaviour { public AmmoType ammoType; public int damage; }
-
实际使用时:
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TestEnum : MonoBehaviour { //记录当前武器的弹药类型还剩多少子弹 private Dictionary<AmmoType, int> ammoInventory; //当前武器 private Weapon weapon; //子弹类型 [SerializeField] private AmmoType[] ammoInventoryKeys; private void Awake() { //初始化字典 ammoInventory = new Dictionary<AmmoType, int>(); // 为每种弹药类型在字典中添加一个初始值 foreach (AmmoType ammoType in ammoInventoryKeys) { ammoInventory[ammoType] = 50; } } void Update() { if (ammoInventory[weapon.ammoType] == 0) { //播放没有子弹的音效 PlayOutOfAmmoSound(); return; } else { //播放射击音效 PlayShootSound(); //减少子弹数量 ammoInventory[weapon.ammoType]--; } } public void PlayOutOfAmmoSound() { //播放没有子弹的音效} } public void PlayShootSound() { //播放射击音效 } }
双重序列化
- ScriptableObjects兼容JsonUtility覆写:
- 这意味着你可以使用Unity内置的
JsonUtility
类来序列化和反序列化ScriptableObject
。这样做的好处是可以轻松地将ScriptableObject
的数据转换为JSON格式,这种格式易于存储、传输和读取。
- 这意味着你可以使用Unity内置的
- 在编辑时间放入资产中,在运行时放入JSON文件中:
- 在Unity编辑器中,
ScriptableObject
通常保存为.asset
文件作为项目资产。然而,在游戏运行时,你可能希望以JSON格式动态加载或保存这些数据。这可以使得在游戏运行时创建、编辑和保存用户生成的内容或游戏设置变得可能。
- 在Unity编辑器中,
- 示例:内置+用户创作的关卡:
- 一个典型的使用场景是游戏中的关卡编辑器。开发者可以创建内置的关卡数据并将其作为
.asset
文件保存,这样关卡就可以在Unity编辑器中编辑和打包。同时,游戏也可以允许玩家创建自己的关卡,并将这些关卡以JSON格式保存,便于共享或在不同的游戏会话间保持。
- 一个典型的使用场景是游戏中的关卡编辑器。开发者可以创建内置的关卡数据并将其作为
[CreateAssetMenu]
class LevelData : ScriptableObject {
// 关卡数据的属性
}
LevelData LoadLevelFromFile(string path) {
// 从指定路径读取全部文本内容到一个字符串中
string json = File.ReadAllText(path);
// 创建一个LevelData的新实例
LevelData result = CreateInstance<LevelData>();
// 使用JsonUtility.FromJsonOverwrite将JSON字符串中的数据反序列化到result实例中
JsonUtility.FromJsonOverwrite(json, result);
// 返回填充了数据的LevelData实例
return result;
}
- 在这个例子中,
JsonUtility.FromJsonOverwrite
函数用于将JSON字符串中的数据覆盖到新创建的LevelData
实例的相应字段中。这使得可以在运行时加载和修改游戏级别数据,而不仅限于在Unity编辑器中编辑.asset
文件。 - 这种方法使得游戏能够支持用户生成的内容,例如玩家创建的自定义级别,因为它们可以将级别数据保存为JSON文件,然后在游戏中动态加载它们。此外,这种方法还允许更易于数据共享和跨平台操作,因为JSON是一种轻量级且广泛支持的数据交换格式。
防重载单例(Reload-proof Singletons)
- ScriptableObject + 静态实例变量:
- 使用
ScriptableObject
创建一个全局访问点,并通过一个静态变量持有其实例。这种方法通常用于需要全局访问且不依赖场景中具体对象的情况。
- 使用
- 使用 FindObjectOfType 恢复在域重载后的实例:
- 在Unity中,当编译脚本或进入/退出播放模式时,会发生域重载,这会清除所有静态变量的状态。为了保持单例状态,可以在需要时使用
FindObjectOfType
方法来查找场景中的实例并重新赋值给静态变量。
- 在Unity中,当编译脚本或进入/退出播放模式时,会发生域重载,这会清除所有静态变量的状态。为了保持单例状态,可以在需要时使用
- 示例:全局游戏状态:
- 例如,可以使用这种单例模式来存储全局游戏状态,如玩家的分数、游戏配置设置或游戏进度。
使用 ScriptableObject
来创建代理对象(Delegate objects)
-
ScriptableObject拥有方法:
- 通常
ScriptableObject
被用来存储数据,但它们也可以包含方法,这些方法可以执行具体的逻辑。
- 通常
-
MonoBehaviour将自己传递给SO方法,SO完成工作:
MonoBehaviour
脚本可以调用ScriptableObject
的方法,并将其自身作为参数传递。这样,ScriptableObject
可以根据传递进来的MonoBehaviour
来执行特定的操作。
-
允许可插拔和可配置的行为:
- 通过这种方式,你可以创建可在运行时更换的行为。例如,你可以为角色或敌人设计不同的AI行为,并通过更改其关联的
ScriptableObject
来在运行时更改这些行为
- 通过这种方式,你可以创建可在运行时更换的行为。例如,你可以为角色或敌人设计不同的AI行为,并通过更改其关联的
-
示例:AI类型、能力增强/削弱:
- 例如,你可以为游戏中的不同角色创建不同的AI
ScriptableObject
。根据角色的当前状态或需要,你可以动态地切换这些AI对象。对于能力增强(buffs)或削弱(debuffs),你可以创建ScriptableObject
来代表这些效果,并在运行时应用它们到角色上。
- 例如,你可以为游戏中的不同角色创建不同的AI
-
官方示例:
-
定义一个抽象的 PowerupEffect:
- 创建了一个名为
PowerupEffect
的抽象类,它继承自ScriptableObject
。这个类定义了一个抽象方法ApplyTo
,该方法需要在子类中被重写,以便实现具体的能力增强效果。
public abstract class PowerupEffect : ScriptableObject { public abstract void ApplyTo(GameObject go); }
- 创建了一个名为
-
创建具体的 PowerupEffect 子类:
HealthBooster
类继承自PowerupEffect
并重写了ApplyTo
方法。这个方法增加了游戏对象的生命值
public class HealthBooster : PowerupEffect { public int Amount; public override void ApplyTo(GameObject go) { go.GetComponent<Health>().currentValue += Amount; } }
-
创建 MonoBehaviour 来使用 PowerupEffect:
Powerup
类是一个MonoBehaviour
,它包含了一个PowerupEffect
类型的字段。在触发器内发生碰撞时,它将调用PowerupEffect
的ApplyTo
方法来应用效果。
public class Powerup : MonoBehaviour { public PowerupEffect effect; public void OnTriggerEnter(Collider other) { effect.ApplyTo(other.gameObject); } }
-
-
补充完整上述案例,实现按E触发
HealthBooster
并打印当前血量-
定义一个抽象的 PowerupEffect:
using System.Collections; using System.Collections.Generic; using UnityEngine; abstract public class PowerupEffect : ScriptableObject { abstract public void ApplyTo(GameObject p); }
-
创建具体的 PowerupEffect 子类:
- 允许在
Create
菜单中的PowerEffects/HealthBooster
创建该脚本实例,默认名"NewHealthBooster"
using System.Collections; using System.Collections.Generic; using UnityEditor; using UnityEngine; [CreateAssetMenu(fileName = "NewHealthBooster", menuName = "PowerEffects/HealthBooster")] public class HealthBooster : PowerupEffect { //该buff的数值,可在实例中具体配置 public int amount; //buff具体效果,回复血量 public override void ApplyTo(GameObject p) { if (p.TryGetComponent<Health>(out Health health)) { health.CurrentValue += amount; } } }
- 允许在
-
创建血条组件:
- 定义私有
currentValue
存放当前血量,同时设置共有字段CurrentValue
,并设置其set
的时候触发HealthChanged
事件 - 定义当血量改变时打印血量的回调函数
OnHealthChanged
,并订阅事件HealthChanged
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class Health : MonoBehaviour { //当血量改变时,触发的事件 public event EventHandler HealthChanged; private int currentValue = 100; public int CurrentValue { get { return currentValue; } set { currentValue = value; HealthChanged?.Invoke(this, EventArgs.Empty); } } //订阅事件 private void OnEnable() { HealthChanged += OnHealthChanged; } //取消订阅事件 private void OnDisable() { HealthChanged -= OnHealthChanged; } //OnHealthChanged 打印当前血量 public void OnHealthChanged(object sender, EventArgs e) { Debug.Log($"Current Health: {CurrentValue}"); } }
- 定义私有
-
创建玩家类,并具体实现按E施加buff效果:
- 定义一个输入
controller
- 定义一个
PowerupEffect
实现多态 - 定义回调函数
OnInteracted
,其中调用buff的效果
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class Player : MonoBehaviour { public GameInput controller; public PowerupEffect powerupEffect; private void OnEnable() { controller.Interacted += OnInteracted; } private void OnDisable() { controller.Interacted -= OnInteracted; } private void OnInteracted(object sender, EventArgs e) { powerupEffect.ApplyTo(gameObject); } }
- 定义一个输入
-
创建的
NewHealthBooster
实例并配置数值 -
将baff配置挂载
-
运行,按E触发效果
-
案例 Tank Demo
使用ScriptableObject
的改进
- Unite Boston 2015 training day demo。来自Unite Boston 2015培训日演示
- Beef up the audio。加强音频
- Destructible buildings。可摧毁建筑
- Pluggable AIs。可插拔AI
- Configurable game settings with load/save。可配置的游戏设置与加载/保存
音频的改进
- 定义一个抽象基类
AudioEvent
public abstract class AudioEvent : ScriptableObject
{
public abstract void Play(AudioSource source);
}
- 做一个简单的配置类
using UnityEngine;
using System.Collections;
using Random = UnityEngine.Random;
[CreateAssetMenu(menuName="Audio Events/Simple")]
public class SimpleAudioEvent : AudioEvent
{
//存放具体的音频数组
public AudioClip[] clips;
//随机获得的音量
public RangedFloat volume;
//最大值和最小值
[MinMaxRange(0, 2)]
public RangedFloat pitch;
public override void Play(AudioSource source)
{
//如果没有音频 则返回
if (clips.Length == 0) return;
//设置音频为其中的随机一个
source.clip = clips[Random.Range(0, clips.Length)];
//设置音量为上下限之间随机数
source.volume = Random.Range(volume.minValue, volume.maxValue);
source.pitch = Random.Range(pitch.minValue, pitch.maxValue);
//播放
source.Play();
}
}
- 实例化一个具体的asset配置
可摧毁建筑
- 定义一个基类
public abstract class DestructionSequence : ScriptableObject
{
//定义一个协程
public abstract IEnumerator SequenceCoroutine(MonoBehaviour runner);
}
- 做一个简单的配置类
[CreateAssetMenu(menuName="Destruction/Hide Behind Effect")]
public class HideBehindEffect : DestructionSequence
{
// 粒子效果
public GameObject Effect;
//具体Destroy时间
public float DestroyOriginalAfterTime = 1f;
//具体协程实现
public override IEnumerator SequenceCoroutine(MonoBehaviour runner)
{
//生成粒子效果
Instantiate(Effect, runner.transform.position, runner.transform.rotation);
//等待一段时间
yield return new WaitForSeconds(DestroyOriginalAfterTime);
//Destroy建筑
Destroy(runner.gameObject);
}
}
- 实例化
可插拔AI
- 定义AI的大脑基类
public abstract class TankBrain : ScriptableObject
{
//虚函数可重写
public virtual void Initialize(TankThinker tank) { }
//抽象函数 必须重写
public abstract void Think(TankThinker tank);
}
-
具体写一个配置类
-
玩家控制的坦克
[CreateAssetMenu(menuName="Brains/Player Controlled")] public class PlayerControlledTank : TankBrain { //玩家编号 public int PlayerNumber; //该玩家的位移名称 private string m_MovementAxisName; //该玩家的选择名称 private string m_TurnAxisName; //该玩家的开火名称 private string m_FireButton; //初始化名称 public void OnEnable() { m_MovementAxisName = "Vertical" + PlayerNumber; m_TurnAxisName = "Horizontal" + PlayerNumber; m_FireButton = "Fire" + PlayerNumber; } // public override void Think(TankThinker tank) { //获取坦克的移动脚本组件 var movement = tank.GetComponent<TankMovement>(); //调用Steer函数 坦克行动 movement.Steer(Input.GetAxis(m_MovementAxisName), Input.GetAxis(m_TurnAxisName)); //获取坦克开火脚本组件 var shooting = tank.GetComponent<TankShooting>(); //开火 if (Input.GetButton(m_FireButton)) shooting.BeginChargingShot(); else shooting.FireChargedShot(); } }
-
简单狙击手AI
[CreateAssetMenu(menuName="Brains/Simple sniper")] public class SimpleSniper : TankBrain { // 瞄准角度阈值 public float aimAngleThreshold = 2f; // 每单位距离的充电时间 [MinMaxRange(0, 0.05f)] public RangedFloat chargeTimePerDistance; // 两次射击之间的时间 [MinMaxRange(0, 10)] public RangedFloat timeBetweenShots; // 重写TankBrain的Think方法 public override void Think(TankThinker tank) { // 获取坦克记忆中的目标 GameObject target = tank.Remember<GameObject>("target"); // 获取坦克的移动组件 var movement = tank.GetComponent<TankMovement>(); // 如果没有目标 if (!target) { // 寻找最近的非自身的坦克作为目标 target =GameObject .FindGameObjectsWithTag("Player") .OrderBy(go => Vector3.Distance(go.transform.position, tank.transform.position)) .FirstOrDefault(go => go != tank.gameObject); // 记住新的目标 tank.Remember<GameObject>("target"); } // 如果还是没有目标 if (!target) { // 没有目标,进行旋转 movement.Steer(0.5f, 1f); return; } // 瞄准目标 Vector3 desiredForward = (target.transform.position - tank.transform.position).normalized; // 如果目标角度大于阈值 if (Vector3.Angle(desiredForward, tank.transform.forward) > aimAngleThreshold) { // 判断旋转方向 bool clockwise = Vector3.Cross(desiredForward, tank.transform.forward).y > 0; // 旋转坦克 movement.Steer(0f, clockwise ? -1 : 1); } else { // 停止移动 movement.Steer(0f, 0f); } // 获取坦克的射击组件 var shooting = tank.GetComponent<TankShooting>(); // 如果不在充电 if (!shooting.IsCharging) { // 如果可以射击 if (Time.time > tank.Remember<float>("nextShotAllowedAfter")) { // 计算目标距离 float distanceToTarget = Vector3.Distance(target.transform.position, tank.transform.position); // 计算充电时间 float timeToCharge = distanceToTarget*Random.Range(chargeTimePerDistance.minValue, chargeTimePerDistance.maxValue); // 记住射击时间 tank.Remember("fireAt", Time.time + timeToCharge); // 开始充电 shooting.BeginChargingShot(); } } else { // 获取射击时间 float fireAt = tank.Remember<float>("fireAt"); // 如果到达射击时间 if (Time.time > fireAt) { // 射击 shooting.FireChargedShot(); // 记住下次可以射击的时间 tank.Remember("nextShotAllowedAfter", Time.time + Random.Range(timeBetweenShots.minValue, timeBetweenShots.maxValue)); } } } }
-