Unity Mirror 多子场景关卡载入和Player生成

3,056 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第32天,点击查看活动详情

问题描述

这是实际项目中遇到的一个具体需求。本项目是使用Mirror开发的联网游戏,现在有一个需求是场景不再是一个单一的场景,而是由可以配置的子场景构成。需要所有初始子场景全部载入后,才能创建Player。否则Player创建早了,可能由于所在位置的子场景没有载入成功,而造成Player由于没有物理层而掉落下去,或者出现其他问题。综上所述,我们需要解决如下几个问题:

  • 配置关卡包含的子场景,并在载入关卡时全部加载。
  • 客户端连接时,要判断所有初始子场景加载完毕后再创建Player
  • 场景中动态加载卸载子场景
  • 所有的操作都要考虑服务器和客户端,首先在服务器加载场景,然后发送消息给客户端加载

何谓子场景

Unity可以同时加载多个场景,使用Additive方式即可。我使用Mirror时,会有一个容器场景作为主场景,其他部分的场景就是子场景,根据需要动态加载和释放。这也是做无缝大地图的一个思路。当然了这是场景级别的,也有使用prefab和地形动态加载做无缝大地图的。根据项目的需求,我使用场景级别的动态加载就足够了,另外Mirror其实已经内置了相应的网络消息在服务器和客户端之间同步场景的加载。

Mirror的场景加载和卸载原理

具体可参考:juejin.cn/post/713609…

解决方案

容器场景和初始子场景

我们一开始所有的关卡内容都是在一个场景中的,包含光,camera,地形,建筑,植物,动物,敌人等等。另外就是关卡管理的一些脚本。而子场景其实主要是要包含不同的地形和游戏对象。因此,我们可以将gameplay相关的部分单独拿出来作为子场景,而原场景里面只包含一些公共的内容,作为容器场景。容器场景作为Mirror的online scene配置到NetworkManager中,使用Mirror默认的加载机制进行加载。

子场景配置

public class SubSceneInfo : ScriptableObject
{      
    public string[] initLoadSubScenes;        
}

定义一个Scriptable Object即可。然后在容器场景中包含一个管理组件:

public class SubSceneMgr : MonoBehaviour
{
    public SubSceneInfo SubScene;
}

在该组件中引用子场景配置。

子场景加载

子场景的加载分为两部分,首先是服务器上的场景加载,然后是发送消息让客户端加载。当容器场景载入后,即可立刻加载服务器上的子场景(当然也可以和容器场景同时加载,主要看项目需求)。而客户端子场景需要客户端连接上之后才可以发送消息(必然的,没有客户端连接也无法发送消息)。为了方便操作,我将这些功能都添加到自定义的NetworkManager中。

MyNetworkManager中相关方法

public class MyNetworkManager : NetworkManager
{
    private static SOCNetworkManager instance;
    public static SOCNetworkManager Instance { 
        get {
            if(instance == null)
            {
                instance = NetworkManager.singleton as SOCNetworkManager;
            }
            return instance;
        } 
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    public static void Reload()
    {
        instance = null;
    }

    private string[] initLoadSubScenes;
    public bool initSubScenesLoaded { get; private set; }
}
  • 首先,我们定义了一个Instance属性,避免使用基类的singleton需要转型。
  • 使用RuntimeInitializeOnLoadMethod在编辑器reload domain时重置静态变量。(这个最好加上,防止项目启用了快速Play)
  • 定义initLoadSubScenes数组记录要加载的子场景名字。
  • 定义一个属性initSubScenesLoaded返回是否子场景全部加载完成。
        public IEnumerator ServerLoadInitSubScenes(string[] subWorldSceneNames)
        {
            initLoadSubScenes = subWorldSceneNames;
            
            if(subWorldSceneNames.Length == 0)
            {
                initSubScenesLoaded = true;            
            }
            else
            {
                foreach(var sceneName in subWorldSceneNames)
                {
                    yield return SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
                }
                initSubScenesLoaded = true;
            }                    
        }

ServerLoadInitSubScenes方法在服务器上载入子场景,当载入完成后,将initSubScenesLoaded设置为true。

容器场景调用子场景载入方法

先回到容器场景中的SubSceneMgr组件,在其Start()方法中,我们调用MyNetworkManager中的方法进行子场景加载。

private void Start()
{
    ServerStart();
}

[ServerCallback]
private async void ServerStart()
{
    StartCoroutine(MyNetworkManager.Instance.ServerLoadInitSubScenes(SubScene.initLoadSubScenes));
}

由于客户端和服务器上的容器场景都包含这个组件,而子场景载入是从服务器开始,因此这儿使用了一个只在服务器上运行的方法,去启动ServerLoadInitSubScenes这个协程。此时,服务器就开始异步载入所有的初始子场景了。

客户端连接时发送子场景载入消息并生成Player

回到MyNetworkManager中,当客户端连接上服务器时,会调用服务器上的回调OnServerReady。我们在这儿进行Player的延迟创建。所谓延迟,是等待服务器上的子场景加载完毕。因为玩家是首先创建在服务器上的,如果服务器上的初始子场景都没有加载完毕就创建玩家,那么玩家在服务器上就有可能出问题,比如没有地面掉下去。

public override void OnServerReady(NetworkConnectionToClient conn)
{
    base.OnServerReady(conn);    

    if (conn.identity == null)
    {
        StartCoroutine(AddPlayerDelayed(conn));
    }
}   

IEnumerator AddPlayerDelayed(NetworkConnectionToClient conn)
{
    while (!initSubScenesLoaded)
        yield return null;
   
    // Send Scene msg to client telling it to load the additive scenes
    foreach(var sceneName in initLoadSubScenes)
    {
        conn.Send(new SceneMessage { sceneName = sceneName, sceneOperation = SceneOperation.LoadAdditive, customHandling = true });
    }


    Transform startPos = GetStartPosition();
    GameObject player = startPos != null
        ? Instantiate(playerPrefab, startPos.position, startPos.rotation)
        : Instantiate(playerPrefab);    

    player.name = $"{playerPrefab.name} [connId={conn.connectionId}]";

    // Wait for end of frame before adding the player to ensure Scene Message goes first
    yield return new WaitForEndOfFrame();

    // Finally spawn the player object for this connection
    NetworkServer.AddPlayerForConnection(conn, player);
}
  • AddPlayerDelayed协程中,我们通过等待initSubScenesLoaded来保证服务器上的子场景全部载入完毕。
  • 然后通过发送SceneMessage,通知该玩家的客户端加载子场景,并且我们使用了自定义加载。关于自定义加载可以参考:juejin.cn/post/713610…
  • 然后我们在服务器上,使用Instantiate从prefab实例化出Player。
  • 最后,通过NetworkServer.AddPlayerForConnection将这个player注册到服务器上。并且这个方法内部最终会调用SendSpawnMessage发送SpawnMessage给客户端,让客户端上生成Player。

关闭自动生成Player功能

上面我们已经完成了载入子场景和生成Player,但是我们还需要关闭NetworkManager上的autoCreatePlayer功能。这只要在Inspector上取消勾选即可。关于autoCreatePlayer的机制,会单独一篇文章分析。