某愿朝闻道译/原文地址

  • 创建一个可以变形的形状生成区
  • 在场景窗口绘制出可视化的生成区Gizmo线框
  • 为每个场景配置不同的生成区
  • 关联不同的场景内的游戏对象
  • (使用抽象类与抽象成员)
  • 创建多种类型的生成区

这是对象管理章节的第五篇教程. 将在上个教程的代码基础上制作. 本篇教程将使用生成的形状构成多种图案, 并且为每个关卡配置这些生成图案

本教程源码使用Unity2017.4.4f1创建
生成区 - 图1
通过生成的形状, 组合为特定图案

生成位置点

我们之前的游戏会随机的生成形状. 材质和颜色是随机的, 位置, 旋转和缩放也是随机的. 尽管生成形状的位置点是随机的, 但是这些点都被限制在一个半径为5, 球心在世界空间原点的球体区域内. 一旦产生了足够多的形状, 它们将会形成一个明显的球体, 这个球体, 就是通过我们之前的代码实现的固定种类的形状生成区.

可以实现使用多种生成区. 我们能让形状按照不同的配置生成. 要做到这一点, 就需要将之前的固定生成区替换为可配置的生成区.

SpawnZone脚本组件

创建一个新的脚本SpawnZone. 它只用来提供形状的生成位置点, 因此为其添加一个名为SpawnPoint的属性. 我们需要通过这个属性获取位置, 而不是设置位置, 所以它只需要get方法, 也就是它是一个只读属性. 首先让该属性依然返回一个半径为5的球体内的随机位置 :

  1. using UnityEngine;
  2. //通过该脚本获取形状生成的位置
  3. public class SpawnZone : MonoBehaviour {
  4. //返回形状生成位置, 只有get方法, 所以是只读的
  5. public Vector3 SpawnPoint {
  6. get {
  7. //返回半径为5的球体内一随机位置
  8. return Random.insideUnitSphere * 5f;
  9. }
  10. }
  11. }

向主场景添加一个空游戏物体, 命名为Spawn Zone, 然后为其添加上述脚本. 这样游戏中就有了一个生成区, 不过现在它还没有被使用
生成区 - 图2
生成区物体

使用生成区

下一步是让Game脚本使用生成区规定的生成位置. 在Game脚本中新增公开字段CreateShape, 用来获得生成位置点 :

  1. //用来引用生成区物体 通过生成区得到形状生成的位置
  2. public SpawnZone spawnZone;
  3. void CreateShape () {
  4. Shape instance = shapeFactory.GetRandom();
  5. Transform t = instance.transform;
  6. //t.localPosition = Random.insideUnitSphere * 5f;
  7. //Game自身不再处理生成位置, 而是使用生成区脚本SpawnZone来获得位置点
  8. t.localPosition = spawnZone.SpawnPoint;
  9. }

在Inspector中将生成区物体拖入spawnZone字段的引用栏.
生成区 - 图3
在Game脚本的Inspector中将Spawn Zone物体拖拽到spawnZone属性上

上述修改后, 尽管游戏的运行表现与之前是相同的, 但是现在形状的生成位置已经由SpawnZone脚本来控制

生成区变形

场景中的生成区是一个游戏物体, 所以我们可以移动它. 如果我们希望它的位置变化可以影响到形状的生成位置, 就需要将它的位置信息与随机计算的位置相加. 此处要使用position属性而不是localPosition属性, 这样才能允许生成区作为其他物体的子物体, 并随着父物体的移动而返回变化的位置 :

  1. public Vector3 SpawnPoint {
  2. get {
  3. //return Random.insideUnitSphere * 5f;
  4. //在随机球体位置基础上, 再加上脚本所属游戏对象的世界坐标系位置
  5. return Random.insideUnitSphere * 5f + transform.position;
  6. }
  7. }

我们还可以进一步处理, 让游戏对象的位置, 旋转和缩放信息全部都作用于生成区产生的位置. 该效果需要调用Transform.TransformPoint方法来实现, 该方法接受一个Vector3的参数, 在我们的代码中将使用单位球体内的随机位置. 这样做之后, 我们就不需要再将位置信息乘以5了, 而是通过调整生成区物体的Scale来控制生成区的半径 :

  1. //return Random.insideUnitSphere * 5f + transform.position;
  2. //TransformPoint方法, 会将游戏对象本地空间坐标系下的位置转换为世界空间坐标系下的位置
  3. //本地空间坐标系, 即以该游戏对象的当前位置为坐标原点
  4. return transform.TransformPoint(Random.insideUnitSphere);

