原文:
https://mp.weixin.qq.com/s/NNLHFLEBJE_UIksBtDcglA
前言
很多童鞋没有系统的Unity3D游戏开发基础,也不知道从何开始学。为此我们精选了一套国外优秀的Unity3D游戏开发教程,翻译整理后放送给大家,教您从零开始一步一步掌握Unity3D游戏开发。 本文不是广告,不是推广,是免费的纯干货!本文全名:喵的Unity游戏开发之路 - 对象管理 - 对象持久化 - 创建,保存和加载
响应按键而生成随机立方体。
使用通用类型和虚拟方法。
将数据写入文件并回读。
保存游戏状态,以便以后加载。
封装持久数据的详细信息。
这是有关管理对象的系列教程中的第一篇。它涵盖了创建,跟踪,保存和加载简单的预制实例。它基于“基础知识”部分中的教程奠定的基础。
本教程使用Unity 2017.3.1p4制作。
效果之一
完了,本文没有视频或动图效果,非程序同学不会不看了吧...
按需创建对象
您可以在Unity编辑器中创建场景,并用对象实例填充场景。这使您可以为游戏设计固定级别。对象可以具有附加的行为,可以在播放模式下更改场景的状态。通常,在播放过程中会创建新的对象实例。发射子弹,产生敌人,随机出现战利品,依此类推。玩家甚至有可能在游戏内部创建自定义关卡。
在游戏中创建新的东西是一回事。记住所有这些,以便玩家可以退出游戏,然后再返回游戏是另一回事。Unity不会自动为我们跟踪潜在的变化。我们必须自己做。
在本教程中,我们将创建一个非常简单的游戏。它所做的只是响应按键而生成随机立方体。一旦我们能够在游戏会话之间跟踪多维数据集,就可以在以后的教程中增加游戏的复杂性。
游戏逻辑
因为我们的游戏非常简单,所以我们将使用单个Game
组件脚本来控制它。它将生成多维数据集,为此我们将使用预制对象。因此,它应该包含一个公共字段以连接预制实例。
using UnityEngine;
public class Game : MonoBehaviour {
public Transform prefab;
}
将游戏对象添加到场景并将此组件附加到场景。然后还创建一个默认的多维数据集,将其转换为预制件,并为游戏对象提供对其的引用。
玩家输入
我们将根据玩家的输入生成多维数据集,因此我们的游戏必须能够检测到这一点。我们将使用Unity的输入系统来检测按键。应该使用哪个键来生成多维数据集?C键似乎合适,但是我们可以通过检查器在上添加一个公共KeyCode枚举字段来使其可配置Game
。通过分配定义字段时,请使用C作为默认选项。
public KeyCode createKey = KeyCode.C;
我们可以通过查询方法中的静态Input
类来检测是否按下了键Update
。该Input.GetKeyDown
方法返回一个布尔值,该布尔值告诉我们是否在当前帧中按下了特定的键。如果是这样,我们必须实例化我们的预制件。
void Update () {
if (Input.GetKeyDown(createKey)) {
Instantiate(prefab);
}
}
Input.GetKeyDown什么时候确切返回true?
仅在帧期间,键的状态从未按下变为按下,这是因为播放器按下了它。通常,按键会保持按下状态几帧,直到玩家放开按钮为止,但仅在第一帧期间Input.GetKeyDown返回true。相反,Input.GetKey不断返回true在按下键的每一帧。还有,在播放器放开键的帧中Input.GetKeyUp返回true。
随机立方体
在游戏模式下,每次按C键或配置为响应的任意键,我们的游戏都会生成一个立方体。但是看起来我们只能得到一个多维数据集,因为它们最终都位于同一位置。因此,让我们随机化我们创建的每个多维数据集的位置。
跟踪实例化的Transform
组件,以便我们可以更改其本地位置。使用static Random.insideUnitSphere
属性获取随机点,将其缩放到五个单位的半径,并将其用作最终位置。因为这不仅仅是琐碎的实例化工作,所以将其代码放在单独的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
方法可用于获取一定范围内的float随机数。让我们从小尺寸的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);
}
开始新游戏
如果要开始新游戏,我们必须退出游戏模式,然后再次进入游戏模式。但这仅在Unity编辑器中可行。玩家需要退出我们的应用,然后再次启动它才能玩新游戏。如果我们可以在保持游戏模式的同时开始新游戏,那就更好了。
我们可以通过重新加载场景来开始新游戏,但这不是必需的。我们可以销毁所有生成的多维数据集。让我们为此使用另一个可配置的密钥,默认为N。
public KeyCode createKey = KeyCode.C;
public KeyCode newGameKey = KeyCode.N;
在Update检查是否按了此键,如果是,则调用一个新BeginNewGame
方法。我们一次只能处理一个键,因此如果未按C键,则仅检查N键。
void Update () {
if (Input.GetKeyDown(createKey)) {
CreateObject();
}
else if (Input.GetKey(newGameKey)) {
BeginNewGame();
}
}
void BeginNewGame () {}
跟踪对象
我们的游戏可以生成任意数量的随机立方体,所有这些立方体都添加到场景中。但是Game
没有记忆它产生的东西。为了销毁多维数据集,我们首先需要找到它们。为了使之成为可能,我们将Game
跟踪对其实例化的对象的引用列表。
为什么不直接用GameObject.Find?
对于简单的情况(在对象之间很容易区分并且场景中没有很多对象),这是可能的。对于较大的场景,依靠GameObject.Find是个坏主意。GameObject.FindWithTag更好,但最好是自己掌握情况,如果您知道以后需要它们。
我们可以在其中添加一个数组字段Game
并用引用填充它,但是我们不提前知道将创建多少个多维数据集。幸运的是,System.Collections.Generic
名称空间包含一个我们可以使用的类List。它的工作方式类似于数组,只是大小不固定。
列表的大小如何动态变化?
在内部,List使用数组存储其内容,并以某种大小对其进行初始化。添加到列表中的项目将放入此数组中,直到已满。如果添加了更多项目,列表将把整个阵列的内容复制到一个新的更大的阵列中,并从现在开始使用该阵列。我们可以手动执行此阵列管理,但是请List为我们处理。同样,Unity支持List字段,就像它支持数组字段一样。它们可以通过检查器进行编辑,其内容由编辑器保存,并且在播放模式下可以重新编译。
using System.Collections.Generic;
using UnityEngine;
public class Game : MonoBehaviour {
…
List objects;
…
}
但是我们不需要通用列表。我们特别想要Transform
参考列表。实际上,List
坚持要求我们指定其内容的类型。List
是一种泛型类型,这意味着它的作用类似于特定列表类的模板,每个特定类均用于具体的内容类型。语法为List<T>
,其中T
在尖括号之间将模板类型附加到通用类型。在我们的情况下,正确的类型是List<Transform>
。
List<Transform> objects;
像数组一样,在使用它之前,我们必须确保拥有一个列表对象实例。我们将通过在Awake
方法中创建新实例来实现这一点。在数组的情况下,我们必须使用new Transform[]
。但是因为我们使用的是列表,所以我们不得不使用列表new List<Transform>()
。这将调用list类的特殊构造函数方法,该方法可以具有参数,这就是为什么我们必须在类型名称后附加圆括号。
void Awake () {
objects = new List<Transform>();
}
接下来,Transform
每当我们实例化一个新列表时,通过的Add
方法在列表中添加引用List
。
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结束再添加引用?
我们可以在拥有列表后立即将引用添加到列表中,因此可以在将Instantiate结果分配给局部变量后立即添加。我只是在最后指出,我们应该只将完全初始化的内容添加到列表中。
清除清单
现在我们可以在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();
}
保存和加载
为了支持在单个播放会话期间进行保存和加载,将一系列转换数据保存在内存中就足够了。在保存时复制所有多维数据集的位置,旋转和比例,并使用记忆中加载的数据重置游戏和生成多维数据集。但是,即使在游戏终止后,真正的保存系统仍能够记住游戏状态。这要求游戏状态必须保留在游戏外部的某个位置。最直接的方法是将数据存储在文件中。
那使用PlayerPrefs呢?
顾名思义,PlayerPrefs设计时要考虑游戏设置和偏好,而不是游戏状态。尽管可以将游戏状态打包为字符串,但这效率低下,难以管理且无法扩展。
保存路径
游戏文件的存储位置取决于文件系统。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");
}
…
}
打开文件进行写入
为了能够将数据写入我们的保存文件,我们首先必须打开它。通过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变量,它引用一个新的二进制writer。在一个表达式中使用了“ writer”一词三遍,这有点多了。当我们显式创建new时BinaryWriter
,同样显式声明变量的类型也是多余的。相反,我们可以使用var
关键字。这隐式声明了变量的类型以匹配立即分配给它的任何内容,在这种情况下,编译器可以弄清楚这一点。
void Save () {
varwriter = new BinaryWriter(File.Open(savePath, FileMode.Create));
}
现在,我们有了一个写程序变量,它引用了一个新的二进制写程序。它的类型很明显。
什么时候应该使用var?
该var关键字是语法糖,你不需要使用的。尽管您可以在编译器可以推断出哪种类型的含义的任何地方使用它,但最好仅在提高可读性且类型明确时才这样做。var在这些教程中,我仅在使用new关键字声明变量并立即将其分配给变量时使用。所以只能在形式上表达var t = new Type。
var在使用语言集成查询(LINQ)和匿名类型时,该关键字非常有用,但这不在本教程的讨论范围之内。
关闭档案
如果打开文件,则必须确保也将其关闭。可以通过一种Close
方法来执行此操作,但这并不安全。如果在打开和关闭文件之间出现问题,则可能会引发异常,并且在关闭文件之前可能会终止该方法的执行。我们必须谨慎处理异常,以确保始终关闭文件。有语法糖可以简化这一过程。将writer
变量的声明和赋值放在圆括号内,将using
关键字放在其前面,并在其后放置一个代码块。该变量在该块内可用,就像i
标准for
循环的迭代器变量一样。
void Save () {
using (
var writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
) {}
}
这将确保writer
在代码执行退出该块后,无论如何都将正确处置所有引用。这适用于特殊的一次性类型,即写和信息流都是。
不使用语法糖的话using是怎么工作的?
在我们的例子中,它看起来像下面的代码。
var writer = new BinaryWriter(File.Open(savePath, FileMode.Create);
try { … }
finally {
if (writer != null) {
((IDisposable)writer).Dispose();
}
}
写数据
我们可以通过调用writer的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();
}
}
进入游戏模式,创建几个立方体,然后按键保存游戏。这将在文件系统上创建一个saveFile文件。如果不确定Debug.Log
文件的位置,可以使用将该文件的路径写入Unity控制台。
您会发现该文件包含四个字节的数据。在文本编辑器中打开文件不会显示任何有用的信息,因为数据是二进制的。它可能什么也没有显示,或者可能会将数据解释为怪异的字符。有四个字节,因为这是整数的大小。
除了编写多少个多维数据集外,我们还必须存储每个多维数据集的转换数据。我们通过遍历对象并写入它们的数据来做到这一点,一次写入一个数字。现在,我们将只限于他们的职位。因此,请按此顺序写入每个多维数据集位置的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并在以后反序列化它。游戏对象层次结构必须手动重新创建。同样,我们自己编写每一个数据可以使我们完全控制和理解。除此之外,手动写入数据需要较少的空间和内存,速度更快,并且可以更轻松地支持不断发展的保存文件格式。有时,已经发布的游戏在更新或扩展后会大大改变存储的内容。这样,其中一些游戏将无法再加载玩家的旧保存文件。理想情况下,游戏与其所有保存文件版本都向后兼容。
加载数据中
要加载刚刚保存的数据,我们必须再次打开文件,这一次FileMode.Open
是第二个参数。代替 BinaryWriter
,我们必须使用 BinaryReader
。使用新Load
方法再次执行此操作,并再次using
声明。
void Load () {
using (
var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
) {}
}
我们写入文件的第一件事是列表的count属性,因此这也是要阅读的第一件事。我们用读reder的方法ReadInt32来做到这一点。我们必须明确所读内容,因为没有参数可以明确说明这一点。后缀32表示整数的大小,即四个字节,即32位。也有越来越大的整数变体,但我们不使用它们。
using (
var reader = new BinaryReader(File.Open(savePath, FileMode.Open))
) {
int count = reader.ReadInt32();
}
读取计数后,我们知道保存了多少个对象。我们必须从文件中读取很多位置。循环执行此操作,每次迭代读取三个浮点数,以获取位置向量的X,Y和Z分量。该ReadSingle
方法读取单精度float。该ReadDouble
方法将读取双精度double。
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))
) {
…
}
}
有Game
调用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();
}
}
现在,玩家可以保存他们的多维数据集,然后在相同的播放会话或另一个播放会话中加载它们。但是因为我们只存储位置数据,所以不存储立方体的旋转和比例。结果,所有加载的多维数据集最终都具有预制件的默认旋转和比例。
如果在保存任何内容之前加载,会发生什么?
然后,您将尝试打开一个不存在的文件,这将导致异常。本教程不会检查文件是否存在或是否包含有效数据,但是在以后的教程中我们会更加小心。
抽象存储
尽管我们需要了解读取和写入二进制数据的细节,但这还是很底层的。编写单个3D向量需要调用的三个Write
。保存和加载对象时,如果我们可以在更高的层次上进行工作,通过一次方法调用来读取或写入整个3D向量,则将更加方便。另外,如果我们仅使用ReadInt
和ReadFloat
,而不必担心我们不使用的所有不同变体,那将是很好的。最后,数据是以二进制,纯文本,base-64还是其他编码方法存储都没有关系。Game
不需要知道这些细节。
游戏数据写和读
为了隐藏读取和写入数据的细节,我们将创建自己的读取器和写入器类。让我们从写开始,用GameDataWriter命名它。
GameDataWriter
不会扩展MonoBehaviour
,因为我们不会将其附加到游戏对象上。它将充当的包装器BinaryWriter
,因此给它一个单一的writer字段。
using System.IO;
using UnityEngine;
public class GameDataWriter {
BinaryWriter writer;
}
可以通过创建新的自定义作家类型的对象实例new 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;
}
持久对象
现在,在中写入多维数据集的转换数据要简单得多Game
。但是,我们可以走得更远。如果Game
可以简单地调用该writer.Write(objects[i])
怎么办?那将是非常方便的,但是将需要GameDataWriter
知道编写游戏对象的细节。但是最好使编写者保持简单,将其限制为原始值和简单结构。
我们可以扭转这种推理。Game
不需要知道如何保存游戏对象,这是对象本身的责任。对象所需的全部就是编写者来保存自己。然后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 {
…
}
将此组件添加到我们的立方体预制件中。
永久储存
现在我们有了持久的对象类型,让我们也创建一个PersistentStorage
类来保存这样的对象。它包含与保存和加载逻辑相同的逻辑Game
,只是它仅保存和加载一个PersistableObject
实例,并通过参数提供给public Save
和Load
method。将其设置为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));
}
}
}
附加此组件的场景中添加一个新的游戏对象。它代表了我们游戏的持久存储。从理论上讲,我们可以有多个这样的存储对象,用于存储不同的事物或提供对不同存储类型的访问。但是在本教程中,我们仅使用此单个文件存储对象。
持久游戏
要使用新的可持久对象方法,我们必须重写Game
。将prefab
和objects
内容类型更改为PersistableObject
。进行调整CreateObject
,使其可以处理此类型更改。然后删除所有特定于读取和写入文件的代码。
using System.Collections.Generic;
//using System.IO;
using UnityEngine;
public class Game : MonoBehaviour {
publicPersistableObjectprefab;
…
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 () {
// …
// }
}
我们将Game
依靠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);
}
}
…
}
通过检查器连接存储。还要重新连接预制件,因为由于字段的类型更改而导致其参考丢失。
覆盖方法
现在,当我们保存和加载游戏时,最终将写入和读取主要游戏对象的转换数据。这没用。相反,我们必须保存并加载其对象列表。
我在保存之前加载了游戏对象,而游戏对象的位置又变了呢?
如果此时要加载较旧的保存文件,则最终会误解数据。计数整数将被误认为X位置,第一个保存的位置的X和Y最终将被用作Y和Z位置,然后旋转将被下一个值填充,依此类推。如果保存的位置少于四个,则该文件包含的数据太少,无法加载完整的转换。然后,您会收到一个错误消息,抱怨您试图读取文件末尾之外的内容。
而不是依赖于中Save
定义的方法PersistableObject
,我们必须提供Game
其自己的Save
带有GameDataWriter
参数的公共版本。在其中,使用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) {
…
}
但是,我们不能仅仅覆盖我们喜欢的任何方法。默认情况下,我们不允许这样做。我们必须通过将virtual
关键字添加到PersistableObject中的Save
和Load
方法声明中来显式启用它。
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关键字改变这种做法。编译器不使用硬编码的位置,而是根据所涉及的类型添加指令以查找跳转到的位置。而不是去“使用这种方法,所以总是跳到那里。” 变为“此类型是否包含此方法的跳转目标?如果是,请转到那里。如果否,请检查其直接父类型。重复此操作,直到找到目标为止。” 这种方法称为虚拟方法,函数或调用表。因此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);
}
}
下一个教程是各色对象。
资源库(Repository)
https://bitbucket.org/catlikecodingunitytutorials/object-management-01-persisting-objects
往期精选
Unity3D游戏开发中100+效果的实现和源码大全 - 收藏起来肯定用得着
喵的Unity游戏开发之路 - 从入门到精通的学习线路和全教程
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
原作者:Jasper Flick
原文:
https://catlikecoding.com/unity/tutorials/object-management/persisting-objects/
翻译、编辑、整理:MarsZhou
More:【微信公众号】 u3dnotes
网友评论