Catlike Coding Unity教程系列 中文翻译 Object Management篇(一)Persisting Objects

持久化对象

创建、保存和加载

原文地址:https://catlikecoding.com/unity/tutorials/object-management/persisting-objects/

按下一个键产生随机立方体。
使用泛型类型和虚方法。
将数据写入文件,然后读取。
保存游戏状态,以便稍后加载。
封装持久化数据的细节。

这是关于管理对象的系列教程的第一篇。它涵盖了创建、跟踪、保存和加载简单的预制实例。它构建在Basics部分教程所奠定的基础上。

本教程使用Unity 2017.3.1p4制作。

请添加图片描述

这些方块在游戏结束后存活了下来。

1. 按需创建对象

你可以在Unity编辑器中创建场景,并用对象实例填充它们。这让你能够为游戏设计固定的关卡。物体可以有附加的行为,这可以改变场景的状态,而在游戏模式。通常,新的对象实例是在游戏过程中创建的。玩家会发射子弹,生成敌人,出现随机战利品等等。玩家甚至可以在游戏中创建自定义关卡。

在游戏过程中创造新内容是一回事。记住这一切,这样玩家就可以退出游戏,之后再回到游戏中。Unity不会自动为我们跟踪潜在的变化。我们必须自己去做。

在本教程中,我们将创建一个非常简单的游戏。它所做的只是在按下一个键后产生随机的立方体。一旦我们能够在游戏过程之间追踪立方体,我们便能够在之后的教程中增加游戏的复杂性。

1.1 游戏逻辑

因为我们的游戏是如此简单,我们将用一个单一的Game组件脚本控制它。它将生成立方体,为此我们将使用预制件。所以它应该包含一个公共字段来连接一个预制件实例。

using UnityEngine;

public class Game : MonoBehaviour {

	public Transform prefab;
}

添加一个游戏对象到场景中,并附加这个组件。然后也创建一个默认立方体,把它变成一个预制件,并给游戏对象一个参考。

请添加图片描述

请添加图片描述

游戏设置。

1.2 玩家输入

我们将根据玩家的输入生成立方体,所以我们的游戏必须能够检测到这一点。我们将使用Unity的输入系统来检测按键。哪个键应该用来生成一个立方体? C键似乎是合适的,但我们可以通过检查器使其可配置,通过添加一个公共KeyCode枚举字段到Game。在定义字段时,通过赋值使用C作为默认选项。

	public KeyCode createKey = KeyCode.C;

请添加图片描述

创建键设置为C。

我们可以通过在Update方法中查询静态Input类来检测是否按下了键。Input.GetKeyDown方法返回一个布尔值,告诉我们当前帧中是否按下了特定的键。如果是这样,我们必须实例化我们的预制件。

	void Update () {
		if (Input.GetKeyDown(createKey)) {
			Instantiate(prefab);
		}
	}
到底什么时候Input.GetKeyDown返回true ?

它只在按键状态从未按下变为按下时才会这样做,因为玩家按下了它。通常情况下,按键会在几帧内保持按下状态,直到玩家松开按钮。GetKeyDown只在第一帧返回true。相比之下,Input.GetKey在键被按下的每一帧都会返回true。还有Input.GetKeyUp,它在玩家松开键的帧中返回true。

1.3 随机立方体

在游戏模式中,我们的游戏现在每次按下C键或任何你配置它响应的键时都会生成一个立方体。但看起来我们只得到了一个立方体,因为它们最终都在相同的位置。让我们随机化我们创建的每个立方体的位置。

跟踪实例化的Transform组件,这样我们就可以改变它的局部位置。使用静态Random.insideUnitSphere属性来获得一个随机的点,将其扩大到5个单位的半径,并使用它作为最终位置。因为这比简单的实例化工作要多,所以将代码放在单独的CreateObject方法中,并在按下键时调用它。

	void Update () {
		if (Input.GetKeyDown(createKey)) {
//			Instantiate(prefab);
			CreateObject();
		}
	}

	void CreateObject () {
		Transform t = Instantiate(prefab);
		t.localPosition = Random.insideUnitSphere * 5f;
	}

