某愿朝闻道译/原文地址

  • 创建复合形状
  • 每个形状支持多个颜色
  • 为每个生成区选择形状工厂
  • 追踪形状的初始生产工厂

这是对象管理章节的第八篇教程. 将在上个教程的代码基础上制作. 本篇教程将会加入更多形状工厂, 并生成更为复杂的形状

本教程源码使用Unity2017.4.12f1编写

更多形状工厂 - 图1
别问, 酷炫就完事儿了

更多形状种类

我们不仅可以使用立方体, 球体和胶囊体这几种形状, 还可以使用任何导入的Mesh网格. 另外, 形状还可以由多个物体构成. 形状可以拥有自己的组织结构, 使用多个Mesh网格、动画、行为以及其他内容. 接下来要通过组合默认Mesg来创建几种复杂的形状

球体+立方体

第一个复合形状是一个立方体和一个球体的简单组合. 制作步骤如下 :

  1. 创建一个立方体命名为Cube With Sphere, 创建一个球体, 命名不变. 将球体设置为立方体的子物体
  2. 两者坐标均设置为(0, 0, 0), 球体的Scale设置为(1.35, 1.35, 1.35)
  3. 为立方体添加Shape脚本组件
  4. 将它们整体做成预制体, 然后在场景中删掉它们

更多形状工厂 - 图2更多形状工厂 - 图3
复合形状Cube With Sphere

复合胶囊体

将三个胶囊体旋转一定角度组合后可以到更为复杂的形状, 制作步骤如下 :

  1. 新建三个胶囊体capsule, 分别命名为Composite Capsule 和 Capsule X 以及 Capsule Z, 后面两者设置为第一个胶囊体的子物体
  2. 三个胶囊体的坐标全部设置为(0, 0, 0),
  3. Capsule X的Rotation设置为(90, 0, 0), Capsule Z的Rotation设置为(0, 0, 90)
  4. 为Composite Capsule物体添加Shpae脚本组件
  5. 将它们整体做成预制体, 然后在场景中删掉它们

更多形状工厂 - 图4更多形状工厂 - 图5
复合形状Composite Capsule

复合立方体

最后一个符合形状由三个立方体构成, 制作步骤如下 :

  1. 新建三个立方体, 分别命名为Composite Cube 和 Cube XY以及Cube YZ, 后两者设置为第一个立方体的子物体.
  2. 三个立方体的坐标全部设置为(0, 0, 0),
  3. Cube XY的Rotation设置为(45, 45, 0), Cube YZ的Rotation设置为(0, 45, 45)
  4. 为Composite Cube物体添加Shape脚本组件
  5. 将它们整体做成预制体, 然后在场景中删掉它们

更多形状工厂 - 图6更多形状工厂 - 图7
复合形状Composite CUbe

产生新形状

要在运行游戏时产生上述制作的复合形状, 只需要将它们的预制体添加到Shape Factory资源的Prefabs列表中 :
image.png更多形状工厂 - 图9
将新建的形状预制体添加到Shape Factory资源的Prefabs列表中

完成上述设置后, 运行游戏, 就可以产生新建的复合形状了(友情提示, 上一篇教程对生成区增加了配置选项, 默认都是0, 记得手动配置下非零值, 不然你的形状缩放是0, 看不见)

但是它们现在大部分都是白色, 因为只有父物体添加了Shape脚本组件, 从而可以获得随机的材质与颜色, 子物体并不受影响.
更多形状工厂 - 图10
复合形状生成的效果, 大部分面积都是白色

配置要调整的Mesh渲染器

要更改复合形状中所有物体的材质和颜色, Shape脚本需要去操作每一个物体的MeshRenderer组件, 这就需要为Shpae脚本增加一个存储这些组件引用的数组 :

  1. [SerializeField]
  2. //存储脚本要操作的所有MeshRenderer组件的引用
  3. MeshRenderer[] meshRenderers;

