Unity遊戲框架設計之場景管理器

珂霖發表於2024-04-30

Unity遊戲框架設計之場景管理器

簡單介紹

在遊戲開發過程中,我們經常對 Scene 進行切換。為了不使場景切換時造成的遊戲卡頓,可以 Unity 官方 API 利用協程的方式非同步載入場景。

同時,為提升 Scene 切換的玩家體驗,我們經常會在場景切換的開始,先顯示過渡 UI ,然後才對目標場景進行載入。在對目標場景載入的過程中,還必須不斷將載入進度更新到過渡 UI 上,以便玩家觀察。

對於一個場景的管理,除了載入場景和解除安裝場景之外,還必須管理場景中的遊戲物件和元件。因此我們還必須提供對場景遊戲物件的建立、載入、啟用、禁用、解除安裝和搜尋等方法。元件也同理。這部分程式碼相對簡單,因此下述程式碼未給出。

程式碼設計

public class SceneManager : SingletonMono<SceneManager>
{
    public float loadingProcess;
    private bool _isLoadingScene;
    private readonly HashSet<string> _sceneAssetPathSet = new();
    private readonly WaitForEndOfFrame _waitForEndOfFrame = new();
    private static string _launchSceneAssetPath;
    private static Action _onLaunchSceneLoaded;
    private static Action<string> _onSceneUnload;

    public static void Initialize(string launchSceneAssetPath, Action onLaunchSceneLoaded, Action<string> onSceneUnload)
    {
        _launchSceneAssetPath = launchSceneAssetPath;
        _onLaunchSceneLoaded = onLaunchSceneLoaded;
        _onSceneUnload = onSceneUnload;
    }

    private void OnEnable()
    {
        UnityEngine.SceneManagement.SceneManager.sceneLoaded += OnSceneLoaded;
        UnityEngine.SceneManagement.SceneManager.sceneUnloaded += OnSceneUnLoad;
    }

    private void OnDisable()
    {
        UnityEngine.SceneManagement.SceneManager.sceneLoaded -= OnSceneLoaded;
        UnityEngine.SceneManagement.SceneManager.sceneUnloaded -= OnSceneUnLoad;
    }

    private void OnSceneLoaded(UnityEngine.SceneManagement.Scene scene, LoadSceneMode mode)
    {
        if (scene.path.Equals(_launchSceneAssetPath))
        {
            if (_onLaunchSceneLoaded == null)
            {
                return;
            }
            _onLaunchSceneLoaded();
            return;
        }
    }

    private void OnSceneUnLoad(UnityEngine.SceneManagement.Scene scene)
    {
        if (_onSceneUnload == null)
        {
            return;
        }
        _onSceneUnload(scene.path);
    }

    public string GetSceneGameObjectName(string sceneAssetPath)
    {
        return StringUtils.GetFileNameWithoutExtension(sceneAssetPath);
    }

    private string SceneAssetPathToSceneName(string sceneAssetPath)
    {
        string substring = sceneAssetPath.Substring("Assets/".Length);
        return substring.Substring(0, substring.IndexOf('.'));
    }

    private bool IsSceneLoaded(string sceneAssetPath)
    {
        if (_sceneAssetPathSet.Contains(sceneAssetPath))
        {
            return true;
        }
        return UnityEngine.SceneManagement.SceneManager.GetSceneByName(SceneAssetPathToSceneName(sceneAssetPath)).isLoaded;
    }

