某愿朝闻道译/原文地址

  • 产生相同的随机结果
  • 保存关卡数据
  • 多个生成区之间依次产生形状
  • 创建一个旋转的关卡

这是对象管理章节的第六篇教程. 将在上个教程的代码基础上制作. 本篇教程将实现保存与加载除了形状与关卡索引之外的更多游戏状态数据

本教程源码使用Unity2017.4.4f1创建
更多游戏状态 - 图1
分布在特定运动轨迹上的随机形状

保存随机性

产生形状的时候使用随机位置点会产生无规律的结果. 假设你保存了游戏, 然后继续产生X个形状, 接着你加载之前保存的游戏, 并依然继续产生X个形状. 那么与加载前产生的X个形状相比, 它们的状态是相同的还是不同的呢? 我们目前的代码, 会产生不同的结果. 但是我们可以改变这种情况, 做做看吧

Unity的Random方法并非真正的随机产生数字, 而是一种伪随机. 实际上它是通过特定的数学公式产生一个数字序列, 每次获取随机结果按照次序在序列中选择数字作为随机数结果. 游戏开始时, 会基于当前的时间, 选择一个随机种子值(seed value)作为计算随机数字序列的参数. 如果你能够通过相同的种子值来计算随机数, 那么你就会得到相同的随机结果.

保存随机状态

想要在加载游戏存到时恢复完整的”随机状态”, 只保存随机种子值还不够, 我们还需要知道在游戏保存时, 选择的是序列中哪个位置的数字, 以便在加载游戏后, Random方法可以从相同随机种子计算出的序列的同样位置继续选择数字.

上述所需的随机状态信息在Unity中使用State结构类型表示, 该结构嵌套在Random类中, 所以声明该结构类型的字段或变量时要使用Random.State.

我们要在GameDataWriter脚本中增加向保存文件写入该值的Write方法, 让我们先只是简单的声明这个方法, 稍后补充具体的功能代码 :

  1. //向保存文件中写入随机状态值的Write方法, 随机状态数据在Unity中的类型是Random.State
  2. public void Write (Random.State value) {}

接下来, 我们要在Game.Save方法中调用新增的写入方法, 就加在保存形状数量的语句后面. 另外, 由于又多了一种新的数据, 所以我们将保存文件的版本也由2升级为3 :

  1. //const int saveVersion = 2;
  2. //游戏保存文件版本升级为3
  3. const int saveVersion = 3;
  4. public override void Save (GameDataWriter writer) {
  5. writer.Write(shapes.Count);
  6. //向保存文件写入随机状态数据, Random.state属性会返回程序的随机状态数据
  7. writer.Write(Random.state);
  8. writer.Write(loadedLevelBuildIndex);
  9. }

读取随机状态

要读取保存的随机状态, 需要向GameDataReader脚本中增加对应的ReadRandomState方法, 不过目前我们暂时先让该方法不读取任何数据, 而是直接返回运行时的随机状态 :

  1. //从保存文件中读取随机状态值的方法
  2. public Random.State ReadRandomState () {
  3. //暂时返回当前运行时的随机状态
  4. return Random.state;
  5. }

在Game.Load方法中设置读取到的随机状态数据时, 需要判断保存文件的版本, 只有版本大于等于3时才应该读取随机状态, 另外随机状态数据保存在了形状数量数据的后面, 也要在形状数量数据加载后加载它 :

  1. public override void Load (GameDataReader reader) {
  2. int count = version <= 0 ? -version : reader.ReadInt();
  3. //判断游戏版本是否大于等3
  4. if (version >= 3) {
  5. //版本3开始, 保存文件中才有随机状态数据可以读取
  6. Random.state = reader.ReadRandomState();
  7. }
  8. StartCoroutine(LoadLevel(version < 2 ? 1 : reader.ReadInt()));
  9. }

JSON序列化

Random.State包含了四个float类型的数字. 然而它们的访问权限不是公开的, 因此不能直接读取它们并写入文件中. 我们必须使用一种间接的方式得到它们.

好在, Random.State是一种可序列化类型, 因此它能够被转化为代表同样信息的字符串, 这需要用到Unity中JsonUtility类的ToJson方法. 该方法能够为我们提供所需的信息字符串, 我们可以把获取到的字符串打印到控制台看一下 :

  1. public void Write (Random.State value) {
  2. //将当前运行时的随机状态信息转换为JSON格式的字符串, 打印到控制台观察下
  3. Debug.Log(JsonUtility.ToJson(value));
  4. }

**

Json是啥?

正确的写法是”JSON”, 每个字母都要大写, 它的全程是”JavaScript Object Notation”(JavaScript对象简谱). 它将数据信息以易于人类阅读的数据格式表示出来.

保存代码, 运行游戏, 然后按下保存快捷键, 会看到在控制台输出了一个花括号包围的字符串, 其中有四个数字, 它们前方分别标记着s0到s3, 比如我运行后输出的是 : {“s0”:-1409360059,”s1”:1814992068,”s2”:-772955632,”s3”:1503742856}

我们接下来要做的就是将这个字符串写到保存文件中. 修改上述代码 :

  1. public void Write (Random.State value) {
  2. //Debug.Log(JsonUtility.ToJson(value));
  3. //将运行时的随机状态数据转换为JSON字符串, 并写入到保存文件中
  4. writer.Write(JsonUtility.ToJson(value));
  5. }