现在需要手动的编辑每一个复合形状预制体, 将所有需要被操纵的物体都拖拽到父物体Inspector中的该数组列表内, Unity会自动获取它们的MeshRenderer组件的引用, 设置完成后类似下图所示 :
更多形状工厂 - 图11
其中一个复合形状预制体的meshRenderers列表
(记得为之前教程创建的单个形状的预制体也设置下这个列表)

因为有了meshRenderers列表存储每一个渲染器, 因此Shape脚本不再需要在Awake方法中去获取MeshRenderer组件了, 可以将该方法与存储单个渲染器的字段一并删除 :

  1. //现在MeshRenderer组件将手动指定, 不需要该变量去自动获取了
  2. //MeshRenderer meshRenderer;
  3. //现在MeshRenderer组件将手动指定, 不需要Awake方法去自动获取了
  4. //void Awake () {
  5. // meshRenderer = GetComponent<MeshRenderer>();
  6. //}

删除上述代码后, 必须在SetMaterial方法中循环遍历meshRenderers列表中的每一个元素, 并为其设置材质 :

  1. public void SetMaterial (Material material, int materialId) {
  2. //meshRenderer.material = material;
  3. //不再为单个MeshRenderer设置材质, 需要为meshRenderers类别中每一个元素设置材质
  4. for (int i = 0; i < meshRenderers.Length; i++) {
  5. meshRenderers[i].material = material;
  6. }
  7. MaterialId = materialId;
  8. }

对SetColor方法也要进行类似修改 :

  1. public void SetColor (Color color) {
  2. ...
  3. //meshRenderer.SetPropertyBlock(sharedPropertyBlock);
  4. //不再为单个MeshRenderer设置材质属性块, 需要为meshRenderers列表中每一个元素设置材质属性块
  5. for (int i = 0; i < meshRenderers.Length; i++) {
  6. meshRenderers[i].SetPropertyBlock(sharedPropertyBlock);
  7. }
  8. }

更多形状工厂 - 图12
进行适当的设置后, 复合形状也都被完全染色了

复合颜色

目前, 复合形状中每一个被Shape脚本影响的子物体的颜色都是相同的. 我们可以让它们之间的颜色有所区别.

要让复合生成区支持的多种颜色数据都能被游戏保存, 需要将Shape脚本中存储形状颜色数据的的color字段替换为一个颜色数组. 该数组应该在Awake方法中进行初始化, 长度与meshRenderers数组相同 :

  1. //Color color;
  2. //使用颜色数组代替单一的颜色
  3. Color[] colors;
  4. //新增Awake方法
  5. void Awake () {
  6. //初始化颜色数组, 其长度与meshRenderers数组一致0
  7. colors = new Color[meshRenderers.Length];
  8. }

当通过SetColor方法配置颜色时, 将每一次配置的颜色数据存储到这个数组中

  1. public void SetColor (Color color) {
  2. //color字段已经被colors数组替代 此处代码也应该一并删除
  3. //this.color = color;
  4. if (sharedPropertyBlock == null) {
  5. sharedPropertyBlock = new MaterialPropertyBlock();
  6. }
  7. sharedPropertyBlock.SetColor(colorPropertyId, color);
  8. for (int i = 0; i < meshRenderers.Length; i++) {
  9. //按照索引, 将每一次配置的颜色数据存储colors数组中
  10. colors[i] = color;
  11. meshRenderers[i].SetPropertyBlock(sharedPropertyBlock);
  12. }
  13. }

目前每一个复合形状依然使用相同的颜色. 要让复合形状可以使用多种颜色, 需要为SetColor方法再增加一个变体, 该变体方法额外接受一个代表颜色数组索引的参数, 通过该索引, 每次只配置一个形状的颜色 :

  1. //SetColor的另一个版本, 接受两个参数,
  2. //代码及功能与一个参数的版本只有一个区别 : 它每次只按照index参数设置对应索引的形状颜色
  3. public void SetColor (Color color, int index) {
  4. if (sharedPropertyBlock == null) {
  5. sharedPropertyBlock = new MaterialPropertyBlock();
  6. }
  7. sharedPropertyBlock.SetColor(colorPropertyId, color);
  8. colors[index] = color;
  9. meshRenderers[index].SetPropertyBlock(sharedPropertyBlock);
  10. }

