某愿朝闻道译/原文地址

  • 操控形状旋转和移动
  • 聚焦游戏更新
  • 为每个生成区增加配置
  • 改进Inspector, 绘制自定义属性UI

这是对象管理章节的第七篇教程. 将在上个教程的代码基础上制作. 本篇教程将对形状增加一些行为特性, 并且会分别为每一个生成区配置它们

本教程源码使用Unity2017.4.12f1编写
操控形状 - 图1
每个生成区都有点自己的小个性了

旋转形状

我们之前的代码可以产生多种不同外观的形状, 但是它们只是静静的等待自己被销毁或是游戏结束. 让我们为游戏加一点料, 让形状可以搞点事. 准确的说, 我们现在要让形状转起来

转转看

让一个游戏物体转起来的最直接方法就是调用它Transform组件的Rotate方法, 就像是前面教程中我们在RotatingObject脚本中做的那样, 为Shape脚本添加FixedUpdate方法并调用Rotate方法, 我们将使用物体自身的前方向作为旋转轴 :

  1. //FixedUpdate方法
  2. void FixedUpdate () {
  3. //调用Rotate
  4. transform.Rotate(Vector3.forward);
  5. }

MaleIncompatibleBlacklemur.webm (329.68KB)转动的形状

默认设置下, FIexedUpdate方法调用的频率是 50次/秒. 因此, 形状获得的旋转速度就是50度/秒. 接下来让我们通过乘以50和乘以Time.deltaTime, 明确的指定旋转速度, 使得旋转速度与FixedUpdate的执行频率无关 :

  1. //transform.Rotate(Vector3.forward);
  2. //乘以希望的旋转速度值50 , 再乘以FixedUpdate的执行间隔时间,
  3. //这样无论FixedUpdate方法调用的频率是多少, 形状的转动速度都不会受到影响
  4. transform.Rotate(Vector3.forward * 50f * Time.deltaTime);

旋转随机化

接下来为每一个形状赋予随机的旋转角速度. 在Shape脚本中新增属性AngularVelocity来获取随机角速度, 并用它的值代替固定的速度值 :

  1. //获取旋转角速度的属性
  2. public Vector3 AngularVelocity { get; set; }
  3. void FixedUpdate () {
  4. //transform.Rotate(Vector3.forward * 50f * Time.deltaTime);
  5. //使用AngularVelocity属性的值获取角速度, 并代替之前设置的固定速度值
  6. transform.Rotate(AngularVelocity * Time.deltaTime);
  7. }

然后要在Game脚本的CreateShape方法中设置形状的角速度. 可以使用Random.onUnitSphere方法来获得一个随机的旋转轴. 将其乘以50后代表这是在该轴上的50度/秒角速度 :

  1. void CreateShape () {
  2. //在将实例添加到shapes列表之前,设置新形状的AngularVelocity属性,
  3. //形状将以该属性的值作为旋转的角速度进行转动
  4. instance.AngularVelocity = Random.onUnitSphere * 50f;
  5. shapes.Add(instance);
  6. }

MaleIncompatibleBlacklemur.webm (329.68KB)随机角速度转动的形状

要让旋转速度变得随机化, 只需要将50替换为一个随机的数值, 比如说0到90之间的一个随机小数, 这样我们就可以让形状被设置0到90之间的一个随机角速度值 :

  1. //instance.AngularVelocity = Random.onUnitSphere * 50f;
  2. //将固定的50替换为0到90之间的随机值
  3. instance.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);

保存角速度

此时, 保存游戏时候还没有保存形状的角速度, 加载游戏后所有的形状都会在CreateShape方法中被设置一个新的随机角速度. 如果为保存文件增加形状的角速度数据, 将改变保存文件的数据格式, 因此需要将保存文件的版本号升级到4 :

  1. //const int saveVersion = 3;
  2. //版本号升级为4, 实现了保存形状的转动角速度
  3. const int saveVersion = 4;

然后, 在Shape脚本的Save方法中, 在保存颜色数据后保存角速度数据 :

  1. public override void Save (GameDataWriter writer) {
  2. base.Save(writer);
  3. writer.Write(color);
  4. //写入形状的角速度
  5. writer.Write(AngularVelocity);
  6. }

加载时也按照保存的顺序读取角速度值, 记得判断版本号是否满足条件. 低于版本号4的保存文件中不存在角速度数据, 因此加载这些保存文件时, 设置为固定值Vector3.zero :

  1. public override void Load (GameDataReader reader) {
  2. base.Load(reader);
  3. SetColor(reader.Version > 0 ? reader.ReadColor() : Color.white);
  4. //如果版本号大于等于4, 读取数据作为角速度值, 否则固定设置为Vector3.zero
  5. AngularVelocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
  6. }

**

一起更新所有形状

在形状旋转之前, 它们不需要被更新. 但是现在Unity必须调用所有已启用形状的FixedUpdate方法. 现在的实现方式, 会导致FixedUpdate和其他一些特定的方法额外增加了性能消耗, 会导致程序运行效率下降. 如果只有少数形状存在, 这不会有什么明显的问题, 但是在产生了大量的形状后, 这会成为我们程序的运行性能瓶颈.
操控形状 - 图4
Profiler性能分析示意 : 大量的形状导致了每一帧中有1000个FixedUpdate方法调用

我们可以自己管理形状的状态更新, 而不是让Unity去处理. Game脚本已经包含了一个所有已启用形状的列表, 我们可以使用这个列表来对这些形状进行更新管理. 首先, 我们将Shape.FixedUpdate方法更改为一个自定义的新方法, 命名为GameUpdate, 这样就避免了Unity去调用每个形状的FixedUpdate. 新增的方法是个公开方法, 以便Game脚本可以调用它 :

  1. //void FixedUpdate () {
  2. //形状不再使用FixedUpdate方法更新自身状态, 而是使用自定义的方法, 通过Game脚本统一管理调用时机
  3. public void GameUpdate () {
  4. transform.Rotate(AngularVelocity * Time.deltaTime);
  5. }