生成区 - 图4
代码修改后, 将生成区的Scale改成5,5,5, 游戏效果与代码修改前相同

你也可以将Scale调整为不同的设置, 生成区也会做出对应的变化
生成区 - 图5
Z轴旋转45度, 缩放设置为(10,2,5)的生成区效果

只在表面生成

我们不需要选择球体范围内部的位置. 可以通过Random.onUnitSphere方法代替Random.insideUnitSphere. 让我们添加一个字段surfaceOnly来控制选择哪一部分的球体位置点 :

  1. [SerializeField]
  2. //该字段为true表示只在生成区代表的三维形状的表面选择生成位置, 反之则生成位置会在整个三维形状空间范围内选择
  3. bool surfaceOnly;
  4. public Vector3 SpawnPoint {
  5. get {
  6. //return transform.TransformPoint(Random.insideUnitSphere);
  7. //surfaceOnly为true, 调用Random.onUnitSphere, 否则调用Random.insideUnitSphere
  8. return transform.TransformPoint(
  9. surfaceOnly ? Random.onUnitSphere : Random.insideUnitSphere
  10. );
  11. }
  12. }

生成区 - 图6
勾选surfaceOnly后, 将只在生成区的三维形状表面选择生成位置

只在表面选取生成位置能让生成区的形状看起来的更明显
生成区 - 图7 生成区 - 图8
左图 : 只在表面选取位置
右图 : 在全部空间范围选取位置

生成区可视化

如果我们可以在生成大量的形状之前就看见调整后的生成区形状, 会让调试生成区形状的工作更有效率. 我们可以在SpawnZone脚本中添加OnDrawGizomos方法, 帮助我们在场景窗口中绘制出视觉辅助线, 它是在一个Unity内置方法, 每次Unity绘制场景窗口图像时都会被调用. 我们要在该方法内调用Gizmo.DrawWireShpere方法来绘制一个由三个圆形组成的球体Gizmo线框, 该方法需要一个位置参数和一个半径参数, 我们将使用Vector3.zero作为位置, 1作为半径, 也就是绘制一个单位球体 :

  1. //Unity每次绘制场景窗口图像时都会调用该方法
  2. void OnDrawGizmos () {
  3. //绘制一个单位球体Gizmos线框
  4. Gizmos.DrawWireSphere(Vector3.zero, 1f);
  5. }

生成区 - 图9
保存代码, 运行后, 场景内出现了三个圆形组成的球体线框

能在游戏窗口下看见绘制的Gizmo吗?

可以, 在游戏窗口(注意不是场景窗口)顶部工具栏右侧, 有个”Gizmo”按钮, 点一下就可以在游戏窗口看到了. 该功能只在编辑器环境下可见, 构建后的程序中不会显示Gizmo内容

默认的Gizmo内容是白色的, 不过可以通过Gizmo.color属性改变默认颜色. 不同的颜色可以帮助我们分辨不同的Gizmo. 让我们设置Gizmo的颜色为青色 :

  1. void OnDrawGizmos () {
  2. //设置接下来要绘制的Gizmos为青色
  3. Gizmos.color = Color.cyan;
  4. Gizmos.DrawWireSphere(Vector3.zero, 1f);
  5. }

生成区 - 图10
青色的Gizmo

现在, 我们的球体框绘制在世界空间的原点, 半径为1, 不受到生成区对象的位置大小和旋转的影响. 这是因为Gizmo默认绘制在世界空间坐标系下. 可以修改Gizmo.matrix属性来改变绘制Gizmo使用的坐标系, 该坐标系可以通过生成区对象Transform组件的localToWorldMatrix属性得到 :

  1. void OnDrawGizmos () {
  2. Gizmos.color = Color.cyan;
  3. //将Gizmo使用的坐标系设置为所属游戏对象的本地坐标系
  4. Gizmos.matrix = transform.localToWorldMatrix;
  5. Gizmos.DrawWireSphere(Vector3.zero, 1f);
  6. }