保存上述代码并运行游戏, 然后进行一次保存操作, 此时使用文本编辑器打开保存文件, 会看到与之前控制台输出一样格式的字符串

在GameDataReader脚本的ReadRandomState方法中, 要使用ReadString方法读取该字符串, 然后将其作为参数传递给JsonUtility.FromJson方法, 该方法可以将JSON字符串转化为对应的随机状态值 :

  1. public Random.State ReadRandomState () {
  2. //return Random.state;
  3. //使用JsonUtility.FromJson方法将ReadString方法读取到的JSON数据字符串转换为程序中的数据值
  4. return JsonUtility.FromJson(reader.ReadString());
  5. }

但是上述代码还不能完成转换JSON字符串的工作, 因为需要告诉它我们想要从JSON字符串中得到哪一种类型的值. 可以使用FromJson方法的泛型版本指定转换后的数据要变成哪一种数据类型, 对于我们目前的代码, 这里需要的是Random.State结构类型 :

  1. public Random.State ReadRandomState () {
  2. //return JsonUtility.FromJson(reader.ReadString());
  3. //使用泛型机制来为FromJson方法指定要返回的数据类型, 此处需要返回Random.State类型的值
  4. return JsonUtility.FromJson<Random.State>(reader.ReadString());
  5. }

关卡解耦

至此, 加载游戏后可以恢复保存时的随机状态, 呈现相同的随机结果. 你可以自己动手验证一下. 你不止可以观察保存后继续生成的形状与加载后继续生成的形状是否一致, 你甚至可以在加载后, 先开始新游戏, 然后再继续产生形状, 随机结果依然是与保存后观察到的结果是一致的. 但是这对于游戏不是一个好事情, 理想的情况应该是, 每一次重新开始游戏, 随机性都是独自计算的, 这才叫”开始新游戏”否则还不如叫”开始旧游戏”. 要达到这个目的, 只需要每次开始新游戏时使用新的随机种子值即可.

新的随机种子值应该随机选择, 这也要使用Random.Range方法, 不过为了确保得到的随机种子使用的是专用的随机序列, 我们需要在Game脚本中新增名为mainRandomState的字段, 存储游戏开始时的随机状态 :

  1. //该字段用于存储每次开始新游戏时, 随机计算新游戏使用的随机种子所需的随机状态
  2. Random.State mainRandomState;
  3. void Start () {
  4. //将游戏开始时的随机状态保存起来, 需要的时候使用
  5. mainRandomState = Random.state;
  6. }

接下来, 修改Game.BeginNewGame方法, 在最开始, 将当前的随机状态恢复为我们游戏开始时保存的随机状态. 之后就可以在该随机状态的影响下获取一个随机数, 作为新开游戏要使用的随机种子, 然后将它作为参数传递给Random.InitState方法, 从而生成新随机数序列 :

  1. void BeginNewGame () {
  2. //将当前随机状态更改为游戏开时保存的随机状态
  3. Random.state = mainRandomState;
  4. //基于上述随机状态, 获取一个随机数作为随机种子
  5. int seed = Random.Range(0, int.MaxValue);
  6. //Random.InitState方法使用参数作为随机种子计算随机数序列
  7. Random.InitState(seed);
  8. }

为了让随机种子更不可预测, 我们将结果与不被时间缩放影响的当前时间Time.unscaledTime进行混合, 对它们使用”按位异或符(bitwise exclusive-OR operator)” ^ 进行计算:

  1. //int seed = Random.Range(0, int.MaxValue);
  2. //将直接随机的到的结果, 与无缩放的当前时间进行按位异或, 最终结果作为随机种子
  3. int seed = Random.Range(0, int.MaxValue) ^ (int)Time.unscaledTime;

“异或”计算做了什么?

对于参与计算的每个”比特位”(bit), 如果两者一个是1一个是0, 则异或的结果是1, 否则是0. 也就是, 异或操作检查二者是否相同, 相同返回0, 不同返回1.

我们还需要在得到随机种子后, 保存此时的随机状态, 用于下一次新开游戏时计算随机种子 :

  1. Random.state = mainRandomState;
  2. int seed = Random.Range(0, int.MaxValue) ^ (int)Time.unscaledTime;
  3. //得到随机种子后, 保存此时的随机状态, 用于下一次新开游戏时计算随机种子
  4. mainRandomState = Random.state;
  5. Random.InitState(seed);

通过上述代码, 在同一次游戏运行过程中, 加载游戏存档不再会影响新开游戏的随机性了. 为了确保不出现问题, 我们也要在Game.Start方法中为每一次新运行的游戏调用BeginNewGame方法 :

  1. void Start () {
  2. //此处存疑, 我不知道作者为什么要在这里调用一次BeginNewGame方法
  3. BeginNewGame();
  4. StartCoroutine(LoadLevel(1));
  5. }

(此处存疑, 我自己测试, 就算是Start中没有调用BeginNewGame方法, 无论是加载存档后新开游戏, 还是直接新开游戏, 均已经实现了每次新开游戏随机结果都不相同, 我自己的实操代码中没有在这里加BeginNewGame方法)

支持两种加载随机性的方式

也许你不希望在加载游戏后保持与保存时一样的随机状态, 而是每次加载均得到不同的后续随机结果. 你可以为Game脚本添加一个名为reseedOnload字段控制是否在要加载保存时的随机状态 :

  1. //false表示加载游戏时不加载之前保存的随机状态
  2. [SerializeField] bool reseedOnLoad;