    private IEnumerator OpenScene(string sceneAssetPath, float waitingTime = 0f, Action openedCallback = null, Action callback = null)
    {
        string sceneName = SceneAssetPathToSceneName(sceneAssetPath);
        if (!_sceneAssetPathSet.Contains(sceneAssetPath))
        {
            ResourceManager.Instance.LoadSceneAsset(sceneAssetPath);
            _sceneAssetPathSet.Add(sceneAssetPath);
        }
        UnityEngine.SceneManagement.Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(sceneName);
        if (scene.isLoaded)
        {
            yield break;
        }
        if (_isLoadingScene)
        {
            yield break;
        }
        _isLoadingScene = true;
        AsyncOperation operation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
        operation.allowSceneActivation = false;
        loadingProcess = 0f;
        while (!operation.isDone)
        {
            loadingProcess = operation.progress;
            if (operation.progress >= 0.9f)
            {
                operation.allowSceneActivation = true;
                if (openedCallback != null)
                {
                    openedCallback();
                    openedCallback = null;
                }
                if (FloatUtils.IsGreaterThan(waitingTime, 0f))
                {
                    yield return new WaitForSeconds(waitingTime);
                }
            }
            yield return _waitForEndOfFrame;
        }
        loadingProcess = 1f;
        _isLoadingScene = false;
        if (callback != null)
        {
            callback();
        }
    }

    private void CoroutineOpenScene<T>(string sceneAssetPath, float waitingTime = 0f, Action openedCallback = null, Action callback = null) where T : Component
    {
        if (IsSceneLoaded(sceneAssetPath))
        {
            if (openedCallback != null)
            {
                openedCallback();
            }
            if (FloatUtils.IsGreaterThan(waitingTime, 0f))
            {
                CoroutineUtils.Instance.CoroutineSleep(waitingTime, () =>
                {
                    if (callback != null)
                    {
                        callback();
                    }
                });
                return;
            }
            if (callback != null)
            {
                callback();
            }
            return;
        }
        StartCoroutine(OpenScene(sceneAssetPath, waitingTime, openedCallback, () =>
        {
            UnityEngine.SceneManagement.Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(SceneAssetPathToSceneName(sceneAssetPath));
            string rootGameObjectName = GetSceneGameObjectName(sceneAssetPath);
            if (IsRootGameObjectExists(sceneAssetPath, rootGameObjectName))
            {
                GameObject rootGameObject = FindRootPrefab(sceneAssetPath, rootGameObjectName);
                if (rootGameObject.GetComponent<T>() != null)
                {
                    return;
                }
                rootGameObject.AddComponent<T>();
            }
            else
            {
                GameObject rootGameObject = new GameObject
                {
                    transform =
                    {
                        parent = null,
                        position = Vector3.zero
                    },
                    name = rootGameObjectName
                };
                UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(rootGameObject, scene);
                rootGameObject.AddComponent<T>();
            }
            if (callback != null)
            {
                callback();
            }
        }));
    }

    private IEnumerator CloseScene(string sceneAssetPath, Action callback = null)
    {
        if (!IsSceneLoaded(sceneAssetPath))
        {
            yield break;
        }
        string sceneName = SceneAssetPathToSceneName(sceneAssetPath);
        UnityEngine.SceneManagement.Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(sceneName);
        foreach (GameObject rootGameObject in scene.GetRootGameObjects())
        {
            rootGameObject.SetActive(false);
        }
        AsyncOperation operation = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(sceneName);
        while (!operation.isDone)
        {
            yield return _waitForEndOfFrame;
        }
        _sceneAssetPathSet.Remove(sceneAssetPath);
        if (callback != null)
        {
            callback();
        }
    }

    private void CoroutineCloseScene(string sceneAssetPath, Action callback = null)
    {
        StartCoroutine(CloseScene(sceneAssetPath, callback));
    }

    public void EnterFirstScene<T>(string sceneAssetPath, float waitingTime = 0f, Action openedCallback = null, Action callback = null) where T : Component
    {
        CoroutineOpenScene<T>(sceneAssetPath, waitingTime, openedCallback, callback);
    }
    
