某愿朝闻道译/原文地址

  • 在运行模式下创建场景
  • 在场景之间移动物体
  • 在多场景下工作
  • 实现关卡选择与关卡情况保存

这是对象管理章节的第四篇教程. 将在上个教程的代码基础上制作. 本教程主要将介绍如何同时使用多个场景, 以及如何加载和卸载场景

本教程源码使用Unity2017.4.4f1创建
加载多个场景 - 图1
夕阳无限好, 只是近黄…对不起 串台了

对于之前教程源码的调整

上个教程中, 我忘记了在BeginNewGame中添加Reclaim方法, 如果你也没有添加, 记得补充上.
(如果你是按照我翻译的教程学习的, 我翻译的上个教程此处错误已经修正, 所以你可以无视这段)

容器场景

在运行期间实例化了很多形状时, 随着形状的增多, Hierarchy窗口中的内容变得越来越杂乱, 这可能会影响你寻找特定的游戏对象, 也会在一定程度上让编辑器运行变慢
加载多个场景 - 图2
运行模式下, 场景中的游戏对象显示在了Hierarchy窗口中

通过将Hierarchy窗口中的内容进行折叠收起或是关闭Hierarchy窗口, 都可以降低编辑器运行速度的潜在影响. 不过最好还是对内容进行折叠而不是关闭Hierarchy窗口. 我们有两种方式做到这一点.

第一个选择是将所有产生的形状作为某个物体的子物体, 从而可以折叠这个父物体来收起所有子物体. 但是很可惜, 这种方法在形状改变时对于游戏的性能有负面影响. 任何时候激活一个游戏对象或是改变它Transform的属性, 都需要通知它的父物体. 所以不必要的情况下, 不要使用这种方式在Hierarchy中隐藏大量游戏对象

第二个选择是将所有形状放在另一个场景中, 这样既不需要为它们设置父物体, 又可以通过折叠其所属的场景来收起它们. 场景并不关心自己游戏对象的状态, 所以这不会因此带来额外的性能开销. 所以本教程将使用这种方式

在运行过程中创建场景

我们要使用专用的场景放置形状. 因为形状的实例只在运行模式下存在, 所以这个场景也只需要在运行模式下存在, 因此要通过代码来创建这个场景, 而不是在编辑器中预先制作它.

ShapeFactory脚本负责创建, 销毁和回收形状, 因此它也应该负责创建包含它们的场景. 要使用与场景有关的代码功能, 需要引入UnityEnine.SceneManagement命名空间 :

  1. //在ShapeFactory脚本中引入该命名空间以便使用与场景有关的代码
  2. using UnityEngine.SceneManagement;

我们要创建一个容器场景来盛放所有可回收的形状实例. 工厂类生产的所有形状都要放到这个容器中, 并且不会从它里面移除. 在ShapeFactory脚本中增加一个Scene类型的字段来存放容器场景的引用 :

  1. //容器场景, 用来盛放所有产生的形状
  2. Scene poolScene;

我们只需要在形状可以回收时才创建容器场景. 因此, 在CreatePools方法的末尾调用SceneManager.CreateScene来创建一个新场景, 并把它的引用保存到poolScene字段中 :

  1. void CreatePools () {
  2. //调用CreatePools方法说明形状可以回收, 则同时创建盛放可回收形状的容器场景
  3. //CreateScene方法的参数代表了创建场景的名字, 此处使用的参数name是ShapeFactory实例游戏对象的名称属性
  4. //注意, name的值不是类的名字, 而是你的Shpae Factory资源文件的名字, 所以创建的场景也会叫Shape Factory
  5. poolScene = SceneManager.CreateScene(name);
  6. }

加载多个场景 - 图3
在形状可回收的前提下运行游戏, 初次创建任意形状时, 将会在Hierarchy窗口中同时创建容器场景Shape Factory

现在初次创建任意形状时, 将会在Hierarchy窗口中同时创建名为与Shpae Factory资源文件名称一样的容器场景, 不过目前它里面还没有放入任何形状. 当游戏停止运行, 该场景也会消失

向容器场景放入游戏对象

当一个游戏对象被实例化, 默认会添加到当前激活的场景中. 在我们的例子中, 激活场景就是我们在编辑器中已经打开的场景. 可以设置其他场景为激活状态, 但是我们不必这样做, 而是可以在形状创建之后再将它们放到Shape Factory场景中, 这需要使用SceneManager.MoveGameObjectToScene方法, 将要移动的游戏对象与目标场景作为参数, 修改ShapeFactor脚本的Get方法 :

  1. public Shape Get (int shapeId = 0, int materialId = 0) {
  2. Shape instance;
  3. if (recycle) {
  4. if (lastIndex >= 0) {
  5. }
  6. else {
  7. instance = Instantiate(prefabs[shapeId]);
  8. instance.ShapeId = shapeId;
  9. //创建新的形状实例后, 将其放入到容器场景中
  10. SceneManager.MoveGameObjectToScene(instance.gameObject, poolScene);
  11. }
  12. }
  13. }