请添加图片描述

随机放置方块。

立方体现在在一个球体内生成,而不是在完全相同的位置。它们仍然可以重叠,但这没问题。然而,它们都是对齐的,这看起来并不有趣。所以让我们给每个立方体一个随机旋转,对此我们可以使用静态Random.rotation属性。

	void CreateObject () {
		Transform t = Instantiate(prefab);
		t.localPosition = Random.insideUnitSphere * 5f;
		t.localRotation = Random.rotation;
	}

请添加图片描述

随机旋转。

最后,我们还可以改变立方体的大小。我们将使用均匀缩放的立方体,所以它们总是完美的立方体,只是大小不同。静态 Random. Range方法可以在一定范围内得到一个随机浮点数。让我们从小的0.1个单位立方体到常规的1个单位立方体。要对所有三个维度都使用这个值,只需简单地乘以 Vector3.one,然后把结果赋给局部缩放。

	void CreateObject () {
		Transform t = Instantiate(prefab);
		t.localPosition = Random.insideUnitSphere * 5f;
		t.localRotation = Random.rotation;
		t.localScale = Vector3.one * Random.Range(0.1f, 1f);
	}

请添加图片描述

随机均匀缩放。

1.4 开始新游戏

如果我们想开始一个新游戏,我们必须退出游戏模式,然后再次进入。但这只能在Unity Editor中实现。玩家需要退出我们的应用并重新开始游戏才能玩到新游戏。如果我们能在保持游戏模式的同时开始一款新游戏,那就更好了。

我们可以通过重新加载场景来开始一款新游戏,但这并不是必要的。我们只要摧毁所有生成的立方体就足够了。让我们使用另一个可配置的键,N作为默认值。

	public KeyCode createKey = KeyCode.C;
	public KeyCode newGameKey = KeyCode.N;

请添加图片描述

新游戏键设置为N。

Update中检查这个键是否被按下,如果按下,调用一个新的BeginNewGame方法。我们应该一次只处理一个键,所以只有在没有按下C键时才检查N键。

	void Update () {
		if (Input.GetKeyDown(createKey)) {
			CreateObject();
		}
		else if (Input.GetKey(newGameKey)) {
			BeginNewGame();
		}
	}

	void BeginNewGame () {}
1.5 记录对象

我们的游戏可以生成任意数量的随机立方体,并将其添加到场景中。但Game储存它产生了什么。为了摧毁立方体,我们首先要找到它们。为了实现这一点,我们将让Game记录它实例化的对象的引用列表。

为什么不直接使用GameObject.find呢?

这在简单的情况下是可行的,在这种情况下很容易区分对象,场景中没有很多对象。对于更大的场景,依赖于GameObject.find是个坏主意。GameObject.FindWithTag更好,但如果您知道以后会用到它们,最好自己跟踪它们。

我们可以向Game添加一个数组字段,并使用引用填充它,但我们事先不知道将创建多少立方体。幸运的是,System.Collections.Generic命名空间包含可以使用的List类。它的工作原理类似于数组,只不过它的大小不是固定的。

列表的大小如何是动态的?

在内部,List使用数组存储其内容,并以一定的大小初始化数组。添加到列表中的项将被放入这个数组中,直到它被填满。如果添加了更多项,列表将把整个数组的内容复制到一个更大的新数组中,并从现在开始使用该数组。我们可以手动进行数组管理,但List会替我们处理。此外,Unity支持列表字段,就像它支持数组字段一样。它们可以通过检查器进行编辑,它们的内容被编辑器保存,并且在播放模式下它们可以在重新编译时存活下来。

using System.Collections.Generic;
using UnityEngine;

public class Game : MonoBehaviour {List objects;}

但我们不想要一个通用的列表。我们具体想要一个Transform引用列表。事实上,List坚持指定其内容的类型。List是一种泛型类型,这意味着它充当特定列表类的模板,每个列表类对应一个具体的内容类型。语法是List,其中模板类型T被添加到泛型类型,位于尖括号之间。在我们的例子中,正确的类型是List。

		List<Transform> objects;

