某愿朝闻道译/原文地址

  • 根据按键随机生成立方体(cube)
  • 使用泛型方法和虚拟方法
  • 将数据写入文件以及从文件中读取数据
  • 游戏存档的保存与加载
  • 完善数据持久化的细节

这是对象管理章节的第一篇教程. 本文涵盖了创建, 追踪, 保存和加载简单预制体实例的方法介绍. 你至少有了C#与Unity基础系列教程的基础知识水平就可以开始本教程了.

本教程使用Unity 2017.3.1p4制作(译者使用Unity2019.2.15f1实践)
对象持久化(游戏保存与加载) - 图1
游戏关闭后, 这些立方体的数据也可以保存下来

根据需要创建物体

你可以在使用Unity创建的场景中放置游戏物体. 你可以用这种方式设计你游戏中的固定关卡. 场景内的物体可以被添加各种特性, 这会改变它们在游戏运行时的状态. 通常, 新的游戏物体应该在游戏运行过程中创建. 比如射击出的子弹, 产生敌人, 随机获得的战利品等等. 甚至你可以在游戏运行过程中打造一个自定义的游戏关卡.

在游戏中创建游戏场景及场景内容后, Unity并不会自动的为我们保存这些运行中发生的数据变化, 关闭游戏再进入游戏后, 这些数据变化会消失. 所以我们需要自己来实现游戏数据的保存与读取功能

本教程将带领你创建一个简单的游戏. 游戏的全部内容就是根据你的按键, 随机的产生立方体. 在后续教程中我们会逐渐增加游戏的复杂程度, 实现更多功能

游戏逻辑

因为我们的游戏非常简单, 所以我们只需要一个单独的Game脚本. 它将利用我们制作的预制体来产生立方体. 因此它应该包含一个公开的字段, 来指定所需的预制体 :

  1. using UnityEngine;
  2. public class Game : MonoBehaviour {
  3. //用来指定所需预制体的字段
  4. public Transform prefab;
  5. }

接着, 在场景中添加一个空物体, 改名为Game, 为它附加新建的脚本. 然后再创建一个默认的cube物体, 使用它创建一个cube的预制体, 并将创建好的预制体拖拽到Game脚本的prefab字段, 然后删掉场景中的cube物体 :
对象持久化(游戏保存与加载) - 图2 对象持久化(游戏保存与加载) - 图3
场景内容

玩家输入

我们需要根据玩家输入来产生cube, 所以需要指定对应的按键. 我们使用Unity的输入系统来检测按键状态. 本例中将使用C键作为这个按键, 不过我们会使用一种可配置的方式来指定按键. 在脚本中新增一个KeyCode枚举类型的公开字段. 使用C键盘作为该字段的默认值 :

  1. //createKey字段用来指定功能按键, 默认设置为C键
  2. public KeyCode createKey = KeyCode.C;

对象持久化(游戏保存与加载) - 图4
createKey字段, 默认设置为按键C

我们可以在按键按下时, 在Update方法中通过静态类Input检测到对应按键. Input.GetKeyDown方法会返回一个代表特定按键是否在本帧被按下的布尔值. 如果按键被按下了, 就实例化我们的预制体 :

  1. void Update()
  2. {
  3. //Input.GetKeyDown不会检测持续按键, 也就是在按键弹起之前, 就算是持续按住, 也只在按键刚刚被按下时返回一次true
  4. if(Input.GetKeyDown(createKey)){
  5. //如果createKey代表的按键被按了一次, 就实例化一个prefab预制体
  6. Instantiate(prefab);
  7. }
  8. }

Input.GetKeyDown方法在什么时候会返回true?

它只会在按键状态从”未被按下”变为”被按下”那一帧才会执行. 一般情况下, 一个按键被按下再抬起的过程会跨越多帧, 不过Input.GetKeyDown方法只会在这个过程的第一帧返回true. 与之对应, 还有另一个方法, 叫做Input.GetKey, 它会在按键被按下的每一帧都持续返回true

随机生成

现在运行程序后, 按下createKey代表的按键就会在场景中产生cube. 不过即便你按下多次按键, 场景中看起来也只有一个cube, 这是因为现在所有生成的cube都重合在一起. 因此我们需要给每个生成的cube设置随机的位置.

我们首先需要获得新生成cube的Transform组件, 然后改变它的本地坐标. 使用静态属性Random.insideUnitSphere可以获得一个随机的位置点, 将它扩大五倍后作为cube的位置. 我们将cube实例化的相关操作按钮都放置到一个新的方法CreateObject中, 然后在按键按下后调用它 :

  1. void Update () {
  2. if (Input.GetKeyDown(createKey)) {
  3. //Instantiate(prefab);
  4. //用来处理cube生成的各种逻辑
  5. CreateObject();
  6. }
  7. }
  8. //用来处理cube生成的各种逻辑
  9. void CreateObjet()
  10. {
  11. //使用变量t获得新生成的cube的Transform组件引用
  12. Transform t = Instantiate(prefab);
  13. //Random.insideUnitSphere方法会返回以半径为1, 球心在原点的球形空间内的随机坐标, 乘以5后设置为新生成的cube的位置
  14. t.localPosition = Random.insideUnitSphere * 5f;
  15. }

对象持久化(游戏保存与加载) - 图5
在随机位置产生cube

以上代码会使得新产生的cube都分布在一个球形空间内部. 不过它们现在依然会发生重叠, 现在这不是一个要紧的事情. 不过它们的角度全部都一样, 看起来有点憨憨, 所以让我们为每个cube设置一个随机的旋转角度, 这就要用到Random.rotation属性 :

  1. void CreateObject () {
  2. Transform t = Instantiate(prefab);
  3. t.localPosition = Random.insideUnitSphere * 5f;
  4. //使用Random.rotation获得一个随机的旋转角度, 赋值给cube的localRotation属性
  5. t.localRotation = Random.rotation;
  6. }