在Game脚本的FixedUpdate的开始位置添加循环遍历shapes列表的代码, 并调用每一个列表中形状的GameUpdate方法,

  1. void FixedUpdate () {
  2. //遍历存储shapes列表, 即场景中所有已启用的形状
  3. for (int i = 0; i < shapes.Count; i++) {
  4. //调用遍历的每一个形状的GameUpdate方法
  5. shapes[i].GameUpdate();
  6. }

操控形状 - 图5
进行上述更改后, 针对上一张图片的例子情况, 首先Unity不会调用每个Shape脚本的FixedUpdate方法了, 形状旋转状态的更新被合并到了Game脚本的FixedUpdate方法中, 只需要调用Game的FixedUpdate即可

这样优化真的有意义吗?

当你需要处理成百上千的相似对象时, 它们都需要更新自身的状态. 而我们也早已因为其他功能的需求追踪了它们, 所以借此顺便统一的管理它们的状态更新是值得的. 这一优化到底会带来多少性能上的提升, 会因具体的目标平台而有所不同. 你可以在编辑器环境下通过Profiler观察到绝大部分的性能变化情况.

顺便提一嘴, 在Unity2018中引入的实体组件系统(Entity Component System 简称ECS), 更适合在这种情况下优化性能, 有兴趣的可以自行搜索资料进行了解, 本篇教程并不会对其进行应用和介绍.

移动形状

形状现在可以转起来了, 但是它们还不能离开自己产生的位置, 我们还可以为每个形状设置一个随机的移动速度

设置移动速度

类似添加角速度的做法, 在Shape脚本中添加一个Velocity属性 :

  1. //获得形状的移动速度
  2. public Vector3 Velocity { get; set; }

每次调用GameUpdate方法时, 将移动速度乘以Time.delatTime的结果加到形状的当前位置上. 我们可以使用本地坐标属性localPosition代替性能消耗更高的世界坐标属性position, 因为生成的形状不是任何其他物体的子物体, 这种情况下localPosition属性的值与position属性的值是一致的 :

  1. public void GameUpdate () {
  2. transform.Rotate(AngularVelocity * Time.deltaTime);
  3. //实现形状的移动
  4. transform.localPosition += Velocity * Time.deltaTime;
  5. }

=============译者补充开始=============
针对上文红字内容, 我使用Unity2019.2.15f1版本进行了代码测试, 代码如下, 运行结果在代码后面 :

  1. void Start()
  2. {
  3. //傀儡变量, 用来取位置属性值
  4. Vector3 temp;
  5. //记录每次循环取属性值的开始时间
  6. float startTime;
  7. //控制每一轮循环取多少次位置属性值
  8. int loopCount = 1000;
  9. //分别记录每次本地坐标和世界坐标循环取值消耗的世界
  10. float countTimePosition, countTimeLocalPosition;
  11. int countPositionCostly=0, countLocalPositionCostly=0;
  12. //1000轮取值耗时测试, 每轮都对localPosition和position分别取值loopCount次,
  13. //看看谁的耗时长, 谁的性能消耗就高
  14. for (int x = 0; x < 1000; x++) {
  15. startTime = Time.realtimeSinceStartup;
  16. for (int i = 0; i < loopCount; i++) {
  17. temp = transform.localPosition;
  18. }
  19. countTimeLocalPosition = Time.realtimeSinceStartup - startTime;
  20. startTime = Time.realtimeSinceStartup;
  21. for (int i = 0; i < loopCount; i++) {
  22. temp = transform.position;
  23. }
  24. countTimePosition = Time.realtimeSinceStartup - startTime;
  25. if(countTimePosition > countTimeLocalPosition) {
  26. countPositionCostly++;
  27. }
  28. else {
  29. countLocalPositionCostly++;
  30. }
  31. }
  32. Debug.Log("Position高消耗" + countPositionCostly + "次 || LocalPosition高消耗" + countLocalPositionCostly + "次");
  33. }

在两种条件下各运行五次 :
条件一 : 游戏对象是根物体的情况下 :

Position高消耗568次 || LocalPosition高消耗432次 Position高消耗458次 || LocalPosition高消耗542次 Position高消耗511次 || LocalPosition高消耗489次 Position高消耗355次 || LocalPosition高消耗645次 Position高消耗659次 || LocalPosition高消耗341次

条件二 : 游戏对象是子物体的情况下 :

Position高消耗856次 || LocalPosition高消耗144次 Position高消耗846次 || LocalPosition高消耗154次 Position高消耗844次 || LocalPosition高消耗156次 Position高消耗856次 || LocalPosition高消耗144次 Position高消耗825次 || LocalPosition高消耗175次

如果子物体的嵌套层级深一些, 比例会更悬殊

先说试验的结论 : 至少在2019.2.15f1版本下, 根物体调用position和localPosition并无性能差异; 子物体调用两者差异巨大, localPosition效率远远高于position

至于原因, 由于看不到Unity源码, 只能猜测下 : position是通过localPosition计算出来的, 根物体取值相等, 没有额外计算过程, 子物体需要进行转换计算得到position

如果您知道准确的原因, 不是猜的, 恳请留言告知或联系我, 万分感谢
=============译者补充结束=============

保存移动速度

在Shape.Save方法中向保存文件写入角速度之后, 写入移动速度 :

  1. public override void Save (GameDataWriter writer) {
  2. base.Save(writer);
  3. writer.Write(color);
  4. writer.Write(AngularVelocity);
  5. //向保存文件写入移动速度 :
  6. writer.Write(Velocity);
  7. }

在ShapeLoad方法中读取速度值时, 依然要判断一下保存文件的版本号, 如果版本号太低, 则使用固定的速度值 :

  1. public override void Load (GameDataReader reader) {
  2. base.Load(reader);
  3. SetColor(reader.Version > 0 ? reader.ReadColor() : Color.white);
  4. AngularVelocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
  5. //如果版本号大于等于4, 读取数据作为移动速度值, 否则固定设置为Vector3.zero
  6. Velocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
  7. }

**

速度随机化

Game.CreateShape方法中生成一个新的形状时, 直接为其赋予一个随机方向上0到2之间的速度值 :

  1. void CreateShape () {
  2. instance.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);
  3. //设置形状的角速度之后再设置形状的移动速度
  4. instance.Velocity = Random.onUnitSphere * Random.Range(0f, 2f);
  5. shapes.Add(instance);
  6. }

GiftedShallowAcornbarnacle-mobile (1).mp4 (467.2KB)向随机方向以随机速度移动的形状

每个生成区的速度设置

所有形状都向随机方向移动显得场景混乱不堪. 我们也可以让所有的形状都向着一个方向移动, 不过更理想的方式是让每个生成区产生的形状统一运动方向, 这样可以为设计出更为精细的关卡效果.

现在, Game脚本创建并配置了每一个新形状的速度, 并从关卡的生成区中获取形状的放置位置. 如果我们希望形状的速度与生成区相关, 那么就还需要Game脚本还能从关卡的生成区中得到速度值, 除此之外, 我们跟进一步, 把所有与形状的状态配置有关的代码全部从Game脚本的CreateShape方法中移动到SpawnZone方法中新增的ConfigureSpawn方法中, 该方法接受一个Shape类型的参数代表要配置的形状, 除了Game.CreateShape方法中第一句创建形状的代码和最后一句将形状添加到列表的方法以外, 全部移动到ConfigureSpawn中, 使用方法接受的参数代替源代码中的instance变量, 而且由于代码已经处于GameLevel内部, 可以直接访问SpawnPoint属性了 :

  1. //每个生成区在该方法中进行对自己产生形状的配置
  2. //以下代码是把Game.CreateShape方法中与形状配置有关的代码移动过来修改而成
  3. public void ConfigureSpawn (Shape shape) {
  4. //Transform t = instance.transform;
  5. //shape参数取代instance
  6. Transform t = shape.transform;
  7. //t.localPosition = GameLevel.Current.SpawnPoint;
  8. //在SpawnZone类内部可以直接访问SpawnPoint属性
  9. t.localPosition = SpawnPoint;
  10. t.localRotation = Random.rotation;
  11. t.localScale = Vector3.one * Random.Range(0.1f, 1f);
  12. //instance.SetColor(Random.ColorHSV(
  13. //shape参数取代instance
  14. shape.SetColor(Random.ColorHSV(
  15. hueMin: 0f, hueMax: 1f,
  16. saturationMin: 0.5f, saturationMax: 1f,
  17. valueMin: 0.25f, valueMax: 1f,
  18. alphaMin: 1f, alphaMax: 1f
  19. ));
  20. //instance.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);
  21. //shape参数取代instance
  22. shape.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);
  23. //instance.Velocity = Random.onUnitSphere * Random.Range(0f, 2f);
  24. //shape参数取代instance
  25. shape.Velocity = Random.onUnitSphere * Random.Range(0f, 2f);
  26. }

在GameLevel中, 移除SpawnPoint属性并添加ConfigureSpawn方法, 它的作用是将需要进行配置的形状传递给关卡生成区的SpawnZone.ConfigureSpawn方法 :

  1. //该属性功能已被ConfigureSpawn方法替代
  2. //public Vector3 SpawnPoint {
  3. // get {
  4. // return spawnZone.SpawnPoint;
  5. // }
  6. //}
  7. //配置关卡形状的方法
  8. public void ConfigureSpawn(Shape shape) {
  9. //将需要进行配置的形状传递给关卡生成区的SpawnZone.ConfigureSpawn方法
  10. spawnZone.ConfigureSpawn(shape);
  11. }