与数组一样,在使用list对象实例之前,必须确保它是一个列表对象实例。我们将通过在Awake方法中创建新实例来实现这一点。对于数组,我们必须使用new Transform[]。但是因为我们使用的是列表,所以必须改用 new List< Transform >()。这将调用list类的特殊构造函数方法,该方法可以有参数,这就是为什么我们必须在类型名称后添加圆括号。

	void Awake () {
		objects = new List<Transform>();
	}

接下来,在每次实例化一个新列表时,通过list的add方法向列表添加一个Transform引用。

	void CreateObject () {
		Transform t = Instantiate(prefab);
		t.localPosition = Random.insideUnitSphere * 5f;
		t.localRotation = Random.rotation;
		t.localScale = Vector3.one * Random.Range(0.1f, 1f);
		objects.Add(t);
	}
我们必须等到CreateObject结束后才添加引用吗?

我们可以在获得引用后立即将其添加到列表中,这样就可以在将实例化结果赋值给局部变量之后直接添加该引用。我只是把它放在最后,指出我们应该只添加完全初始化的东西到列表中。

1.6 清除列表

现在我们可以循环遍历BeginNewGame中的列表并销毁所有被实例化的游戏对象。这与数组的工作方式相同,不同的是列表的长度是通过它的Count属性找到的。

	void BeginNewGame () {
		for (int i = 0; i < objects.Count; i++) {
			Destroy(objects[i].gameObject);
		}
	}

这给我们留下了一个对已销毁对象的引用列表。我们也必须通过调用它的Clear方法来清空列表,从而消除这些元素。

	void BeginNewGame () {
		for (int i = 0; i < objects.Count; i++) {
			Destroy(objects[i].gameObject);
		}
		objects.Clear();
	}

2. 保存和加载

为了支持在单个游戏会话中保存和加载,在内存中保留一个转换数据列表就足够了。复制保存的所有立方体的位置,旋转和缩放,重置游戏并生成立方体使用储存的数据加载。然而,真正的保存系统能够在游戏终止后记住游戏状态。这需要将游戏状态保存在游戏外部的某个地方。最直接的方法是将数据存储在文件中。

使用PlayerPrefs怎么样?

顾名思义,PlayerPrefs是根据游戏设置和偏好设计的,而不是游戏状态。虽然可以将游戏状态打包在字符串中,但这是低效的,难以管理,且无法扩展。

2.1 保存路径

游戏文件应该存储在哪里取决于文件系统。Unity为我们处理了这些差异,通过Application.persistentDataPath属性,使我们可以使用的文件夹路径可用 。我们可以从这个属性获取文本字符串,并将其存储在Awake中的savePath字段中,因此我们只需要检索它一次。

	string savePath;

	void Awake () {
		objects = new List<Transform>();
		savePath = Application.persistentDataPath;
	}

这将给我们一个文件夹的路径,而不是一个文件。我们必须在路径后面附加一个文件名。我们只用saveFile,不用文件扩展名。我们应该使用正斜杠还是反斜杠来将文件名与路径的其余部分再次分隔开来,这取决于操作系统。我们可以使用Path.Combine 方法为我们照顾细节。Path是System.IO命名空间的一部分。

using System.Collections.Generic;
using System.IO;
using UnityEngine;

public class Game : MonoBehaviour {void Awake () {
		objects = new List<Transform>();
		savePath = Path.Combine(Application.persistentDataPath, "saveFile");
	}}
2.2 打开文件进行写入

为了能够将数据写入保存文件,我们首先必须打开它。这是通过File.Open方法完成的,并为其提供路径参数。它还需要知道我们为什么要打开文件。我们想要向它写入数据,如果文件不存在就创建它,或者替换一个已经存在的文件。我们通过提供FileMode.Create作为第二个参数。在一个新的Save方法中这样做。

	void Save () {
		File.Open(savePath, FileMode.Create);
	}

