找回密码
 立即注册
查看: 524|回复: 0

[简易教程] Unity基础教程-对象管理 (一)——持久化对象(Creating,Saving和Loading)

[复制链接]
发表于 2020-11-25 09:25 | 显示全部楼层 |阅读模式
200+篇教程总入口,欢迎收藏:
本文重点:

1、按下按键的时候随机产生一个立方体
2、使用泛型和虚函数
3、把数据写进文件再读取出来
4、保存游戏状态之后再加载回来
5、封装数据持久化的细节
这是有关 对象管理 系列教程中的第一篇。它涵盖了创建,跟踪,保存和加载简单的预制实例。它基于“基础知识”部分中的教程奠定的基础。
本教程使用Unity 2017.3.1p4制作。
这些立方体在游戏结束后可以留存下来
1 按需创建对象
你可以在Unity编辑器中创建场景,并用对象实例填充它们。它允许你为你的游戏设计固定的关卡。这些对象可以绑定一些行为,比如在Play模式下修改场景的状态。通常,在游戏过程中都会创建新的对象实例。例如子弹被发射,敌人生成,随机战利品出现等等。玩家甚至可以在游戏中创建一些自定义关卡。
在游戏中创造新物体是一回事。但玩家退出游戏,然后再回到游戏中又是另外一回事。记住,Unity不会自动为我们记录过程当中进行的变化。我们必须自己去做。
在本教程中,我们会创建一个非常简单的游戏。它所做的一切就是在按下一个键时生成一个随机立方体。只要我们能够跟踪不同游戏会话(sessions)之间的立方体,就可以在以后的教程中增加游戏的复杂性。
1.1 游戏逻辑
因为我们的游戏非常简单,所以我们将只使用一个Game组件脚本来控制它。它会根据我们的预制体生成立方体。所以它需要包含一个公共字段来连接一个预置实例。
在场景中添加一个游戏对象,并将此组件附加到上面。然后再创建一个默认的立方体,把它转化成一个预置,并给游戏对象一个它的引用。
1.2 玩家输入
我们会根据玩家的输入来生成立方体,所以游戏必须能够检测到这一点。可以使用Unity的输入系统来检测按键。但哪个键应该被用来产生一个立方体呢?暂定就用C键吧,除此之外,我们还可以通过检查器来配置它,通过在Game中添加一个公共KeyCode枚举字段来实现。当使用赋值来定义字段的时候,使用C作为默认选项。
创建一个key 设置为C
我们可以通过在Update方法中查询静态输入类来检测键是否被按下。Input.GetKeyDown方法返回一个布尔值,该值告诉我们当前帧中是否按下了某个特定的键。如果是这样,我们必须实例化预置。
什么时候Input.GetKeyDown返回true?
它只在key的状态从未按下到按的帧里才会返回true,因为玩家按了键。通常情况下,按键会在几个帧内保持按下状态,直到玩家放开按键,但Input.GetKeyDown只在第一帧中返回true。相比之下,Input.GetKey在键被按下的每一帧都返回true。还有Input.GetKeyUp,它在玩家放开键的帧中返回true。
1.3 随机立方体
在游戏模式下,每次按C键或配置为响应的任意键,我们的游戏都会生成一个立方体。但是看起来我们只能得到一个立方体,这是因为它们最终都位于同一位置。因此,让我们随机化创建的每一个立方体的位置。
跟踪实例化的Transform组件,以便我们可以更改其本地位置。使用静态Random.insideUnitSphere属性获取随机点,将其缩放至5个单位的半径,然后将其用作最终位置。因为这不仅仅是实例化一个对象,所以将其代码放在单独的CreateObject方法中,并在按下键时调用它。
随机放置立方体
现在,立方体在一个球体内部生成,而不是在完全相同的位置生成。它们仍然可能重叠,这无关紧要。但是,它们都是对齐的,看起来不是很有意思。因此,让我们给每个立方体一个随机旋转值,可以直接使用它的静态Random.rotation属性。
随机旋转
最后,我们还可以更改立方体的大小。我们将使用均匀缩放的立方体,因此它们始终是完美的立方体,只是大小不同。静态Random.Range方法可用于获取一定范围内的随机浮点数。我们使用从小尺寸的0.1立方到常规尺寸的1立方。要将此值用于比例尺的所有三个维度,只需将Vector3.one与之相乘,然后将结果分配给local scale即可。
随机 不一致的 缩放
1.4 开始新游戏
如果要开始新游戏,我们必须退出游戏模式,然后再次进入游戏模式。但这仅在Unity编辑器中可行。玩家则需要退出应用,然后重新启动它才能玩新游戏。如果我们可以在保持游戏模式的同时开始新游戏,那就更好了。
我们可以通过重新加载场景来开始新游戏,但这不是必需的,也可以通过销毁所有生成的立方体。为此可以使用另一个可配置的Key,默认为N。
新游的的key设置为N
检查这个键在Update中是否被按下,如果是,调用一个新的BeginNewGame方法。我们应该一次只处理一个键,所以只有在C键没有按下时才检查N键。
1.5 保持对物体的追踪
我们的游戏现在可以产生任意数量的随机立方体,它们都会被添加到场景中。但是游戏并没有对它产生记忆。如果要摧毁立方体,我们首先需要找到它们。为了实现这一点,我们需要让Game 保持跟踪它实例化的对象的引用列表。
为什么不直接用GameObject.Find呢?
对于简单的情况(在对象之间很容易区分并且场景中没有很多对象),这是可以的。对于较大的场景,依赖GameObject.Find是个差的选择。GameObject.FindWithTag更好,但是如果你知道以后会需要它们,最好的方式就是自己追踪。
我们可以在Game中添加一个数组字段,并用引用填充它,但是我们不能提前知道会创建多少个立方体。幸运的是,System.Collections.Generic命名空间包含一个我们可以使用的List类。它的工作方式类似于数组,只是大小不固定。
List的大小如何动态变化?
在内部,List使用数组存储其内容,并以某种大小对其进行初始化。添加到List中的Item将放入此数组中,直到它满为止。如果还要添加更多Item,List将把整个数组的内容复制到一个新的更大的数组中,并从当下开始使用该数组。我们可以手动执行此数组管理,但List会为我们处理。同样,Unity支持List字段,就像它支持数组字段一样。它们可以通过检查器进行编辑,其内容由编辑器保存,并且在播放模式下可以重新编译。
但是我们不需要普通的List。我们需要的是一个Transform List。实际上,List是要求我们指定其内容的类型。List是一种通用类型,这意味着它的作用类似于特定列表类的模板,每个List类均应该用于具体的内容类型。语法为List  
像数组一样,在使用它之前,我们必须确保拥有一个List对象实例。通过在Awake方法中创建新实例来实现。对于数组,我们必须使用新的Transform []。但是因为我们使用的是列表,所以我们需要使用新的List  
接下来,每次通过List的Add方法实例化一个新的引用时,向我们的列表添加一个Transform引用。
我们是否必须等到CreateObject结束才添加引用?
不用,可以在拥有引用后立即将引用添加到列表中,因此在将Instantiate结果分配给局部变量之后即可添加。我只是在最后指出,将完全初始化好的内容添加到列表中。