对象持久化(游戏保存与加载) - 图6
随机旋转效果

最后, 我们可以让每个cube的大小也不一样. 静态方法Random.Range可以得到一个特定范围内的随机数. 让我们在0.1到1之间随机对每个cube进行缩放. 由于我们需要对cube的长宽高三个维度都进行缩放, 所以可以将随机系数与Vector3.one相乘来直接得到需要的代表新缩放尺寸的向量 :

  1. void CreateObject () {
  2. Transform t = Instantiate(prefab);
  3. t.localPosition = Random.insideUnitSphere * 5f;
  4. t.localRotation = Random.rotation;
  5. //Vector3.one 等价于 New Vector3(1,1,1), 乘以随机系数后赋值给cube的localScale
  6. t.localScale = Vector3.one * Random.Range(0.1f, 1f);
  7. }

对象持久化(游戏保存与加载) - 图7
随机尺寸效果

开始新游戏

目前, 如果我们想要重新开始游戏, 需要退出Play模式, 然后重新进入Play模式, 而且也只能在Unity编辑器环境下这么做, 真正交到玩家手中的应用程序, 需要提供可以不关闭游戏就重新开始的功能.

我们可以通过重新加载场景来达到开始新游戏的目的, 但是并不只能这样做. 我们可以销毁所有生成的cube, 设置一个新的按键来触发这个功能, 新建字段存储该按键值, 默认使用N键 :

  1. //代表开始新游戏的按键
  2. public KeyCode newGameKey = KeyCode.N;

对象持久化(游戏保存与加载) - 图8
newGameKey, 默认设置为N

在Update方法中检测该按键是否按下, 如果按下, 就执行新建的BeginNewGame方法. 同一时间我们应该只检测一个按键, 所以只在没有检测到C键被按下时才检测N键 :

  1. void Update () {
  2. if (Input.GetKeyDown(createKey)) {
  3. CreateObject();
  4. }
  5. //原文此处应该是写错了用的GetKey方法, 我改为了GetKeyDown方法
  6. else if (Input.GetKeyDown(newGameKey)) {
  7. //如果没有按下C键, 并且按下了N键, 则执行BeginNewGame方法
  8. BeginNewGame();
  9. }
  10. }
  11. //用来执行开始新游戏的逻辑
  12. void BeginNewGame ()
  13. {
  14. //代码暂时为空
  15. }

跟踪游戏对象

我们的游戏可以产生任意数量的cube. 但是Game脚本现在还没有去记录每个生成的cube. 为了可以销毁所有的cube. 首先需要找到它们. 要做到这一点, 我们需要让Game脚本保存一个所有cube引用的列表.

为什么不用GameObject.Find方法来找到每个Cube?

对于场景中不会太多元素的简单游戏来说可以这样做. 对于大型场景, 依赖GameObject.Find去寻找游戏对象不是一个理想的方法. GameObject.FindWithTag会更好一些, 但是最好的办法还是使用脚本去存储所有需要随时访问的游戏对象

可以在Game脚本中添加一个数组字段来存储cube引用数据, 不过我们的cube生成数量是不固定的, 也就无法知道创建多大长度的数据. 幸运的是, System.Collections.Generic命名空间中包含了叫做List的类, 它的用途类似数组, 但是不需要为它指定固定的长度.

List是如何动态的变化长度的?

List也在自身内部使用一个若干长度的数组存储所有内容. 当向List添加新项时, 也就是在向这个内部数组添加新项. 一旦内部数组长度不足以继续存储新内容了, List将会赋值数组的所有内容到一个更大一些的新数组中, 代替原来的数组.
另外, List像数组一样可以作为一个能在Inspector中进行修改的字段.