最后, 在Game.CreateShape方法中移除全部与形状配置有关的代码, 并通过调用关卡的ConfigureSpawn方法代替它们的作用 :

  1. void CreateShape () {
  2. Shape instance = shapeFactory.GetRandom();
  3. //方法中原来的代码, 除了第一句和最后一句, 全删掉了, 它们的功能通过调用关卡的ConfigureSpawn方法代替
  4. //Transform t = instance.transform;
  5. //…
  6. //instance.Velocity = Random.onUnitSphere * Random.Range(0f, 2f);
  7. //将需要配置的形状传递给关卡的ConfigureSpawn方法, 完成各种配置, 代替了CreateShape方法中原来相关代码
  8. GameLevel.Current.ConfigureSpawn(instance);
  9. shapes.Add(instance);
  10. }

此时, 运行游戏, 一切与之前看起来并没有什么不同, 但是在代码层面, SpawnSone脚本已经具有了配置形状的功能.

相关运动

现在已经可以在SpawnZone脚本内配置形状了, 接着就可以让形状的运动方向与生成区的朝向角度有关. 我们可以使用生成区的本地”前”方向作为形状的运动方向 :

  1. public void ConfigureSpawn (Shape shape) {
  2. //shape.Velocity = Random.onUnitSphere * Random.Range(0f, 2f);
  3. //将形状的运动速度由随机方向改为生成区自身的z轴正方向,
  4. //transform.forwoard代表当前游戏物体本地坐标系下的(0,0,1)向量
  5. shape.Velocity = transform.forward * Random.Range(0f, 2f);
  6. }

FluidUltimateAsianlion-mobile.mp4 (506.6KB)向生成区自身Z轴正方向移动的形状
上述方法在球体生成区和立方体生成区上可以良好的生效, 但是对于复合生成区则存在问题, 它只会让形状按照复合生成区的自身Z轴方向设置运动速度, 而不是使用组成复合生成区的每一个基本生成区的Z轴方向设置. 要修复这个问题, 需要在CompositeSpawnZone类中重写ConfigureSpawn方法, 将需要进行配置的形状传递给对应的基本生成区, 方法所需的代码可以全部从SpawnPrint属性的get方法中复制, 只需要将最后一句代码进行修改即可 :

  1. public override Vector3 SpawnPoint {
  2. //复合生成区的该属性代码将全部被复制到下面的ConfigureSpawn方法中,
  3. //该属性目前已经没有其他代码调用了
  4. //但是不要删掉该属性的代码! 后面"根据复合生成区重写配置"一节该属性还要用到,
  5. //我之前做到这里自作主张删了, 做到后面找了半天错误 笑死
  6. ...
  7. }
  8. //复合生成区的ConfigureSpawn方法, 在选定了一个本次要产生形状的生成区后, 将形状的实例传递给该生成区处理
  9. public override void ConfigureSpawn (Shape shape) {
  10. //以下代码, 除了最后一句之外, 全部是从该类的SpawnPrint属性的Get方法复制来的
  11. int index;
  12. if (sequential) {
  13. index = nextSequentialIndex++;
  14. if (nextSequentialIndex >= spawnZones.Length) {
  15. nextSequentialIndex = 0;
  16. }
  17. }
  18. else {
  19. index = Random.Range(0, spawnZones.Length);
  20. }
  21. //return spawnZones[index].SpawnPoint;
  22. //将原SpawnPrint属性Get方法的最后一句代码改为下面这句,把本次生成的形状传递给选定的基本生成区进行处理
  23. spawnZones[index].ConfigureSpawn(shape);
  24. }

现在编译器会报错, 因为父类SpawnZone中的ConfigureSpawn方法不是虚拟方法, 派生类不可以对其重写, 所以需要我们在SpawnZone脚本为它加上virtual关键字使其变成虚拟方法 :

  1. //public virtual void ConfigureSpawn (Shape shape) {
  2. //想要被派生类重写, 就必须用virtual关键字变成虚拟方法
  3. public virtual void ConfigureSpawn (Shape shape) {

FantasticDistinctJapanesebeetle-mobile.mp4 (342.28KB)形状运动方向现在与每个基本生成区各自的配置相关

配置每个生成区

将形状配置的过程从Game脚本迁移到SpawnZone脚本中后, 不仅可以让形状的运动情况与生成区相关, 还能为每个生成区进行不同种类的运动配置

运动方向

首先, 可以为”向前”和”向上”两个方向之间选择运动方向. 为了明确清晰的指出配置的运动方向, 创建一个结构类型SpawnMovementDirection来代表每一个方向. 因为它只用于形状配置, 所以将其定义写在SpawnZone类内部, 而不不需要为它单独创建一个脚本. 然后为SpawnZone增加一个该枚举类型的字段 :

  1. //代表形状可选的运动方向的枚举类型
  2. public enum SpawnMovementDirection {
  3. //代表形状应该向生成区的前方向运动
  4. Forward,
  5. //代表形状应该向生成区的上方向运动
  6. Upward
  7. }
  8. [SerializeField]
  9. //用来配置该生成区为形状配置哪一个运动方向
  10. SpawnMovementDirection spawnMovementDirection;

**

我们新建的枚举类型必须是公开的吗?

不是必须声明为公开的, 但是此处也没有什么特别的理由需要将其设置为非公开类型. 比如某些情况你可能需要可以在类的外部访问它 , 像是制作自定义编辑器的时候. 在SpawnZone外部访问该公开枚举类型的方式是使用其完整类型名称SpawnZone.SpawnMovementDirection

这样就可以在ConfigureSpawn方法中根据spawnMovementDirection字段的值判断要为形状设置哪一个运动方向. 如果需要向上, 使用transform.up, 如果需要向前, 使用transform.forward :

  1. public virtual void ConfigureSpawn (Shape shape) {
  2. //…
  3. shape.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);
  4. //shape.Velocity = transform.forward * Random.Range(0f, 2f);
  5. //存储得到的速度方向
  6. Vector3 direction;
  7. //判断枚举字段的值
  8. if (spawnMovementDirection == SpawnMovementDirection.Upward) {
  9. //如果枚举字段代表向上, 则速度方向为transform.up;
  10. direction = transform.up;
  11. }
  12. else {
  13. //如果所有的if条件都不满足, 则速度方向为transform.forward;
  14. direction = transform.forward;
  15. }
  16. //使用得到的速度方向来为形状设置移动速度
  17. shape.Velocity = direction * Random.Range(0f, 2f);
  18. }

**
操控形状 - 图9
在每个生成区的Inspector中可以设置形状的运动方向为向上或向前

向外运动

除了选择一个具体的运动方向外, 还可以配置形状的向远离生成区中心的方向运动, 首先为枚举类型新增一个可选值OutWard :

  1. public enum SpawnMovementDirection {
  2. Forward,
  3. Upward,
  4. //代表形状应该从当前位置向远离生成区中心方向运动
  5. Outward
  6. }

向”外”运动的方向要通过形状的坐标与生成区坐标相减计算出来, 然后我们将得到的结果归一化. 注意, 由于要使用两者的世界坐标 我们必须使用transform.position而不是localPosition, 因为生成区有可能是一个子物体 :

  1. if (spawnMovementDirection == SpawnMovementDirection.Upward) {
  2. direction = transform.up;
  3. }
  4. //新增else-if判断
  5. else if (spawnMovementDirection == SpawnMovementDirection.Outward) {
  6. //如果枚举字段代表向外, 则运动方向由生成区中心指向形状当前位置
  7. direction = (t.localPosition - transform.position).normalized;
  8. }
  9. else {
  10. direction = transform.forward;
  11. }

SandyImaginaryFallowdeer-mobile.mp4 (274.06KB)远离生成区中心的形状

随机移动