1.6 清空列表
现在,我们可以遍历BeginNewGame中的列表并销毁所有实例化的游戏对象。除了通过Count属性找到列表的长度外,此方法与array相同。
这给我们留下了对销毁对象的引用列表。我们还必须通过调用其Clear方法清空列表来结束它们。
2 保存和加载
如果只支持在单个播放会话中进行保存和加载,那么将一系列转换数据保存在内存中就足够了。在保存时复制所有立方体的位置,旋转和缩放,并在加载时使用记住的数据重置游戏和生成立方体。这样,即使在游戏终止后,真正的保存系统仍能够记住游戏状态。这就要求游戏状态必须保留在游戏外部的某个位置。最直接的方法是将数据存储在文件中。
使用PlayerPrefs如何?
顾名思义,PlayerPrefs的设计考虑了游戏设置和偏好,而不是游戏状态。尽管可以将游戏状态打包为字符串,但这效率低下,难以管理并且无法扩展。
2.1 保存路径
游戏文件的存储位置取决于文件系统。Unity会为我们处理差异,并通过Application.persistentDataPath属性提供具体路径地址。我们可以从该属性中获取文本字符串,并将其存储在Awake的savePath字段中,因此我们只需要检索一次即可。
它为我们提供了文件夹而不是文件的路径。我们需要在路径后附加一个文件名。我们只用saveFile,而不用担心文件扩展名。是否应该使用正斜杠或反斜杠再次将文件名与路径的其余部分分开,取决于操作系统。也可以使用Path.Combine方法为我们处理细节。路径是http://System.IO命名空间的一部分。
2.2 打开文件以便写入
为了能够将数据写入我们的保存文件,我们首先必须打开它。这是通过File.Open方法完成的,可以为其提供一个path参数。它还需要知道为什么我们要打开文件。因为我们要向其中写入数据,如果尚不存在则创建文件,或替换已存在的文件。可以通过提供FileMode.Create作为第二个参数来指定它。这里使用新的Save方法执行此操作。
File.Open返回一个文件流,它本身并没有什么用。我们需要一个可以写入数据的数据流。该数据必须具有某种格式。我们将使用最紧凑的未压缩格式,即原始二进制数据。http://System.IO命名空间具有BinaryWriter类,以实现此目的。使用其构造函数方法创建此类的新实例,并提供文件流作为参数。我们不需要保留对文件流的引用,因此我们可以直接使用File.Open调用作为参数。但我们需要保留对writer的引用,因此将其分配给变量。
现在,我们有一个名为writer的二进制writer变量,它引用一个新的二进制writer。在一个表达式中使用了“ writer”一个词三遍,这有点多了。当我们显式创建新的BinaryWriter时,同样显式声明变量的类型也是多余的。相反,我们可以使用var关键字。这隐式声明了变量的类型以匹配立即分配给它的任何内容,这种情况,编译器知道它的实际类型。
现在,我们有了一个writer变量,它引用了一个新的二进制写程序。它的类型是确定的。
什么时候应该使用var?
var关键字是语法糖,你根本不需要使用它。尽管你可以在编译器可以推断出类型的含义的任何地方使用它,但最好仅在提高可读性且类型明确时才执行此操作。在这些教程中,我仅在使用new关键字声明变量并立即将其分配给变量时使用var。因此,仅在形式为var t = new Type的表达式中。
使用语言集成查询(LINQ)和匿名类型时,var关键字非常有用,但这不在本教程的讨论范围之内。
2.3 关闭文件
如果文件被打开,则必须确保也将其关闭。可以通过Close方法执行此操作,但这并不安全。如果在打开和关闭文件之间出现问题,则可能会引发异常,并且在关闭文件之前可能会终止该方法的执行。我们必须谨慎处理异常,以确保能始终关闭文件。有语法糖可以简化这一过程。将writer变量的声明和赋值放在圆括号中,将using关键字置于其前面,并将代码块置于其后。该变量在该块内可用,就像标准for循环的迭代器变量i一样。
这将确保在代码执行退出该块之后,无论如何,都将正确处理所有writer的引用。这适用于特殊的一次性类型,即writer 和stream都可以。
不用Using的语法糖,该怎么写工作?
在我们的例子中,它看起来像下面的代码。
2.4 写数据
我们可以通过调用写程序的Write方法将数据写到文件中。可以一次写入一个简单的值,例如布尔值,整数等。比如,我们只写实例化了多少个对象。
要实际保存此数据,我们需要调用Save方法。我们将再次通过key控制此操作,在这种情况下,将S作为默认值。
保存键设置为S
进入游戏模式,创建几个立方体,然后按键保存游戏。这将在文件系统上创建一个saveFile文件。如果不确定文件的位置,则可以使用Debug.Log将文件的路径写入Unity控制台。
你会发现该文件包含四个字节的数据。在文本编辑器中打开文件不会显示任何有用的信息,因为数据是二进制的。它可能什么也没有显示,或者可能会将数据解释为怪异的字符。有四个字节,因为这是整数的大小。
除了写入了多少个立方体外,我们还必须存储每个立方体的transform数据。通过遍历对象并写入它们的数据来实现,一次写入一个数字。现在,我们将只限于他们的位置。因此,请按此顺序写入每个立方体位置的X,Y和Z分量。
在四字节块中包含七个立方体位置的文件
为什么不使用BinaryFormatter?
尽管依赖BinaryFormatter可能很方便,但无法仅使用BinaryFormatter序列化游戏对象层次结构,并在以后对其进行反序列化。游戏对象层次结构必须手动重新创建。同样,我们自己编写每一个数据可以使我们完全控制和理解。除此之外,手动写入数据需要较少的空间和内存,速度更快,并且可以更轻松地支持不断发展的保存文件格式。有时,已经发布的游戏在更新或扩展后会大大改变存储的内容。这样,其中一些游戏将无法再加载玩家的旧存档文件。理想情况下,游戏与其所有保存文件版本都应该向后兼容。
2.5 加载数据
要加载刚刚保存的数据,我们必须再次打开文件,这次使用FileMode.Open作为第二个参数。这次不是使用BinaryWriter,而是需要使用BinaryReader。再次使用using语句在新的Load方法中执行此操作。
我们写入文件的第一件事是列表的count属性,因此这也是要读取的第一个内容。使用Reader的ReadInt32方法进行此操作。我们需要明确所读内容,因为没有参数可以明确说明这一点。后缀32表示整数的大小,即四个字节,即32位。也有更大的整数变体,但我们不使用它们。
使用向量设置新实例化的立方体的位置,并将其添加到列表中。
此时,我们可以重新创建保存的所有立方体,但是它们会添加到场景中已经存在的立方体中。为了正确加载以前保存的游戏,我们必须在重新创建游戏之前将其重置。可以通过在加载数据之前调用BeginNewGame来实现。
按下键时让游戏调用Load,默认为L。
加载键设置为L
现在玩家可以保存他们的立方体并在以后加载它们,可以是在同一个游戏会话中,也可以是在另一个会话中。但是因为我们只存储位置数据,没有存储立方体的旋转和缩放。因此,加载的立方体都以预置的默认旋转和缩放结束。
如果在保存任何内容之前加载,会发生什么?
尝试打开一个不存在的文件,这会导致异常。本教程不会费心检查文件是否存在或是否包含有效数据,但我们将在以后的教程中注意这些。
3 抽象存储
尽管我们需要了解读取和写入二进制数据的细节,但这还是很底层的。编写单个3D向量需要三个Write调用。保存和加载对象时,如果我们可以在更高的层次上进行工作,则只需一次方法调用就可以读取或写入整个3D向量,因此会更加方便。另外,如果我们可以只使用ReadInt和ReadFloat,而不用担心我们不使用的所有不同变体,那将是更好的体验。最后,数据是以二进制,纯文本,base-64还是其他编码方法存储都没有关系。游戏不需要知道这些细节。
3.1 游戏数据的读取器和写入器
为了隐藏读取和写入数据的细节,我们将创建自己的读取器和写入器类。让我们从写入器开始,将其命名为GameDataWriter。
GameDataWriter不需要扩展MonoBehaviour,因为我们不会将其附加到游戏对象上。它将充当BinaryWriter的包装器,因此给它一个单一的writer字段。
可以通过新的GameDataWriter()创建自定义Writer类型的新对象实例。但这只有在我们需要包装一个Writer的情况下才有意义。因此,使用BinaryWriter参数创建一个自定义构造函数方法。这是一个使用其类的类型名称作为其自身名称的方法,该方法还用作其返回类型。它替换了隐式默认构造函数方法。
尽管调用构造函数方法会产生一个新的对象实例,但此类方法不会显式返回任何内容。在调用构造函数之前先创建对象,然后该对象可以进行任何必需的初始化。在我们的例子中,这只是将writer参数分配给对象的字段。由于我为两者使用了相同的名称,因此我必须使用this关键字来明确表示我是在指对象的字段而不是参数。
最基本的功能是编写单个float或int值。为此添加公共Write方法,只需将调用转发给实际的writer。
除此之外,还添加一些方法来写入四元数(用于旋转)和Vector3。这些方法必须写入其参数的所有组件。对于四元数,这是四个组成部分。
接下来,使用与写入器相同的方法创建一个新的GameDataReader类。在这种情况下,我们包装一个BinaryReader。
给它简单地命名为ReadFloat和ReadInt的方法,这些方法将调用转发给ReadSingle和ReadInt32。
还创建ReadQuaternion和ReadVector3方法。以与写入它们相同的顺序阅读它们的组件。
3.2 持久化对象
现在,在Game中写入立方体的transform数据要简单得多。但是,我们可以更进一步。如果Game可以简单地调用writer.Write(objects )会怎样?那将非常方便!但是需要GameDataWriter知道编写游戏对象的细节。但是需要让写入器保持简单的话,就将其限制为原始值和简单结构体。
我们可以逆向思维一下。游戏不需要知道如何保存游戏对象,这是对象自己的责任。对象所需的全部就是写入器来保存自己。然后游戏可以使用对象 .Save(writer)。
我们的立方体是简单的对象,没有附加任何自定义组件。因此,唯一要保存的是transform组件。让我们创建一个PersistableObject组件脚本,该脚本知道如何保存和加载该数据。它只是扩展了MonoBehaviour,并具有一个公共Save方法和Load方法,分别带有一个GameDataWriter或GameDataReader参数。让它保存变换位置,旋转和缩放,并以相同顺序加载它们。
现在我们的想法是,每一个只能持久保存的游戏对象会附加一个PersistableObject组件。具有多个这样的组件是没有意义的。我们可以通过向类添加DisallowMultipleComponent属性来强制执行此操作。
将此组件添加到我们的立方体预制件中。
可持久化的预制体
3.3 持久化存储
现在我们有了一个持久化对象类型,我们也创建一个PersistentStorage类来保存这样的对象。它包含与Game相同的保存和加载逻辑,不同之处在于,它仅保存和加载单个PersistableObject实例,该实例通过参数提供给公共Save和Load方法。将其设为MonoBehaviour,这样我们就可以将其附加到游戏对象上,并且可以初始化其保存路径。
附加此组件的场景中添加一个新的游戏对象。它代表了我们游戏的持久存储。从理论上讲,我们可以有多个这样的存储对象,用于存储不同的事物或提供对不同存储类型的访问。但是在本教程中,我们仅使用此单个文件存储对象。
存储对象
3.4 可持久化游戏
为了利用新的可持久对象方法,我们必须重写Game。将预制和对象的内容类型更改为PersistableObject。调整CreateObject,使其可以处理此类型更改。然后删除所有特定于读取和写入文件的代码。
我们将让Game依赖PersistentStorage实例来处理存储数据的细节。添加此类型的公共存储字段,以便我们可以为Game提供对存储对象的引用。为了再次保存和加载游戏状态,我们让Game本身扩展了PersistableObject。然后,它可以使用存储加载并保存自身。
通过检查器连接存储。还要重新连接预制件,因为由于字段的类型更改而导致其参考丢失。
Game连接了预制体和存储
3.5 重写方法
现在,当我们保存和加载游戏时,最终将写入和读取主要游戏对象的transform数据。这没用。相反,我们必须保存并加载其对象列表。
我在保存之前加载了游戏对象,而游戏对象的位置又变了呢?
如果此时要加载较旧的保存文件,则最终会数据错位。计数整数将被误认为X位置,第一个保存的位置的X和Y最终将被用作Y和Z位置,然后旋转将被下一个值填充,依此类推。如果保存的位置少于四个,则该文件包含的数据太少,无法加载完整的转换。然后,你会收到一个错误消息,报错说你试图读取文件末尾之外的内容。
不必依赖PersistableObject中定义的Save方法,我们需要使用GameDataWriter参数为Game提供自己的Save Publish版本。在里面,像以前一样编写列表,现在可以使用对象的便捷Save方法。
这还不足以使其正常工作。编译器抱怨Game.Save隐藏了继承的成员PersistableObject.Save。尽管Game可以使用其自己的Save版本,但PersistentStorage只知道PersistableObject.Save。因此它将调用此方法,而不是Game中的方法。为了确保调用正确的Save方法,我们必须显式声明我们重写Game从PersistableObject继承的方法。这是通过将override关键字添加到方法声明中来完成的。
但是,我们不能随意覆盖喜欢的任何方法。默认情况下,编译器不允许这样做。必须通过将virtual关键字添加到PersistableObject中的Save和Load方法声明中来显式启用它。
virtual 关键字是什么意思?

