Unity3D RPG Core | 32 跨场景传送

这节我们完成不同场景的传送。

1. 创建新的场景

我们可以点击菜单 File - New Scene 创建一个新的场景。此处使用 Low Poly Dungeons Lite 素材来创建一个地下城场景。

新的场景就是之前学习,我们来重新温习一下:

1. 将素材转化成 URP 版本。可参照文章 《Unity3D RPG Core | 01 创建项目导入素材》

2. 导航地图烘焙,设置可行走和不可行走区域。可参照文章 《Unity3D RPG Core | 04 智能导航地图烘焙》

注意地面的标签设置,我们判断鼠标点击内容是通过标签确定的。

3. 摄像机设置,此处我们还是使用 Cinemachine FreeLook 相机。可参照文章 《Unity3D RPG Core | 12 找到 Player 追击》

4. 放置传送门,注意设置好传送门的属性。

1.1 灯光设置

因为是地下城,所以我们不需要自然光:

1. 如图 1 所示,在相机属性下,把原来的背景色设置为黑色。

图1 设置背景颜色

2. 去掉原有的天空盒。天空盒相关的设置可参考文章 《Unity3D RPG Core | 02 尝试熟悉基本工具》

地下城中的灯光来源是壁灯和蜡烛,我们通过设置点光源来实现。如图 2 所示,我们在物体上添加 Light 组件,其中 Type 选择 Point。Emission 下的 Color 属性指定灯光颜色;Intensity 属性指定光的强度。

图2 点光源设置

在 Pipeline Settings 配置文件中对光源的数量有限制。如图 3 所示,我们可以更改 Per Object Limit 属性,最多可以有 8 个光源。

图3 光源数量

1.2 预制体设置

直接加载场景会报图 4 所示的错误,提示预制体的 Mesh 不能读写。

图4 Mesh 报错

如图 5 所示,我们通过预制体定位到对应的 Mesh,使能读写。

图5 开启 Mesh 读写

2. 实现不同场景传送

在上一节相同场景传送的基础上,我们实现不同场景传送的功能,核心方法是 SceneManager.LoadSceneAsync()

如代码清单 1 所示,我们在协程中不断通过 AsyncOperation.isDone 判断场景是否加载完成。完成加载后,我们通过预制体生成角色人物,生成的位置和相同场景一样通过传送门指定。

代码清单 1 不同场景传送
  1. public class SceneController : Singleton<SceneController>
  2. {
  3.     public GameObject   m_playerPrefab = null;
  4.     GameObject   m_player = null;
  5.     NavMeshAgent m_agent = null;
  6.  
  7.     public void TransferToDestination(PortalController portal)
  8.     {
  9.         switch (portal.m_transmissionType)
  10.         {
  11.             case PortalController.TransmissionType.TransmissionSameScene:
  12.                 TransferSameScene(portal.m_destinationPortalName);
  13.                 break;
  14.             case PortalController.TransmissionType.TransmissionDifferentScene:
  15.                 StartCoroutine(TransferDifferentScene(portal.m_sceneName, portal.m_destinationPortalName));
  16.                 break;
  17.         }
  18.     }
  19.  
  20.     void TransferSameScene(string destPortalName)
  21.     {
  22.         if (m_player == null)
  23.         {
  24.             m_player = GameManager.GetInstance().m_playerData.gameObject;
  25.             m_agent = m_player.GetComponent<NavMeshAgent>();
  26.         }
  27.         PortalController destPortal = GetDestinationPortal(destPortalName);
  28.         m_agent.enabled = false;
  29.         m_player.transform.SetPositionAndRotation(
  30.             destPortal.m_point.position,
  31.             destPortal.m_point.rotation);
  32.         m_agent.enabled = true;
  33.     }
  34.  
  35.     IEnumerator TransferDifferentScene(string sceneName, string destPortalName)
  36.     {
  37.         AsyncOperation asyncLoad = SceneManager.LoadSceneAsync(sceneName);
  38.  
  39.         // Wait until the asynchronous scene fully loads
  40.         while (!asyncLoad.isDone)
  41.         {
  42.             yield return null;
  43.         }
  44.  
  45.         PortalController destPortal = GetDestinationPortal(destPortalName);
  46.         Instantiate(m_playerPrefab,
  47.             destPortal.m_point.position,
  48.             destPortal.m_point.rotation);
  49.     }
  50. }

