本文重点: 1、追踪随机性 2、保存关卡数据 3、在生成区做循环 4、创建旋转的关卡对象
这是关于对象管理的系列教程中的第六篇。除了生成形状和关卡索引之外,它还包括保存更多游戏状态。
本教程使用Unity 2017.4.4f1编写。
(可重复生成的随机形状)
1 保存随机性
当生成形状时使用随机性的重点是会得到不可预知的结果,但这不一定是我们想要的。假设你先保存了游戏,又再生成了一些形状。然后,再次加载游戏并重新生成刚才一样多的形状。那么你会得到完全相同的形状呢,还是不同的呢?就目前而言,你会得到不同的。但如果想让两次生成的形状完全一致,我们也是可以支持的。
由Unity的随机方法生成的数字并不是真正随机的,是伪随机。它是由数学公式生成的一串数字。在游戏开始时,这个序列会根据当前时间用一个任意的种子值初始化。如果你使用相同的种子开始一个新的序列,你将得到完全相同的数字。
1.1 记录随机状态
只存储初始种子值是不够的,因为这将把我们带回到序列的开始,而不是游戏被保存时序列中的点。但是Random必须跟踪它在序列中的位置。如果我们能到达这个状态,那么我们可以稍后恢复它,以继续旧的序列。
随机状态定义为一个状态结构,嵌套在随机类中。所以我们可以声明Random.State这种类型的字段或参数。为了保存它,我们必须向GameDataWriter添加一个可以写入这样一个值的方法。现在添加这个方法,但将它的实现留到之后。
通过这种方法,我们可以保存游戏的随机状态。让我们在Game.Save一开始的时就做这个操作。然后,在写下形状计数后立即保存。同样,增加保存版本号以表示新的格式。
1.2 读取随机状态
若要读取随机状态,请向GameDataReader添加ReadRandomState方法。由于我们尚未编写任何内容,因此暂时不阅读任何内容。取而代之,我们返回当前的随机状态,因此,实际上没有任何变化。当前状态可以通过静态Random.state属性找到。
随机状态的设置是通过相同的属性完成的,我们会在Game.Load中做,但仅用于保存文件版本为3或更高的时候。
1.3 JSON序列化
Random.State包含四个浮点数。但是,它们不能公开访问,因此我们不可能简单地写入它们。必须使用一些间接方法。
幸运的是,Random.State是可序列化的类型,因此可以使用Unity的JsonUtility类的ToJson方法将其转换为相同数据的字符串表示形式。我们会得到一个JSON字符串。要查看它的内容的话,请将其记录到控制台。
Json是什么意思? 正确的拼写是JSON,所有字母均为大写。它代表JavaScript对象表示法。它定义了一种简单的人类可读数据格式。
保存游戏后,控制台现在将在大括号之间记录一个字符串,该字符串包含四个从s0到s3的数字。类似于{“ s0”:-1409360059,“ s1”:1814992068,“ s2”:-772955632,“ s3”:1503742856}。
我们将此字符串写入文件。如果使用文本编辑器打开保存的文件的话,则可以在文件开头附近看到此字符串。
同样,在ReadRandomState中,通过调用ReadString读取此字符串,然后使用JsonUtility.FromJson将其转换回适当的随机状态。
除了数据之外,FromJson还需要知道应该从JSON数据创建的何种类型。我们可以使用该方法的通用版本,指定应创建一个Random.State值。
1.4 解耦关卡
我们的游戏现在有保存和恢复随机状态的能力了。你可以通过开始一个游戏,保存,之后再创建一些形状,然后加载,它再次创建完全相同的形状。但你可以更进一步。甚至可以在加载后开始一个新游戏,并且在那之后仍然创建相同的形状。所以我们是可以通过在一个新游戏开始之前,先加载一个状态来影响它的随机性,但这是不太好的实现方式。理想情况下,不同游戏的随机性应该是独立的,就好像我们重新启动了整个游戏一样。但我们可以通过每次开始一个新游戏时指定一个新的随机种子来实现这一点。
要选择一个新的种子值,我们必须使用随机性。可以用Random.value,但必须确保这些值来自它们自己的随机序列。为此,在游戏中添加一个主随机状态字段。在游戏开始时,将其设置为由Unity初始化的随机状态。
当玩家开始一个新游戏时,第一步就是恢复主随机状态。然后获取一个随机值并使用它作为种子,在InitState方法里,通过random初始化一个新的伪随机序列。
为了使种子更加不可预测,我们将它们与当前播放时间混合在一起,可以通过Time.unscaledTime访问。按位异或运算符^会是很好的方式。
异或的作用是什么? 对于每个位,如果两个输入1个是1,1个是0的话,则结果为1,不同则结果为0。换句话说,就是看输入是否不同。因为是位操作,结果在数学上并不明显,就像加法一样,只是不带进位。
为了跟踪主要随机序列的进展,请在获取下一个值后存储状态,然后再为新游戏初始化状态。
现在正在加载游戏,并且你在每个游戏中所做的事情不再影响同一会话中其他游戏的随机性。但是要确保此方法正确运行,我们还必须为每个会话的第一个游戏调用BeginNewGame。
1.5 两种方式都支持
当然,你也有可能不希望使用可重现的随机性,而是希望在加载后获得新结果。因此,通过向Game添加一个reseedOnLoad切换开关,来支持这两种方法。
(控制是否需要重新生成种子)
我们需要更改的只是加载游戏时是否需要重新设置随机状态。所以可以继续保存和加载它,也因此保存文件可以始终支持这两个选项。
2 持久化关卡数据
我们可以保存游戏中产生的形状,可以保存正在玩的关卡,还可以保存随机状态。当然我们也可以使用相同的方法来保存可比较的数据,例如产生和破坏了多少个形状,或者在播放时可以创建的其他东西。但是,如果我们想保存关卡中某些内容的状态怎么办?假如在关卡场景中放了些物体,但是在游玩的过程中它们会发生变化吗?为了支持这一点,我们也必须保存关卡的状态。
为了保存关卡,游戏必须在保存时包含它。这意味着它必须以某种方式获得对当前关卡的引用。我们可以在Game中添加一个属性,并为已加载的关卡分配自己的属性,但是接下来,我们将有关关卡的两个相关联的事物直接放在Game内部:关卡本身及其生成区域。这可能是一种有效的方法,但让我们转换一下。不必依赖Game单例,而是可以全局访问当前关卡。
将静态Current属性添加到GameLevel。每个人都可以获取当前关卡,但是只有关卡本身才可以设置它,在OnEnabled里执行此操作。
现在,无需设置游戏的生成点,关卡就可以公开其生成点供游戏使用。实际上,我们可以更进一步,让GameLevel直接提供SpawnPoint属性,将请求转发到其生成区域。因此,该关卡充当其生成区域的门面。
这意味着游戏不再需要了解生成区域。它只是需要当前关卡。
(Game只知道当前的关卡)
此时,GameLevel不再需要引用Game。由于静态实例未在其他任何地方使用,因此将其删除。
不使用Game.Instance了,我们不能保留它吗? 可以,但是在项目中留下被称为死代码的未使用代码会使维护更加困难。现在是比较简单的代码,如果我们在将来需要它,我们只需再次添加它即可。
2.2 存储游戏关卡
为了可以保存关卡,请将其设置为PersistableObject。关卡对象本身的transform数据没有用,因此请覆盖Save和Load方法,以使它们暂时不执行任何操作。
在Game.Save中,有意义的是在玩游戏时创建的所有内容之前写入关卡数据。让我们将其放在关卡构建索引之后。
2.3 加载关卡数据
加载时,我们现在必须在读取关卡构建索引之后读取关卡数据。但是,只有在加载了关卡场景之后才能这样做,否则我们会将其应用于将要卸载的关卡场景。因此,需要推迟读取其余的保存文件,直到LoadLevel协程完成为止。为了实现这一点,让我们将整个加载过程变成协程。
确认支持保存版本后,启动新的LoadGame协程,然后结束Game.Load。在此之后使用的代码将成为新的LoadGame协程方法,该方法需要 reader 作为参数。
在LoadGame中,在LoadLevel上产生收益,而不是调用StartCoroutine。之后我们可以调用gamelev.current。加载,当然,是需要我们在版本3或更高的文件的情况下。
幸的是,我们在尝试加载游戏时会出现错误。
2.4 缓冲数据
我们得到的错误告诉我们我们正在尝试从一个封闭的BinaryReader实例中读取。由于PersistentStorage.Load中的using块而被关闭。它保证了该方法调用完成后,我们对文件的保留将被释放。我们现在试图稍后通过协程读取关卡数据,因此它失败了。
有两种方法可以解决此问题。首先是取消using块,稍后通过显式关闭阅读器来手动释放对保存文件的保留。这要求我们更小心追踪是否要持有Reader并确保将其关闭,即使我们在途中遇到错误也是如此。第二种方法是一次性读取整个文件,对其进行缓冲,然后再从缓冲区中读取。这意味着我们不必担心释放文件,而只需要将其全部内容存储在内存中一段时间??。由于我们的保存文件很小,因此我们将使用缓冲区的方法。
可以通过调用file来读取整个文件。ReadAllBytes,它给我们一个字节数组。这将是我们在PersistentStorage.Load中的新方法。
我们仍然必须使用BinaryReader,它需要一个流,而不是一个数组。我们可以创建一个包装数组的MemoryStream实例,并将其提供给读取器。然后,像以前一样加载GameDataReader。
3 关卡状态
我们已经可以保存关卡数据,但是目前我们还没有什么可存储的。因此,先搞一些要保存的东西。
3.1 序列化符合生成区
到目前为止,我们拥有的最复杂的关卡结构是复合生成区域。它具有一组生成区域,每次需要新的生成点时都会使用一个元素。在实际操作中,你无法预测下一个使用的区域。形状的放置也是任意的,不需要统一,但从长远来看,它将平均分布在所有区域中。
(随机生成区)
我们可以通过依次遍历生成区域来更改此设置。两种方法都是可行的,因此我们将同时支持这两种方法。向CompositeSpawnZone添加一个切换选项。
(顺序复合生成区)
顺序生成需要我们跟踪下一步必须使用哪个区域索引。因此,如果我们处于顺序模式,则添加一个nextSequentialIndex字段并将其用于SpawnPoint中的索引。之后增加字段。
为了使其循环,当我们经过数组的末尾时,跳回到第一个索引。
顺序生成区的行为与随机生成区明显不同。尽管它们在每个区域中的位置仍然是随机的,但其生成模式清晰,形状在区域之间均匀分布。
(顺序生成)
3.2 记住下一个索引
保存游戏时,现在必须保存顺序复合生成区域的状态,否则序列将在加载后重置。因此,它必须成为可持久的对象。它已经继承了SpawnZone,因此我们必须使SpawnZone继承自PersistableObject。这使得所有生成区域类型都可以保留其状态。
只需编写和读取nextSequentialIndex,即可覆盖CompositeSpawnZone中的Save和Load方法。无论区域是连续的还是无序的,我们都会这样做。我们还可以调用基本方法,以保存区域的transform数据,但现在我们仅关注序列。该区域不会自行移动。
3.3 追踪持久对象
生成区域现在可以持久保存,但尚未保存。GameLevel必须调用其Save和Load方法。我们可以简单地使用spawnZone字段,但是只允许保存一个生成区域。如果我们想将多个顺序的生成区域放置在一个关卡(复合区域层次结构的所有部分)中,该怎么办?
我们可以使复合区域负责保存和加载它包含的所有区域,但是如果我们在应该保存的关卡上添加其他内容,该怎么办?为了使其尽可能灵活,让我们添加一种方法来配置保存关卡时应该保留的对象。最简单的方法是向GameLevel添加一系列的持久对象,我们可以在设计关卡场景时进行填充。
现在GameLevel可以保存很多这样的物体,然后保存每个物体,就像Game为它的形状列表所做的那样。
加载过程也是如此,但是由于关卡对象是场景的一部分,因此无需实例化任何内容。
请注意,从现在开始,你必须确保放入该数组的内容保持在同一索引下,否则将破坏与较早保存文件的向后兼容性。但是,你将来可以添加更多内容。加载旧文件时,这些新对象将被跳过,保留它们在场景中的保存方式。
另一个重要的点是,我们所有场景中的GameLevel实例都没有自动获得新的数组。你必须打开并保存所有关卡场景,否则在加载关卡时可能会出现空引用异常。另外,我们可以检查在播放中启用关卡对象时是否存在数组。如果没有,请创建一个。如果有多个关卡,这是一种更方便的方法,如果有第三方为你的游戏创建了你也希望支持的关卡,则这是唯一的选择。
现在,我们可以通过将顺序组合生成区域显式添加到关卡的持久对象中来最终保存它。
(Level3)
3.4 为新游戏重新加载
现在,在加载关卡时,序列索引会恢复,但是当玩家在同一关卡中开始新游戏时,它目前不会重置。解决方案是在这种情况下也加载关卡,从而重置整个关卡状态。
3.5 旋转对象
让我们添加另一种也必须存储状态的关卡对象。一个简单的旋转对象。这是一个具有可配置角速度的持久对象。使用3D向量,因此速度可以沿任何方向。要使其旋转,请给它提供一个Update方法,该方法调用其转换的Rotate方法,并使用由时间增量缩放的速度作为参数。
为了演示旋转的对象,我创建了第四个场景。在其中,有一个根对象绕Y轴以90的速度旋转。它的唯一子对象是另一个绕X轴以15的速度旋转的对象。更深一层的位置是一个顺序复合生成区域,其中有两个球形生成区域子级。两个球体的半径均为1,并且在沿Z轴的两个方向上距原点十个单位。
(旋转生成区的层级)
要持久化关卡状态,必须将旋转对象和复合生成区域都放入持久对象数组中。它们的顺序无关紧要,但以后不应更改。
(关卡4的持久化对象)
这种配置会在较大球体的相对两侧创建两个小生成区,围绕它们旋转并上下移动。
(围绕生成区旋转)
通过自动创建速度而不是手动生成形状,很容易看到它的实际效果。然后,你还可以测试保存和加载,以验证关卡状态确实存在并已还原。但是,有时我们会得到不同的生成结果。我们将在下一部分中处理。
4 创建和释放
自动创建和销毁过程也是游戏状态的一部分。我们目前尚未保存,因此创建和销毁进度不受保存和加载的影响。这意味着当创建速度大于零时,加载游戏后,你可能不会获得完全相同的形状放置。形状破坏的时间也一样。我们应该确保时间安排完全相同。
4.1 保存和加载
保存进度仅需在Game.Save中写入两个值即可。在写入随机状态之后进行。
加载时,请在适当的时候读回它们。
4.2 确切时间
我们仍然没有完全相同的时机。那是因为我们游戏的帧频不是很稳定。每个帧的时间增量是可变的。如果帧花费的时间比以前更长,那么足以早于上一次生成一个形状就足够了。否则可能会在以后显示一帧。结合基于相同时间增量的移动生成区,形状可能会终止于其他位置。
通过使用一个固定的时间增量来更新创造和释放的进程,从而使时间精确。这是通过将相关代码从Update方法移动到新的FixedUpdate方法来实现的。
现在,形状的自动创建和销毁不再受可变帧速率的影响。但是旋转器仍然是。为了使其完美,我们也应该对RotatingObject中的旋转使用FixedUpdate。
FixedUpdate什么时候调用? 在Update之后,它会在每个帧中被调用。调用多少次取决于帧时间和固定时间步长,你可以通过“Edit/ Project Settings / Time”进行配置。
默认的固定时间步长为0.02,即每秒50次。因此,如果你的游戏以每秒恰好10帧的速度运行,则FixedUpdate将每帧调用五次。而且,如果你的游戏每秒运行50帧以上,则有时在一帧内根本不会调用FixedUpdate。如果需要更多或更少的时间粒度,则可以使用不同的时间步长。
在使用物理引擎或需要可靠的可重复计时时,可以使用FixedUpdate,在本教程中就是这种情况。
4.3 速度设置
除了进度外,我们还可以考虑游戏状态中的速度设置部分。我们要做的就是在保存时也写入速度属性。
并在加载时读取它们。
在开始新游戏时重置速度也很有必要。
4.4 更新文本标签
现在,速度设置已保存,并在我们加载游戏时恢复。但是UI并没有意识到这一点,因此如果我们碰巧加载了不同的速度的时候,则不会变化。加载后,我们必须手动刷新滑块。为了使之成为可能,游戏需要引用滑块,因此为它们添加两个配置字段。
(对滑动条的引用)
不能把UI绑定到属性上吗? 目前没有内置的方法可以做到这一点。我们可以提出一个自定义解决方案,但这超出了本教程的范围。对于我们的简单情况,滑块引用就足够了。
重置速度时,我们现在可以通过分配滑块的value属性来更新它们。
通过语法糖赋值,可以使此代码更加简洁。
在Load方法中执行相同的操作。
现在,在加载或开始新游戏后,UI也会更新了。
下一个教程是 可配置形状。