新增用来存储cube引用的List字段 :

  1. //List隶属于System.Collections.Generic命名空间
  2. using System.Collections.Generic;
  3. public class Game : MonoBehaviour {
  4. //用来存储所有生成的cube引用
  5. List objects;

List会强制要求我们指定需要保存什么类型的内容. List是一种泛型, 它就好比是一个模板, 你需要告诉它, 实际要使用的是哪一种类型的List. 指定类型的语法为 Lsit, 其中T就代表指定的内容类型. 在我们的例子中, 这个类型是Transform, 所以写法就是 :

  1. //List objects;
  2. List<Transform> objects;

我们需要像数组一样, 在使用List之前创建一个实例(instance). 可以在Awake方法中完成这一步操作. 对于数组, 可以使用new Transform[]这种语法进行实例化. 而List的实例化语法是new List(). 该语法会调用指定类型的List构造方法 :

  1. void Awake () {
  2. //新建List<Transform>实例, 并将引用赋值给objects字段
  3. objects = new List<Transform>();
  4. }

接下来就可以在每次生成cube时, 通过List.Add方法将其Transform组件的引用添加到objects中 :

  1. void CreateObject () {
  2. Transform t = Instantiate(prefab);
  3. t.localPosition = Random.insideUnitSphere * 5f;
  4. t.localRotation = Random.rotation;
  5. t.localScale = Vector3.one * Random.Range(0.1f, 1f);
  6. //将新建的cube的Transform组件引用添加到objects中
  7. objects.Add(t);
  8. }

**

必须在CreateObject方法的末尾执行objects.Add(t)吗?

你可以在为t赋值后马上将其添加到obecjts中, 不过我将这个操作放置在了对cube进行了所有初始化操作之后.

清空列表

新增我们可以在BeginNewGame方法中遍历objects的每一个cube并销毁. 这与遍历数组的方式类型, 区别是List需要使用Count来获取它的元素数量 :

  1. void BeginNewGame () {
  2. //遍历objects列表
  3. for (int i = 0; i < objects.Count; i++) {
  4. //销毁列表中的每一个cube, 由于objects存储的是cube的Transform组件, 所以还需要使用.gameObject属性获取到cube本身
  5. Destroy(objects[i].gameObject);
  6. }
  7. }

上述代表虽然删除了场景中的所有cube, 但是此时objects却还保留着被销毁的数据, 所以需要在cube全部被销毁后, 清空objects中的所有旧数据 :

  1. void BeginNewGame () {
  2. for (int i = 0; i < objects.Count; i++) {
  3. Destroy(objects[i].gameObject);
  4. }
  5. //清空已被销毁的cube的Transform数据
  6. objects.Clear();
  7. }

**

保存和加载

如果想在不关闭游戏的前提下, 支持保存和加载功能, 可以使用一个List在内存中保存cube的数据. 保存时, 复制它们的位置, 旋转和缩放, 然后在加载时, 重置游戏, 并按照保存的数据重新生成所有的cube. 然而, 真正的游戏保存系统应该可以在游戏关闭后依然保存数据. 这需要游戏的状态数据可以持久化的保存在设备中. 最直接的方法就是将数据保存在一个文件中

使用PlayerPrefs保存游戏怎么样?

就像是它的名字一样, PlayerPrefs在设计功能时主要考虑的是游戏设置和偏好选项, 而不是游戏状态. 虽然可以将游戏状态转换为字符串后使用PlayerPrefs进行保存, 但是这样做效率低下, 难以管理, 并且无法扩展

保存路径

游戏文件的存储路径取决于所在平台的文件系统, Unity通过Application.persistentDataPath属性可以帮助我们得到游戏文件的路径. 我们可以在Awake方法中通过该属性将该属性返回的文件路径字符串存储起来 :

  1. //游戏存档文件的保存路径字段
  2. string savePath;
  3. void Awake () {
  4. objects = new List<Transform>();
  5. //使用Application.persistentDataPath得到游戏文件在文件系统中的路径
  6. savePath = Application.persistentDataPath;
  7. }

我们通过上述代码只是得到了一个文件夹路径, 而并非一个具体的文件路径. 我们需要将文件名称添加到文件夹路径中. 文件名称可以使用”svaeFile”, 不需要指定文件扩展名. 而连接文件夹路径与文件名需要使用斜杠”/“还是反斜杠”\”也取决于所在平台的文件夹系统, Unity提供了一个方法Path.Combine帮助我们处理不同文件夹系统的路径斜杠要求, 而不需要我们进行判断处理. Path是System.IO命名空间下的一个类 :

  1. //Path位于System.IO命名空间中
  2. using System.IO;
  3. public class Game : MonoBehaviour {
  4. string savePath;
  5. void Awake () {
  6. objects = new List<Transform>();
  7. //savePath = Application.persistentDataPath;
  8. //使用Path.Combine方法将两个字符串作为文件路径进行连接, Unity字段会为我们选择适合当前平台的斜杠或是反斜杠进行连接
  9. savePath = Path.Combine(Application.persistentDataPath, "saveFile");
  10. }

为写入数据打开文件

要想我们的保存文件内写入数据, 首先要打开它. 在Unity中通过File.Open方法打开一个文件, 它需要使用文件路径作为参数. 该方法还需要知道我们为什么要打开这个文件. 我们的目的是向文件写入数据, 并且如果文件不存在的话还需要先创建这个文件, 如果文件存在则覆盖已有文件. 我们可以向File.Open再传入一个参数来指定这些规则, 新建一个方法Save来进行这些操作 :

  1. //执行游戏保存逻辑
  2. void Save () {
  3. //使用File.Open方法打开由savePath指定的路径下的文件, 并通过FileMode.Create来指定打开操作的类型
  4. File.Open(savePath, FileMode.Create);
  5. }

(关于FileMode.Create的含义参阅C# FileMode 枚举)

File.Open方法将返回一个文件流(file stream), 我们还需要一个可以向里面写入数据的数据流(data stream). 数据流具有特定的数据格式, 这里我们使用最紧凑的无压缩格式, 也就是原始二进制数据(raw binary data). 这需要用到File.IO命名空间中的BinaryWriter类.

我们需要创建一个该类的实例, 在构造方法中传入一个文件流作为参数. 我们不需要存储该文件流的引用, 所以直接将File.Open方法作为参数传入. 不过我们后续要用到该BinaryWriter的实例, 所以将其引用保存在一个变量write中 :

  1. void Save () {
  2. //File.Open(savePath, FileMode.Create);
  3. //新建指定文件流的二进制数据流
  4. BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create));
  5. }

现在我们有了一个用于存储BinaryWriter新实例引用的BinaryWriter类型的变量writer. 这句话, 用了三个writer, 啰嗦的一批. 对于我们的代码, 也存在啰嗦的地方, 我们不需要显示的声明writer的类型, 而可以使用var代替BinaryWrite. 该关键字会隐式的声明变量类型, 以便与分配给变量的数据类型相匹配, 这个过程由编译器完成 :

  1. void Save () {
  2. //BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create));
  3. //使用var关键字代替BinaryWriter, 根据赋值的数据类型来隐式的声明writer的类型
  4. var writer = new BinaryWriter(File.Open(savePath, FileMode.Create));
  5. }

什么时候应该使用var关键字?

var关键字是一种语法糖(syntactic sugar), 其实你完全可以不使用它. 虽然你可以在任何地方使用它来让编译器去推断变量的类型. 不过最好还是在可读性良好, 类型显而易见时使用它. 我的教程中, 只会在变量声明后立即通过new语句赋值时才使用var, 也就是只会在var t = new Type这种形式的代码处才会使用var.
var关键字在使用语言集成查询(Language Integrated Query, LINQ)以及处理匿名类型时都非常有用, 不过这超出了目前教程的涉猎范围, 不在此赘述

关闭文件

如果我们打开了一个文件, 还需要在文件使用完毕之后关闭它. 可以通过Close方法做到这一点, 但是这样做并不安全. 如果在关闭文件之前发生了什么错误, 那么就会抛出一个异常, 从而有可能终止程序运行导致文件关闭语句没有被执行. 我们需要小心的处理可能出现的异常, 以保障打开的文件始终可以被关闭. 可以借助一种语法糖来轻松的处理这个问题. 将writer的声明和赋值语句写在一堆圆括号内, 并在起始圆括号前方使用using关键字, 在结束圆括号后方书写一对大括号. 在圆括号内的变量依然可用 :

  1. void Save () {
  2. //使用using()将writer变量包围起来, 并在圆括号结束处书写一对大括号, 这是try{}catch{}final{(IDisposable)writer).Dispose();}的语法糖写法
  3. using(
  4. var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
  5. )
  6. //打开文件之后,关闭文件之前的代码会再下面的大括号内书写, 无论大括号内的代码是否出现异常错误, 通过上述using语句, 都可以保障最终会释放writer所占用的资源,在这里就是指的关闭已打开的文件什么的
  7. {}
  8. }

这将保障writer所占用的资源(比如本例中, 它占用了一个打开的文件)会在下方大括号内的代码执行完毕后, 被正确的释放掉, 无论大括号内的代码是否出现了错误异常. 这种写法适用于特殊的一次性类型, 比如说写入器(writer)和流(stream)

“using(){}”语法糖的作用是什么?

在我们的例子中, 它等价于以下代码
================================
var writer = new BinaryWriter(File.Open(savePath, FileMode.Create);
try {
//…打开文件之后. 关闭文件之前, 需要执行的代码内容写在这里
}
finally {
if (writer != null) {
//Dispose方法会释放被执行对象占用的资源
((IDisposable)writer).Dispose();
}
}
================================
(查了下, using写法是C#6.0加入的特性, 可选扩展阅读 : C#中的异常处理) :

写入数据

我们可以通过调用writer的Write方法向其指定的文件中写入数据. 可以写入简单的值, 比如布尔, 整型等等. 让我们从写入产生了多少个cube作为开始 :

  1. void Save () {
  2. using(
  3. var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
  4. )
  5. {
  6. //通过Write方法, 将objects.Count的值写入writer
  7. writer.Write(objects.Count);
  8. }
  9. }

我们还需要新增一个按键, 来告诉Update方法何时调用Save方法进行保存操作, 设置该按键默认为S :

  1. //新增saveKey保存触发文件保存过程的按键, 默认设置为S
  2. public KeyCode saveKey = KeyCode.S;
  3. void Update () {
  4. if (Input.GetKeyDown(createKey)) {
  5. CreateObject();
  6. }
  7. else if (Input.GetKey(newGameKey)) {
  8. BeginNewGame();
  9. }
  10. //新增else if逻辑分支
  11. else if (Input.GetKeyDown(saveKey)) {
  12. //如果按下了saveKey对应的按键, 则执行Save方法
  13. Save();
  14. }
  15. }

对象持久化(游戏保存与加载) - 图9
saveKey字段

保存代码, 运行游戏, 创建一些cube, 然后按下S键进行保存. 这将会在你的文件系统中创建叫做saceFile的文件. 如果你不确定这个文件在哪个路径下, 你可以在Save方法中使用Debug.Log来在控制台输出它的路径 :

  1. void Save () {
  2. using(
  3. var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
  4. )
  5. {
  6. //使用Debug.Log方法向控制台输出保存文件路径字符串savePath
  7. Debug.Log("游戏保存文件位于: " + savePath);
  8. writer.Write(objects.Count);
  9. }
  10. }

找到该文件后, 你会发现它包含4个字节的数据.
image.png

右键该文件, 选择打开方式为txt记事本将其打开, 可能看不到任何内容, 又或是看到乱码一样的字符, 因为这显示的是二进制的数据. 文件大小是4字节, 是因为我们保存的是一个整型数据, 整型类型的大小就是4字节.

除了保存我们有多少个cube, 还需要存储每个cube的transform组件的状态数据. 我们可以通过遍历objects列表中来获得每个cube的以上数据, 获取一条, 保存一条. 首先, 我们依次保存它们在三个轴上的的position数据 :

  1. writer.Write(objects.Count);
  2. //遍历objects列表中的每一个代表cube的transform组件的元素
  3. for (int i = 0; i < objects.Count; i++) {
  4. //当前列表元素存储到变量t中
  5. Transform t = objects[i];
  6. //向文件写入x坐标
  7. writer.Write(t.localPosition.x);
  8. //向文件写入y坐标
  9. writer.Write(t.localPosition.y);
  10. //向文件写入z坐标
  11. writer.Write(t.localPosition.z);
  12. }

对象持久化(游戏保存与加载) - 图11
创建七个cube后进行保存的文件内容示意, 一个存储了22个四字节的数据, 也就是22个四字节的整数

为什么不使用BinaryFormatter代替BinaryWriter?

虽然使用BinaryFormatter可以方便的序列化和反序列化游戏对象, 但是使用它并不能同时将游戏对象的层级关系一并序列化. 而且我们自己进行每一个数据的保存能有助于我们理解游戏保存的原理, 以及拥有更高的控制性. 除此之外, 使用BinaryWriter手动写入保存数据, 占用的空间更少, 速度更快, 并且更易于扩展功能.

实际的游戏项目中, 有时会彻底更新旧版本的文件存储规则, 一些游戏可能会在更新后出现不能使用旧版本存档的问题, 理想情况下, 游戏更新应该保障其新版本程序可以兼容旧版本存档文件.

加载数据

要加载我们刚刚保存的游戏数据, 需要再次打开文件, 这次我们将用到FileMode.Open参数来代替FileMode.Create参数. 除此之外, 我们还将使用BinaryReader代替BinaryWriter. 新建方法Load, 在其中实现存档数据的加载功能, 并且依然要用到我们之前使用过的using(){}语法糖 :

  1. //实现文件加载功能
  2. void Load () {
  3. using (
  4. //以File.Open方法返回的文件流为参数, 使用BinaryReader构造方法获取到要读取的数据流
  5. var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
  6. )
  7. {}
  8. }

我们向存档文件中写入的第一条数据是cube列表的数量, 所以也会被第一个读取到. 我们必须明确的定义要读取的数据, 可以使用reader变量的ReadInt32方法读取到它. 这代表它会读取32字节整数, 也就是4字节的整数 :

  1. using (
  2. var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
  3. )
  4. {
  5. //在reader数据流中以整数类型读取32位数据, 存储到count变量
  6. int count = reader.ReadInt32();
  7. }

读取cube数量之后, 我们就知道了保存了多少个cube的数据. 使用一个for循环来读取它们的position的X,Y,Z数据, 存储到一个Vector3类型变量p中, 读取这些数据要用到reader的ReadSingle方法, 该方法将读取float类型的数据 :

  1. int count = reader.ReadInt32();
  2. //读取count个cube的position数据
  3. for (int i = 0; i < count; i++) {
  4. Vector3 p;
  5. //读取x坐标
  6. p.x = reader.ReadSingle();
  7. //读取y坐标
  8. p.y = reader.ReadSingle();
  9. //读取z坐标
  10. p.z = reader.ReadSingle();
  11. }

接着我们产生一个新的cube, 并使用p设置它的坐标 :

  1. for (int i = 0; i < count; i++) {
  2. Vector3 p;
  3. p.x = reader.ReadSingle();
  4. p.y = reader.ReadSingle();
  5. p.z = reader.ReadSingle();
  6. //新生成一个cube, 并使用t存储它的transform组件引用
  7. Transform t = Instantiate(prefab);
  8. //设置cube坐标为p
  9. t.localPosition = p;
  10. //将新生的cube的transform作为新的元素添加到objects列表中
  11. objects.Add(t);
  12. }

通过上述代码我们就可以在场景中创造所有存储的cube了. 但是场景中此时可能存在加载数据之前创建的cube, 所以我们在加载数据之前调用一下BeginNewGame方法来重置游戏场景内容 :

  1. void Load () {
  2. //加载数据之前先使用BeginNewGame反复重置场景内容
  3. BeginNewGame();
  4. using (
  5. var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
  6. )

此时我们可以设置一个新的按键, 用来在Update方法中触发Load方法, 设置其默认按键为L :

  1. //设置按键触发数据加载, 设置L为默认按键
  2. public KeyCode loadKey = KeyCode.L;
  3. void Update () {
  4. else if (Input.GetKeyDown(saveKey)) {
  5. Save();
  6. }
  7. //新增else if分支检测loadKey按键
  8. else if (Input.GetKeyDown(loadKey)) {
  9. //如果按下loadKey对应按键, 则执行Load方法
  10. Load();
  11. }
  12. }

对象持久化(游戏保存与加载) - 图12
loadKey字段

现在可以在同一次游戏运行过程中保存和加载cube数据了, 不过我们只保存了cube的位置数据, 而没有保存旋转和缩放数据, 所以这些被加载的cube都是默认旋转角度与缩放尺寸

如果在没有保存任何数据的时候按下进行加载会发生什么?

当你尝试打开一个不存在的文件时, 会导致程序出现异常. 本教程不进行文件是否存在的条件检测, 但是在其他涉及到数据记载的教程中将会对其进行一定的检查

抽象存储

我们目前的数据读取方法有一些可以改进的地方. 首先我们使用了三次Write方法或ReadSingle方法写入或读取一个3D向量数据, 如果可以使用一个方法直接读取或写入3D向量会更好一些. 另外, 我们还应该使用ReadInt和ReadFloat方法来读取数据, 而不需要特别指定读取的是多少位长度的整数, 或是哪一种精度的浮点数. 再有一点就是, Game脚本也并不需要了解数据保存文件是二进制, 纯文本, 还是64位等其他编码方法.

游戏数据的写入与读取

为了隐藏读取和写入数据的细节, 我们要创建一个自定义的读取类和写入类. 首先我们创建写入类, 新建一个C#脚本文件, 命名为GameDataWriter

GameDataWriter并不需要基础MonoBehavior类, 因为我们并不需要将该脚本附加到游戏物体上. 由于它的功能类似BinaryWriter, 所以我们为它增加一个BinaryWriter类型的字段writer :

  1. using System.IO;
  2. using UnityEngine;
  3. // 自定义的游戏数据写入类
  4. public class GameDataWriter {
  5. //用于获取写入数据流的字段
  6. BinaryWriter writer;
  7. }

应该通过new GameDataWriter()语句创建该类的实例. 不过我们还应该同时设置实例所需要的writer字段的值, 所以要自定义一个带有BinaryWriter类型参数的构造方法. 该方法的名称与类名一致, 这就像是它的返回类型说明 :

  1. public class GameDataWriter {
  2. BinaryWriter writer;
  3. //自定义的带有BinaryWriter参数的构造方法
  4. public GameDataWriter(BinaryWriter writer)
  5. {
  6. }
  7. }

虽然调用构造方法会产生一个新的实例, 不过并不需要为构造方法显示的书写return语句. 我们要在构造方法中为实例的writer字段赋值为传入的BinaryWriter参数, 由于字段名与参数名称都是writer, 所以我们要在字段writer的前方加上this关键字, 表示它是类的字段而不是方法的参数 :

  1. public GameDataWriter (BinaryWriter writer) {
  2. //this关键字将明确定义它后面书写的名称代表当前类的字段
  3. this.writer = writer;
  4. }

该脚本最基础的功能应该是向文件写入单独的浮点型或整型值. 添加公开的Write方法来实现这样的功能, 只需要再方法内调用writer字段的对应方法即可 :

  1. //写入浮点数的方法
  2. public void Write (float value) {
  3. //调用writer的Write方法
  4. writer.Write(value);
  5. }
  6. //写入整型数字的方法
  7. public void Write (int value) {
  8. //调用writer的Write方法
  9. writer.Write(value);
  10. }

除了写入上面两个类型的方法, 还应该为旋转数据增加写入四元数的方法和写入三维向量的方法. 这些方法将会把对应属性的全部分量都写入文件. 比如说四元数, 就有四个分量 :

  1. //写入四元数数据的方法, 四元数顾名思义, 需要四个数字进行定义, 分别是x,y,z,w分量
  2. public void Write (Quaternion value) {
  3. writer.Write(value.x);
  4. writer.Write(value.y);
  5. writer.Write(value.z);
  6. writer.Write(value.w);
  7. }
  8. //写入三维向量数据的方法, 三维向量, 三个分量, x,y,z
  9. public void Write (Vector3 value) {
  10. writer.Write(value.x);
  11. writer.Write(value.y);
  12. writer.Write(value.z);
  13. }

接下来, 新建GameDataReader脚本文件, 在这个类中, 需要一个BinaryReader类型的字段 :

  1. using System.IO;
  2. using UnityEngine;
  3. public class GameDataReader {
  4. //用于获取读取数据流的字段
  5. BinaryReader reader;
  6. //构造方法, 传入一个BinaryReader参数
  7. public GameDataReader (BinaryReader reader) {
  8. //this关键字将明确定义它后面书写的名称代表当前类的字段
  9. this.reader = reader;
  10. }
  11. }

接着新增一个ReadFloat方法和一个ReadInt方法, 它们将分别调用reader的ReadSingle方法和ReadInt32方法 :

  1. //读取浮点数据的方法
  2. public float ReadFloat () {
  3. //调用reader的ReadSingle方法
  4. return reader.ReadSingle();
  5. }
  6. //读取整型数据的方法
  7. public int ReadInt () {
  8. //调用reader的ReadInt32方法
  9. return reader.ReadInt32();
  10. }

接着创建ReadQuaternion方法和ReadVector3方法. 按照与写入方法一样的顺序去读取每个分量数据 :

  1. //读取四元数数据的方法
  2. public Quaternion ReadQuaternion () {
  3. Quaternion value;
  4. //读取每个分量数据的顺序与写入方法的顺序一样
  5. value.x = reader.ReadSingle();
  6. value.y = reader.ReadSingle();
  7. value.z = reader.ReadSingle();
  8. value.w = reader.ReadSingle();
  9. return value;
  10. }
  11. //读取Vector3向量的方法
  12. public Vector3 ReadVector3 () {
  13. Vector3 value;
  14. //读取每个分量数据的顺序与写入方法的顺序一样
  15. value.x = reader.ReadSingle();
  16. value.y = reader.ReadSingle();
  17. value.z = reader.ReadSingle();
  18. return value;
  19. }

可持久对象

通过上面的写入类, 现在向文件写入cube的transform数据就很简单了. 不过依然还可以进一步做点什么, 比如我们让Game脚本可以使用writer.Writer(objects[i])这种写法直接完成cube数据的写入怎么样? 这样会让我们的代码更加方便.

这样做需要GameDataWriter类能够详细的知道要写入的游戏对象的内容, 不过为了让GameDataWriter类不会过于复杂, 我们希望它只处理基本类型和简单结构类型的保存逻辑, 而不处理更为复杂的游戏对象的保存

我们还是有解决办法的. Game脚本不需要包含保存游戏对象的代码, 我们让游戏对象自身来处理保存过程, Game脚本中将只是用objects[i].Save(writer)语句来调用对象的保存方法

我们生成的cube内容较为简单, 没有添加其他组件. 所以只需要将cube的transform组件数据进行保存即可. 让我们创建一个新的脚本PersistableObject, 它将处理对象数据的保存和加载过程. 它将继承MonoBehavior类并带有两个公开的方法Save和Load, 它们通过各自GameDataWriter和GameDataReader的参数. 来实现对transform的位置, 旋转以及缩放数据的保存和加载 :

  1. using UnityEngine;
  2. //cube用来实现对自身数据保存和加载的脚本
  3. public class PersistableObject : MonoBehaviour {
  4. //数据保存方法, 传入一个GameDataWriter类型的参数
  5. public void Save (GameDataWriter writer) {
  6. //依次写入自身transform的位置, 旋转和缩放数据
  7. writer.Write(transform.localPosition);
  8. writer.Write(transform.localRotation);
  9. writer.Write(transform.localScale);
  10. }
  11. //数据加载方法, 传入一个GameDataReader类型的参数
  12. public void Load (GameDataReader reader) {
  13. //依次加载自身transform的位置, 旋转和缩放数据
  14. transform.localPosition = reader.ReadVector3();
  15. transform.localRotation = reader.ReadQuaternion();
  16. transform.localScale = reader.ReadVector3();
  17. }
  18. }

由于对同一个可保存的游戏对象附加多个PersistableObject脚本没有任何意义, 所以我们可以通过添加DisallowMultipleComponent特性关键字来强制一个游戏对象只能添加一个该脚本 :

  1. //该特性将保障一个游戏对象只能添加一个该脚本
  2. [DisallowMultipleComponent]
  3. public class PersistableObject : MonoBehaviour {

之后保存代码, 将该脚本添加到cube的预制体中 :
对象持久化(游戏保存与加载) - 图13
预制体添加PersistableObject脚本

PersistentStorage类

现在我们生成的cube就包含可对自身数据进行保存的脚本了. 我们还需要创建一个PersistentStorge类, 用来执行保存与加载过程. 它包含与Game脚本相同的保存与加载逻辑, 但是不同的是它的保存和加载方法只需要一个PersistableObject类型的参数. 它需要基础MonoBehavior类, 因为我们要将它添加到场景中的Game物体上, 另外它还可以初始化保存文件的路径字符串 :

  1. using System.IO;
  2. using UnityEngine;
  3. //管理保存和加载功能的类
  4. public class PersistentStorage : MonoBehaviour {
  5. //用来存放保存文件路径字符串
  6. string savePath;
  7. void Awake () {
  8. //初始化保存文件路径字符串
  9. savePath = Path.Combine(Application.persistentDataPath, "saveFile");
  10. }
  11. //参数代表要被保存的cube的PersistableObject脚本实例
  12. public void Save (PersistableObject o) {
  13. using (
  14. //获取保存文件写入数据流
  15. var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
  16. )
  17. {
  18. //控制台输出保存路径, 便于查看存档文件路径
  19. Debug.Log("保存路径在 :" + savePath);
  20. //调用方法参数o的保存方法, 将当前文件写入数据流作为参数传递
  21. o.Save(new GameDataWriter(writer));
  22. }
  23. }
  24. //参数代表要被加载的cube的PersistableObjet脚本实例
  25. public void Load (PersistableObject o) {
  26. using (
  27. //获取文件加载数据流
  28. var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
  29. )
  30. {
  31. //调用方法参数o的加载方法, 将当前文件加载数据流作为参数传递
  32. o.Load(new GameDataReader(reader));
  33. }
  34. }
  35. }

保存脚本, 回到编辑器, 新建一个名为Storage的游戏物体, 并将该脚本添加给它. 理论上, 我们可以有多个带有该脚本的游戏物体, 用于保存或加载不同的场景内容. 但是在本教程中, 我们只使用单独的一个Storage物体来保存cube数据 :
对象持久化(游戏保存与加载) - 图14

修改Game脚本

要使用新的游戏保存方式, 需要对Game进行重写.

首先修改prefab字段和objects字段的类型为PersistableObject. 调整CreateObject方法中对应的类型变化. 然后删掉之前所有与数据保存和加载有关的代码 :

  1. using System.Collections.Generic;
  2. //不再处理文件读写了, 所以不再需要该命名空间
  3. //using System.IO;
  4. using UnityEngine;
  5. public class Game : MonoBehaviour {
  6. //public Transform prefab;
  7. //修改类型
  8. public PersistableObject prefab;
  9. //List<Transform> objects;
  10. //修改类型
  11. List<PersistableObject> objects;
  12. //该类不再需要这个字段
  13. //string savePath;
  14. void Awake () {
  15. //objects = new List<Transform>();
  16. //修改类型
  17. objects = new List<PersistableObject>();
  18. //该类不再需要这个字段
  19. //savePath = Path.Combine(Application.persistentDataPath, "saveFile");
  20. }
  21. void Update () {
  22. else if (Input.GetKeyDown(saveKey)) {
  23. //该类不再使用给自己的保存方法
  24. //Save();
  25. }
  26. else if (Input.GetKeyDown(loadKey)) {
  27. //该类不再使用给自己的加载方法
  28. //Load();
  29. }
  30. }
  31. void CreateObject () {
  32. //新增变量PersistableObject o
  33. PersistableObject o = Instantiate(prefab);
  34. //Transform t = Instantiate(prefab);
  35. //t不再通过Instantiate方法获取, 而是得到o所对应的cube的transform
  36. Transform t = o.transform;
  37. //objects.Add(t);
  38. //objects的类型已经变成了PersistableObject
  39. objects.Add(o);
  40. }
  41. //该类不再使用给自己的保存方法
  42. // void Save () {
  43. //…
  44. // }
  45. //该类不再使用给自己的加载方法
  46. // void Load () {
  47. //…
  48. // }
  49. }

接下来, 需要在Game脚本中借助PersistentStorage类的实例来完成数据存储和加载的功能. 添加一个该类型的公开的字段storage, 这样我们就可以在Inspector中将Game直接拖到该字段上获得所需的实例. 接着我们让Game脚本继承PersistableObject类, 这样它就可以通过storage字段来对自身进行保存和加载 :

  1. //public class Game : MonoBehavior {
  2. //继承PersistableObject, 以便通过storage字段对自身进行保存
  3. public class Game : PersistableObject {
  4. //用来获取PersistentStorage实例的字段
  5. public PersistentStorage storage;
  6. void Update () {
  7. else if (Input.GetKeyDown(saveKey)) {
  8. //通过storage保存自身数据
  9. storage.Save(this);
  10. }
  11. else if (Input.GetKeyDown(loadKey)) {
  12. //在此处调用BeginNewGame方法将新建游戏的逻辑与加载游戏的逻辑合并在一起
  13. BeginNewGame();
  14. //通过storage保存自身数据
  15. storage.Load(this);
  16. }
  17. }
  18. }

保存代码, 回到场景, 在Game物体的Inspector中将它自身拖拽至storage字段上, 完成对storage字段的赋值. 同时由于prefab字段的类型被修改了, 原来的赋值已经丢失, 需要重新将cube的预制体向这里拖拽一次.
对象持久化(游戏保存与加载) - 图15
prefab和storage字段都在Inspector中进行赋值

重写方法

目前为止, 以上代码的保存和加载功能, 保存的其实是Game物体的Transform数据, 我们实际需要保存的是Game脚本的objects字段中保存的cube的Transform数据.

我在保存之前进行了加载, 为什么Game物体跑到了奇怪的位置?

如果你加载了一个教程前半部分代码保存的文件数据, 你会的到错位的加载数据. 保存文件中的cube数量数据会被作为Game物体的x坐标, 而第一个cube的x坐标则会被作为Game物体的y坐标, 以此类推, Game的z坐标及旋转数据和缩放数据都会被错误的设置.

另外, 如果你的旧保存文件内cube数据过少, 还会导致无法加载当前代码所需的数据, 产生报错.

(如果你遇到了, 不用担心, 继续跟着做后面的代码)

那么我们还是需要Game方法中包含一个Save方法, 不过该方法将被传递一个GameDataWrite类型的参数, 在这个新的Save方法内部, 需要时先保存cube的数量, 然后再使用for循环保存所有的cube的Transform :

  1. //用于在Game方法中增加新的Save方法, 该方法接受一个GameDataWriter类型的参数
  2. public void Save (GameDataWriter writer) {
  3. //先保存cube数量
  4. writer.Write(objects.Count);
  5. //遍历objects列表, 调用每一个cube的PersistableObject.Save方法进行数据保存
  6. for (int i = 0; i < objects.Count; i++) {
  7. objects[i].Save(writer);
  8. }
  9. }

(为了更好地向萌新解释为什么要”重写”方法, 下面的一段内容我没有按照原文翻译, 并且配了一张图, 如果想更透彻的理解, 可以阅读C#继承机制参考)

目前代码还不足以完成所有工作. 现在编译器会提示, Game类隐藏了继承的成员, 如下图所示 :
image.png

此时, 对于PersistentStorage类的Save方法来说, 接收的是PersistableObject类型的参数, 那么即便是将Game的实例传递给PersistentStorage.Svae, 在该方法内调用o.Svae时, 不会调用Game的Save方法, 而是Game继承的类PersistableObject的Save方法, 如果希望Game实例参数可以在PersistentStorage.Save中调用自己的Save方法, 就需要对其父类的Save方法进行重写(Override), 这需要在Game的Svae方法声明处添加关键字override :

  1. //public void Save (GameDataWriter writer) {
  2. //通过override关键字, 告诉编译器, 子类将重写父类的同名同参数方法, 从而使得通过该子类的实例调用该方法时, 始终是执行的子类重写的方法
  3. public override void Save (GameDataWriter writer) {

然而, 我们不能只是重写父类中的一个普通的方法, 需要在父类中使用virtual关键字将方法标记为”虚方法”, 告诉编译器哪个方法可以被子类所重写, 让我们在PersistableObject类中的Sace方法和Load方法都加上该关键字 :

  1. //public void Save (GameDataWriter writer) {
  2. //增加virtual关键字后, 表示可以在子类中重写该方法
  3. public virtual void Save (GameDataWriter writer) {
  4. writer.Write(transform.localPosition);
  5. writer.Write(transform.localRotation);
  6. writer.Write(transform.localScale);
  7. }
  8. //public void Load (GameDataReader reader) {
  9. //增加virtual关键字后, 表示可以在子类中重写该方法
  10. public virtual void Load (GameDataReader reader) {
  11. transform.localPosition = reader.ReadVector3();
  12. transform.localRotation = reader.ReadQuaternion();
  13. transform.localScale = reader.ReadVector3();
  14. }

virtual关键字的意义是什么?

在程序的底层, 没有所谓方法或是对象, 有的只是数据, 其中一部分数据作为CPU的执行指令. 方法的调用, 在这个层面其实也是指令, 方法对应的指令用来告诉CPU, 跳转到一个新的数据位置并在那里继续执行指令. 除此之外, 它可能也会设置一些参数值.

所以, 当PersistentStorage调用PersistableObject类的Save方法时, 其实是在告诉CPU跳转到一个固定的数据位置. 当我们将Game脚本的实例作为参数, 它是PersistableObject的子类型, 在底层来说, 其实还是在使用的PersistableObject中的Save方法告诉CUP应该前往的数据位置

但是virtual关键字改变了上面的规则. 添加该关键字后, 方法将不再代表一个固定的数据位置指令, 编译器会基于调用方法的类型, 增加用于查找应该让CPU跳转到哪个数据点的指令.

就好比在之前, 编译器会判断”这个方法, 应该直接跳转到某某位置”, 而增加virtual关键字后, 编译器将会思考”这是一个虚方法, 那么我需要先看看这个子类型是否指定了新的数据点位置(重写), 如果它没有指定新的数据点, 我才应该去看看父类是否说明了数据点位置, 以此类推, 直到我找到CPU应该跳转的数据点位置”

增加了该关键字的方法就被叫做虚方法. 这些方法允许被子类重新定义, 即重写

注意, CPU执行的底层指令可能有很大的变化, 尤其是使用Unity的”IL2CPP”来创建本地可执行文件时. “IL2CPP”尽可能的消除了虚方法的使用

现在PersitentStorage.Save方法中的o.Save语句将会在接受Game脚本实例作为参数时, 调用Game的Save方法. 同样的, 我们还需要让Game也重写Load方法 :

  1. //在Game中重写父类的Load方法
  2. public override void Load (GameDataReader reader) {
  3. //先加载cube的数量
  4. int count = reader.ReadInt();
  5. //按照cube的数量, 循环加载每一个cube的数据
  6. for (int i = 0; i < count; i++) {
  7. //生成加载的cube, 并获取其PersistableObject实例的引用, 存储到变量o中
  8. PersistableObject o = Instantiate(prefab);
  9. //调用PersistableObject.Laod, 从文件读取数据流中获取保存数据并用来设置cube的Transform组件属性
  10. o.Load(reader);
  11. //将新生成的cube添加到objects列表中
  12. objects.Add(o);
  13. }
  14. }

对象持久化(游戏保存与加载) - 图17
包含两个cube的位置, 旋转和缩放状态的保存文件数据示意图

下一个教程是 保存多种对象

教程源码
PDF