更多游戏状态 - 图2
勾选表示加载时超级拍卖行自, 否则不加载

为了让该字段控制加载后的随机状态处理方式, 需要对Game.Load方法进行如下修改 :

  1. public override void Load (GameDataReader reader) {
  2. if (version >= 3) {
  3. //Random.state = reader.ReadRandomState();
  4. //为了保持后续文件数据依然可以按照正确顺序读取, 无论是否要加载随机状态,
  5. //均需要先从文件读取流中把随机状态读取出来
  6. Random.State state = reader.ReadRandomState();
  7. //判断是否要加载保存时的随机状态
  8. if (!reseedOnLoad) {
  9. //reseedOnload为false表示需要加载保存时的随机状态
  10. Random.state = state;
  11. }
  12. }
  13. }

关卡数据持久化

我们可以保存在游戏运行期间产生的形状, 也能保存当前处于哪个关卡, 并且还能保存游戏的随机状态. 使用类似的方法能保存更多种类的数据, 比如说有多少形状被产生过, 有多少被销毁过, 或是其他在运行期间发生的事情. 但是假如我们想要保存的是关卡场景中某些内容的状态呢? 这就需要我们将关卡的状态数据也进行保存

当前关卡代替Game单例

要保存关卡, Game脚本必须在执行Save方法时能够保存所需的关卡数据. 这也就意味着Game脚本需要通过某种方式拿到当前关卡的引用. 我们可以为Game脚本添加一个代表当前关卡的字段, 并将当前关卡的引用分配给它. 目前在Game脚本中与关卡有关的数据内容就是SpawnZoneOfLevel, 它满足我们的功能需要, 不过现在我们要换一种方式来得到关卡的生成区信息, 不再依赖Game单例的SpawnZoneOfLevel属性, 而是让当前关卡可以被全局访问.

在GameLevel脚本中增加一个教态属性Current. 每一个脚本都可以通过该属性得到当前的关卡信息, 但是只有在一个关卡被启用时才可以设置它 :

  1. //用来存储当前游戏关卡中的GameLevel脚本实例
  2. public static GameLevel Current { get; private set; }
  3. //脚本启用时, 设置GameLevel脚本的静态Current属性引用未当前脚本实例的引用
  4. void OnEnable () {
  5. Current = this;
  6. }

然后, 我们可以在GameLevel脚本中提供一个用于获取形状生成位置的公开属性, 代替在Game脚本中使用它自己的SpawnZoneOfLevel属性获取形状生位置 :

  1. //该属性用于获取当前关卡生成区的一个位置
  2. public Vector3 SpawnPoint {
  3. get {
  4. return spawnZone.SpawnPoint;
  5. }
  6. }
  7. //GameLevel不再需要负责为Game单例的SpawnZoneOfLevel属性赋值
  8. //void Start () {
  9. // Game.Instance.SpawnZoneOfLevel = spawnZone;
  10. //}

由于现在Game脚本可以直接访问GameLevel.Current来得到当前关卡的引用, 并通过SpawnPoint属性得到关卡中生成区内的一个位置, 所以Game脚本不再需要SpawnZoneOfLevel属性 :

  1. //现在通过GameLevel的静态属性Current获取当前关卡实例, Game不再需要下面的属性
  2. //public SpawnZone SpawnZoneOfLevel { get; set; }
  3. void CreateShape () {
  4. //t.localPosition = SpawnZoneOfLevel.SpawnPoint;
  5. //通过GameLevel的单例属性Current获取当前关卡生成区的一个位置
  6. t.localPosition = GameLevel.Current.SpawnPoint;
  7. }

更多游戏状态 - 图3
主场景与关卡场景中的脚本关系示意

目前位置, GameLevel中没有与Game有关的代码了, 也就是说Game脚本的静态属性Instance没有任何其他脚本在使用了, 所以要将它删除掉 :

  1. //没有任何其他脚本在使用Instance了, 将它删除掉
  2. //public static Game Instance { get; private set; }
  3. //没有任何其他脚本在使用Instance了, 将它删除掉
  4. //void OnEnable () {
  5. // Instance = this;
  6. //}

(说下个人的额外理解, 我的看法是, 这也是在进行脚本关系的解耦 : Game脚本中不再有一个属性存储关卡的GameLevel脚本实例, GameLevel脚本中也不再去设置Game单例的SpawnZoneOfLevel属性值, 它们之间的依赖程度变低了, 特别是GameLevel脚本在修改后, 其代码已经没有任何与Game脚本有关的内容)
**

虽然Instance没用了, 我可以继续留下它吗?

你当然可以这么做. 不过我希望知道, 未使用的代码, 也就是所谓的”死亡代码(dead code)”, 会让代码不易阅读, 从而使项目变得难以维护. 你完全可以删掉它, 如果以后真的还需要它, 再把它加回来就是.

保存关卡数据

我要通过让GameLevel继承PersistableObject类, 来实现它对自身所属关卡数据的保存. 对于继承来的Save和Load方法, 暂时只进行重写声明, 不书写具体的代码 :

  1. //public class GameLevel : MonoBehavior {
  2. //继承PersistableObject类来实现保存和加载方法
  3. public class GameLevel : PersistableObject {
  4. //重写父类的Save方法
  5. public override void Save (GameDataWriter writer) {}
  6. //重写父类的Load方法
  7. public override void Load (GameDataReader reader) {}
  8. }