让我们再支持一种随机运动方向, 效果就像是我们最开始让形状发生运动时一样. 在枚举中添加一个新的可选项Random代表随机运动方向 :

  1. public enum SpawnMovementDirection {
  2. Forward,
  3. Upward,
  4. Outward,
  5. //代表随机选择形状的运动方向
  6. Random
  7. }

**
然后通过Random.onUnitSphere属性得到随机的方向向量 :

  1. else if (spawnMovementDirection == SpawnMovementDirection.Outward) {
  2. direction = (t.localPosition - transform.position).normalized;
  3. }
  4. //新增else-if判断
  5. else if (spawnMovementDirection == SpawnMovementDirection.Random) {
  6. //如果枚举字段代表随机方向, 则运动方向随机设置
  7. direction = Random.onUnitSphere;
  8. }
  9. else {
  10. direction = transform.forward;
  11. }

AltruisticAnxiousAdder-mobile.mp4 (320.06KB)随机方向移动的形状

速度范围

除了运动方向, 我们还可以控制运动大小, 只需要添加两个代表最大速度和最小速度的字段即可, 修改SpawnZone代码如下 :

  1. //代表形状可配置的最小速度与最大速度
  2. [SerializeField] float spawnSpeedMin, spawnSpeedMax;
  3. public virtual void ConfigureSpawn (Shape shape) {
  4. //shape.Velocity = direction * Random.Range(0f, 2f);
  5. //使用spawnSpeedMin和spawnSpeedMax作为速度随机取值的上下限范围
  6. shape.Velocity = direction * Random.Range(spawnSpeedMin, spawnSpeedMax);
  7. }

操控形状 - 图12
(务必不要忘记为生成区配置这俩速度范围, 因为它们的默认值是0)

MaleIncompatibleBlacklemur.webm (329.68KB)1.5到2.5之间的移动速度

使用两个字段来控制一个范围不是很方便, 尤其是稍后还要继续增加更多范围值配置. Unity并没有专门为范围值控制提供一种数据类型, 所以让我们自己做一个. 创建新建脚本FloatRange用来创建同名的公开结构类型, 该类型有一个min字段和一个max字段. 为其添加一个便于使用的属性RandomValueInRange, 用来获得介于这两个字段值之间的随机小数.

注意, FloatRange结构要写在一个新建的脚本文件中, 而不是其他已有脚本中 :

  1. using UnityEngine;
  2. //代表特定范围内小数的结构类型
  3. public struct FloatRange {
  4. //最大值和最小值
  5. public float min, max;
  6. //该属性可以获得最大值和最小值之间的随机数
  7. public float RandomValueInRange {
  8. get {
  9. return Random.Range(min, max);
  10. }
  11. }
  12. }