File.Open返回一个文件流,这个文件流本身没有用处。我们需要一个可以写入数据的数据流。该数据必须具有一定的格式。我们将使用最紧凑的非压缩格式,即原始二进制数据。System.IO命名空间有BinaryWriter类来实现这一点。使用该类的构造函数方法创建该类的新实例,并提供文件流作为参数。我们不需要保留对文件流的引用,因此可以直接使用File.Open作为参数。我们需要保留对Writer的引用,所以把它赋值给一个变量。

	void Save () {
		BinaryWriter writer =
			new BinaryWriter(File.Open(savePath, FileMode.Create));
	}

现在我们有一个名为writer的二进制写入器变量,它引用一个新的二进制写入器。在一个表达中三次使用“writer”这个词,这有点多了。当我们显式地创建一个新的BinaryWriter时,显式地声明变量的类型也是多余的。相反,我们可以使用var关键字。这隐式地声明了变量的类型,以匹配立即赋给它的任何类型,在这种情况下,编译器可以弄清楚这一点。

	void Save () {
		var writer = new BinaryWriter(File.Open(savePath, FileMode.Create));
	}

现在我们有了一个写入器变量,它引用一个新的二进制写入器。它的类型很明显。

什么时候应该使用var ?

var关键字是你根本不需要使用的语法糖。虽然编译器可以在任何地方使用它来推断是哪种类型,但最好只在可读性提高和类型显式时才这样做。在这些教程中,我只在使用new关键字声明变量并立即赋值时才使用var。所以只有在形如var t = new Type的表达式中使用。
var关键字在使用语言集成查询(LINQ)和匿名类型时非常有用,但这超出了这些教程的范围。

2.3 关闭文件

如果我们打开一个文件,我们必须确保我们也关闭了它。可以通过Close方法来实现这一点,但这并不安全。如果在打开和关闭文件之间出现错误,就会引发异常,并在关闭文件之前终止方法的执行。我们必须小心地处理异常,以确保文件总是关闭的。有一些语法糖可以使这个过程变得简单。将writer变量的声明和赋值放在圆括号内,在它前面放置using关键字,在它后面放置一个代码块。该变量在该块中是可用的,就像标准for循环中的迭代器变量i一样。

	void Save () {
		using (
			var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
		) {}
	}

这将确保在代码执行退出块后,无论如何都将正确地处理任何写入器引用。这适用于特殊的一次性类型,即写入器和流

没有糖,怎么使用呢?

在我们的例子中,它看起来像下面的代码。