我们要在Game.Svae方法向文件写入所有形状数据之前写入关卡的数据 :

  1. public override void Save (GameDataWriter writer) {
  2. writer.Write(shapes.Count);
  3. writer.Write(Random.state);
  4. writer.Write(loadedLevelBuildIndex);
  5. //在写入关卡索引数据后调用当前关卡GameLevel实例的Save方法
  6. GameLevel.Current.Save(writer);
  7. for (int i = 0; i < shapes.Count; i++) {
  8. }
  9. }

**

加载关卡数据

加载时也要按照保存时的顺序, 在加载了当前关卡索引后, 再加载关卡数据, 然而我们需要在新关卡场景加载完之后才能这么做, 否则我们会把关卡数据加载到即将被卸载的旧关卡场景. 因此我们需要等待协程方法LoadLevel完成对新关卡场景的加载过程之后再加载对应的关卡数据. 让我们把上述整个过程全部都变为一个协程方法.

在验证了当前程序是否支持正在加载的保存文件版本后, 开始一个新的协程方法LoadGame, 将此后的所有代码都放到新方法LoadGame中, 另外需要将Load方法中的reader参数床底给LoadGame方法 :

  1. public override void Load (GameDataReader reader) {
  2. int version = reader.Version;
  3. if (version > saveVersion) {
  4. Debug.LogError("Unsupported future save version " + version);
  5. return;
  6. }
  7. //开始新增的协程方法, 将在该方法内完成加载关卡场景与加载关卡数据的过程
  8. //原Load方法中此处之后的代码全部移动到了LoadGame方法中
  9. StartCoroutine(LoadGame(reader));
  10. }
  11. //新增的协程方法, 将在该方法内完成加载关卡场景与加载关卡数据的过程
  12. IEnumerator LoadGame (GameDataReader reader) {
  13. //获取保存文件版本号
  14. int version = reader.Version;
  15. int count = version <= 0 ? -version : reader.ReadInt();
  16. if (version >= 3) {
  17. Random.State state = reader.ReadRandomState();
  18. if (!reseedOnLoad) {
  19. Random.state = state;
  20. }
  21. }
  22. StartCoroutine(LoadLevel(version < 2 ? 1 : reader.ReadInt()));
  23. for (int i = 0; i < count; i++) {
  24. int shapeId = version > 0 ? reader.ReadInt() : 0;
  25. int materialId = version > 0 ? reader.ReadInt() : 0;
  26. Shape instance = shapeFactory.Get(shapeId, materialId);
  27. instance.Load(reader);
  28. shapes.Add(instance);
  29. }
  30. }

在LoadGame方法内, 使用对LoadLevel方法使用yield return语句, 代替调用它的StartCoroutine语句. 然后接着就可以调用GameLevel.Current.Load方法加载关卡数据, 但是有个前提条件, 需要保存文件的版本号大于等于3 :

  1. //StartCoroutine(LoadLevel(version < 2 ? 1 : reader.ReadInt()));
  2. //下面的yield return语句的作用是, 只有LoadLevel方法的全部代码执行完毕, 才会继续执行后面的代码
  3. yield return LoadLevel(version < 2 ? 1 : reader.ReadInt());
  4. //判断版本号是否大于等于3
  5. if (version >= 3) {
  6. //如果大于等于3, 说明保存文件中包括关卡数据, 可以加载
  7. GameLevel.Current.Load(reader);
  8. }

不幸的是, 上述代码保存运行之, 对游戏存档, 再加载存档, 会报错

缓存数据

上面提到的, 加载游戏时的报错大意是, 我们尝试去读取一个已经关闭了的BinaryReader实例. 它的关闭是由于我们在PersistentStorage.Load方法中书写的using代码块导致的, 在using代码块中的代码执行完毕后, 将直接释放掉我们正在读取的文件, 而我们恰恰是在这之后, 通过协程, 尝试去读取关卡数据, 所以出错了.

有两个办法可以解决这个问题, 第一个办法是不使用费using代码块, 而是在完成所有数据加载工作后再释放读取的文件, 不过这也需要我们小心的注意是否我们已经在文件读取完毕后进行了释放, 特别要注意的是还要把释放代码执行前出现程序运行错误的情况考虑进去; 第二个办法是一次性的读取所有的文件数据, 然后把这些数据缓存起来, 之后通过缓存的数据去加载游戏. 这样做的话就不需要我们担心文件会不会因为失误而没有被释放, 代价就是需要在内存中存储缓存数据一段时间, 好在我们的保存文件体积很小, 我们将使用第二种方法, 缓存文件数据 :

可以通过File.ReadAllBytes方法读取整个文件的数据, 该方法将返回一个byte类型的数组, 我们将基于该方法重新实现PersistentStorage.Load方法 :

  1. public void Load (PersistableObject o) {
  2. //using (
  3. // var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
  4. //) {
  5. // o.Load(new GameDataReader(reader, -reader.ReadInt32()));
  6. //}
  7. //不再打开文件流逐条读取数据, 而是一次性的将文件的全部数据粗存到一个byte数组, 所以也不再需要处理文件流是否正确关闭的问题
  8. byte[] data = File.ReadAllBytes(savePath);
  9. }