生成区 - 图11
根据生成区的Transform属性改变Gizmo的样子

是不是应该在绘制后重置Gizmo的颜色和坐标系?

不需要, 它们会自动重置

每个关卡一个生成区

现在已经完成了配置生成区的功能, 接下来要让每个关卡拥有属于自己的生成区

迁移到不同的场景

我们可以通过在Hierarchy窗口内进行拖拽, 从而在打开的场景之间移动物体, 让我们这样将Spawn Zone从Main Scene内, 拖拽到Level 1名称上, 然后松开鼠标 :
生成区 - 图12
将Spawn Zone移动到了Level 1内

现在生成区是关卡场景的一部分了, 此时Unity会在控制台输出警告信息提示我们, 大概内容是说发现了跨越场景的引用(Cross scene reference). 不幸的是, 直接在不同场景的物体之间的引用不能被保存下来, 这是因为Unity不能确定这两个场景是否总是同时打开. 因此, 移动生成区到Level 1场景后, Game脚本的Inspector中生成区的引用处会显示”Scene mismatch(场景失配)”, 当我们保存并运行游戏后, Game脚本中引用的生成区域将会被清空 :
生成区 - 图13
生成区 - 图14
场景失配

Game脚本需要生成区的引用, 但是因为生成区已经被我们移动到了另一个场景中, 所以Game无法保存所需的引用. 解决这个问题的最简单办法是把spawnZone字段替换为一个公开的属性. 让我们在Game脚本中新增属性SpawnZoneOfLevel, 它的名字表示它来自关卡场景 :

  1. //public SpawnZone spawnZone;
  2. //用于获得关卡场景内的生成区
  3. public SpawnZone SpawnZoneOfLevel { get; set; }
  4. void CreateShape () {
  5. Shape instance = shapeFactory.GetRandom();
  6. Transform t = instance.transform;
  7. //t.localPosition = spawnZone.SpawnPoint;
  8. //用SpawnZoneOfLevel代替spawnZone
  9. t.localPosition = SpawnZoneOfLevel.SpawnPoint;
  10. }

寻找Game脚本实例

现在需要有个”负责人”在关卡场景加载之后设置SpawnZoneOfLevel属性的值, 每个关卡加载之后都需要这样做, 因为不同关卡的生成区可能是不同的. 但是现在的问题是应该让哪个脚本负责SpawnZoneOfLevel属性的设置工作?

尽管Game脚本控制着关卡的加载, 但是它并不能直接访问关卡内容, 而是需要检索关卡场景内容后才能搜索到所需的游戏对象. 我们可以试试让关卡在加载完成后, 自己负责设置SpawnZoneOfLevel属性, 一起做做看吧

为了设置SpawnZoneOfLeve, 关卡必须首先得到主场景中的Game脚本实例. 游戏中只有一个Game的实例, 因此我们可以在Game脚本中增加一个静态属性Instance来存储这个实例. 所有其他脚本均可访问Instance属性, 但是只有Game脚本可以设置它的值. 这其实是一种不严格的单例设计模式(singleton design pattern, 单例单例, 单一实例, “不严格”指的是本篇教程只是简单的使用了单一实例, 而没有通过代码保障实例必定存在并不会被实例化多份) :

  1. //访问Game脚本实例的属性, 公开的get方法, 私有的set方法
  2. public static Game Instance { get; private set; }

在Game脚本实例被唤醒(awake)时, 需要将自身的引用分配给Instance属性. 脚本可以通过this关键字得到自身当前实例的引用, 为Game脚本添加Awake方法 :

  1. void Awake () {
  2. //脚本唤醒时, 将当前实例的引用赋值给Instance属性
  3. Instance = this;
  4. }

**

上面的代码并不能保障有且只有一个Game脚本实例, 为什么不处理一下?

一般情况需要那样做.
但是在我们教程的这个项目中, 我们很清楚整个游戏中只有主场景存在一个Game脚本的实例, 它只会加载一次并且不会被卸载.