以上功能实现后,我们尝试传送,会发现各种空指针错误。加载完场景后,当前场景的内容都会被销毁掉,比如 SceneController 会被销毁掉,无法执行后续逻辑;再比如人物无法使用 GameManager 类。

为了消除以上问题,我们选择在加载新场景时,不销毁相关类。如代码清单 2 所示,可以通过 DontDestroyOnLoad() 指定,我们在 SceneController、GameManager 和 MouseManager 的 Awake() 函数中调用。

代码清单 2 不销毁相关类
  1. protected override void Awake()
  2. {
  3.     base.Awake();
  4.     DontDestroyOnLoad(this);
  5. }

销毁原先场景还会带来一个问题:加了 DontDestroyOnLoad() 后,MouseManager 就变成真正意义上的全局类了,但是之前在 MouseManager 中我们注册了角色人物的移动和攻击等回调函数。此时之前的角色人物已经销毁,再调用相关函数就会出错。

如代码清单 3 所示,在角色人物被销毁时,我们移除之前注册的移动和攻击函数。

代码清单 3 删除之前的注册函数
  1. public class PlayerController : MonoBehaviour
  2. {
  3.     private void OnDisable()
  4.     {
  5.         MouseManager.GetInstance().OnMoveMouseClick -= DoMoveAction;
  6.         MouseManager.GetInstance().OnAttackMouseClick -= DoAttackAction;
  7.     }
  8. }

2.1 预制体实例化人物带来的其他问题

因为角色人物是预制体实例化生成的,所以摄像头指定的跟随对象还需要再次指定。

如代码清单 4 所示,我们首先通过 FindObjectOfType() 找到 CinemachineFreeLook 摄像头,然后设置其 LookAt 和 Follow 属性。

代码清单 4 重新指定摄像头跟随
  1. public class PlayerController : MonoBehaviour
  2. {
  3.     void Start()
  4.     {
  5.         MouseManager.GetInstance().OnMoveMouseClick   += DoMoveAction;
  6.         MouseManager.GetInstance().OnAttackMouseClick += DoAttackAction;
  7.  
  8.         GameManager.GetInstance().RegisterPlayerData(m_characterData);
  9.  
  10.         CinemachineFreeLook cinemachine = FindObjectOfType<CinemachineFreeLook>();
  11.         if (cinemachine != null)
  12.         {
  13.             cinemachine.LookAt = m_LookAtPoint;
  14.             cinemachine.Follow = m_LookAtPoint;
  15.         }
  16.     }
  17. }

还有一个问题,因为我们旧场景中的角色人物原本就是存在的,当我们从新场景返回旧场景中还会实例化一个人物,就变成了两个。此处把原先的角色人物 Disable 掉,运行游戏时再 Enable 人物。我们先通过这种手动的方式规避掉这个问题,后续主界面场景加载会修复此问题。

3. 实现传送门开启

以上不同场景的传送功能已经实现,接下来我们来实现传送门开启的功能,把石头人打败了之后才开启地下城的传送门。

实现的方式也很简单,我们先不显示传送门,如代码清单 5 所示,待石头人被销毁时,再显示传送门。

代码清单 5 开启传送门
  1. public class GolemController : EnemyController
  2. {
  3.     public GameObject m_portal = null;
  4.  
  5.     private void OnDisable()
  6.     {
  7.         if (m_portal != null)
  8.             m_portal.SetActive(true);
  9.     }
  10. }

Unity 的时序一直是个大问题,需要特别注意。

此处需要注意,切换新场景时,可能在销毁石头人之前,传送门就被销毁了。

图 6 是最终的效果,当击败石头人后会开启传送门,可以传送到地下城,并且可以从地下城传送回来。

图6 最终效果