加载多个场景 - 图4
可回收形状都被移动到了Shape Factory场景中

这样, 运行时创建的可回收形状都被整齐的移动到了容器场景中, 你可以根据需要, 点击Shape Factory场景名称左侧的三角箭头展开或收起形状列表

在重编译时恢复数据

当你在运行状态下重新编译了代码时, pools字段会变成null, 这是因为Unity在重编译时会序列化MonoBehavior的私有字段, 而不会序列化ScriptableObject的私有字段(标红这段文字, 经本人反复测试, 说的并不对, Scriptable对象的私有字段, 只要是符合Unity序列化机制要求的类型, 均可在重编译时不丢失运行期间的赋值, 经过我查阅资料后, 找到一篇文章Unity中的序列化, 较为详细的介绍了序列化机制, 所以pools重编译后丢失运行时赋值的根本原因是, 一个泛型列表数组类型的字段List[], 不能被序列化, 所以在重编译后, 运行期间为它赋的值, 丢了, 如果谁看到这里, 发现是我错了, 恳请留言告知或联系我)

因此, 运行时重编译后, 需要再一次调用CreatePools方法.

我们不可以将pools标记为[SerializeField]吗?

这会导致Unity将pools保存为资源的一部分, 会在结束游戏后依然持久的保存它的值, 并且会加入到构建后的程序中. 这不是我们希望的
(我是使用Unity2019跟着做的, 但是我觉得这应该跟版本没有关系, 泛型列表数组List[], 无法被序列化, 我试过加public, 加SerializeField, 甚至给ShapeFactor类加了System.Serializable, 都没用, 运行期间重编译, pools就变null)

不过如果我们只是简单的在重编译后就调用CreatePools方法, 将会再一次尝试创建容器场景, 这会导致一个运行错误. 所以我们应该通过Scene.isLoaded属性判断Shape Factory场景是否已经存在 :

  1. void CreatePools () {
  2. if (poolScene.isLoaded) {
  3. //如果容器场景poolScene已经存在, 则在此终止方法的执行
  4. return;
  5. }
  6. poolScene = SceneManager.CreateScene(name);
  7. }

不过上述代码依然存在问题, 因为poolScene字段的类型Scene是一个结构(Struct), 它的值不是Shape Factory场景的直接引用. 它不可序列化, 运行时重编译后会恢复其默认值, 是一个未加载的场景.(我使用了2017.4.4f1与2019.2.15f1分别测试, 发现2017符合此处文字描述, 而2019中, Scene类型已经可以序列化, 在运行时重编译后无需特殊处理, 也依然保持与重编译前一样的引用)

我们需要通过Scene.Manager.GetSceneByName方法, 重新获取与Shape Factory场景的关联 :

  1. //2017.4.4f1版本没有下面这句代码, 会导致运行中重编译poolScene丢失引用;
  2. //译者使用的2019.2.15f1版本无需下面这句代码, 重编译后依然保持正确的引用
  3. poolScene = SceneManager.GetSceneByName(name);
  4. if (poolScene.isLoaded) {
  5. return;
  6. }
  7. poolScene = SceneManager.CreateScene(name);

不过, 运行时重编译只会发生在编辑器环境中, 构建后的程序是不会发生的. 所以我们应该通过Application.isEditor属性的值判断当前代码是否运行在编辑器环境中, 如果不是, 则无需处理运行时重编译导致的问题 :

  1. //如果处于编辑器环境, Application.isEditor会返回True
  2. //只有在编辑器环境中才需要进行以下处理
  3. if (Application.isEditor) {
  4. poolScene = SceneManager.GetSceneByName(name);
  5. if (poolScene.isLoaded) {
  6. return;
  7. }
  8. }
  9. poolScene = SceneManager.CreateScene(name);

运行时重编译还会导致第二个不那么明显的问题, 那就是在重编译前禁用的形状在重编译后不会被重新利用了. 这个问题是因为我们丢失了追踪这些形状的列表. 我们可以通过重新生成追踪它们的列表来解决这个问题. 首先, 要通过Scene.GetRootGameObject方法获取到Shape Factory场景中所有的根节点物体(没有父物体就是根节点物体) :

  1. if (Application.isEditor) {
  2. poolScene = SceneManager.GetSceneByName(name);
  3. if (poolScene.isLoaded) {
  4. //在运行时重编以后, 获取容器场景中的所有根节点物体
  5. GameObject[] rootObjects = poolScene.GetRootGameObjects();
  6. return;
  7. }
  8. }

这不是会创建一个临时数组吗?

是的, 此处你也可以通过为GetRootGameObjects方法增加一个列表参数代替使用数组的方法, 从而避免使用临时数组. 不过其实这没有必要, 因为我们在处理问题只会在编辑器环境下出现, 所以你不需要为它追求非常高的运行效率.