要让Unity能够保存该结构类型的数据(注, 此处”保存”二字指的不是我们做的保存功能, 而是指的Unity才能在编辑器环境下存储这个类型的字段值, 从而才能在Inspector中显示和配置它们), 需要为该结构添加Serializable特性. 该特性隶属于System命名空间, 但是这个命名空间同时包含了一个Random类型, 与我们正在使用的UnityEngine命名空间中的Random类有冲突, 为了避免这种类型名称冲突的问题, 我们不引用Syetem命名空间, 而是通过System.Serializable这种写法直接使用特性关键字 :

  1. //自定义结构只有添加了该特性, 该结构类型的字段才能出现在Inspector窗口中
  2. [System.Serializable]
  3. public struct FloatRange {

现在就可以在SpawnZone脚本中使用一个FloatRange类型的字段代替之前控制速度范围的两个字段 :

  1. //[SerializeField] float spawnSpeedMin, spawnSpeedMax;
  2. [SerializeField]
  3. //使用自定义的结构类型代替之前的两个字段进行运动速度的限制
  4. FloatRange spawnSpeed;
  5. public virtual void ConfigureSpawn (Shape shape) {
  6. //…
  7. //shape.Velocity = direction * Random.Range(spawnSpeedMin, spawnSpeedMax);
  8. //spawnSpeed.RandomValueInRange可以直接获得其min字段与max字段之间范围内的随机数
  9. shape.Velocity = direction * spawnSpeed.RandomValueInRange;
  10. }

操控形状 - 图14
使用FloatRange类型的字段设置速度范围

整合配置数据

我们还可以创建一个专门用于存储所有形状所需配置信息的类型, 将所有配置内容全部整合到这个类型中. 对于每个配置数据的名称也不需要都加上spawn前缀了, 因为这个类型的名称将叫做SpawnConfiguration, 已经可以说明它内部所有数据的目的了. 该类型将作为SpawnZone的一个内部结构类型, 并且在之前创建的枚举类型将放在这个结构类型的内部. 经过这样的修改之后, SpawnZone也就只需要一个该类型的字段即可配置所有形状 :

  1. //该枚举将改名为MovementDirection后放到新增的结构类型SpawnConfiguration中
  2. //public enum SpawnMovementDirection {
  3. // …
  4. //}
  5. //该字段将改名为movementDirection后放到新增的结构类型SpawnConfiguration中
  6. //[SerializeField]
  7. //SpawnMovementDirection spawnMovementDirection;
  8. //该字段将改名为speed后放到新增的结构类型SpawnConfiguration中
  9. //[SerializeField]
  10. //FloatRange spawnSpeed;
  11. //添加该特性后, 自定义结构类型的字段才能出现在Inspector中进行配置
  12. [System.Serializable]
  13. //用于包含所有与形状配置有关内容的结构
  14. public struct SpawnConfiguration {
  15. //SpawnMovementDirection改名后放在该结构内
  16. public enum MovementDirection {
  17. Forward,
  18. Upward,
  19. Outward,
  20. Random
  21. }
  22. //spawnMovementDirection改名后放在该结构内
  23. public MovementDirection movementDirection;
  24. //spawnSpeed改名后放在该结构内
  25. public FloatRange speed;
  26. }
  27. [SerializeField]
  28. //存储与形状配置有关的数据
  29. SpawnConfiguration spawnConfig;

~~

为什么SpawnConfiguration不定义成一个类?

之所以创建一个结构类型, 是为了将配置数据整合在一起, 同时依然保持所有的数据都存储在SpawnZone对象内. 而对于一类来说, 数据将作为这个类对象的一部分存储在内存的其他位置, 而非SpawnZone对象的一部分, 我们不需在两个对象中进行这些数据的传递, 因此使用结构而不是类.

(“值类型引用类型 是此处的相关知识点, Struct是值类型, 而Class是引用类型, 有兴趣的自行百度)

接下来要对ConfigureSpawn方法进行对应的调整, 由于现在要通过结构体获取每一种代表运动方向的枚举值, 其名称会写的很长, 所以我们使用switch语句来代替if-else语句 :

  1. public virtual void ConfigureSpawn (Shape shape) {
  2. //…
  3. Vector3 direction;
  4. //删除原来判断形状方向配置的if-else语句, 其功能由下方新增的switch语句代替
  5. //if (spawnMovementDirection == SpawnMovementDirection.Upward) {
  6. // direction = transform.up;
  7. //}
  8. .....
  9. //else {
  10. // direction = transform.forward;
  11. //}
  12. //switch将会检查每一个case关键字后面的值与其括号内写的值是否相等,
  13. //相等则执行case下的语句, 不相等则继续向下寻找下一个case, 直到遇到相等情况或是switch语句全部执行完毕
  14. switch (spawnConfig.movementDirection) {
  15. case SpawnConfiguration.MovementDirection.Upward:
  16. //如果枚举字段代表向上, 则速度方向为transform.up;
  17. direction = transform.up;
  18. //该语句用于跳出switch
  19. break;
  20. case SpawnConfiguration.MovementDirection.Outward:
  21. //如果枚举字段代表向外, 则运动方向由生成区中心指向形状当前位置
  22. direction = (t.localPosition - transform.position).normalized;
  23. break;
  24. case SpawnConfiguration.MovementDirection.Random:
  25. //如果枚举字段代表随机方向, 则运动方向随机设置
  26. direction = Random.onUnitSphere;
  27. break;
  28. default:
  29. //如果spawnConfig.movementDirection的值与任何case关键字后面的值都不相等, 则速度方向为transform.forward
  30. direction = transform.forward;
  31. break;
  32. }
  33. //shape.Velocity = direction * spawnSpeed.RandomValueInRange;
  34. //spawnConfig.speed
  35. shape.Velocity = direction * spawnConfig.speed.RandomValueInRange;
  36. }

操控形状 - 图15
Inspector中的变化, 不要忘记去设置值, 默认都是0

switch语句是什么作用?

switch语句是一种判断单个变量决定分支语句的教早期的方式. 它使用标签来控制代码执行流程. 每个标签由case关键字定义, case后书写要比较的值, 值后面书写一个冒号. 程序将由上到下的检查每一个case提供的值, 如果switch括号内的值与case后的值相等, 则执行该case冒号下的代码.

另外, 有一个特殊的标签, 写作default, 如果书写了该标签, 则如果所有case提供的值都不满足条件, 就执行default标签冒号下的代码.

每一个标签对应的执行代码都要以break终止, 否则程序会继续检查该标签后面的标签.

if (x == 1) { DoA(); } else if (x == 2) { DoB(); } else { doC(); }
上述代码功能等同于 :
switch (x) { case 1: DoA(); break; case 2: DoB(); break; default: DoC(); break; }

除此之外, 还可以将多个标签作为复合条件使用, 比如说 :**case** 1: **case** 2: DoAB(); **break**; 该写法等同于**if** (x == 1 || x == 2) { DoAB(); }.

也可以使用goto语句跳转到另一个case标签. 但是很少需要这样用.

根据复合生成区重写配置

目前为止所有的生成区类型都包含了各自的生成配置选项, 这也包括复合生成区. 我们可以使用复合生成区的配置重写组成它的基本生成区的配置.

首先向CompositeSpawnZone脚本添加一个布尔字段作为控制开关, 如果该字段为true, 表示需要使用复合生成区的配置重写基本生成区的配置, 那么就直接调用CompositeSpawnZone的父类的ConfigureSpawn方法, 而不是将形状传递给基本生成区处理. 这样就可以使用复合生成区的配置来处理形状, 但是形状的位置依然在基本生成区内选择 :

  1. [SerializeField]
  2. //控制是否要使用复合生成区的形状配置重写基本生成区的形状配置
  3. bool overrideConfig;
  4. //…
  5. public override void ConfigureSpawn (Shape shape) {
  6. //判断是否使用复合生成区自身的spawnConfig数据配置形状
  7. if (overrideConfig) {
  8. //如果要使用复合生成区的配置, 调用其父类的ConfigureSpawn方法
  9. //base的用法我们之前介绍过, 会执行父类的同名方法的逻辑
  10. //但是注意, 只是调用父类方法逻辑, 过程中要用的字段和属性值还是自己的
  11. base.ConfigureSpawn(shape);
  12. }
  13. //用else语句块把之前的代码都包围起来
  14. else {
  15. //现在只有overrideConfig为false时才会把形状传递给基本生成区进行配置
  16. int index;
  17. if (sequential) {
  18. index = nextSequentialIndex++;
  19. if (nextSequentialIndex >= spawnZones.Length) {
  20. nextSequentialIndex = 0;
  21. }
  22. }
  23. else {
  24. index = Random.Range(0, spawnZones.Length);
  25. }
  26. spawnZones[index].ConfigureSpawn(shape);
  27. }
  28. }

操控形状 - 图16

ThriftyClassicGelding-mobile.mp4 (334.83KB)使用复合生成区对形状进行配置

高级配置

现在已经可以为每个生成区单独配置生成形状的运动状态, 该功能还能继续扩展. 有更多可以被我们控制的状态, 并且能进一步优化配置选项在Inspector中呈现的方式

角速度和缩放

要继续增加的配置是形状的旋转速度与缩放. 向SpawnConfiguration结构中添加两个代表它们的FloatRange字段, 并在ConfigureSpawn方法中使用它们 :

  1. public struct SpawnConfiguration {
  2. //配置形状的角速度范围
  3. public FloatRange angularSpeed;
  4. //配置形状的缩放范围
  5. public FloatRange scale;
  6. }
  7. public virtual void ConfigureSpawn (Shape shape) {
  8. Transform t = shape.transform;
  9. t.localPosition = SpawnPoint;
  10. t.localRotation = Random.rotation;
  11. //t.localScale = Vector3.one * Random.Range(0.1f, 1f);
  12. //使用spawnConfig.scale配置形状的缩放
  13. t.localScale = Vector3.one * spawnConfig.scale.RandomValueInRange;
  14. shape.SetColor(Random.ColorHSV(
  15. hueMin: 0f, hueMax: 1f,
  16. saturationMin: 0.5f, saturationMax: 1f,
  17. valueMin: 0.25f, valueMax: 1f,
  18. alphaMin: 1f, alphaMax: 1f
  19. ));
  20. //shape.AngularVelocity = Random.onUnitSphere * Random.Range(0f, 90f);
  21. //使用spawnConfig.angularSpeed配置形状的角速度
  22. shape.AngularVelocity = Random.onUnitSphere * spawnConfig.angularSpeed.RandomValueInRange;
  23. }

操控形状 - 图18
Inspector中又多了几个输入框

继续增加更多的配置选项也是可以的, 不过照着这个趋势加下去, 配置选项的数量很快就会变得巨大冗繁. 每个float配置范围在Inspector中展开后都要占据三行, 有点浪费空间, 也不便于显示更多配置内容. 如果每个配置只占据一行就好了

属性自定义显示

Unity允许重写在Inspector中内容的绘制方式, 从而改变它们的显示样子. 为此我们需要先新建一个脚本FloatRangeDrawer, 由于它的任务是处理编辑器中的界面, 因此它应该被防止在规定好的名为Editor文件夹内, 自己手动在Asset目录下创建该文件夹后, 新建的脚本放入其中, 这就会告诉Unity在编译代码的时候将其处理为与编辑器有关的代码, Build构建独立的程序时不会处理这一类代码.

操控形状 - 图19
在Editor文件夹下的FloatRangeDrawer脚本

编辑器有关的脚本依赖UnityEditor命名空间中的内容, 因此为该脚本引用这个命名空间. 要让脚本的类能够进行属性绘制, 需要让其继承PropertyDrawer类 :

  1. //与编辑器操作有关的内容都需要引用该命名空间
  2. using UnityEditor;
  3. using UnityEngine;
  4. //用来绘制FloatRange类型成员在Inspector中的显示方式
  5. public class FloatRangeDrawer : PropertyDrawer {
  6. }

除此以外, 还需要告诉Unity我们想要为哪一种类型的属性提供自定义绘制. 因此要为脚本添加CustomPropertyDrawer特性, 该特性需要为其指定一种类型本身作为参数, 这就要借助于typeof方法来做到 :

  1. //该特性将typeof()中书写的类型作为参数, 用来指定要在Inspector中自定义哪一种类型属性的显示方式
  2. [CustomPropertyDrawer(typeof(FloatRange))]
  3. public class FloatRangeDrawer : PropertyDrawer {
  4. }

通过上述代码, 当Unity必须要在Inspector中显示FloatRange类型的属性时, 就会调用该脚本的OnGUI方法. 我们要重写OnGUI方法来创建自定义UI. 该方法有三个参数 : 一个Rect参数, 定义了绘制的范围; 一个SerializedProperty参数, 代表了数值范围; 最后一个GUIContent参数包含了用于绘制的默认标签.

声明方法后, 先不需要书写具体的方法代码 :

  1. //重写OnGUI方法, 进行自定义UI的绘制
  2. public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) {
  3. }