当外部类调用上述新增方法时, 需要知道颜色数组总共有多少个元素, 因此再添加一个公开的ColorCount属性, 用来得到colors数组的长度 :

  1. //用来得到颜色数组长度, 也就是复合形状需要设置不同颜色的形状数量
  2. public int ColorCount {
  3. get {
  4. return colors.Length;
  5. }
  6. }

**

保存所有颜色数据

目前的代码编译会出错, 因为color字段在前面被我们删除了. 现在我们来修复这个编译错误.

首先, Game脚本中, 将保存文件的版本号增加到5 :

  1. //const int saveVersion = 4;
  2. //版本号升级为5
  3. const int saveVersion = 5;

然后调整Shape.Save方法, 让它保存colors数组中所有的颜色数据, 代替已经被删除的color字段 :

  1. public override void Save (GameDataWriter writer) {
  2. base.Save(writer);
  3. //writer.Write(color);
  4. //color字段被colors数组替代, 现在要遍历colors数组, 保存每一个元素代表的颜色数据
  5. for (int i = 0; i < colors.Length; i++) {
  6. writer.Write(colors[i]);
  7. }
  8. writer.Write(AngularVelocity);
  9. writer.Write(Velocity);
  10. }

当加载游戏时, 如果我们在加载的文件版本号大于等于5, 就需要按照colors数组的长度, 依次读取每一个颜色数据, 并调用SetColor方法设置对应顺序的颜色; 否则如果版本号小于5, 则像之前一样只读取并设置一次颜色 :

  1. public override void Load (GameDataReader reader) {
  2. base.Load(reader);
  3. //版本号大于等于5才支持读取多个颜色数据
  4. if (reader.Version >= 5) {
  5. //按照colors数组的长度, 依次读取每一个颜色数据
  6. for (int i = 0; i < colors.Length; i++) {
  7. //调用SetColor方法设置对应索引的形状颜色
  8. SetColor(reader.ReadColor(), i);
  9. }
  10. }
  11. //如果版本号小于5, 使用旧的颜色读取代码
  12. else {
  13. SetColor(reader.Version > 0 ? reader.ReadColor() : Color.white);
  14. }
  15. AngularVelocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
  16. Velocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
  17. }

切换配色方式