但是在编辑环境下, 上述代码会导致运行时重编后Instance属性的引用丢失, 这是因为静态属性不是游戏不是Unity游戏状态的一部分. 为了重编译脚本后引用不会丢失, 我们可以在OnEnable方法中设置Instance而不是在Awake方法中设置它. OnEnable方法会在脚本组件启用时触发一次, 每次Unity重编译后也会被调用一次 :

  1. //void Awake () {
  2. //使用OnEnable方法可以保障在运行时候重编译后再次设置Instance, 防止引用丢失
  3. //注意, 教程项目的代码会在重编以后执行两次OnEnable方法, 这是因为在Start方法中调用了LevelLoad方法
  4. //而LevelLoad方法中会禁用再启用Game脚本
  5. void OnEnable () {
  6. Instance = this;
  7. }

~~

OnEnable方法到底是什么时候被调用的?

每次一个被禁用的组件启用时都会触发该方法, 在运行时重编译时, 首先会禁用所有组件, 然后游戏状态得到保存, 接着开始进行代码重编译, 编译完成后会恢复保存的游戏状态, 并再次启用因重编译而被禁用的组件. 因此重编译也会导致OnEnable方法被触发.

另外. OnEnable方法会在Awake方法执行完毕后立即调用, 除非组件当前是禁用状态, 稍后我们会利用这个规则.

注意, 我们的项目会在重编以后执行两次Game脚本的OnEnable方法, 这是因为在Start方法中调用了LevelLoad方法, 而LevelLoad方法中会禁用再启用Game脚本. 对于我们的项目这不会造成问题, 因为再执行一次也是使用相同的引用去设置Instance属性

因为现在Game脚本需要被其他代码访问, 那么应该适当的隐藏那些不应该被其他脚本访问的配置字段. 将shapeFactory字段的public关键字去掉使其变为私有字段, 然后为其增加[SerializeField]特性, 使其依然可以通过Inspector进行配置 :

  1. //public ShapeFactory shapeFactory;
  2. //将shapeFactory改为私有字段, 使其不能被其他脚本的代码方法,
  3. //增加[SerializeField]特性使其依然可以通过Inspector进行配置
  4. [SerializeField] ShapeFactory shapeFactory;

我只展示了对于shpaeFactory字段的修改, 实际上还需要对如下公开字段进行同样的修改 :

  1. 所有的KeyCode类型的按键配置字段
  2. storage字段
  3. levelCount字段

以上字段的修改请逐个完成. 另外, [SerializeField]特性语句放在字段上面一行或是写在同一行字段前方, 都可以生效.

游戏关卡

接下来我们需要添加一些代码, 来动态的关联关卡中包含的生成区. 虽然我们可以将这部分功能添加到SpawnZone脚本中, 但是从设计的角度来说, SpawnZone应该只负责生成位置, 不处理其他事务, 它不需要了解游戏的其余功能模块要做什么. 因此我们需要创建一个新的脚本组件GameLevel来处理这一部分事务, 这个新建的脚本需要知道要使用哪一个生成区, 所以应该给它设置一个配置生成区用的字段, 在程序运行时, 将该脚本得到的生成区引用分配给可全局访问的Game.Instance的SpawnZoneOfLevel属性.

我们要在GameLevel脚本的Start方法中完成生成区的关联工作, 因为Start方法执行的时候关卡场景已经加载完成. 而且, 运行时重编译后会首先加载编译前活跃的场景, 所以在Start方法中进行生成区关联的过程, 可以在Main Scene不是活跃场景的前提下运行时重编译后, 依然保障Game.OnEnable方法已经完成了对Game.Instace属性的赋值 :
(同时加载的非禁用脚本的OnEnable方法的初次执行都早于所有Start方法, 无论是在哪个脚本中的. 有兴趣的可以查看所有Unity事件函数执行顺序)

  1. using UnityEngine;
  2. //专门用于将关卡场景的生成区管理到主场景的Game脚本的SpawnZoneOfLevel属性的脚本组件
  3. public class GameLevel : MonoBehaviour {
  4. [SerializeField]
  5. //该字段用来存储该组件所在场景的生成区引用
  6. SpawnZone spawnZone;
  7. void Start () {
  8. //Start方法会在同时加载的所有其他脚本的Awake方法和OnEnable方法执行之后才执行
  9. //在这里进行生成区的关联可以保障代码所需的游戏对象已经全部初始化完成
  10. Game.Instance.SpawnZoneOfLevel = spawnZone;
  11. }
  12. }