    public void SwitchScene<T>(string currentSceneAssetPath, string targetSceneAssetPath, float waitTimeBeforeEnterScene, ILoadingUI loadingUI, string loadingUIName,
        string[] retainPrefabNameSet = null, Action callbackBeforeLoadScene = null, Action callback = null) where T : Component
    {
        UIManager.Instance.OpenUI(currentSceneAssetPath, loadingUIName);
        loadingUI.StartProcess();
        if (callbackBeforeLoadScene != null)
        {
            callbackBeforeLoadScene();
        }
        string sceneRootGameObjectName = GetSceneGameObjectName(currentSceneAssetPath);
        UnityEngine.SceneManagement.Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(SceneAssetPathToSceneName(currentSceneAssetPath));
        foreach (GameObject rootGameObject in scene.GetRootGameObjects())
        {
            string rootGameObjectName = rootGameObject.name;
            if (rootGameObjectName.Equals(sceneRootGameObjectName) || rootGameObjectName.Equals(loadingUIName))
            {
                continue;
            }
            if (retainPrefabNameSet != null)
            {
                bool isRetain = false;
                foreach (string retainObjectName in retainPrefabNameSet)
                {
                    if (rootGameObjectName.Equals(retainObjectName))
                    {
                        isRetain = true;
                        break;
                    }
                }
                if (isRetain)
                {
                    continue;
                }
            }
            rootGameObject.SetActive(false);
        }
        InputSystemManager.Instance.Disable();
        CoroutineOpenScene<T>(targetSceneAssetPath, waitTimeBeforeEnterScene, () =>
        {
            CoroutineUtils.Instance.CoroutineSleep(waitTimeBeforeEnterScene * 0.9f, loadingUI.EndProcess);
        }, () =>
        {
            InputSystemManager.Instance.Enable();
            CoroutineCloseScene(currentSceneAssetPath, callback);
        });
    }
}

程式碼執行流程

EnterFirstScene() 進入首個 Scene。

(一)載入 Scene 的 Asset 資源。

(二)透過 UnityEngine.SceneManagement.SceneManager.LoadSceneAsync 非同步載入場景。

(三)建立場景遊戲物件,並將場景指令碼新增到場景遊戲物件上。


SwitchScene() 切換 Scene。

(一)啟用在當前 Scene 中已載入但被禁用的過渡 UI,然後開始過渡 UI 的偽進度載入。

(二)禁用當前 Scene 中的所有遊戲物件,除了場景遊戲物件、過渡 UI 遊戲物件和指定不禁用的遊戲物件。指定不禁用的遊戲物件通常包括 Camera 遊戲物件、AudioListener 遊戲物件等等。

(三)禁用輸入系統(可選的)。

(四)載入目標 Scene 的 Asset 資源。

(五)透過 UnityEngine.SceneManagement.SceneManager.LoadSceneAsync 非同步載入場景。

(六)當目標場景開啟成功後,先睡眠小於 waitTimeBeforeEnterScene 時間,可以取 waitTimeBeforeEnterScene * 0.9f,然後才結束過渡 UI 的偽進度載入。引入 waitTimeBeforeEnterScene 的原因是,防止場景載入速度過快時導致過渡 UI 頁面一閃而過、使用者無法觀察進度條變化等問題。

(七)當目標場景開啟成功後,先睡眠 waitTimeBeforeEnterScene 時間,然後建立目標場景遊戲物件並將目標場景指令碼新增到目標場景遊戲物件上,然後啟用輸入系統(可選的)並解除安裝當前場景。第六步的睡眠時間小於第七步的睡眠時間,因此可以保證使用者先觀察到進度值為 100 % ,然後才進入場景。

(八)解除安裝當前場景的流程為,先禁止用當前場景中所有的遊戲物件,然後透過 UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync 非同步解除安裝當前場景。

程式碼說明

(一)實現場景的非同步載入。

(二)引入過渡 UI,實現場景的絲滑切換。

(三)場景必須為空場景,場景中所有的遊戲物件必須透過場景指令碼來載入。

後記

由於個人能力有限,文中不免存在疏漏之處,懇求大家斧正,一起交流,共同進步。

相關文章