我们依然需要使用BinaryReader变量来读取数据, 它需要一个Stream类型的参数才能初始化, 而非一个数组, 使用数据数组作为参数创建MemoryStream的实例, 可以将数组封装成一个BinaryReader所需的Stream值, 接着就可以像代码修改前一样去BinaryReader变量中读取所需的数据 :

  1. public void Load (PersistableObject o) {
  2. byte[] data = File.ReadAllBytes(savePath);
  3. //通过MemoryStream, 将data数组转换为一个Stream类型的值
  4. var reader = new BinaryReader(new MemoryStream(data));
  5. //调用参数o的Load方法, 加载游戏数据
  6. o.Load(new GameDataReader(reader, -reader.ReadInt32()));
  7. }

关卡状态

目前我们还没有在GameLevel脚本的Save方法中书写任何功能代码, 让我们现在开始完善它

有序的复合生成区

目前位置我们做的最复杂的关卡中使用了复合生产区. 复合生成区使用一个数组保存组成它的基本生成区, 每次获取形状生成位置时会随机选择数组中的一个生成区中的位置. 这个过程中, 你无法预测下一个产生位置会在哪个生成区中选择, 每一次选择都是任意的.
GreenScornfulGangesdolphin-mobile.mp4 (45.83KB)随机产生在复合生成区的形状

不过我们还可以为CompositeSpawnZone脚本再增加一种依照数组顺序循环选择基本生成区的方式, 并通过新增一个开关字段来控制该方式与随机选择方式的切换 :

  1. [SerializeField]
  2. //控制复合生成区如何选择数组中的基本生成区, 为ture表示有序循环选择基本生成区, false表示随机选择
  3. bool sequential;

更多游戏状态 - 图5
添加好sequential字段后, 在Inspector中将其勾选

有序的选择基本生成区需要追踪下一个要选择的生成区数组索引. 因此再增加一个字段nextSequentialIndex存储这个索引, 并在每次得到生成位置后增加索引的值, 修改代码如下 :

  1. //在使用有序选择基本生成区的方式时, 该字段用来追踪需要选择的下一个生成区数组索引
  2. int nextSequentialIndex;
  3. public override Vector3 SpawnPoint {
  4. get {
  5. //int index = Random.Range(0, spawnZones.Length);
  6. //要根据sequential字段的值来决定如何为index赋值
  7. int index;
  8. //检查sequential字段的值以决定基本生成区的选择方式
  9. if (sequential) {
  10. //如果sequential为true, 有序的设置数组索引, 每次+1使得下次会在下一个基本生产区中选择位置
  11. index = nextSequentialIndex++;
  12. }
  13. else {
  14. //如果sequential为false, 则随机设置数组索引
  15. index = Random.Range(0, spawnZones.Length);
  16. }
  17. return spawnZones[index].SpawnPoint;
  18. }
  19. }

还需要实现循环选择基本生成区, 这就需要在数组索引达到末尾时变为第一个索引 :

  1. if (sequential) {
  2. index = nextSequentialIndex++;
  3. //判断当前的数组所以与数组长度的关系
  4. if (nextSequentialIndex >= spawnZones.Length) {
  5. //如果发现当前数组索引已经到达甚至超过数组末尾, 则将索引重置为零, 变成第一个索引
  6. nextSequentialIndex = 0;
  7. }
  8. }

保存代码, 运行游戏, 你会发现有序选择生成区与随机选择生成区的不同, 在有序的选择方式下, 形状会均匀的在每一个基本生成区内一次产生. 不过在每个基本生成区内依然是随机选择一个位置 NeighboringSimpleDrake-mobile.mp4 (50.32KB)依次的在复合生成区的每个基本生成区中产生形状

保存有序选择的下一个索引

保存游戏时, 也应该保存有序复合生成区的数组选择索引, 否则加载游戏后会重置索引, 不按照保存时的顺序选择生成区. 因此SpawnZone也应该成为一个可持久化的对象. 它当前已经继承了SpawnZone类, 我们可以让SpawnZone类继承PersistableObject类, 从而使得继承了SpawnZone类的所有生成区脚本也得到保存自身状态数据的能力 :

  1. //public abstract class SpawnZone : MonoBehavior {
  2. //让SpawnZone继承PersistableObject类, SpawnZone是所有基本生成区脚本与符合生成区脚本的父类
  3. public abstract class SpawnZone : PersistableObject {
  4. public abstract Vector3 SpawnPoint { get; }
  5. }

接着, 在CompositeSpawnZone脚本中重写Save和Load方法, 让它们分别写入和读取nextSequentialIndex字段, 不需要考虑当前是否设置为有序选择方式. 另外, 复合生成区不需要调用父类的Save方法来保存自身的Transform信息, 因为游戏中不会操纵复合生成区的Transform组件属性 :

  1. //重写父类的Save方法
  2. public override void Save (GameDataWriter writer) {
  3. //写入有序选择复合生成区需要的下一个数组索引
  4. writer.Write(nextSequentialIndex);
  5. }
  6. //重写父类的Load方法
  7. public override void Load (GameDataReader reader) {
  8. //读取有序选择复合生成区需要的下一个数组索引
  9. nextSequentialIndex = reader.ReadInt();
  10. }

**

保存多个关卡元素

继承了PersistableObject类后, 每一种生成区也都可以持久化了, 但是它们还没有在保存流程中处理, 因为在GameLevel脚本中没有调用它们的Save和Load方法, 虽然能够通过GameLevel脚本的spawnZone字段去调用对应生成区脚本的Save方法, 但是这只适用于保存单个生成区, 如果我们想要保存的是由若干个复合生成区构成的一个更复杂的复合生成区呢? 我们怎么去保存每一个复合生成区的有序选择索引?