参数position为什么不使用为area或是rect之类的名字?

你想的确实有道理, 因为这个参数确实不止是描述”位置”, 它代表的是UI矩形区域的范围. 不过Unity的默认参数名使用的position, 因此我没有对其名称作出修改

(原作者心思很细腻, 他在写教程的时候想了很多, 好像把自己当成一个萌新, 或是将自己萌新时候的问题加了进来)
**
操控形状 - 图20
空空如也的一行

因为还没有在重写的OnGUI方法中书写任何代码, 所以Inspector中不会为FloatRange类型绘制任何内容, 不过Inspector中依然会默认为它们保留出一行空白. 接着要做的就是在方法一开始, 告诉Unity我们要为属性创建UI了, 这要调用EditorGUI.BeginProperty方法, 将OnGUI方法的三个参数都传递给它, 只不过需要将第二个与第三个参数调换一下顺序; 一旦UI创建的代码全部完成, 还要在方法的末尾调用EditorGUI.EndProperty方法. 虽然调用它们之后Inspector中没有发生任何变化, 但是它们确保了编辑器可以处理它们之间要添加的创建UI用的代码 :

  1. public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) {
  2. //告诉编辑器要开始绘制属性自定义UI了
  3. EditorGUI.BeginProperty(position, label, property);
  4. //告诉编辑器属性自定义UI绘制完毕了
  5. EditorGUI.EndProperty();
  6. }

我们的数值范围属性包括了两个部分, 最小值min和最大值max. 可以对property参数调用FindPropertyRelative方法, 并根据它们的名字来访问它们, 该方法的返回值也是SerializedProperty类型. 绘制UI最简单的方式是电泳EditorGUI.PropertyField方法, 将要绘制UI的位置和一个SerializedProperty类型的参数传递给它即可, 让我们对最小值的UI按照上述方法进行绘制 :

  1. EditorGUI.BeginProperty(position, label, property);
  2. //PropertyField方法, 在参数1代表的位置处, 绘制参数2代表的属性内容
  3. EditorGUI.PropertyField(position, property.FindPropertyRelative("min"));
  4. EditorGUI.EndProperty();

操控形状 - 图21
最小值的UI都显示出来了

除了最小值, 我们还需显示最大值, 如法炮制 :

  1. EditorGUI.BeginProperty(position, label, property);
  2. EditorGUI.PropertyField(position, property.FindPropertyRelative("min"));
  3. //与min一样绘制max
  4. EditorGUI.PropertyField(position, property.FindPropertyRelative("max"));
  5. EditorGUI.EndProperty();

操控形状 - 图22
最大值的UI与最小值的UI叠加在了一起(仔细看, 前面的属性名字重影儿了)

min和max的UI现在都显示在了同样的位置, 重叠在了一起, 因为对它们指定了同样的位置参数position. 当绘制UI时, 我们还必须自己处理UI的位置与布局关系. 我们的情况只要将整个UI区域的宽度减半, 每个属性各占据一半即可 :

  1. EditorGUI.BeginProperty(position, label, property);
  2. //将position中代表UI显示宽度的width属性减半, 使用它作为位置参数进行绘制的UI就只占一半宽度
  3. position.width = position.width / 2f;
  4. EditorGUI.PropertyField(position, property.FindPropertyRelative("min"));
  5. //将position中代表UI横坐标的x属性加上偏移值, 使用它作为位置参数进行绘制的UI就会向右偏移指定距离
  6. position.x += position.width;
  7. EditorGUI.PropertyField(position, property.FindPropertyRelative("max"));
  8. EditorGUI.EndProperty();

操控形状 - 图23
重叠问题得到了解决

下一步, 我们需要调用EditorGUI.PrefixLabel方法来为每个范围添加名称标签, 该方法接受两个参数, 一个代标签的显示位置, 一个代表标签的显示内容. 因为标签也会占据一定的面积, 该方法会返回剩余的可用区域 :

  1. EditorGUI.BeginProperty(position, label, property);
  2. //在position位置处显示label代表的标签内容, 并将标签显示后剩余的可绘制区域信息存储到position中
  3. position = EditorGUI.PrefixLabel(position, label);
  4. position.width = position.width / 2f;

操控形状 - 图24
范围名称显示出来了, 然而布局又凌乱了

标签显示出来后, 布局又变乱了, min和max的UI被挤在了一起, 这是因为Unity默认为标签显示使用的宽度太宽了. 不过好在可以通过EditorGUIUtility.labelsWidth属性重写该宽度. 让我们设置标签宽度是min或max宽度的一半 :

  1. position.width = position.width / 2f;
  2. //在position的宽度减半之后, 使用减半之后的宽度再减半的值设置标签宽度
  3. //注意, 这不止设置的是最左边的范围名称的标签宽度, 也是设置了Min和Max这俩文字的宽度, 它俩也是标签
  4. EditorGUIUtility.labelWidth = position.width / 2f;

操控形状 - 图25
调整标签宽度后, 布局显示正常了

此时我们发现, 选择min的输入框后, 其左侧的范围名称也高亮了. 这是因为它们使用了相同的UI控制ID. 可以在调用PrefixLabel方法时, 增加一个指定的控制ID作为第二个参数, 从而避免出现这种情况. 另外, 焦点选中左侧的范围名称文字也没有任何意义, 所以要使用GUItility.GetControlID(FocusType.Passive), 通过FocusType.Passive, 告诉Untiy, 该控制ID代表的UI不可以被用户焦点选中, 比如鼠标点击 :

  1. //position = EditorGUI.PrefixLabel(position, label);
  2. //代码太长, 参数分行写清晰点
  3. //增加了第二个参数, 范围名称标签指定了一个与min不同的控制标签,这样二者不会一同被高亮选中
  4. //FocusType.Passive保障了该标签文字也不能单独被选中, 选中它不是错误, 但没有意义
  5. position = EditorGUI.PrefixLabel(
  6. position,
  7. GUIUtility.GetControlID(FocusType.Passive),
  8. label
  9. );

操控形状 - 图26
选中min后, 左侧范围名称文字不会一同高亮了

最后, 我们应该在完成UI绘制后将标签宽度和缩进距离恢复到绘制前的原始值, Unity编辑器会自动完成这个工作, 但通常不可以依赖编辑器 :

  1. //记录UI绘制前的原始缩进
  2. int originalIndentLevel = EditorGUI.indentLevel;
  3. //记录UI绘制前的原始标签宽度
  4. float originalLabelWidth = EditorGUIUtility.labelWidth;
  5. EditorGUI.BeginProperty(position, label, property);
  6. EditorGUI.EndProperty();
  7. //UI绘制完成后, 恢复原始缩进, 否则如果在此之后绘制其他UI还会使用被更改的缩进值
  8. EditorGUI.indentLevel = originalIndentLevel;
  9. //UI绘制完成后, 恢复原始标签宽度, 否则如果在此之后绘制其他UI还会使用被更改的宽度值
  10. EditorGUIUtility.labelWidth = originalLabelWidth;

(是否恢复原始的宽度和缩进, 对于我们现在做的UI显示效果没有任何影响, 不过这应该是一种好的习惯, 现在明白代码干了什么即可)

颜色配置