接下来, 在Level 1场景内添加一个叫做Game Level的空物体, 为其添加GameLevel脚本组件, 记得在Inspector中把场景内的生成区物体拖到spawnZone字段上 :
生成区 - 图15
生成区 - 图16
创建Game Level空物体, 添加GameLevel组件, 并把生成区物体分配给spawnZone字段

这意味着Game Level物体现在保存了关卡场景中的生成区引用, 这是因为它们二者均处于同一场景中. 当游戏运行时, GameLevel脚本会将该生成区的引用分配给Game.Instance的SpawnZoneOfLevel属性.

我们梳理一下上述脚本的配合情况 :

  1. GameLevel将关卡场景内的生成区与主场景的Game关联起来, 它的代码”知道”Game脚本也知道SpawnZone脚本;
  2. 而对于Game脚本, 它只”知道”SpawnZone, 而不知道SpawnZone的引用是通过GameLevel分配给它的;
  3. 对于SpawnZone脚本, 它既不”知道”Game, 也不”知道”GameLevel, 它的代码中没有任何内容与这二者有关.

生成区 - 图17
脚本组件之间的引用情况, 其中虚线表示的引用关系只会在运行时生效

这是设计依赖关系(dependency)的最好方式吗?

我们所完成的并不是一种设计依赖关系的通用方法. 在本教程的项目中, 我们将Game组件的spawnZone字段改为了一个属性, 并引入了GameLevel脚本来为该属性关联生成区.

我们其实还有别的方式来实现这个目的, 比如说可以设置一个GameLevel的静态实例属性, 让Game脚本通过该静态属性去得到生成区的引用; 或是为Game脚本添加一个GameLevel类型的属性代替SpawnZone类型的属性, 通过该属性直接获取生成区等等(可选的方法并不固定, 也没有绝对的优劣)

因为现在GameLevel只需要将生产区引用传递给Game脚本实例, 所以我们目前的所使用的方法没有问题. 如果GameLevel脚本被赋予了更多的其他任务, 可能就必须要调整我们的设计了. 类似这种因为功能需求的变化而导致的代码调整是项目开发过程的一部分, 所以也会在我的教程中得到体现.

好了, 现在对Level 2进行相同的操作(添加Spawn Zone物体和Game Level物体, 并与Level 1一样添加同样的脚本并设置Inspector)

完成上述工作后, 运行游戏, 虽然看起来与之前没什么区别, 但是你已经可以分别为每个关卡的配置不同的生成区

生成区类型

我们为定义生成区的空间位置范围而创建了专门的脚本SpawnZone, 接下来让我们为这个脚本扩展功能, 使得它可以创建其他类型的生成区, 比如说, 除了球形区域, 还可以支持立方体区域

抽象生成区

无论是哪一种生成区, 它们的功能都是用来提供产生形状的位置. SpawnZone脚本定义了基础的位置规则. 在SpawnZone脚本中移除所有只用于球体生成区的代码, 只保留使用默认get方法的SpawnPoint属性 :

  1. public class SpawnZone : MonoBehaviour {
  2. //[SerializeField]
  3. //bool surfaceOnly;
  4. //除了下面这行代码, 其余全干掉
  5. public Vector3 SpawnPoint { get; }
  6. // get {
  7. // return transform.TransformPoint(
  8. // surfaceOnly ? Random.onUnitSphere : Random.insideUnitSphere
  9. // );
  10. // }
  11. //}
  12. //void OnDrawGizmo () {
  13. // …
  14. //}
  15. }

我们要将SpawnZone定义为代表生成区的抽象功能, 在语法上, 我们需要为SpawnZone类的声明语句的添加abstract关键字, 对SpawnPoint属性也是如此 :

  1. //public class SpawnZone : MonoBehaviour {
  2. //abstract关键字, 把SpawnZone类变成了一个抽象类
  3. //"抽象"是面相对象编程的一种概念, 抽象类仅可用于被其他类继承,自身不能实例化
  4. public abstract class SpawnZone : MonoBehaviour {
  5. //public Vector3 SpawnPoint { get;}
  6. //通过添加abstract关键字, 将SpawnPoint声明为抽象属性,
  7. //对于抽象属性和抽象方法, 只需做出声明, 不需要也不可以书写任何具体的功能逻辑代码
  8. public abstract Vector3 SpawnPoint { get; }
  9. }