为了明确的告知GameLevel脚本, 关卡中哪些内容需要调用自身的Save方法来保存数据, 可以在GameLevel脚本中增加一个PersistableObject类型的的数组, 然后在场景编辑过程中手动设置该数组, 指定要进行保存的游戏对象 :

  1. [SerializeField]
  2. //该数组用于指定要参与游戏保存的关卡内容
  3. PersistableObject[] persistentObjects;

接下来, 我们要让GameLevel写入该关卡总共保存的对象数量, 即persistentObjects数组的长度. 然后遍历调用数组中的每一个元素的Save方法, 就像是Game脚本对形状列表做的那样 :

  1. public override void Save (GameDataWriter writer) {
  2. //数组长度代表了该关卡总共保存了几个对象
  3. writer.Write(persistentObjects.Length);
  4. //遍历persistentObjects数组
  5. for (int i = 0; i < persistentObjects.Length; i++) {
  6. //调用persistentObjects数组中每个元素的Save方法
  7. persistentObjects[i].Save(writer);
  8. }
  9. }

加载过程需要针对保存过程进行对应的数据读取, 不过每一个加载保存数据的游戏对象不需要进行实例化, 因为它们是场景的一部分(加载场景的时候都已经实例化完毕了) :

  1. public override void Load (GameDataReader reader) {
  2. //先得到文件中该关卡保存对象的数量, 即存档时保存了了几个对象的数据
  3. int savedCount = reader.ReadInt();
  4. //根据读取到的保存对象数量, 遍历persistentObjects数组,.为每个数组元素加载保存数据
  5. for (int i = 0; i < savedCount; i++) {
  6. persistentObjects[i].Load(reader);
  7. }
  8. }

需要注意的是, 从现在开始你必须要确保persistentObjects数组中的元素的索引不会发生改变, 否则会导致其与保存文件中的数据不能按照正确顺序对应. 不过为persistentObjects数组继续添加元素是不会出问题的, 因为加载时只会按照之前保存对象的数量遍历数组中的元素, 文件保存之后新增的数组元素不会被加载过程处理

还有一个要注意的事情, 如果我们不去每个关卡场景手动设置它们的persistentObjects数组内容, 该数组的默认值就是null, 这会导致读取该场景的保存数据时出现空引用(null-reference)报错.

不过如果你的关卡非常多, 为了方便测试保存功能, 你不需要手动配置一遍每个场景的该数组, 只需要检查每个运行时启用的场景, 其数组是否为null, 如果是, 则直接使用代码将其初始化为一个长度为0的数组 :

  1. void OnEnable () {
  2. Current = this;
  3. //检查当前关卡的保存对象数组
  4. if (persistentObjects == null) {
  5. //如果数组为null, 将其初始化为一个长度为0的数组
  6. persistentObjects = new PersistableObject[0];
  7. }
  8. }

现在, 我们可以通过在Inspector中向persistentObjects数组分配游戏对象, 来明确的规定哪些游戏对象需要保存.
更多游戏状态 - 图7
(复合生成区只会保存自身的有序索引字段nextSequentialIndex, 你也可以把基本生成区拖拽到这里, 它们会保存自身的Transform信息, 这个在下面的教程部分会有更多相关内容)

重载新游戏

现在加载游戏时也会将恢复保存游戏时的有序选择索引, 但是我们还需要在开始新游戏时将该索引重置, 办法就是在开始新游戏之后, 重载整个关卡场景, 以达到重置全部关卡状态数据的目的 :

  1. else if (Input.GetKeyDown(newGameKey)) {
  2. BeginNewGame();
  3. //开始新游戏时, 调用完BeginNewGame后, 将关卡重新加载一遍, 重置所有数据为初始状态
  4. StartCoroutine(LoadLevel(loadedLevelBuildIndex));
  5. }

旋转效果

现在为游戏增加另一种可保存的关卡内容, 一个可以进行旋转的游戏对象. 它含有一个可配置的角速度值, 角速度使用一个Vector3类型的值表示, 所以可以是在任何方向上的角速度. 要让它发生旋转, 就需要在Update方法中调用Transform组件的Rotate方法, 将被每帧时间增量缩放后的角速度作为参数传递进去, 新增脚本RotatingObject, 代码如下 :

  1. using UnityEngine;
  2. //继承PersistableObject类, 从而获得保存自身数据的功能
  3. public class RotatingObject : PersistableObject {
  4. [SerializeField]
  5. //存储自身旋转的角速度
  6. Vector3 angularVelocity;
  7. void Update () {
  8. //根据角速度, 每帧进行旋转
  9. transform.Rotate(angularVelocity * Time.deltaTime);
  10. }
  11. }

为了方便演示这个旋转的对象, 创建一个新的场景, 命名为Level 4. (然后按照下述步骤对Level 4进行配置 :

  1. 首先删除它的默认摄像机物体Main Camera
  2. 前往Lighting窗口, 点击Generate Lighting按钮, 生成Level 4的光照数据文件.
  3. 添加Game Level物体, 并为其添加GameLevel脚本组件
  4. 前往Build Settings窗口, 将Level 3场景拖拽到Scenes In Build列表的末尾
  5. ctrl+S保存你的修改 )