接着继续为形状增加一个配置, 为其在给定范围内设置随机颜色, 目前为形状设置随机颜色的范围是固定的, 我们可以创建一个专用的ColorRangeHSV结构, 用于配置颜色的随机范围, 该结构应该提供一个可以方便的获取到随机颜色结果的属性, 该结构像FloatRange结构一样需要使用单独的脚本文件编写, 新建脚本ColorRangeHSV, 代码如下 :

  1. using UnityEngine;
  2. [System.Serializable]
  3. //用于配置随机颜色范围的结构
  4. public struct ColorRangeHSV {
  5. //使用三个FloatRange字段分别代表颜色的色度范围,饱和度范围和明度范围
  6. public FloatRange hue, saturation, value;
  7. //获取对应范围内的随机颜色
  8. public Color RandomInRange {
  9. get {
  10. return Random.ColorHSV(
  11. hue.min, hue.max,
  12. saturation.min, saturation.max,
  13. value.min, value.max,
  14. 1f, 1f
  15. );
  16. }
  17. }
  18. }

然后就可以在SpawnConfiguration结构中增加一个ColorRangeHSV类型的字段用来配置随机颜色范围 :

  1. public struct SpawnConfiguration {
  2. //该字段用于形状的随机颜色范围配置
  3. public ColorRangeHSV color;

这样, 就可以在SpawnZone脚本的ConfigureSpawn方法中, 使用spawnConfig.color.RandomInRange代替之前获取随机颜色的代码 :

  1. //shape.SetColor(Random.ColorHSV(
  2. // hueMin: 0f, hueMax: 1f,
  3. // saturationMin: 0.5f, saturationMax: 1f,
  4. // valueMin: 0.25f, valueMax: 1f,
  5. // alphaMin: 1f, alphaMax: 1f
  6. //));
  7. //使用spawnConfig.color.RandomInRange代替之前获取随机颜色的代码
  8. shape.SetColor(spawnConfig.color.RandomInRange);

image.png
颜色范围显示在Inspector中了, 但是窗口宽度较小时, 其min和max文字被盖住了一部分

如上图所指示, 颜色范围每个属性的min和max文字, 比我们之前添加的速度等属性的对应文字更靠右, 这是因为Unity会自动的按照属性嵌套的层级增加缩进, 对于我们的情况不需要这样, 因此可以修改FloatRangeDrawer脚本, 在绘制min及max的代码之前, 通过修改EditorGUI.indentLevel属性, 设置它们标签的缩进固定为1 :

  1. //在这之后绘制的标签缩进都会被设置为1
  2. EditorGUI.indentLevel = 1;
  3. EditorGUI.PropertyField(position, property.FindPropertyRelative("min"));

(此处关于标签缩进的内容原文是写在属性自定义显示那一节的末尾处的. 但是在哪里写这个, 会困惑控制缩进干嘛, 因为那时候还没有暴露出缩进问题, 所以把这一部分内容移动到了这里)

操控形状 - 图28 操控形状 - 图29
标签文字的缩进已经正常了, 配置不同属性, 运行游戏体验下效果

范围滑动条

hue, saturation和value字段的值只能设置在0到1之间, 我们可以尝试使用之前用过的Range特性, 来限制它们的取值范围 :

  1. [Range(0f, 1f)]
  2. public FloatRange hue, saturation, value;

操控形状 - 图30
为FloatRange类型的字段加上Range特性后

你会发现, 直接添加Range特性后, Inspector中会显示提示文字, 告诉我们只有float或int类型可以应用该特性. 那么我们就需要自己创建所需的特性了, 可以通过继承PropertyAttribute的类来创建自定义特性. 新建脚本FloatRangeSliderAttribute, 它虽然是为编辑器UI功能服务的, 不过要放在Editor文件夹之外, 因为要在ColorRangeHSV脚本中使用它

该特性只包含两个可公开访问的属性Min和Max, 不过只能由特性本身设置它们的值 :

  1. using UnityEngine;
  2. //创建一个为FloatRange类型值使用的滑动条特性
  3. public class FloatRangeSliderAttribute : PropertyAttribute {
  4. //代表滑动条最小值的属性
  5. public float Min { get; private set; }
  6. //代表滑动条最大值的属性
  7. public float Max { get; private set; }
  8. }

接着为该类添加一个构造函数, 该构造函数接受两个参数, 一个代表最小值, 一个代表最大值, 用于初始化Min和Max属性. 另外, 初始化的时候还应该保障Max不会小于Min的值 :

  1. //初始化Min和Max的构造函数
  2. public FloatRangeSliderAttribute (float min, float max) {
  3. //如果参数max小于参数min, 则让max至少等于min
  4. if (max < min) {
  5. max = min;
  6. }
  7. //初始化Min属性
  8. Min = min;
  9. //初始化Max属性
  10. Max = max;
  11. }

现在就可以使用自定义特性代替之前添加的Range特性, 由于自定义特性的命名是以Attribute结尾的, 在Unity中有一个特殊的规定就是这种自定义特性名称在使用时可以省略末尾的Attribute字符串, 因此我们只需要使用FloatRangeSlider就可以引用这个特性 :

  1. //[Range(0f,1f)]
  2. //使用自定义特性约束FloatRange类型字段在Inspector中的取值范围
  3. //Unity的特殊规则, 使用Attribute结尾的特性名称, 使用的时候可以省略这个字符串
  4. //此处其实就是在调用我们为自定义特性类写的的构造函数
  5. [FloatRangeSlider(0f, 1f)]
  6. public FloatRange hue, saturation, value;

但是只靠上面的代码还不能在Inspector中显示出我们需要的取值滑动条, 因为这只是刚刚把自定义特性与要影响的字段关联了起来, 而这种关联应该产生的编辑器UI变化, 依然需要我们自己定义绘制规则. 在Editor文件夹中新建脚本FloatRangeSlider, 用来为自定义特性绘制UI, 先书写以下基本代码 :

  1. using UnityEditor;
  2. using UnityEngine;
  3. //该特性将typeof()中书写的类型作为参数, 用来指定要在Inspector中自定义哪一种类型属性的显示方式
  4. [CustomPropertyDrawer(typeof(FloatRangeSliderAttribute))]
  5. public class FloatRangeSliderDrawer : PropertyDrawer {
  6. //重写OnGUI方法, 进行自定义UI的绘制
  7. public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) {
  8. //告诉编辑器要开始绘制属性自定义UI了
  9. EditorGUI.BeginProperty(position, label, property);
  10. //告诉编辑器属性自定义UI绘制完毕了
  11. EditorGUI.EndProperty();
  12. }
  13. }

在属性UI绘制之前, Unity编辑器会先检查这个属性是否被添加了什么特性, 如果添加了, 则使用该特性的UI绘制规则; 如果没有特性, 才会使用属性自身的UI绘制规则. 所以我们的Inspector中现在又出现空白行了, 因为Unity在使用自定义特性的UI绘制规则, 而我们还没有写任何绘制代码.

添加自定义特性后还是需要访问min和max值的, 只不过是需要绘制为滑动条来代表它们并对它们进行设置, 而不是使用两个分开的文本输入框, 继续在FloatRangeSliderDrawer中添加代码 :

  1. EditorGUI.BeginProperty(position, label, property);
  2. //得到代表min属性的SerializedProperty对象
  3. SerializedProperty minProperty = property.FindPropertyRelative("min");
  4. //得到代表max属性的SerializedProperty对象
  5. SerializedProperty maxProperty = property.FindPropertyRelative("max");
  6. EditorGUI.EndProperty();

可以通过flatValue属性从SerializedProperty对象中得其代表的属性的小数值. 我们要首先得到min和max的值用于决定滑动条滑块的位置, 并且如果滑动条被滑动, 我们还要将变化的值再设置给它们 . Unity会检查滑动条值的变化并为我们提供”撤销”和”重做” 功能 :

  1. SerializedProperty maxProperty = property.FindPropertyRelative("max");
  2. //取出min值, 用于设置滑动条滑块
  3. float minValue = minProperty.floatValue;
  4. //取出max值, 用于设置滑动条滑块
  5. float maxValue = maxProperty.floatValue;
  6. //用于在滑动条变化后更新min属性值
  7. minProperty.floatValue = minValue;
  8. //用于在滑动条变化后更新max属性值
  9. maxProperty.floatValue = maxValue;