现在SpawnZone变成了一个抽象类型, 一个不能创建实例的类. 因此, 运行时Unity编译时会在控制台输出相关信息提示我们对SpawnZone脚本的实例化无效. 我们需要使用SpawnZone的派生类(继承该类的类) 替换它们在场景中的引用来修复这个问题.
(如果想了解更多关于C#中abstract关键字的功能, 可以查看微软官方”abstract C# 参考文档)

球形生成区

首先, 我们需要新创建一个定义球形生成区的脚本组件SphereSpawnZone, 它要继承SpawnZone类而不是MonoBehavior类. 它的代码与之前定义定义球形生成区的代码类似, 只有一处不同, 那就是在SpawnPoint属性的声明语句中要加入override关键字, 表示它重写了父类的抽象属性SpawnPoint, 加入了具体的功能代码(抽象VS具体, 对于派生类, 必须要重写所有其父类中的抽象属性或抽象方法)

  1. using UnityEngine;
  2. //该类继承自抽象类SpawnZone, 用来定义球形生成区
  3. public class SphereSpawnZone : SpawnZone {
  4. [SerializeField]
  5. //该字段为true表示只在生成区代表的三维形状的表面选择生成位置,
  6. //反之则生成位置会在整个三维形状空间范围内选择
  7. bool surfaceOnly;
  8. //返回形状生成位置, 只有get方法, 所以是只读的
  9. public override Vector3 SpawnPoint {
  10. get {
  11. //TransformPoint方法, 会将游戏对象本地空间坐标系下的位置转换为世界空间坐标系下的位置
  12. //本地空间坐标系, 即以该游戏对象的当前位置为坐标原点
  13. //surfaceOnly为true, 调用Random.onUnitSphere, 否则调用Random.insideUnitSphere
  14. return transform.TransformPoint(
  15. surfaceOnly ? Random.onUnitSphere : Random.insideUnitSphere
  16. );
  17. }
  18. }
  19. //Unity每次绘制场景窗口图像时都会调用该方法
  20. void OnDrawGizmo () {
  21. //设置接下来要绘制的Gizmos为青色
  22. Gizmos.color = Color.cyan;
  23. //将Gizmo使用的坐标系设置为所属游戏对象的本地坐标系
  24. Gizmos.matrix = transform.localToWorldMatrix;
  25. //绘制一个单位球体Gizmos线框
  26. Gizmos.DrawWireSphere(Vector3.zero, 1f);
  27. }
  28. }

调整Level 1场景中的Spawn Zone物体, 移除原来的SpawnZone脚本, 添加SpherSpawnZone脚本, 并且将Spawn Zone物体重新拖拽到Game Level物体的Inspector中; 对Level 2中的对应物体执行同样的操作.
生成区 - 图18image.png
左图 : 为Spawn Zone物体添加SphereSpawnZone脚本
右图 : 将添加了新脚本的Spawn Zone物体拖拽到Game Level物体Inspector中的对应位置

立方体生成区

创建一个新的脚本CubeSpawnZone. 依然继承SpawnZone类, 它也必须重写虚拟属性SpawnPoint :

  1. using UnityEngine;
  2. //该脚本将用来定义立方体生成区
  3. public class CubeSpawnZone : SpawnZone {
  4. //重写父类的抽象属性SpawnPoint
  5. public override Vector3 SpawnPoint {
  6. get {
  7. }
  8. }
  9. }

与获取随机球形空间位置的不同, 并没有获取随机立方体空间位置的便捷方法可以用, 因此我们需要自己计算满足需要的位置. 一个单位立方体的中心位于原点, 每一条边的长度都是1. 因此它在每个维度上都只有一半的体积. 要获得一个单位立方体内的随机位置, 我们可以调用Random.Range(-0.5,0.5)方法获得单位立方体中在三个轴上的随机坐标, 从而得到一个随机的三维坐标位置, 然后再将该坐标位置从所属对象的本地坐标转换为世界坐标 :

  1. public override Vector3 SpawnPoint {
  2. get {
  3. //变量p, 存储获得的随机立方体内坐标位置
  4. Vector3 p;
  5. //获取单位立方体空间的随机x轴坐标
  6. p.x = Random.Range(-0.5f, 0.5f);
  7. //获取单位立方体空间的随机y轴坐标
  8. p.y = Random.Range(-0.5f, 0.5f);
  9. //获取单位立方体空间的随机z轴坐标
  10. p.z = Random.Range(-0.5f, 0.5f);
  11. //将得到的单位立方体上随机位置从所属对象的本地坐标系转换为世界坐标系
  12. return transform.TransformPoint(p);
  13. }
  14. }

接着为该脚本添加OnDeawGizmo方法, 通过Gizmo.DrawWireCube方法可以帮助我们在场景内绘制立方体线框. 它的第一个参数代表立方体中心的位置, 第二个参数代表立方体每条边的长度 :接着为该脚本添加OnDeawGizmo方法, 通过Gizmo.DrawWireCube方法可以帮助我们在场景内绘制立方体线框. 它的第一个参数代表立方体中心的位置, 第二个参数代表立方体每条边的长度 :

  1. //每次场景窗口创建绘制图像时调用
  2. void OnDrawGizmos()
  3. {
  4. //设置Gizomo颜色
  5. Gizmos.color = Color.cyan;
  6. //设置Gizomo坐标系为所属对象的本地坐标系
  7. Gizmos.matrix = transform.localToWorldMatrix;
  8. //绘制中心点本地坐标在(0,0,0), 三条边长度为(1,1,1)的立方体
  9. Gizmos.DrawWireCube(Vector3.zero, Vector3.one);
  10. }

保存代码, 然后在Level 2场景中, 将Spawn Zone物体上的SphereSpawnZone脚本替换为CubeSpawnZone :
生成区 - 图20生成区 - 图21
在Level 2场景中配置的立方体生成区效果

同样的, 也未CubeSpawnZone脚本添加surfaceOnly字段, 用来控制获取生成位置的方式. 当该字段设置为true时, 需要让随机获取的坐标位置最终停靠到立方体的某个表面上, 我们可以让立方体内的随机位置点沿着一条轴移动最终与某个表面对. 这个轴可以随机决定 :

  1. [SerializeField]
  2. //该字段控制是否只在代表生成区的三维区域的表面选择生成位置
  3. bool surfaceOnly;
  4. public override Vector3 SpawnPoint {
  5. get {
  6. Vector3 p;
  7. p.x = Random.Range(-0.5f, 0.5f);
  8. p.y = Random.Range(-0.5f, 0.5f);
  9. p.z = Random.Range(-0.5f, 0.5f);
  10. //检查是否只在区域表面选择生成位置
  11. if (surfaceOnly) {
  12. //如果只在区域表面选择生成位置, 首先获得0-2之间的随机整数作为停靠轴的索引
  13. int axis = Random.Range(0, 3);
  14. }
  15. return transform.TransformPoint(p);
  16. }
  17. }

可以使用axis的值作为索引, 像访问数组成员一样访问Vector3类型的每一个分量的值, 0-2的索引分别对应x,y和z轴. 然后我们根据索引到的分量值来决定将随机得到的位置点向该轴的正方向还是负方向的表面停靠, 如果该分量为负数, 则沿着该轴负方向停靠, 否则沿着该坐标轴正方形停靠, 也就是我们向在该轴方向上距离位置点较近的表面停靠 :

  1. int axis = Random.Range(0, 3);
  2. //以axis的值作为索引获取生成位置点对应坐标轴的值, 如果该值为负数, 则将其设置为-0.5, 否则设置为0.5
  3. p[axis] = p[axis] < 0f ? -0.5f : 0.5f;

生成区 - 图22生成区 - 图23
在立方体表面选择生成位置的效果

复合生成区

最后, 让我们通过组合不同的生成区来创建一个复合类型的生成区. 这样使我们可以创建更为复杂的生成区.

首先新建一个继承SpawnZone类的脚本CompositeSpawnZone, 为其添加一个SpawnZone类型的数组字段 :

  1. using UnityEngine;
  2. //用于定义复合类型的生成区的脚本组件
  3. public class CompositeSpawnZone : SpawnZone {
  4. [SerializeField]
  5. //存储构成复合生成区所需的基本生成区的数组
  6. SpawnZone[] spawnZones;
  7. }

它的SpawnPoint属性将在spawnZones数组中随机选择一个元素, 并返回该元素的SpawnPoint属性值作为生成位置 :

  1. //复合生成区的SpawnPoint属性, 依然需要重写父类的抽象属性
  2. public override Vector3 SpawnPoint {
  3. get {
  4. //在spawnZones的所有索引中随机选择一个索引
  5. int index = Random.Range(0, spawnZones.Length);
  6. //返回随机索引对应的数组元素的SpawnPoint属性值作为生成位置
  7. return spawnZones[index].SpawnPoint;
  8. }
  9. }

**

我们不应该先检查下数组是不是空的吗?

你可以这样做. 你也可以检查数组是否存在, 因为如果该脚本组件在运行模式下动态创建, 该数组默认为null. 但是理想的情况是我们只需要在编辑环境下设计复合生成区所需的数组内容, 并在运行游戏之前检查是否已经完成了对数组的配置. 因此我们不需要担心这个数组如果为空的情况. 万一你忘记了在编辑模式下为数组分配内容, 运行时Unity会针对这种情况报错提示你.

为了测试我们的复合生成区, 新建一个叫做Level 3的场景, 进行然后按照以下步骤操作 :

  1. 首先删除它的默认摄像机物体Main Camera
  2. 设置其默认光源物体的Rotation属性, 使得其光照角度与其他两个关卡有所区别, 比如设置为(5, 5, 0), 然后前往Lighting窗口, 点击Generate Lighting按钮, 生成Level 3的光照数据文件.
  3. 添加Spawn Zone物体, 为其添加CompositeSpawnZone脚本组件,
  4. 添加Game Level物体, 并为其添加GameLevel脚本组件, 然后将Spawn Zone物体拖拽到Inspector中的spawnZone属性对应的位置
  5. 前往Build Settings窗口, 将Level 3场景拖拽到Scenes In Build列表的末尾
  6. ctrl+S保存你的修改

晚上上述操作, 还需要一步工作, 那就是在Level 3中添加若干基本类型的生成区. 比如, 创建两个球体生成区物体Sphere Spawn Zone和两个立方体生成区物体Cube Spawn Zone, 每一种生成区分别有一个勾选了surfaceOnly, 有一个没勾选.

然后依次将上述四个基本生成区物体拖拽到Spawn Zone物体Inspector窗口的spawnZones数组的名字上面来完成对数组的设置. 顺便介绍一种快速设置Inspector中数组的方法, 首先点击Spawn Zone物体Inspector右上角的锁头图标按钮, 这会使得你接下来更换选择对象也会保持展示Spawn Zone物体的Inspector窗口. 然后我们在Hierarchy窗口中同时选择四个基本生成区物体, 一次性的将它们拖拽到spawnZones数组的名字上面就可以完成四个数组元素的设置, 设置完毕后再次点击右上角的锁头图标就可以解除对Inspector窗口的锁定.

四个基本生成区的位置, 旋转与缩放, 自己根据喜好设置一下即可.
生成区 - 图24生成区 - 图25
复合生成区参考设置

这几个组成复合生成区的生成区可以在场景中的任何位置. 它们并不一定要成为Spawn Zone的子物体, 但是如果你这么做了, Spawn Zone物体的位置, 旋转和缩放就可以影响到它们四个, 从而帮你进行一些快速的统一设置, 如下图所示
生成区 - 图26

(原作者忘了描述一处设置, 由于你现在有三个关卡了, 记得将Main Scene的Game物体Inspector中的Level Count设置为3, 不然你按3也不会切换到Level 3)

除了球体和立方体, 你还可以根据自己的理解创建更多其他类型的生成区. 而如何为你自己创建的生成区脚本显示Gizmo线框, 就需要你发挥自己的创造力了.

下一篇教程是更多游戏状态
教程源码
PDF