接着在该场景中, 加入如下内容 :

  1. 新建空物体Y Rotation并添加RotatingObject脚本, 设置脚本的Y轴角速度为90
  2. 新建空物体X Rotation并添加RotatingObject脚本, 设置脚本的X轴角速度为15
  3. 参考Level 3场景, 配置一个代表复合生成区的物体Composite Spawn Zone, 它由两个球体生成区Spawn Zone A和Spawn Zone B组成
  4. 在Game Level的Inspector中, 把复合生成区物体拖拽到spawnZone字段上
  5. 上述物体层级按照下图设置 :

更多游戏状态 - 图8

在层级关系如上图设置完成之后, 分别将两个球体生成区的Position设置为(0, 0, 10)和(0, 0, -10)

为了将旋转物体与复合生成区数据全部保存到文件中, Game Level的Inspector中按照下图所示配置persistentObject数组, 注意, 初次配置的顺序并无要求, 但是就像前面提到的, 不要在游戏存档之后改变数组中元素的顺序 :
更多游戏状态 - 图9

对Level 4进行上述所有配置之后, 我们就获得了沿着半径为20的球形表面, 反复上下环绕的两个球体生成区, 运行游戏的效果如下方视频所示 :

(不要忘了前往主场景, 在Game物体的Inspector中将Level Count设置为4)
PleasantPowerfulArachnid.webm (196.36KB)旋转跳跃 它闭着眼

你还可以测试下该场景数据的保存与加载, 不过你可能会发现一个问题, 在自动创建形状的前提下, 即便设置每次加载的随机状态一致, 多次加载后也会发现形状生成情况出现了略微不同(表现为, 每次加载后, 下一个生成形状的时间点, 与位置, 均不完全一致, 因为设置了加载随机状态数据, 所以不存在随机结果变化的影响, 这表明有其他问题存在)

下面的章节会讨论和解决这个问题

创建和销毁

自动创建和自动销毁形状的剩余间隔时间也是游戏状态的一部分. 目前为止游戏存档时没有对保存和加载它们, 因此当自动创建速度大于0时, 游戏加载完成那一刻, 你可能不会得到与保存时完全一致的形状放置情况, 因为代表自动创建间隔进度的字段creationProgress还是加载前的状态, 对于字段destructionProgress也是如此

为了解决这个问题, 只需要将这两个字段的数据进行保存和加载即可

保存和加载自动过程状态