var writer = new BinaryWriter(File.Open(savePath, FileMode.Create);
try {}
finally {
	if (writer != null) {
		((IDisposable)writer).Dispose();
	}
}
2.4 写入数据

我们可以通过调用写入器的write方法将数据写入文件。可以一次写一个简单的值,比如布尔值、整型值等等。让我们从只写我们实例化了多少对象开始。

	void Save () {
		using (
			var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
		) {
			writer.Write(objects.Count);
		}
	}

要真正保存这些数据,我们必须调用save方法。我们将再次通过一个键来控制它,在本例中使用S作为默认值。

	public KeyCode createKey = KeyCode.C;
	public KeyCode saveKey = KeyCode.S;void Update () {
		if (Input.GetKeyDown(createKey)) {
			CreateObject();
		}
		else if (Input.GetKey(newGameKey)) {
			BeginNewGame();
		}
		else if (Input.GetKeyDown(saveKey)) {
			Save();
		}
	}

请添加图片描述

保存键设置为S。

进入游戏模式,创建几个立方体,然后按下这个键保存游戏。这将在文件系统上创建一个saveFile文件。如果你不确定它位于哪里,你可以使用Debug.Log将文件的路径写入到Unity控制台。

您将发现该文件包含4个字节的数据。在文本编辑器中打开文件不会显示任何有用的内容,因为数据是二进制的。它可能什么都不显示,或者可能将数据解释为奇怪的字符。有四个字节,因为这是一个整数的大小。

除了写入我们有多少个数据集外,我们还必须存储每个数据集的转换数据。我们通过遍历对象并写入它们的数据(每次一个数)来实现这一点。现在,我们只讨论他们的位置。按这个顺序写出每个立方体位置的X Y Z分量。

			writer.Write(objects.Count);
			for (int i = 0; i < objects.Count; i++) {
				Transform t = objects[i];
				writer.Write(t.localPosition.x);
				writer.Write(t.localPosition.y);
				writer.Write(t.localPosition.z);
			}

请添加图片描述

以四字节块表示,包含七个位置的文件。

为什么不使用BinaryFormatter?

虽然依赖BinaryFormatter很方便,但不可能只使用BinaryFormatter序列化游戏对象层次结构,然后再反序列化。游戏对象层次必须手动重新创建。此外,我们自己编写每一点数据也让我们能够完全控制和理解。此外,手动写入数据需要更少的空间和内存,速度更快,更容易支持更新的保存文件格式。有时候,已经发行的游戏在更新或扩展后会彻底改变存储内容。其中一些游戏无法再加载

2.5 载入数据

要加载刚刚保存的数据,我们必须再次打开文件,这次使用FileMode.Open作为第二个参数。我们必须使用BinaryReader而不是BinaryWriter。在一个新的Load方法中执行此操作,同样使用using语句。

	void Load () {
		using (
			var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
		) {}
	}

我们写入文件的第一件事是列表的count属性,所以这也是要读取的第一件事。我们使用reader的ReadInt32方法来实现这一点。我们必须明确我们所读的内容,因为没有参数能够明确说明这一点。后缀32表示整数的大小,它是4个字节,因此是32位。也有较大和较小的整数变体,但我们不使用它们。

		using (
			var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
		) {
			int count = reader.ReadInt32();
		}

读取计数后,我们就知道保存了多少对象。我们必须从文件中读取这么多位置。通过循环来完成此操作,每次迭代读取三个浮点数,用于位置向量的X、Y和Z分量。使用ReadSingle方法读取单精度float对象。双精度double对象可以用ReadDouble方法读取。

			int count = reader.ReadInt32();
			for (int i = 0; i < count; i++) {
				Vector3 p;
				p.x = reader.ReadSingle();
				p.y = reader.ReadSingle();
				p.z = reader.ReadSingle();
			}

使用该向量可以设置新实例化立方体的位置,并将其添加到列表中。

			for (int i = 0; i < count; i++) {
				Vector3 p;
				p.x = reader.ReadSingle();
				p.y = reader.ReadSingle();
				p.z = reader.ReadSingle();
				Transform t = Instantiate(prefab);
				t.localPosition = p;
				objects.Add(t);
			}

此时,我们可以重新创建我们保存的所有立方体,但它们会被添加到场景中已经存在的立方体中。为了正确加载之前保存的游戏,我们必须在重新创建之前重置游戏。我们可以通过在加载数据之前调用BeginNewGame来做到这一点。

	void Load () {
		BeginNewGame();
		using (
			var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
		) {}
	}

让游戏在按下一个键时调用Load,使用L作为默认值。

	public KeyCode createKey = KeyCode.C;
	public KeyCode saveKey = KeyCode.S;
	public KeyCode loadKey = KeyCode.L;void Update () {else if (Input.GetKeyDown(saveKey)) {
			Save();
		}
		else if (Input.GetKeyDown(loadKey)) {
			Load();
		}
	}

请添加图片描述

载入键设置为L。

现在玩家可以保存自己的立方体并在之后加载它们,不管是在相同的游戏过程中还是在另一个过程中。但因为我们只存储位置数据,立方体的旋转和缩放不存储。因此,加载的立方体都以预设的旋转和比例结束。

如果我们在保存任何东西之前加载会发生什么?

然后尝试打开一个不存在的文件,这将导致异常。本教程不检查文件是否存在或是否包含有效数据,但我们将在以后的教程中更加小心。

3. 抽象存储

虽然我们需要知道读取和写入二进制数据的细节,但这是相当低级的。编写单个3D向量需要三次Write调用。当保存和加载我们的对象时,如果我们能够在稍微高一点的层次上工作,即通过一个方法调用读取或写入整个3D向量,将会更加方便。另外,如果我们只使用ReadInt和ReadFloat就好了,而不用担心所有不用的变量。最后,数据是存储在二进制、纯文本、base-64还是其他编码方法都不重要。Game不需要知道这些细节。

3.1 游戏数据写入器和读取器

为了隐藏读取和写入数据的细节,我们将创建自己的读取器和写入器类。让我们从写入器开始,将其命名为GameDataWriter。

GameDataWriter并没有扩展MonoBehaviour,因为我们不会将它附加到游戏对象上。它将充当BinaryWriter的包装器,因此给它一个writer字段。

using System.IO;
using UnityEngine;

public class GameDataWriter {

	BinaryWriter writer;
}

我们的自定义写入器类型的新对象实例可以通过新的GameDataWriter()创建。但这只有在我们要包装一个写入器的时候才有意义。因此创建一个带有BinaryWriter参数的自定义构造函数方法。这是一个方法,它的类的类型名作为它自己的名称,也作为它的返回类型。它替换隐式的默认构造函数方法。

	public GameDataWriter (BinaryWriter writer) {
	}

尽管调用构造函数方法会产生一个新的对象实例,但这类方法不会显式返回任何东西。在调用构造函数之前创建对象,然后构造函数可以负责任何所需的初始化。在我们的例子中,这只是将writer参数赋值给对象的字段。因为我对两者使用了相同的名称,所以我必须使用this关键字来显式地表明我引用的是对象的字段而不是参数。

	public GameDataWriter (BinaryWriter writer) {
		this.writer = writer;
	}

最基本的功能是编写一个float或int值。为此添加公共Write方法,只需将调用方法交给实际的写入器。

	public void Write (float value) {
		writer.Write(value);
	}

	public void Write (int value) {
		writer.Write(value);
	}

除此之外,还要添加编写Quaternion(用于旋转)和Vector3的方法。这些方法必须编写其参数的所有分量。在四元数的情况下,有四个分量。

	public void Write (Quaternion value) {
		writer.Write(value.x);
		writer.Write(value.y);
		writer.Write(value.z);
		writer.Write(value.w);
	}
	
	public void Write (Vector3 value) {
		writer.Write(value.x);
		writer.Write(value.y);
		writer.Write(value.z);
	}

接下来,使用与编写器相同的方法创建一个新的GameDataReader类。在本例中,我们封装了一个BinaryReader

using System.IO;
using UnityEngine;

public class GameDataReader {

	BinaryReader reader;

	public GameDataReader (BinaryReader reader) {
		this.reader = reader;
	}
}

给它简单命名为ReadFloat和ReadInt的方法,它们将调用交给ReadSingle和ReadInt32。

	public float ReadFloat () {
		return reader.ReadSingle();
	}

	public int ReadInt () {
		return reader.ReadInt32();
	}

同时创建ReadQuaternion和ReadVector3方法。读取组件的顺序与写入组件的顺序相同。

	public Quaternion ReadQuaternion () {
		Quaternion value;
		value.x = reader.ReadSingle();
		value.y = reader.ReadSingle();
		value.z = reader.ReadSingle();
		value.w = reader.ReadSingle();
		return value;
	}

	public Vector3 ReadVector3 () {
		Vector3 value;
		value.x = reader.ReadSingle();
		value.y = reader.ReadSingle();
		value.z = reader.ReadSingle();
		return value;
	}
3.2 可持久化对象

现在在Game中编写立方体的转换数据就简单多了。但我们可以更进一步。如果Game能够简单地调用writer.Write(objects[i])会怎么样?这将非常方便,但需要GameDataWriter知道编写游戏对象的细节。但是限制于原始值和简单结构,最好保持编写器的简单性。

我们可以把这个推理颠倒过来。游戏不需要知道如何保存游戏对象,这是对象本身的责任。对象所需要的只是一个保存自己的写入器。然后Game可以使用objects[i].save(writer)。

我们的立方体是简单的对象,不附加任何自定义组件。唯一需要保存的就是变换组件。让我们创建一个PersistableObject组件脚本,它知道如何保存和加载该数据。它简单地扩展了MonoBehaviour,并拥有一个公共的Save方法和Load方法,分别带有一个GameDataWriter或GameDataReader参数。让它保存变换位置、旋转和缩放,并以相同的顺序加载它们。

using UnityEngine;

public class PersistableObject : MonoBehaviour {

	public void Save (GameDataWriter writer) {
		writer.Write(transform.localPosition);
		writer.Write(transform.localRotation);
		writer.Write(transform.localScale);
	}

	public void Load (GameDataReader reader) {
		transform.localPosition = reader.ReadVector3();
		transform.localRotation = reader.ReadQuaternion();
		transform.localScale = reader.ReadVector3();
	}
}

我们的想法是,一个的游戏对象可以持久化,当只有一个PersistableObject组件附加到它。拥有多个这样的组件毫无意义。我们可以通过在类中添加DisallowMultipleComponent属性来实现这一点。

[DisallowMultipleComponent]
public class PersistableObject : MonoBehaviour {}

将这个组件添加到我们的立方体预制件。
请添加图片描述

可持久化预制。

3.3 可持久化存储

现在我们有了一个可持久化对象类型,让我们也创建一个PersistentStorage类来保存这样的对象。它包含与Game相同的保存和加载逻辑,不同的是它只保存和加载一个单一的PersistableObject实例,通过一个参数提供给公共的Save和Load方法。将它设置为MonoBehaviour,这样我们就可以将它附加到一个游戏对象上,它就可以初始化它的保存路径。

using System.IO;
using UnityEngine;

public class PersistentStorage : MonoBehaviour {

	string savePath;

	void Awake () {
		savePath = Path.Combine(Application.persistentDataPath, "saveFile");
	}

	public void Save (PersistableObject o) {
		using (
			var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
		) {
			o.Save(new GameDataWriter(writer));
		}
	}

	public void Load (PersistableObject o) {
		using (
			var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
		) {
			o.Load(new GameDataReader(reader));
		}
	}
}

添加一个新的游戏对象到场景,并附加此组件。它代表游戏的持久存储。理论上,我们可以有多个这样的存储对象,用于存储不同的东西,或提供对不同存储类型的访问。但在本教程中,我们只使用单个文件存储对象。

请添加图片描述

存储对象。

3.4 可持久化的Game

为了使用新的可持久化对象方法,我们必须重写Game。将预制件和对象内容类型更改为可持久化对象。调整CreateObject,使其能够处理这种类型更改。然后删除所有特定于读取和写入文件的代码。

using System.Collections.Generic;
//using System.IO;
using UnityEngine;

public class Game : MonoBehaviour {

	public PersistableObject prefab;List<PersistableObject> objects;

//	string savePath;

	void Awake () {
		objects = new List<PersistableObject>();
//		savePath = Path.Combine(Application.persistentDataPath, "saveFile");
	}

	void Update () {else if (Input.GetKeyDown(saveKey)) {
//			Save();
		}
		else if (Input.GetKeyDown(loadKey)) {
//			Load();
		}
	}void CreateObject () {
		PersistableObject o = Instantiate(prefab);
		Transform t = o.transform;
		…
		objects.Add(o);
	}

//	void Save () {
//		…
//	}

//	void Load () {
//		…
//	}
}

我们将让游戏依赖于一个PersistentStorage实例来处理存储数据的细节。添加一个这种类型的公共storage字段,这样我们就可以给Game一个存储对象的引用。为了再次保存并加载游戏状态,我们让Game本身扩展PersistableObject。然后,它可以使用存储加载和保存自己。

public class Game : PersistableObject {public PersistentStorage storage;void Update () {
		if (Input.GetKeyDown(createKey)) {
			CreateObject();
		}
		else if (Input.GetKeyDown(saveKey)) {
			storage.Save(this);
		}
		else if (Input.GetKeyDown(loadKey)) {
			BeginNewGame();
			storage.Load(this);
		}
	}}

通过检查器连接存储。也重新连接预制件,因为由于字段的类型变化它的引用丢失了。

请添加图片描述

游戏连接到预制件和存储。

3.5 重写方法

当我们现在保存和加载游戏时,我们最终需要写入和读取主要游戏对象的转换数据。这是无用的。相反,我们必须保存并加载它的对象列表。

我加载前保存,游戏对象得到一个奇怪的位置?

如果此时加载的是较旧的保存文件,则会导致对数据的误解。计数整数将被误认为是X位置,第一个保存位置的X和Y最终被用于Y和Z位置,然后旋转将被下一个值填充,以此类推。如果您保存的位置少于四个,则该文件将包含太少的数据,无法加载完整的转换。然后您会得到一个错误,您试图读取文件末尾以外的内容。

不依赖于在PersistableObject中定义的保存方法,我们必须给Game一个带有GameDataWriter参数的公共版本的Save。在其中,像以前那样编写列表,现在使用对象的方便的Save方法。

	public void Save (GameDataWriter writer) {
		writer.Write(objects.Count);
		for (int i = 0; i < objects.Count; i++) {
			objects[i].Save(writer);
		}
	}

这还不足以让它发挥作用。编译器指出Game.Save隐藏继承的成员PersistableObject.Save。虽然Game可以使用自己的Save版本,但PersistentStorage只知道PersistableObject.Save。它会调用这个方法,而不是Game中的那个。为了确保正确的Save方法被调用,我们必须显式声明重写Game从PersistableObject继承的方法。这可以通过在方法声明中添加override关键字来实现。

	public override void Save (GameDataWriter writer) {}

但是,我们不能只重写任何我们喜欢的方法。默认情况下,我们不允许这样做。我们必须显式地启用它,方法是在PersistableObject中的Save和Load方法声明中添加virtual关键字。

	public virtual void Save (GameDataWriter writer) {
		writer.Write(transform.localPosition);
		writer.Write(transform.localRotation);
		writer.Write(transform.localScale);
	}

	public virtual void Load (GameDataReader reader) {
		transform.localPosition = reader.ReadVector3();
		transform.localRotation = reader.ReadQuaternion();
		transform.localScale = reader.ReadVector3();
	}
virtual关键字是什么意思?

在非常低的层次上,没有真正的对象或方法。只有数据,其中一部分作为指令由CPU执行。方法调用(除非经过优化)会变成指令,告诉CPU跳转到另一个数据点并从那里继续执行。除此之外,它还可能在适当的位置放置一些参数值。因此,当PersistentStorage调用PersistableObject类型的Save方法时,它就变成了跳转到固定位置的指令。我们给它传递了一个game的实例(PersistableObject的子类型),这一点都不影响。用于调用方法的对象实例只是另一个参数。

virtual关键字改变了这种方法。编译器不是使用硬编码的位置,而是添加指令,根据所涉及的类型查找跳转到哪里。不是“它是这个方法,所以总是跳转到那里”,而是“这个类型包含这个方法的跳转目的地吗?”如果是,就去那里。否,请检查其直系父类型。重复,直到找到目的地。”这种方法被称为虚方法、函数或调用表。因此虚拟。它允许子类型覆盖父类型的功能。

请注意,最终由CPU执行的低级指令的细节可能会有很大的变化,特别是当使用Unity的IL2CPP来创建本机可执行文件时。IL2CPP尽可能地消除了虚方法表的使用。

PersistentStorage将最终调用我们的Game.Save方法,即使它作为一个PersistableObject参数传递给它。同时让Game覆盖Load方法。

	public override void Load (GameDataReader reader) {
		int count = reader.ReadInt();
		for (int i = 0; i < count; i++) {
			PersistableObject o = Instantiate(prefab);
			o.Load(reader);
			objects.Add(o);
		}
	}

请添加图片描述

包含两个转换的文件。

下一个教程是对象多样性。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值