在非常低的层级上,实际上并没有对象或方法。仅存在数据,其中一部分被用作要由CPU执行的指令。除非经过优化,否则方法调用会成为告诉CPU跳转到另一个数据点并从那里继续执行的指令。除此之外,它还可能放置一些参数值。因此,当PersistentStorage调用PersistableObject类型的Save方法时,它成为跳转到固定位置的指令。我们为其传递了一个Game实例(PersistableObject的子类型),完全没有影响。用于调用该方法的对象实例只是另一个参数。
virtual关键字改变了这种方法。编译器不使用硬编码的位置,而是根据所涉及的类型添加指令以查找跳转到的位置。而不是去“使用这个函数,所以总是跳到那里。” 变为“此类型是否包含此方法的跳转目标?如果是,请转到那里。如果否,请检查其直接父类型。重复此操作,直到找到目标为止。” 这种方法称为virtual方法,函数或调用表。因此是虚拟的。它允许子类型覆盖其父类型的功能。
请注意,最终由CPU执行的低级指令的细节可能会有很大差异,特别是在使用Unity的IL2CPP创建本机可执行文件时。在可能的情况下,IL2CPP会消除了虚函数表的使用。
现在,PersistentStorage最终将调用我们的Game.Save方法,即使该方法作为PersistableObject参数传递给了它。也可以让Game覆盖Load方法。
包含了两个transform的文件
下一篇,我们介绍 对象种类
本文翻译自 Jasper Flick的系列教程
原文地址:
https://catlikecoding.com/unity/tutorials

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-6-6 12:14 , Processed in 0.129306 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表