每个生成区生应该可以决定成的形状使用统一的颜色还是不同的颜色, 因此需要在SpawnZone脚本的SpawnConfiguration结构中增加一个控制配色方式的布尔字段 :

  1. public struct SpawnConfiguration {
  2. //控制生成区对形状的配色方式, false表示对构成形状的每个组成部分使用不同颜色
  3. public bool uniformColor;

接着修改SpawnZone脚本的ConfigureSpawn方法, 当一个生成区需要为形状的每个组成部分使用不同的颜色时, 就为为每一个组成形状的物体分别设置随机颜色 :

  1. public virtual void ConfigureSpawn (Shape shape) {
  2. Transform t = shape.transform;
  3. t.localPosition = SpawnPoint;
  4. t.localRotation = Random.rotation;
  5. t.localScale = Vector3.one * spawnConfig.scale.RandomValueInRange;
  6. //spawnConfig.uniformColor为true表示配置统一的颜色, 则执行之前的颜色设置代码
  7. if (spawnConfig.uniformColor) {
  8. shape.SetColor(spawnConfig.color.RandomInRange);
  9. }
  10. //spawnConfig.uniformColor为false, 表示对构成形状的每个组成部分使用不同颜色
  11. else {
  12. //根据shape.ColorCount属性得到的颜色数量进行循环, 为形状每一个组成部分分别设置随机颜色
  13. for (int i = 0; i < shape.ColorCount; i++) {
  14. //调用两个参数的SetColor方法
  15. shape.SetColor(spawnConfig.color.RandomInRange, i);
  16. }
  17. }
  18. shape.AngularVelocity = Random.onUnitSphere * spawnConfig.angularSpeed.RandomValueInRange;
  19. }

更多形状工厂 - 图13更多形状工厂 - 图14
如果不勾选uniformColor, 生成的复合形状每个部分的颜色都单独随机设置

能不能为形状设置随机颜色时使用固定的色调(hue)?

是的, 可以. 你可以在为一个形状设置各个部分的随机颜色时, 只随机确定一次颜色的色调(hue), 每个部分只有饱和度(saturation)和明度(value)是不同的. 更进一步, 你可以使用三个开关字段来分别控制色调, 饱和度和明度是否分别随机设置, 还是统一设置. 不过这样也会让你的颜色设置代码变得更加复杂一些.

稳健的保存数据

此时, 我们的游戏已经支持了复合形状, 并且复合形状的每个组成部分都可以配置不同的颜色. 但是在之后我们有可能会更改形状预制体的meshRenderers列表中的元素, 如果我们这样做了, 代表colors数组的长度可能发生了变化, 与存档文件中保存的颜色数据的数量变得不一致. 这样就会因加载时数据不匹配导致游戏加载失败. 为了预防发生这种问题, 需要在Shape脚本保存游戏时额外保存形状包含颜色的总数, 并在加载文件时, 检查加载的颜色总数与当前处理的形状包含的颜色总数是否一致 :

  1. public override void Save (GameDataWriter writer) {
  2. base.Save(writer);
  3. //将形状所包含的颜色总数写入保存文件
  4. writer.Write(colors.Length);
  5. for (int i = 0; i < colors.Length; i++) {
  6. writer.Write(colors[i]);
  7. }
  8. writer.Write(AngularVelocity);
  9. writer.Write(Velocity);
  10. }

对应的, 还要修改Load方法中加载颜色的代码, 为了提高代码的可读性, 将这些要修改的代码放到一个新建的LoadColors方法中, 并在Load方法中调用该方法 :

  1. if (reader.Version >= 5) {
  2. //for (int i = 0; i < colors.Length; i++) {
  3. // SetColor(reader.ReadColor(), i);
  4. //}
  5. //删除上方与颜色加载有关的代码, 调用单独的LoadColors方法处理颜色加载过程
  6. LoadColors(reader);
  7. }

接着摇完成LoadColors方法的代码. 加载颜色数据时, 我们必须先读取形状包含的颜色总数, 之后要使用读取到的颜色总数与当前形状包含的颜色总数中的较小值作为读取的颜色数据次数, 这样就可以保障实际读取颜色的次数不会超过保存的颜色总数 :

  1. //该方法专门用于处理颜色数据加载
  2. void LoadColors (GameDataReader reader) {
  3. //加载保存的颜色总数
  4. int count = reader.ReadInt();
  5. //读取颜色数据的次数取保存的颜色总数与形状所需的颜色总数之间的较小值
  6. int max = count <= colors.Length ? count : colors.Length;
  7. //按照计算出来的次数循环读取颜色数据
  8. //循环控制变量i没有写在循环内, 因为下面马上还要用它循环后的值做其他处理
  9. int i = 0;
  10. for (; i < max; i++) {
  11. SetColor(reader.ReadColor(), i);
  12. }
  13. }

上述代码, 在保存的颜色总数与形状所需的颜色总数一致时, 可以正常工作.

玩意由于某些原因, 导致两者不相等, 就需要处理两者情况, 一种情况是保存的比所需的多, 另一种情况是保存的比所需的少.

第一种情况下, 保存的颜色总数更多, 在为形状设置完颜色后, 也必须读取它们, 以便后续加载流程可以读取到颜色数据后面的其他数据 :

  1. for (; i < max; i++) {
  2. SetColor(reader.ReadColor(), i);
  3. }
  4. //如果保存的颜色总数比形状需要的多, 则需要额外的多余的颜色数据读取出来,
  5. //否则后续应该读取其他保存数据的代码会接着读取剩余的颜色数据, 就出错了
  6. if (count > colors.Length) {
  7. //还需要继续读取颜色数据的次数是(count - i)个
  8. for (; i < count; i++) {
  9. reader.ReadColor();
  10. }
  11. }

第二种情况, 形状所需的颜色数量更多, 这表示即便加载了保存的所有颜色数据, 依然不满足形状设置颜色的需求, 那么就需要为多出来的形状, 设置固定的颜色, 比如白色 :

  1. if (count > colors.Length) {
  2. for (; i < count; i++) {
  3. reader.ReadColor();
  4. }
  5. }
  6. //如果形状需要的颜色总数比保存的多, 则需要为多出来的部分设置固定颜色
  7. //否则如果这个形状用的是回收池里的, 这些多出来的部分就还是销毁前的配色
  8. else if (count < colors.Length) {
  9. //还需要配置固定颜色的部分是(count - i)个
  10. for (; i < colors.Length; i++) {
  11. SetColor(Color.white, i);
  12. }
  13. }

第二个形状工厂

现在游戏使用单个形状工厂, 由于形状的种类很少, 并且也不需要对形状划分更多类别, 所以只使用一个工厂还算合情合理. 不过从现在开始, 要将形状分为两个类别 : 简单形状 和 复合形状. 这种情况下, 为每一个类别的形状使用各自的工厂可以更好地对它们进行区别处理, 并让我们可以对产生的形状进行更多的控制

复合形状工厂

创建一个新的ShpaeFactory资源, 可以直接复制一个之前教程创建的, 在Inspector中为其只保留复合形状的预制体引用, 然后将其更名为Composite Shape Factory.

接着将原有的Shape Factory资源更名为Simple Shape Factory, 并在Inspector中为其只保留单独形状的预制体引用

参考设置如下图所示 :
更多形状工厂 - 图15
更多形状工厂 - 图16 更多形状工厂 - 图17
两个工厂资源的命名与Inspector设置

这样, 就可以通过为Game物体分配不同的工厂, 来决定生成简单形状还是复合形状.

每个生成区的工厂

随着新的工厂类型的假如, 有必要让每个生成区自己决定使用哪一个工厂, 而不是使用游戏的统一设置的工厂. 并且形状不止可以选择一个工厂, 我们将通过在SpawnConfiguration结构中新增一个工厂数组来为生成区配置多个工厂 :

  1. public struct SpawnConfiguration {
  2. //用来为生成区配置要使用的形状工厂
  3. public ShapeFactory[] factories;

保存代码, 回到编辑器, 在Inspector中为每个生成区配置你希望它使用的工厂, 每个生成区至少要配置一个工厂(包括复合生成区). 生成形状时, 将在factories数组中随机选择一个工厂 :
更多形状工厂 - 图18
生成区的factories数组配置示意

同一个工厂还可以在数组中设置多个, 这样它就有更大的概率被选择. 比如, 在数组中加入两个Composite Shape Factory和一个Simple Shape Factory, 将会使得前者被随机选择的概率是后者的两倍
更多形状工厂 - 图19
复合形状工厂依靠数量优势得到了更高的选择概率

生成区功能扩展

由于每个生成区现在自主的旋转要使用的工厂, 因此Game脚本也就不再需要负责产生新形状了, 这个工作现在已经交给了SpawnZone脚本. 但是Game脚本的其他功能依然需要追踪生成的形状, 因此我们将SpawnZoen.ConfigureSpawn方法改名为SpawnShape, 改名后的方法不再需要参数, 并会返回新产生的形状 :

  1. //public virtual void ConfigureSpawn (Shape shape) {
  2. //ConfigureSpawn方法改名
  3. public virtual Shape SpawnShape () {
  4. //随机获取一个工厂的数组索引
  5. int factoryIndex = Random.Range(0, spawnConfig.factories.Length);
  6. //使用随机索引对应的工厂生产形状
  7. Shape shape = spawnConfig.factories[factoryIndex].GetRandom();
  8. Transform t = shape.transform;
  9. //…
  10. shape.Velocity = direction * spawnConfig.speed.RandomValueInRange;
  11. //返回新生成的形状
  12. return shape;
  13. }

在CompositeSpawnZone脚本中进行同样的修改 :

  1. //public override void ConfigureSpawn (Shape shape) {
  2. //ConfigureSpawn方法改名
  3. public override Shape SpawnShape () {
  4. if (overrideConfig) {
  5. //base.ConfigureSpawn(shape);
  6. //返回父类SpawnShape方法生成的形状
  7. return base.SpawnShape();
  8. }
  9. else {
  10. //spawnZones[index].ConfigureSpawn(shape);
  11. //返回指定索引的基本生成区生成的形状
  12. return spawnZones[index].SpawnShape();
  13. }
  14. }

在GameLevel脚本中也要将ConfigureSpawn方法改为SpawnShape方法 :

  1. //public void ConfigureSpawn(Shape shape) {
  2. //ConfigureSpawn方法改名
  3. public Shape SpawnShape () {
  4. //spawnZone.ConfigureSpawn(shape);
  5. //返回生成区产生的形状
  6. return spawnZone.SpawnShape();
  7. }

最后, Game.CreateShape方法中, 只需要将当前关卡生成的形状添加到它用来追踪生成形状的列表中即可 :

  1. void CreateShape () {
  2. //Shape instance = shapeFactory.GetRandom();
  3. //GameLevel.Current.ConfigureSpawn(instance);
  4. //shapes.Add(instance);
  5. //形状生成的任务已经交给了关卡中的生成区,
  6. //Game的CreateShape方法现在只拿到生成的形状并添加到shapes列表中
  7. shapes.Add(GameLevel.Current.SpawnShape());
  8. }

**
更多形状工厂 - 图20

Using different factories per zone.

回收形状

因为现在可以同时使用两个形状工厂, 在游戏运行时候就创建用于存放这两个工厂各自生产的形状的场景, 分别与各自对应的工厂资源的名称相同
更多形状工厂 - 图21
多个工厂场景示意

看上去工厂生产形状的时候一切正常, 但是在回收销毁的形状时候其实发生了错误. 所有形状最终都会被一个工厂回收, 可能导致再次重用形状时产生的形状与工厂的类型不匹配. 这是因为Game脚本始终使用同一个工厂去回收形状, 无论这个形状到底是哪个工厂产生的.

形状应该只被产生它的工厂所回收. 要做到这一点, 每个形状都应该记录下是哪个工厂生产的自己. 在Shape脚本中添加一个属性OriginFactory, 用来设置和取得生产它的工厂 :

  1. //用来获取生成形状的工厂类型
  2. public ShapeFactory OriginFactory {
  3. //get方法直接返回属性值
  4. get {
  5. return originFactory;
  6. }
  7. //set方法只能被设置一次属性值, 重复设置会显示文字提示
  8. set {
  9. if (originFactory == null) {
  10. originFactory = value;
  11. }
  12. else {
  13. Debug.LogError("不允许篡改形状的生产工厂");
  14. }
  15. }
  16. }
  17. //存储生产该形状的工厂引用
  18. ShapeFactory originFactory;

然后让ShapeFactory在生成每个形状的时候将自身记录为它们的生产工厂 :

  1. public Shape Get (int shapeId = 0, int materialId = 0) {
  2. Shape instance;
  3. if (recycle) {
  4. else {
  5. instance = Instantiate(prefabs[shapeId]);
  6. //生产形状后将自身记录为形状的生产工厂, 注意代码位置别加错了
  7. instance.OriginFactory = this;
  8. instance.ShapeId = shapeId;
  9. SceneManager.MoveGameObjectToScene(instance.gameObject, poolScene);
  10. }
  11. }
  12. }

这样就可以使用正确的工厂去回收每一个形状了, 另外再向Shape脚本中增加一个方便调用的Recycle方法 :

  1. //方便形状在销毁时调用自身生产工厂的Reclaim方法
  2. public void Recycle () {
  3. OriginFactory.Reclaim(this);
  4. }

在Game.DestroyShape方法中调用上述方法, 取代之前直接调用固定工厂进行回收的代码 :

  1. void DestroyShape () {
  2. if (shapes.Count > 0) {
  3. int index = Random.Range(0, shapes.Count);
  4. //shapeFactory.Reclaim(shapes[index]);
  5. //现在要根据每个形状自身的记录来决定使用哪个工厂对其进行回收
  6. shapes[index].Recycle();
  7. }
  8. }

在Game.BeginNewGame方法中也进行同样的修改 :

  1. void BeginNewGame () {
  2. for (int i = 0; i < shapes.Count; i++) {
  3. //shapeFactory.Reclaim(shapes[i]);
  4. //现在要根据每个形状自身的记录来决定使用哪个工厂对其进行回收
  5. shapes[i].Recycle();
  6. }
  7. shapes.Clear();
  8. }

在形状工厂中, 每次回收形状前都要检查自身是否有资格回收该形状, 确保不会出现错误的回收调用 :

  1. public void Reclaim (Shape shapeToRecycle) {
  2. //每次回收形状前都要检查自身是否有资格回收该形状
  3. if (shapeToRecycle.OriginFactory != this) {
  4. //如果自身与形状中记录的生成工厂不符, 表示没有资格回收形状, 报错并终止方法
  5. Debug.LogError("当前工厂与形状记录的生产工厂不一致, 无权回收该形状");
  6. return;
  7. }
  8. }

保存生产工厂数据

由于支持了多个工厂, 所以保存和加载功能也要做出一些调整, 必须将每个形状记录的生产工厂也写入保存文件, 这就需要为每个工厂分配一个ID代号, 保存的时候将ID写入文件中. 在ShapeFactory脚本中增加新的属性FactoryId. 用于得到当前工厂的ID, 该属性只能被赋值一次, 之后不可以被随意修改. 这只是针对”同一次游戏过程中”这一前提, 由于ShapeFactory类可以在同一次编辑器会话(一次”会话”指的打开编辑器到关闭编辑器之间的时间)内始终保存, 即便退出运行模式. 因此需要通过System.NonSerialized特性, 将记录工厂ID的字段声明为不可序列化的 :

  1. //该属性用于获取和设置工厂ID
  2. public int FactoryId {
  3. get {
  4. return factoryId;
  5. }
  6. set {
  7. //只有工厂id是默认值且不是要为其赋值整型的最小值时才能进行赋值操作
  8. if (factoryId == int.MinValue && value != int.MinValue) {
  9. //满足以上条件, 则表示是对属性初次赋值, 允许执行
  10. factoryId = value;
  11. }
  12. else {
  13. //如果不是默认值以外的值, 表示已经进行过明确的赋值, 不可以再次更改
  14. Debug.Log("工厂Id不能重复设置");
  15. }
  16. }
  17. }
  18. //该特性会使得被指定的字段不会被序列化, 即每次运行游戏都会恢复其代码中的默认值
  19. [System.NonSerialized]
  20. //工厂id字段, 使用指定的默认值
  21. int factoryId = int.MinValue;

为什么必须将字段factoryId变成不可序列化的?

Unity不会保存ScriptableObject类中未标记为可序列化的私有字段. 然而ScriptableObject类的实例会在一次编辑器会话期间暂时实例化其数据, 不因退出运行模式而重置, 这也意味着, 在一次编辑器会话期间, 即便是未标记为可序列化的私有字段的值也会在运行模式之外持久保. 不过只要关闭Unity再打开就会重置这些私有字段.

但是这依然会给我们的编辑工作带来困扰, 试想一下, 你在运行模式之外编辑了工厂的数据, 使其工厂id发生了变化(在下文将介绍工厂ID是如何定义的) , 那么当你再次运行时. 却因为私有字段factoryId依然保持上次运行的值而无法使修改后的工厂Id生效, 因为按照逻辑, factoryId已经在之前的运行模式下被赋值一次了

因此, 为了方便编辑和调试, 我们使用[System.NonSerialized]特性标记它, 则该字段的数据就绝对不会被实例化, 包括上面说到的情况, 即便是临时的实例化也不可以. 这样我们就不需要重启Unity来修改工厂Id了

现在需要定义每个工厂类型的Id是什么. 首先向Game脚本增加一个工厂数组, 这个数组用来存放每一种工厂, 工厂在数组中的索引即代表了它们的工厂Id. 在OnEnable方法中完成工厂Id的赋值 :

  1. //将工厂资源文件拖拽分配给该数组, 数组索引代表了该工厂的Id
  2. //每个工厂资源文件只需要在数组中存在, 因为一个工厂Id只能赋值一次, 分配多次也不会有效果
  3. [SerializeField] ShapeFactory[] shapeFactories;
  4. //新增OnEnable方法
  5. void OnEnable () {
  6. //脚本启用时, 使用数组索引为每个工厂设置其工厂Id
  7. for (int i = 0; i < shapeFactories.Length; i++) {
  8. shapeFactories[i].FactoryId = i;
  9. }
  10. }

我们在之前的教程中, 为了防止玩家的控制指令在加载时关卡场景时导致错误, 加载关卡场景时会禁用Game脚本, 加载完成后则再次启用, 这也会触发Game的OnEnable方法, 不过这时就不需要也不能再次为工厂Id赋值了, 因此合理的做法是, 判断一下工厂Id是否已经赋值过了, 如果是, 则不需要重复赋值 :

  1. void OnEnable () {
  2. //多种情况都可能导致OnEnable方法被触发, 因此先检查工厂Id是否已经赋值过了,
  3. //如果是, 则不需要重复赋值
  4. if (shapeFactories[0].FactoryId != 0) {
  5. for (int i = 0; i < shapeFactories.Length; i++) {
  6. shapeFactories[i].FactoryId = i;
  7. }
  8. }
  9. }

现在, 保存游戏的过程在写入形状数据时, 就可以将形状的生产工厂Id也写入保存文件了, 由于加载游戏时, 产生形状的第一步就应该确定使用哪个工厂, 所以在保存游戏时, 也要最先保存工厂Id :

  1. public override void Save (GameDataWriter writer) {
  2. for (int i = 0; i < shapes.Count; i++) {
  3. //加载游戏时, 产生形状的第一步就应该确定使用哪个工厂, 所以在保存游戏时, 也要最先保存工厂Id
  4. writer.Write(shapes[i].OriginFactory.FactoryId);
  5. writer.Write(shapes[i].ShapeId);
  6. writer.Write(shapes[i].MaterialId);
  7. shapes[i].Save(writer);
  8. }
  9. }

加载游戏过程中读取每个形状的数据时, 首先读取形状的生产工厂Id, 然后以此为索引确定要实例化形状的工厂. 要注意的是, 如果保存文件的版本小于5, 说明没有工厂Id数据, 这种情况下使用固定的索引0作为形状的生产工厂Id :

  1. IEnumerator LoadGame (GameDataReader reader) {
  2. //…
  3. for (int i = 0; i < count; i++) {
  4. //读取形状的生产工厂Id, 然后以此为索引确定要实例化形状的工厂.
  5. //如果保存文件的版本小于5, 说明还没有工厂Id数据, 则使用0作为形状的生产工厂Id
  6. int factoryId = version >= 5 ? reader.ReadInt() : 0;
  7. int shapeId = version > 0 ? reader.ReadInt() : 0;
  8. int materialId = version > 0 ? reader.ReadInt() : 0;
  9. //Shape instance = shapeFactory.Get(shapeId, materialId);
  10. //使用工厂Id作为索引确定使用哪个工厂产生形状
  11. Shape instance = shapeFactories[factoryId].Get(shapeId, materialId);
  12. instance.Load(reader);
  13. shapes.Add(instance);
  14. }
  15. }

至此, Game脚本中的ShapeFactory字段已经没有任何用处, 删掉它 :

  1. //已经被工厂数组shapeFactories取代
  2. //[SerializeField] ShapeFactory shapeFactory;

记得, 每一个每一个关卡中用到的不同种类的工厂都应该分配给Game脚本的工厂数组, 并且让Simple Shape Factory位于数组的首位, 这样可以很好地兼容版本号5以下的游戏保存数据. 一旦为Game工厂数组分配好了元素, 如无必要, 不要再更改它们之间的顺序, 保障加载过程可以正确进行.
更多形状工厂 - 图22
在Inspector中为Game脚本分配游戏用到的每一类工厂

下一篇教程是 为形状赋予行为
教程源码
PDF