修改Game.Save方法, 在写入随机状态数据后, 写入代表自动过程的数据 :

  1. public override void Save (GameDataWriter writer) {
  2. writer.Write(shapes.Count);
  3. writer.Write(Random.state);
  4. //写入保存时的creationProgress字段值
  5. writer.Write(creationProgress);
  6. //写入保存时的destructionProgress字段值
  7. writer.Write(destructionProgress);

加载时, 在LoadGame方法中按照保存的顺序读取它们 :

  1. IEnumerator LoadGame (GameDataReader reader) {
  2. int version = reader.Version;
  3. int count = version <= 0 ? -version : reader.ReadInt();
  4. if (version >= 3) {
  5. Random.State state = reader.ReadRandomState();
  6. if (!reseedOnLoad) {
  7. Random.state = state;
  8. }
  9. //读取保存的creationProgress字段值
  10. creationProgress = reader.ReadFloat();
  11. //读取保存的destructionProgress字段值
  12. destructionProgress = reader.ReadFloat();
  13. }

让时间增量变精确

我们依然不能得到准确的时间, 这是因为的游戏帧率并不是完全稳定的. Time.deltaTime的值在每一帧都会变化, 于是在每次加载后, 形状的产生时机就可能会有所差异, 比如可能导致一个形状与另一次加载相比, 也许会早一帧出现也许会晚一帧出现. 另一方面, 旋转运动的球体生成区也使用Time.deltaTime来控制每一帧的运动位置, 最终就导致多次加载后继续产生的同一个形状, 每次出现的位置却有所差异(该差异相对来说难以在编辑环境下用肉眼观察到, 因为我们电脑的帧率一般都比较高, 时间差异太小了, 理解这个问题存在的原因即可)

要解决这个问题, 需要使用一个固定的时间增量来更新自动过程的进度变化. 这就需要将与进度变化有关的代码从Update方法移动到FixedUpdate方法中 :

  1. void Update () {
  2. if (Input.GetKeyDown(createKey)) {
  3. CreateShape();
  4. }
  5. //…
  6. else {
  7. for (int i = 1; i <= levelCount; i++) {
  8. if (Input.GetKeyDown(KeyCode.Alpha0 + i)) {
  9. BeginNewGame();
  10. StartCoroutine(LoadLevel(i));
  11. return;
  12. }
  13. }
  14. }
  15. //检测按键的if-eles代码块结束之后的全部代码, 移动到下方的FixedUpdate方法中
  16. }
  17. //在FixedUpdate方法中, Tiem.deltaTime每次取值固定, 不受游戏运行帧率的影响
  18. void FixedUpdate () {
  19. creationProgress += Time.deltaTime * CreationSpeed;
  20. while (creationProgress >= 1f) {
  21. creationProgress -= 1f;
  22. CreateShape();
  23. }
  24. destructionProgress += Time.deltaTime * DestructionSpeed;
  25. while (destructionProgress >= 1f) {
  26. destructionProgress -= 1f;
  27. DestroyShape();
  28. }
  29. }

(有兴趣的可以分别在Update和FixedUpdate中直接打印Time,deltaTime的值, 对比一下会更容易理解为什么使用FixedUpdate就可以消除时间增量上的差异)

还有, 不要忘记让控制生成区旋转运动的代码, 移动到FixedUpdate中, 从而不受帧率变化而影响运动计算结果 :

  1. //void Update() {
  2. //在FixedUpdate方法中, Tiem.deltaTime每次取值固定, 不受游戏运行帧率的影响
  3. void FixedUpdate () {
  4. transform.Rotate(angularVelocity * Time.deltaTime);
  5. }

FixedUpdate方法是什么时候调用的?

该方法会在每一帧所有脚本的Update方法都执行完毕后调用. 而这一帧到底要调用多少次FixedUpdate方法取决于实际帧率与Project Setting的Time设置中的Fixed Timestep属性
默认的Fixed Timestep是0.02, 这表示每秒会调用该方法50次. 假如你的游戏帧率恰好是10帧/秒, 那么每一帧就会调用FixedUpdate方法5次; 假如你的游戏帧率大于50帧/秒, 那么平均下来每一帧不到一次调用, 实际导致的结果就是有些帧根本不会调用FixedUpdate方法, 不过在1秒钟内还是会总共调用50次.

速度设置

除了自动过程的进度数据, 我们还可以把保存游戏时的自动过程速度也保存起来, 做法依然是在执行Svae方法时将代表自动创建或自动销毁速度的字段值写入文件 :

  1. public override void Save (GameDataWriter writer) {
  2. writer.Write(shapes.Count);
  3. writer.Write(Random.state);
  4. //保存自动创建进度信息前保存自动创建速度
  5. writer.Write(CreationSpeed);
  6. writer.Write(creationProgress);
  7. //保存自动销毁进度信息前保存自动销毁速度
  8. writer.Write(DestructionSpeed);
  9. writer.Write(destructionProgress);

依然不要忘了在LoadGame方法中按照保存的顺序读取它们 :

  1. if (version >= 3) {
  2. Random.State state = reader.ReadRandomState();
  3. if (!reseedOnLoad) {
  4. Random.state = state;
  5. }
  6. //读取自动创建进度信息前读取自动创建速度
  7. CreationSpeed = reader.ReadFloat();
  8. creationProgress = reader.ReadFloat();
  9. //读取自动销毁进度信息前读取自动销毁速度
  10. DestructionSpeed = reader.ReadFloat();
  11. destructionProgress = reader.ReadFloat();
  12. }

另外, 开始新游戏后, 较为合理的设置是, 不自动创建或销毁形状, 所以在BeginNewGame方法中将它们重置为0 :

  1. void BeginNewGame()
  2. {
  3. //开始新游戏时默认自动创建速度为0
  4. CreationSpeed = 0;
  5. //开始新游戏时默认自动销毁速度为0
  6. DestructionSpeed = 0;

**

根据速度更新滑动条

现在的游戏运行后, 如果调整了控制自动过程速度的滑动条, 变化的速度值会保存到对应的字段中, 但是反过来, 对应的速度字段值被改变后, 滑动条并不会调整自己的状态来匹配改变后的速度. 要让滑动条可以根据速度变化更新状态, 首先需要在Game脚本中添加两个字段存储滑动条对象的引用 :

  1. //滑动条对象类型Slider需要引用该命名空间
  2. using UnityEngine.UI;
  3. public class Game : PersistableObject {
  4. //存储控制自动创建速度的滑动条引用
  5. [SerializeField] Slider creationSpeedSlider;
  6. //存储控制自动销毁速度的滑动条引用
  7. [SerializeField] Slider destructionSpeedSlider;

保存代码后, 前往Game的Inspector中, 将两个滑动条物体拖拽给对应的字段, 如下图所示 :**
更多游戏状态 - 图11
通过设置Inspector得到两个滑动条的引用

接着修改BeginNewGame方法, 在重置速度字段的时也一并设置滑动条的value属性 :

  1. //CreationSpeed = 0;
  2. //将自动创建速度字段与对应滑动条的value都设置为0, 下方的写法等同于分别写赋值为0的两句代码
  3. CreationSpeed = creationSpeedSlider.value = 0;
  4. //DestructionSpeed = 0;
  5. //将自动销毁速度字段与对应滑动条的value都设置为0, 下方的写法等同于分别写赋值为0的两句代码
  6. DestructionSpeed = destructionSpeedSlider.value = 0;

最后的最后, 在LoadGame方法中需要使用读取到的速度值设置对应的滑动条的value属性 :

  1. //CreationSpeed = reader.ReadFloat();
  2. //将自动创建速度字段与对应滑动条的value都设置为读取到的速度数据
  3. creationSpeedSlider.value = CreationSpeed = reader.ReadFloat();
  4. creationProgress = reader.ReadFloat();
  5. //DestructionSpeed = reader.ReadFloat();
  6. //将自动销毁速度字段与对应滑动条的value都设置为读取到的速度数据
  7. destructionSpeedSlider.value = DestructionSpeed = reader.ReadFloat();
  8. destructionProgress = reader.ReadFloat();

至此, 滑动条也将会开始新游戏或或是加载保存文件后发生与速度对应的UI变化, 本篇教程到此结束.

下一篇教程是操控形状
教程源码
PDF