接下来, 循环所有获取到的物体对象, 获取它们的Shape脚本组件. 因为是在Shape Factory场景中获取的对象, 所以它们必定包含该组件 :

  1. if (poolScene.isLoaded) {
  2. GameObject[] rootObjects = poolScene.GetRootGameObjects();
  3. //遍历rootObject数组
  4. for (int i = 0; i < rootObjects.Length; i++) {
  5. //依次提取所有的数组中对象包含的Shape脚本引用
  6. Shape pooledShape = rootObjects[i].GetComponent<Shape>();
  7. }
  8. return;
  9. }

如果该Shape脚本所属的形状物体是禁用状态, 则需要将其放置到与其形状代号对应的池中 :

  1. Shape pooledShape = rootObjects[i].GetComponent<Shape>();
  2. //一个游戏对象是启用状态, activeSelf属性返回true, 否则返回false
  3. if (!pooledShape.gameObject.activeSelf) {
  4. //如果形状物体是禁用状态, 则需要将其放置到与其形状代号对应的池中
  5. pools[pooledShape.ShapeId].Add(pooledShape);
  6. }

不需要用activeInHierarchy属性来判断对象是否启用吗?

不需要, 因为我们判断是根节点物体, 而非任何子物体

现在, 运行时重编译后, 我们的回收对象池也会根据需要重新创建, 保障在运行时进行代码修改测试而不会出错

关卡1

场景不止可以用于在运行模式下容纳游戏对象. 通常, 项目会划分多个场景. 最场景的一种用途就是用一个场景来对应一个游戏关卡. 但是游戏会有一些需要在多个场景复用的游戏对象, 它们并不只属于某个场景, 而是属于整个游戏. 它们可以只放置在某个场景中, 而不需要在每个场景都放置它的副本, 当你要在某个场景进行有关另一个或多个场景中的游戏对象的编辑工作时, 需要同时打开它们.

编辑多个场景

我们将把现在的游戏划分为两个场景. 当前场景将作为主要场景, 所以将其重命名为Main Scene. 接着创建第二个场景, 在Project窗口的Asset目录下的任意位置点击右键菜单 > Creat > Scene, 将其命名为Level 1. 这个新场景代表了我们游戏的第一关(请先Ctrl+S再进行这些操作, 放置丢失未保存的修改) :
加载多个场景 - 图5
主场景和第一关场景

场景创建完毕后, 双击打开Level 1场景,
保持Main Scene打开, 然后在Project窗口中, 将Level 1场景拖拽到Hierarchy窗口中, 这就可以同时打开Level 1场景, 你会看到它在Hierarchy窗口中的样子与运行时创建的Shape Factory场景类似. Main Scene场景的名字此时为加粗样式, 这是因为它是当前的活跃场景. 如果现在运行游戏, 创建形状后, Hierarchy窗口中就会存在三个场景 : Main Scene, Level 1和Shape Factory
image.png
运行游戏, 创建形状后, Hierarchy窗口中存在三个场景

理想情况是, Main Scene应该包含游戏的每一个关卡的必要内容. 在我们的例子中, 这些必要内容是 : 摄像机Main Camera, Game物体, Storage物体, Canvas物体和EventSystem物体. 每一关的光照则与具体的关卡要求有关. 综上所述, 我们需要在Main Scene中删除光源(如果你没有自己改过, 默认光源是Directional Light), 在Level 1中删除摄像机Main Camera
加载多个场景 - 图7
主场景删除了光源, 第一关场景删除了摄像机

场景光照

现在场景中的光照将有Level中的光源物体提供. 游戏运行后, 功能一切正常, 但是环境中的光照变得暗淡了一些 :
加载多个场景 - 图8
场景中的光线变得有些昏暗

每个场景都有自己的光照设置, 由于我们将Main Scene的光源删除了, 而此时Main Scene又是活跃场景, 所以光线变暗了.

在Level 1场景中保留了光源物体, 所以可以让Level 1场景作为活跃场景, 从而应用它的光照设置, 改善场景中的光照效果. 点击Hierarchy窗口中, Level 1右侧的下拉菜单, 点击”Set Active Scene”, 设置它作为活跃场景, 这也意味着Main Scene不再是活跃场景.
image.png加载多个场景 - 图10
设置Level 1为活跃场景后, 光照变得明亮了一些
(果发现没变化,甚至还不如Main Scene的光照亮, 请确认以下设置 :
1) 通过菜单 : Window > Lighting> Settings, 打开Lighting窗口, 这是2017的菜单位置, 其他版本可能略有不同
2) 在Lighiting窗口中勾选Auto Generate选项, 如下图所示 :

image.png

在构建时包含多个场景

随着Level 1作为活跃场景, 目前为止一切都符合我们的要求了, 至少在编辑器中是这样. 如果希望构建出的程序也能正常工作, 需要确保将所需的场景全部包括在内. 前往菜单 : File > Build Settings.. , 在打开的Build Settings窗口中, 通过点击Add Open Scenes按钮或是从Project窗口中拖动每个场景到Scenes In Build列表中. 请确保列表中Main Scene右侧的序号是0, Level 1的序号是1(排在最上面的场景序号就是0, 你可以在列表里拖拽场景名称进行排序).
加载多个场景 - 图12
Scenes In Build设置示意