下一步, 我们需要得到滑动条需要显示出的取值范围, 该范围就储存在自定义特性中. 我们可以通过PropertyDrawer的attribute属性访问自定义特性. 由于attribute属性的类型是PropertyAttribute, 所以要先通过”as”关键字将其转换为自定义特性的类型 :

  1. float maxValue = maxProperty.floatValue;
  2. //通过attribute得到要绘制的属性的特性, 需要将其转换为要使用的自定义特性的类型
  3. FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
  4. minProperty.floatValue = minValue;

至此, 绘制滑动条的准备工作都完成了, 接下来只需要调用EditorGUI.MinMaxSlider方法就可以绘制带有两个滑块的滑动条. 该方法需要六个参数, 依次是, 1) 绘制滑动条的position 2) 滑动条的属性标签 3) 滑动条左滑块指示的值 4) 滑动条右滑块指示的值 5) 滑动条的最小值 6) 滑动条的最大值. 这些参数中, 由于3和4属性需要被滑动条的变化所调整, 因此需要将它们作为引用参数传递到方法内, 引用参数的传递方法是在参数名前面加上”ref”关键字, 这样, 在方法执行后也会改变作为参数的变量的值. 由于return语句只能返回一个值, 因此需要通过方法得到多个返回值时, 可以使用这个办法 :

  1. FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
  2. EditorGUI.MinMaxSlider(
  3. //绘制滑动条的position
  4. position,
  5. //滑动条的属性标签
  6. label,
  7. //滑动条左滑块指示的值, ref表示引用传递, 这样方法内修改该参数即改变了minValue变量的值
  8. ref minValue,
  9. //滑动条左滑块指示的值, ref表示引用传递, 这样方法内修改该参数即改变了maxValue变量的值
  10. ref maxValue,
  11. //滑动条的最小值
  12. limit.Min,
  13. //滑动条的最大值
  14. limit.Max
  15. );


操控形状 - 图31
范围在0到1之间的双滑块滑动条

显示数值的滑动条

虽然滑动条还是挺酷的, 但是无法通过它精确的指定任意数值. 如果想要能够这样做, 就需要为左滑块和右滑块分别增加一个对应的数字输入框

首先我们要移除MinMaxSlider绘制的标签文字, 以便可以将输入框插入到稍后单独绘制的标签与滑动条之间. 只需要在MinMaxSlider方法中去掉代表标签的参数即可 :

  1. EditorGUI.MinMaxSlider(
  2. position,
  3. //删除标签参数, 不通过该方法绘制标签
  4. //label,
  5. ref minValue,
  6. ref maxValue,
  7. limit.Min,
  8. limit.Max
  9. );

操控形状 - 图32
标签参数去掉后, 左侧的属性标签不见了

下一步, 需要使用之前用过的PrefixLabel方法绘制单独的标签文字, 而且我们不希望标签的缩进影响布局, 因此还要将绘制的缩进设置为0, 在绘制完标签后再还原为默认的缩进距离 :

  1. //记录下原始的缩进值
  2. int originalIndentLevel = EditorGUI.indentLevel;
  3. EditorGUI.BeginProperty(position, label, property);
  4. //绘制标签, 并使用position存储绘制后的剩余区域
  5. position = EditorGUI.PrefixLabel(position, GUIUtility.GetControlID(FocusType.Passive), label);
  6. //设置接下来绘制内容的缩进
  7. EditorGUI.indentLevel = 0;
  8. EditorGUI.EndProperty();
  9. //还原缩进距离为原始值.
  10. EditorGUI.indentLevel = originalIndentLevel;

我们应该将绘制标签后剩余的空间划分为相对的三个部分, 先使用EditorGUI.FloatField方法绘制一个没有标签的小数输入框, 它要显示minValeu的值, 并且它的输入值被改变后也会改变minValue值; 绘制完第一个输入框, 绘制滑动条, 然后绘制显示maxValue的第二个输入框 :

  1. float minValue = minProperty.floatValue;
  2. float maxValue = maxProperty.floatValue;
  3. //将绘制完左侧标签的剩余宽度划分为三份
  4. //这三份区域准备依次绘制第一个输入框, 滑动条, 和第二个输入框
  5. position.width /= 3;
  6. //绘制显示minValue的输入框, 输入框的值改变后会再赋值给minValue
  7. minValue = EditorGUI.FloatField(position, minValue);
  8. //为绘制滑动条而移动绘制位置的x坐标
  9. position.x += position.width;
  10. FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
  11. EditorGUI.MinMaxSlider(
  12. position,
  13. ref minValue,
  14. ref maxValue,
  15. limit.Min,
  16. limit.Max
  17. );
  18. //为绘制第二个输入框而移动绘制位置的x坐标
  19. position.x += position.width;
  20. //绘制显示maxValue的输入框, 输入户口的值改变后会再复制给maxValue
  21. maxValue = EditorGUI.FloatField(position, maxValue);
  22. minProperty.floatValue = minValue;
  23. maxProperty.floatValue = maxValue;

操控形状 - 图33
输入框和滑动条都显示出来了

现在布局有点不够美观, 可以按照以下思路进行美化 :

  1. 让滑动条占据剩余宽度的1/2, 左右输入框各占据1/4
  2. 在输入框与滑动条之间略微增加一点距离, 比如4像素的间隔, 这可以通过减少输入框绘制跨度来实现, 也就是左右输入框最终占据的宽度是 剩余宽度的1/4再减去4像素

根据上述优化思路, 代码修改如下 :

  1. //计算出输入框要占据的宽度, 存储到变量中, 这里的减4就是减去4像素宽度
  2. float fieldWidth = position.width / 4f - 4f;
  3. //计算出滑动条要占据的宽度, 存储到变量中
  4. float sliderWidth = position.width / 2f;
  5. //position.width /= 3;
  6. //使用fieldWidth变量设置输入框绘制的宽度
  7. position.width = fieldWidth;
  8. minValue = EditorGUI.FloatField(position, minValue);
  9. //position.x += position.width;
  10. //绘制滑动条的x坐标偏移应该在输入框宽度基础上再加4像素
  11. position.x += fieldWidth + 4f;
  12. //使用sliderWidth变量设置滑动条绘制的宽度
  13. position.width = sliderWidth;
  14. FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
  15. EditorGUI.MinMaxSlider(
  16. position,
  17. ref minValue,
  18. ref maxValue,
  19. limit.Min,
  20. limit.Max
  21. );
  22. //position.x += position.width;
  23. //绘制第二个输入框的x坐标偏移应该是滑动条宽度再加4像素
  24. position.x += sliderWidth + 4f;
  25. //使用fieldWidth变量设置输入框绘制的宽度
  26. position.width = fieldWidth;
  27. maxValue = EditorGUI.FloatField(position, maxValue);

操控形状 - 图34
优化后的布局

另外, 还需要在强制让输入的最小值不能大于最大值, 并且不能输入滑动条可输入范围以外的值 :

  1. maxValue = EditorGUI.FloatField(position, maxValue);
  2. //如果输入的最小值小于滑块支持的最小值, 则将其设置为滑块最小值
  3. if (minValue < limit.Min) {
  4. minValue = limit.Min;
  5. }
  6. //如果输入的最大值小于输入的最小值, 则最大值设置月最小值相等
  7. if (maxValue < minValue) {
  8. maxValue = minValue;
  9. }
  10. //如果输入的最大值大于滑块支持的最大值, 则将其设置为滑块最大值
  11. else if (maxValue > limit.Max) {
  12. maxValue = limit.Max;
  13. }
  14. minProperty.floatValue = minValue;
  15. maxProperty.floatValue = maxValue;

下一篇教程是更多形状工厂

教程源码
PDF