Unity ScriptableObject 学习笔记

Unity ScriptableObject 学习笔记

作者:hzb

开始时间:2023.12.12

最后一次更新时间:2023.12.12

资料来源:Unite Europe 2016 - Overthrowing the MonoBehaviour tyranny in a glorious ScriptableObject revolution (youtube.com)

为什么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项目中。
  • 在检视器中编辑,作为单一文件提交到版本控制系统(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 {}
    

    image-20231212191736843

  • 定义Weapon

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class Weapon : MonoBehaviour
    {
        public AmmoType ammoType;
        public int damage;
    }
    
  • 实际使用时:

    image-20231212191919646

    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格式,这种格式易于存储、传输和读取。
  • 在编辑时间放入资产中,在运行时放入JSON文件中:
    • 在Unity编辑器中,ScriptableObject 通常保存为 .asset 文件作为项目资产。然而,在游戏运行时,你可能希望以JSON格式动态加载或保存这些数据。这可以使得在游戏运行时创建、编辑和保存用户生成的内容或游戏设置变得可能。
  • 示例:内置+用户创作的关卡:
    • 一个典型的使用场景是游戏中的关卡编辑器。开发者可以创建内置的关卡数据并将其作为 .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 方法来查找场景中的实例并重新赋值给静态变量。
  • 示例:全局游戏状态:
    • 例如,可以使用这种单例模式来存储全局游戏状态,如玩家的分数、游戏配置设置或游戏进度。

使用 ScriptableObject 来创建代理对象(Delegate objects)

  • ScriptableObject拥有方法:

    • 通常 ScriptableObject 被用来存储数据,但它们也可以包含方法,这些方法可以执行具体的逻辑。
  • MonoBehaviour将自己传递给SO方法,SO完成工作:

    • MonoBehaviour 脚本可以调用 ScriptableObject 的方法,并将其自身作为参数传递。这样,ScriptableObject 可以根据传递进来的 MonoBehaviour 来执行特定的操作。
  • 允许可插拔和可配置的行为:

    • 通过这种方式,你可以创建可在运行时更换的行为。例如,你可以为角色或敌人设计不同的AI行为,并通过更改其关联的 ScriptableObject 来在运行时更改这些行为
  • 示例:AI类型、能力增强/削弱:

    • 例如,你可以为游戏中的不同角色创建不同的AI ScriptableObject。根据角色的当前状态或需要,你可以动态地切换这些AI对象。对于能力增强(buffs)或削弱(debuffs),你可以创建 ScriptableObject 来代表这些效果,并在运行时应用它们到角色上。
  • 官方示例:

    • 定义一个抽象的 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 类型的字段。在触发器内发生碰撞时,它将调用 PowerupEffectApplyTo 方法来应用效果。
      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实例并配置数值

      image-20231213115127793

    • 将baff配置挂载

      image-20231213115215773

    • 运行,按E触发效果

      image-20231213115259081

案例 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配置

image-20231213144642533

可摧毁建筑
  • 定义一个基类
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);
	}
}
  • 实例化

image-20231213145834488

可插拔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));
                  }
              }
          }
      }
      
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值