现在开始, 两个场景都加入到了构建设置中.

另外再介绍两个操作 :
1) Hierarchy窗口中, 在Level 1名称右侧的下拉菜单中点击”Unload Scene”, 可以将场景卸载, 卸载后场景依然存在与Hierarchy窗口中, 但是被禁用, 名称变灰
2) 在Level 1名称右侧的下拉菜单中点击”Remove Scene”, 将会从Hierarchy窗口中卸载并移除该场景, 不过它没有被从项目中删除, 其场景文件依然存在与Project窗口中.
加载多个场景 - 图13加载多个场景 - 图14
左图 : Level 1被卸载
右图 : Level 1被移除

加载场景

即便我们已经将两个场景都加入到了构建设置中, 也只有第一个场景, 即序号为0的场景会在构建的程序运行时被加载. 这就相当于在编辑环境下, 我们打开Main Scene后没有拖入Level 1, 直接运行. 要确保每个场景在运行时都被加载, 需要我们手动加载Level 1场景.

在Game脚本中添加一个新的方法LoadLevel, 在该方法中将Level 1常见的名字作为参数来调用SceneManager.LoadScene方法 :

  1. //SceneManager.LoadScene方法需要引入下面的命名空间
  2. using UnityEngine.SceneManagement;
  3. public class Game : PersistableObject {
  4. //加载场景的方法
  5. void LoadLevel () {
  6. //加载Level 1场景
  7. SceneManager.LoadScene("Level 1");
  8. }

我们的游戏没有启动画面, Logo图标, 或是主菜单, 因此可以在Game脚本的Awake方法中立即加载Level 1 :

  1. void Awake () {
  2. shapes = new List<Shape>();
  3. //加载场景
  4. LoadLevel();
  5. }

上述代码运行后, Unity会卸载当前打开的场景, 然后单独打开Level 1场景, 这不是我们想要的效果. 这其实等同于我们在编辑器中先双击打开了Level 1场景, 然后再运行游戏.

我们想要的是, 在保持当前场景为打开状态的前提下, 额外加载Level 1场景. 这就需要对我们加载场景的方法[SceneManager](http://docs.unity3d.com/Documentation/ScriptReference/SceneManagement.SceneManager.html).LoadScene再传入第二个参数, LoadSceneMode.Addtive :

  1. void LoadLevel () {
  2. //SceneManager.LoadScene("Level 1");
  3. //第二个参数LoadSceneMode.Additive表示, 要打开的场景会作为额外场景加入到当前已打开的场景中
  4. SceneManager.LoadScene("Level 1", LoadSceneMode.Additive);
  5. }

保存修改, 移除之前在Hierarchy窗口中拖入的Level 1场景, 然后运行游戏. 这次, 加载Level 1后没有卸载Main Scene场景了. 不过我们发现, 好像光照又变暗了
加载多个场景 - 图15
光照又变暗了

这依然是由于动态Level 1场景后, 没有将它设置为活跃场景导致的. 这一次需要在运行时通过SceneManager.SetActiveScene方法将它设置为活跃场景, 该方法接收一个Scene类型的参数, 代表要设置为活跃状态的目标场景. 我们可以通过SceneManager.GetSceneByName得到目标场景的引用 :

  1. void LoadLevel () {
  2. SceneManager.LoadScene("Level 1", LoadSceneMode.Additive);
  3. //通过SceneManager.SetActiveScene方法将Level 1设置为活跃场景
  4. SceneManager.SetActiveScene(SceneManager.GetSceneByName("Level 1"));
  5. }

_
不幸的是, 新加的这条代码会导致一个运行时报错. SceneManager.SetActiveScene方法只能对已经加载成功的场景进操作, 报错的信息显示, 该方法操作的场景Level 1还没有被加载. 这是因为在LoadScene方法调用后, 需要一点时间执行, 在下一帧才能完成全部场景加载过程.

等待一帧

由于代码加载的场景不会立即加载完成, 我们必须等到加载代码执行后的下一帧才可以激活被加载的场景. 达到这个目的最简单的方法是将加载和激活场景的代码放在一个协程(coroutine)中, 然后在两段代码之间通过yield reuturn语句等待一帧的时间 :

  1. //协程方法的返回类型IEnumerator需要引入该命名空间
  2. using System.Collections;
  3. public class Game : PersistableObject {
  4. void Awake () {
  5. shapes = new List<Shape>();
  6. //LoadLevel();
  7. //通过协程调用LoadLevel方法
  8. StartCoroutine(LoadLevel());
  9. }
  10. //void LoadLevel() {
  11. //将LoadLevel的返回值由void改为IEnumerator, 以便在协程中调用
  12. IEnumerator LoadLevel () {
  13. SceneManager.LoadScene("Level 1", LoadSceneMode.Additive);
  14. //在加载和激活场景的代码之间加入yield return null语句, 将会使得代码暂停执行一帧, 下一帧再继续执行
  15. yield return null;
  16. SceneManager.SetActiveScene(SceneManager.GetSceneByName("Level 1"));
  17. }

烘焙光照

虽然Level 1场景现在正确的被设置为了活跃场景, 我们依然没有获得正确的光照效果. 至少, 编辑器中没有. 构建后的程序中光照效果是正常的, 因为在构建过程中所有的光照设置都被适当的处理了. 但是在编辑器环境运行时, 动态加载的场景的自动生成的光照数据不会生效, 解决这个问题, 需要关闭Lighting设置中的Auto Generate选项, 并手动生成光照数据

因此, 打开Level 1场景, 然后通过菜单 : Window > Lighting > Settings打开场景的Lighting窗口(这是2017版本的Lighting窗口打开方式, 其他版本可能略有不同). 在Lighting窗口最下方, 取消Auto Generate选项, 然后点击Generate Lighting按钮 :_
加载多个场景 - 图16
点击按钮, 手动生成光照数据

为Level 1场景点击上述按钮生成光照数据后, 会在该场景文件所在的目录, 建立一个与场景名称同名的文件夹, 文件夹内就是动态加载场景时所需的光照数据 :
加载多个场景 - 图17
为Level 1场景生成的光照数据

现在只需要手动创建Level 1场景的光照数据, Main Scene场景不需做此处理, 所以Main Scene的光照设置依然保持勾选Auto Generate选项即可.
_

异步加载

加载场景所需的时间长短取决于场景包含内容的多少. 在我们的项目中, Level 1只包含一个光源物体, 因此它加载的非常快. 但是一般来说, 动态加载的场景经常可能含有较多内容, 会需要更多的加载时间, 从而导致游戏出现卡顿, 直到场景加载完成才会恢复. 要防止出现这种问题, 应该通过SceneManager.LoadSceneAsyns方法来异步加载场景. 该方法会开始执行场景加载过程, 并返回一个AsyncOperation类型对象的引用值, 该返回值可用于判断场景是否已经加载完成, 我们可以在协程中使用它 :

  1. IEnumerator LoadLevel () {
  2. //SceneManager.LoadScene("Level 1", LoadSceneMode.Additive);
  3. //yield return null;
  4. //以下代码的效果是, 如果在本帧场景Level 1加载完成, 则执行它之后的下一行代码,
  5. //否则, 该方法在本帧将在此结束, 到下一帧继续执行该方法, 以此类推, 直到场景加载完成为止
  6. yield return SceneManager.LoadSceneAsync("Level 1", LoadSceneMode.Additive);
  7. SceneManager.SetActiveScene(SceneManager.GetSceneByName("Level 1"));
  8. }

现在, 无论我们动态加载的场景内容大小, 都不会因为加载而导致运行卡顿了, 这也就是说, 在Level 1场景加载完成并被设置为活跃场景之前, Update方法可以执行任意次. 但是这样也可能会导致问题, 因为场景加载完之前, 玩家可以发出游戏指令. 要避免这个问题, 就需要在加载场景之前禁用Game脚本, 场景加载完毕再启用它 :

  1. IEnumerator LoadLevel () {
  2. //通过Unity游戏对象内置的enabled属性, 在场景加载完成之前禁用Game脚本
  3. enabled = false;
  4. yield return SceneManager.LoadSceneAsync("Level 1", LoadSceneMode.Additive);
  5. SceneManager.SetActiveScene(SceneManager.GetSceneByName("Level 1"));
  6. //通过Unity游戏对象内置的enabled属性, 在场景加载完成之后启用Game脚本
  7. enabled = true;
  8. }

实际上更为复杂的游戏中, 在这个阶段也可能会提供一个专门用于表示加载状态的场景

防止二次加载

如果我们在编辑状态下已经向Hierarchy中拖入了一个Level 1场景, 现在的代码运行后会导致Hierarchy窗口最终会出现两个Level 1场景.
加载多个场景 - 图18
运行时出现了两个Level 1场景

因为动态加载的场景包含光源, 上述情况会导致运行时存在两个光源, 导致场景内光线过于明亮
加载多个场景 - 图19
两个光源, 光照更强

这依然是一个只有在编辑环境下运行才会出现的问题, 但是我们也应该解决它, 因为你需要在编辑环境下对游戏进行测试, 那就应该尽量保障运行效果与构建后的程序保持一致或接近.

要防止二次加载一个场景, 应该在Awake方法中调用LoadLevel方法前检查该场景是否已经被加载, 如果它已经加载, 将其设置为活跃场景, 并结束该脚本的Awake方法, 不执行LoadLevel方法 :

  1. void Awake () {
  2. shapes = new List<Shape>();
  3. //获取Level 1场景的引用, 存储到变量loadedLevel中
  4. Scene loadedLevel = SceneManager.GetSceneByName("Level 1");
  5. //检查Level 1场景是否已经被加载
  6. if (loadedLevel.isLoaded) {
  7. //如果Level 1场景已经被加载了, 将其设置为活跃场景, 然后终止当前Awake方法
  8. SceneManager.SetActiveScene(loadedLevel);
  9. return;
  10. }
  11. StartCoroutine(LoadLevel());
  12. }

不顾上述代码还不能解决二次加载的问题, 因为在Unity将场景标记为”已加载”状态之前, 就执行了Awake方法, 所以在Awake方法中检查场景的加载状态永远是未加载. 因此我们应该让上述检查代码晚些执行, 可以在Start方法中执行它们来达到这个效果. Awake方法会在场景加载时立即执行, 但是此时场景的状态还没有被标记为”已加载”. Start方法和Update方法会依次随后执行, 在它们执行之前场景也已经变成了”已加载状态” :

  1. //void Awake () {
  2. //使用Start方法代替Awake, 代码内容不变, 防止方法执行过早导致错误的判断场景加载状态
  3. void Start () {
  4. shapes = new List<Shape>();
  5. }

另外, 对于场景加载状态的检查只有在编辑器环境下才有必要进行 :

  1. void Start () {
  2. shapes = new List<Shape>();
  3. //检查是否是在编辑环境下, 如果不是编辑环境, 不能在运行前就拖拽额外打开一个场景, 所以也就不会存在二次加载问题
  4. if (Application.isEditor) {
  5. Scene loadedLevel = SceneManager.GetSceneByName("Level 1");
  6. if (loadedLevel.isLoaded) {
  7. SceneManager.SetActiveScene(loadedLevel);
  8. return;
  9. }
  10. }
  11. StartCoroutine(LoadLevel());
  12. }

更多关卡

一些游戏只有一个关卡场景, 但是更多的游戏会包含多个关卡. 因此也为我们的项目试着添加另一个关卡场景, 并且在不同关卡之间切换.

Level 2

要制作第二个关卡, 你可以将Level 1场景复制一个副本, 然后把副本改名为Level 2. 要让它们加载后的样子有所区分, 打开Level 2场景, 调整下它的光照, 比如, 设置其光源的X轴旋转为1, 这样Level 2的光照效果会变得暗一些. 然后记得去Lighting窗口点击Generate Lighting生成Level 2场景的光照数据 :
加载多个场景 - 图20
Level 2场景与它的光照数据文件夹

然后我们要去Build Setting窗口, 将Level 2场景加入到Scenes In Build列表中, 放在第三行 :
加载多个场景 - 图21
三个场景的Scene In Build列表

检测已加载的关卡

虽然可以同时打开两个关卡场景, 但是多数情况下应该只使用其中一个关卡. 也许同时打开多个场景可以为复制或移动场景元素带来方便, 不过还是应该只暂时这样做. 运行游戏后, 除了Main Scene场景之外, 应该至多只再打开一个额外场景. 如果将Level 2场景拖入到Main Scene场景的Hierarchy窗口中, 在运行后, Level 1和Level 2都会被加载 :
加载多个场景 - 图22
两个关卡场景全都被加载了

要防止两个场景同时被加载, 我们应该修改Game.Start方法中检测场景加载状态的代码, 不再只针对Level 1关卡, 而是检查任意关卡场景. 现在我们有两个关卡场景, 但是我们可以支持更多关卡场景的加载检测
_
我们的方法有个前提条件, 就是所有的关卡场景都必须在它们的名字中包含”Level”这个字符串, 并且字符串后面要跟随一个英文空格符. 这样我就可以检查所有已加载的场景中有没有这样名称的场景, 如果有, 则将其设置为活跃场景, 并且不再调用LoadLevel方法.

我们可以使用SceneManager.sceneCount属性获得当前加载的场景数量, 并且通过SceneManager.GetSceneAt方法来得到指定索引数字的场景引用 :

  1. void Start () {
  2. shapes = new List<Shape>();
  3. if (Application.isEditor) {
  4. //Scene loadedLevel = SceneManager.GetSceneByName("Level 1");
  5. //if (loadedLevel.isLoaded) {
  6. // SceneManager.SetActiveScene(loadedLevel);
  7. // return;
  8. //}
  9. //循环遍历每一个已经加载的场景
  10. for (int i = 0; i < SceneManager.sceneCount; i++) {
  11. //通过索引得到对应的场景引用
  12. Scene loadedScene = SceneManager.GetSceneAt(i);
  13. //如果得到的场景名称是否包含字符串"Level "
  14. if (loadedScene.name.Contains("Level ")) {
  15. //场景名称包含"Levle "则表示当前存在已加载的关卡场景, 设置该场景为活跃场景, 并终止方法的执行
  16. SceneManager.SetActiveScene(loadedScene);
  17. return;
  18. }
  19. }
  20. }
  21. StartCoroutine(LoadLevel());
  22. }

现在, Hierarchy创建中拖入Level 2场景的情况下运行游戏, 将不会动态加载Level 1场景, 这样就可以方便的直接测试任意关卡场景, 而不需要通过游戏的关卡选择流程_
加载多个场景 - 图23
加载多个场景 - 图24
只加载了Level 2

加载指定关卡

为了加载指定关卡, 我们需要调整LoadLevel方法. 为该方法添加一个整型参数, 它代表的是场景在Build Setting窗口中加入到Scene In Build列表后被分配的索引数字, 比如目前 Main Scene的索引是0, Level 1是1, Level 2是2. 然后我们需要将GetSceneByName方法替换我GetSceneByBuildIndex方法 :

  1. IEnumerator LoadLevel (int levelBuildIndex) {
  2. enabled = false;
  3. //yield return SceneManager.LoadSceneAsync("Level 1", LoadSceneMode.Additive);
  4. //使用levelBuildIndex参数代替之前的"Level 1"作为第一个参数, 异步加载指定索引的场景
  5. yield return SceneManager.LoadSceneAsync(levelBuildIndex, LoadSceneMode.Additive);
  6. //SceneManager.SetActiveScene(SceneManager.GetSceneByName("Level 1"));
  7. //使用场景在构建设置中分配的索引号来获取指定场景的引用, 并检查该场景是否被加载
  8. SceneManager.SetActiveScene(SceneManager.GetSceneByBuildIndex(levelBuildIndex));
  9. enabled = true;
  10. }

默认加载Level 1场景, 它的索引是1, 所以修改Start方法, 为LoadLevel方法传入该参数 :

  1. void Start () {
  2. //StartCoroutine(LoadLevel());
  3. //LoadLevel方法现在可以接受一个场景索引作为参数, 加载指定场景
  4. StartCoroutine(LoadLevel(1));
  5. }

**

如果存在更多关卡场景, 我该怎么办?

如果游戏有许多关卡场景, 那么更实用的方式是把它们放到一个单独的资源包(AssetBundle)中, 然后根据需要下载它们. 这也让你可以在游戏发布后更新或是添加关卡. 不过本教程并不包括与AssetBundle有关的内容

选择关卡

对于我们这样的简单小游戏, 可以使用非常简单粗暴的关卡选择方式——只需要按下代表对应关卡的数字按键, 就可以切换关卡. 9个关卡以内都可以用这种方式. 为了方便配置可以选择的关卡数量, 在Game脚本中新增一个levelCount字段存储关卡总数, 并在Inspector中将其设置为2 :

  1. //代表项目中关卡场景的数量, 目前的关卡场景切换方式, 只能支持9个以内的关卡数量
  2. public int levelCount;

加载多个场景 - 图25
levelCount设置为了2

接着需要能够检查玩家按下了哪个数字按键. 可以通过一个loop循环来检查所有的关卡索引来进行按键检查. 使用KeyCode.Alpha0加上对应的场景索引就能得到与场景索引对应的按键值, 如果对应的按键被按下了, 就加载该关卡, 并终止Update方法的执行 :

  1. void Update () {
  2. if (Input.GetKeyDown(createKey)) {
  3. CreateShape();
  4. }
  5. //在一系列进行按键检查的if和else if代码的最后, 增加这个esle块
  6. else {
  7. //使用关卡场景的数量作为循环次数
  8. for (int i = 1; i <= levelCount; i++) {
  9. //KeyCode.Alpha0代表按键0, 将其与1-9的数字相加,就可以得到1-9的按键值
  10. if (Input.GetKeyDown(KeyCode.Alpha0 + i)) {
  11. //如果按下了代表某个场景索引的数字按键, 则加载该场景, 并终止当前脚本Update方法的执行
  12. StartCoroutine(LoadLevel(i));
  13. return;
  14. }
  15. }
  16. }
  17. creationProgress += Time.deltaTime * CreationSpeed;
  18. }

(千万别忘了去Inspector中把Game脚本的levelCount设置为2, 默认是0)
**

使用这种方法不能支持十个关卡场景吗?

你可以将循环代码的i初始值改成0, 这样你就可以使用0-9这十个按键来切换十个关卡

卸载关卡

现在, 运行游戏后, 就可以加载按键对应的关卡场景了, 但是每次关卡加载后, 都会保留之前已经加载的关卡, 而不是替代之前的关卡.
加载多个场景 - 图26
加载新关卡后, 之前的关卡场景都会被保留下来

我们可以通过持续追踪当前加载关卡的索引来解决这个问题, 在Game脚本中添加一个新的字段来保存当前加载的关卡索引 :

  1. //保存当前加载的关卡索引
  2. int loadedLevelBuildIndex;

该字段默认值为0 , 如果运行游戏时已经有某个关卡场景被加载了, 就需要在Game.Start方法中为该字段赋值 :

  1. if (loadedScene.name.Contains("Level ")) {
  2. SceneManager.SetActiveScene(loadedScene);
  3. //如果已经有某个场景被加载了, 使用该场景的索引值为loadedLevelBuildIndex字段赋值
  4. loadedLevelBuildIndex = loadedScene.buildIndex;
  5. return;
  6. }

在我们加载了一个新关卡后, 也需要更新该字段的值, 所以需要修改Game.LoadLevel方法 :

  1. IEnumerator LoadLevel (int levelBuildIndex) {
  2. SceneManager.SetActiveScene(SceneManager.GetSceneByBuildIndex(levelBuildIndex));
  3. //加载了指定索引的关卡场景后, 将其索引值保存在loadedLevelBuildIndex字段中
  4. loadedLevelBuildIndex = levelBuildIndex;
  5. enabled = true;
  6. }

现在我们就可以在加载一个新关卡之前, 检查loadedLevelBuildIndex存储的索引是不是0, 如果不是0, 表示已经有一个关卡场景被加载了, 我们必须先卸载这个关卡场景, 这需要用到异步卸载场景的方法SceneManager.UnloadSceneAsync, 该方法接收一个整型参数, 代表要卸载的场景索引. 我们要在加载下一个场景之前检查卸载过程是否已经完成, 方法与检查加载过程是否完成类似, 同样需要用到yield return语句 :

  1. IEnumerator LoadLevel (int levelBuildIndex) {
  2. enabled = false;
  3. //判断是否存在已加载的关卡
  4. if (loadedLevelBuildIndex > 0) {
  5. //如果存在已加载的关卡, 要先异步卸载该关卡场景, 才继续执行该方法后面的代码
  6. yield return SceneManager.UnloadSceneAsync(loadedLevelBuildIndex);
  7. }
  8. yield return SceneManager.LoadSceneAsync(levelBuildIndex, LoadSceneMode.Additive);
  9. SceneManager.SetActiveScene(SceneManager.GetSceneByBuildIndex(levelBuildIndex));
  10. loadedLevelBuildIndex = levelBuildIndex;
  11. enabled = true;
  12. }

最后, 还需要让加载一个关卡后像是重新开始了游戏, 这就需要清除所有之前产生的形状. 因此, 在加载其他场景之前, 调用BeginNewGame方法 :

  1. if (Input.GetKeyDown(KeyCode.Alpha0 + i)) {
  2. //每次加载任意关卡场景, 都先调用BeginNewGame方法, 清除所有之前产生的形状
  3. BeginNewGame();
  4. StartCoroutine(LoadLevel(i));
  5. return;
  6. }

如果我要加载的关卡在当前已经加载, 我可以跳过加载关卡的代码吗?

假设Level 2已经被加载, 并且玩家在这时按下了数字2键. 然后首先会清除所有形状, 卸载旧的的Level 2关卡, 接着加载新的Level 2关卡. 这种情况我们可以只清除形状, 而跳过重复加载Level 2的过程吗?

对于多数常见的游戏, 不可以这样做, 因为一个旧关卡的内容与状态都可能发生了大量的变化, 因此, 选择同一个关卡时, 也需要重新加载关卡来将其重置为初始状态.

不过在我们的教程项目中, 是可以的. 我们的关卡场景只包含一个简单的光源, 在运行期间我们也没有对关卡内容做任何修改.

记住关卡

此时, 我们可以在运行时选择加载不同的关卡, 但是保存游戏和加载游戏的过程还没有考虑到关卡的加载情况, 我们可以在某个关卡加载的时候保存游戏, 并在切换到另一个关卡时加载游戏. 我们接下来要让游戏保存数据也记录下关卡的加载情况

保存关卡情况

保存关卡加载情况需要我们对保存文件中增加新的数据, 这与我们旧版本游戏的保存文件不兼容, 因此要将保存文件的版本号从1变成2 , 修改Game脚本 :

  1. //const int saveVersion = 1;
  2. //版本2实现了关卡场景加载情况的保存
  3. const int saveVersion = 2;

当游戏保存时, 要将当前加载的关卡场景的索引也写到保存文件中, 在Game.Save方法中写入形状数量后执行这一步 :

  1. public override void Save (GameDataWriter writer) {
  2. writer.Write(shapes.Count);
  3. //写入当前加载的关卡场景索引
  4. writer.Write(loadedLevelBuildIndex);
  5. for (int i = 0; i < shapes.Count; i++) {
  6. }
  7. }

由于关卡索引与Build Setting中关卡列表的顺序有关, 所以在保存了索引数据后, 我们不可以修改这个顺序, 否则将会可能导致加载得到的关卡与保存时关卡并不匹配

加载关卡情况

当加载游戏时, 需要根据保存文件的版本号来决定如何加载关卡情况, 如果版本号低于2, 表示是旧版本的保存文件, 没有与关卡情况有关的数据, 那么就默认加载Level 1 :

  1. public override void Load (GameDataReader reader) {
  2. int count = version <= 0 ? -version : reader.ReadInt();
  3. //如果版本号小于2,表示保存文件教旧, 还没有关卡数据, 则直接加载索引为1的关卡; 否则加载读取到的关卡索引
  4. StartCoroutine(LoadLevel(version < 2 ? 1 : reader.ReadInt()));
  5. for (int i = 0; i < count; i++) {
  6. }
  7. }

这样, 我们就可以正确的加载保存游戏时的关卡加载情况了.

下一个教程是生成区
教程源码
PDF