Unity场景加载及简单存档系统
场景加载
场景切换
- 一个场景本质上就是一些GameObject的集合
很显然,任何一个游戏都不能只依靠单一场景实现,那么我们怎么样实现在多个场景之间切换呢?
答:加载场景
所谓加载场景,可以理解为把当前场景中的物体全部删除,并换成新场景中的物体。
这也是为什么有些游戏在加载场景的时候会很需要很长时间
加载场景主要分为两类:同步加载和异步加载
- 同步加载(sync):当我们下达加载新场景的命令时,游戏会卡住,等待加载完毕后直接切换到新场景。
- 这样能让玩家更快进入新的场景,通常用于加载游戏的第一个场景时。
- 异步加载(async):当下达指令时,游戏会继续而场景会在后台加载。加载完成后再切换到新的场景。
- 这是更常见的,因为加载场景时对玩家体验影响较小。缺点是相较于同步加载速度更慢。
同步与异步注意区分哦
Scene和SceneManager
Unity为我们提供了一些与场景管理相关的数据结构与方法。
它们在 UnityEngine.SceneManagement这个命名空间中。
Scene代表了一个场景文件,其中包含了以下信息:
- buildIndex:该场景在打包(导出)时的顺序。数字越小优先级越高。
- name:场景名
- isDirty:场景是否被修改
- Get Root Game Objects():获取场景中的所有的根物体
- ······
而SceneManager提供了许多用于管理场景的工具:
- GetActiveScene():获得当前场景
- GetSceneByBuildIndex():通过buildIndex获取场景
- GetSceneByName():通过文件名来获取场景
- LoadScene():同步加载场景
- LoadSceneAsync():异步加载场景
- ······
简单示例
让我们来编写一个简单的场景切换脚本吧
- 既然我们要写场景加载相关的功能,别忘了
UnityEngine.SceneManager命名空间
重新加载场景
首先我们来实现按R重新加载场景
if (Input.GetKeyDown(KeyCode.R)){ SceneManager.LoadScene(SceneManager.GetActiveScene().name);}这里我们是通过获取到当前激活的场景来进行重新加载,因此此处使用name/buildindex均不影响效果。 不过要注意的是,要使用BuildIndex这种方法,必须将该场景添加至BuildSettings里面
那么在 SceneManager.GetActiveScene() 后面能接什么呢
首先需要明确的是 GetActiveScene() 返回的是一个Scene结构体 ,所以后面能接的,本质上就是Scene这个类型自带的属性和方法 。
name
SceneManager.GetActiveScene().name;- 场景名(
.unity文件名)
buildIndex
SceneManager.GetActiveScene().buildIndex;- Build Settings 中的索引
path
SceneManager.GetActiveScene().path;-
场景文件在 Assets 中的路径
例子:
Assets/Scenes/Level1.unity用途:
- 调试
- 编辑器工具
- 区分同名场景
isLoaded
SceneManager.GetActiveScene().isLoaded;-
是否已经被加载到内存
-
返回
bool常用于 多场景加载(Additive)
if (SceneManager.GetActiveScene().isLoaded){ Debug.Log("场景已加载");}IsValid()
SceneManager.GetActiveScene().IsValid();- 判断 Scene 是否有效
- 防止空引用 / 非法场景
if (!SceneManager.GetActiveScene().IsValid()){ return;}rootCount
int count = SceneManager.GetActiveScene().rootCount;- 当前场景中 根物体(没有父物体的 GameObject)数量
-
GetRootGameObjects()GameObject[] roots = SceneManager.GetActiveScene().GetRootGameObjects();- 获取所有根物体 典型用途:
- 场景扫描
- 管理器查找
- 编辑器工具
foreach (var go in roots){Debug.Log(go.name);}
这里先介绍几个常用的,还有部分可自行探索
同步加载
我们可以使用SceneManager.LoadScene()方法进行场景的同步加载。
例如:
SceneManager.LoadScene("MainMenu"); //加载名为“MainMenu”这个场景SceneManager.LoadScene(2); //加载buildindex为2的场景我们现在来实现一个从主页面点击 Start 进入游戏场景的效果
我们要实现什么
- 场景切换函数
- 将这个函数与按钮切换事件绑定
非常简单就可以实现
public void LoadSceneSync(){ SceneManager.LoadScene("class_06");}不过需要注意,如果目标场景内物体过多,会出现明显卡顿的现象,这是非常影响游戏体验的,那么我们就要使用异步加载。
异步加载
我们可以使用 SceneManager.LoadSceneAsync()方法进行场景的异步加载。
异步加载相较于同步加载会更加复杂一些:SceneManager.LoadSceneAsync()方法会返回一个类型为**AsyncOperation** 的变量,其中记录着这次加载的相关信息。
AsycOperation 中有以下常用的成员:
isDone
bool done = asyncOp.isDone;- 是否完成
true表示异步任务结束(不一定已经显示)
if (asyncOp.isDone){ Debug.Log("加载完成");}progress
float p = asyncOp.progress;-
进度值:
0 ~ 1注意如果将
allowSceneActivation设为false,progress会卡在0.9,即**场景加载时最大只到 **0.9
Debug.Log(asyncOp.progress);为什么到 0.9?
剩下 0.1 等待 激活场景
allowSceneActivation
asyncOp.allowSceneActivation = false;-
是否允许场景激活
-
默认是
true常见写法(加载完成但不切场)
AsyncOperation op = SceneManager.LoadSceneAsync("Game");op.allowSceneActivation = false;
while (op.progress < 0.9f){ yield return null;}
// 等玩家点击“开始”op.allowSceneActivation = true;completed(事件)
asyncOp.completed += OnLoaded;
void OnLoaded(AsyncOperation op){ Debug.Log("异步任务完成");}- 异步完成时自动触发
- 推荐事件方式 ,比
while更优雅
priority
asyncOp.priority = 100;-
加载优先级
-
数值越大,优先级越高(默认 0)
用于:
-
后台加载
-
多资源同时异步加载时调度顺序
那么,完成一个较为完整的异步加载的步骤是:
- 使用
SceneManager.LoadSceneAsync()发出异步加载的指令并得到AsyncOperation - 将
allowSceneActivation设为false - 用进度条将progress展现出来
- 当加载完成时将
allowSceneActivition设为true
为什么我们需要先将
allowSceneActivition先设为false再改为true?
接下来,我们就可以实现一个异步加载的流程了
[SerializeField] private GameObject loading;[SerializeField] Slider progressBar;[SerializeField] TMP_Text progressText;[SerializeField] private GameObject mainmenu;
public void LoadSceneAsync(){ StartCoroutine(LoadAsyncScene("class_06"));}
IEnumerator LoadAsyncScene(string scenename){ mainmenu.SetActive(false); loading.SetActive(true); AsyncOperation asyncLoad = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(scenename);
asyncLoad.allowSceneActivation = false; while (!asyncLoad.isDone) { float progress = Mathf.Clamp01(asyncLoad.progress / 0.9f); progressText.text = progress * 100 + "%"; progressBar.value = progress; if (asyncLoad.progress >= 0.9f) { progressText.text = "Press any key to continue"; if (Input.anyKeyDown) { asyncLoad.allowSceneActivation = true; } }
yield return null; }}简易场景加载管理器
public class LoadSceneManager{ //单例模式 private static LoadSceneManager _instance = new LoadSceneManager(); public static LoadSceneManager Instance => _instance;
///<summary> /// 异步加载场景 ///</summary> ///<param name="sceneName">目标场景名称</param> ///<param name="onLoadProgressUpdateCallBack">加载进度回调函数,每次更新加载进度时都会触发,且将加载进度作为参数传入</param> ///<param name="onLoadFinishedCallBack">当场景加载完毕后,触发的回调函数</param> public void LoadSceneAsync(string sceneName,Action<float> onLoadProgressUpdateCallBack = null,Action onLoadFinishedCallBack = null) { StartCoroutine(AsyncLoadScene(sceneName , onLoadProgressUpdateCallBack , onLoadFinishedCallBack)); }
IEnumerator AsyncLoadScene(string sceneName, Action<float> onLoadProgress =null,Action onLoadFinished = null) {
var ao = SceneManager.LoadSceneAsync(sceneName); ao.allowSceneActivation = false;
float curProgress = 0f;
float maxProgress = 100f;
while (curProgress < 90) { curProgress = ao.progress * 100;
//触发加载进度回调函数,比如更新加载界面UI进度条 onLoadProgress?.Invoke(curProgress); yield return null; }
//伪造进度,防止加载进度过快导致UI闪现 //一定会至少加载0.1s while (curProgress < maxProgress) { curProgress++;
//触发加载进度回调函数,比如更新加载界面UI进度条 onLoadProgress?.Invoke(curProgress); yield return null; }
//激活场景 ao.allowSceneActivation = true;
//等待下一帧 yield return null;
//触发回调函数 , 比如隐藏加载界面UI onLoadFinished?.Invoke();
}}保持在场景中的物体
当我们加载新的场景时,原来场景中的物体会被全部销毁。
但是有些时候我们希望能将一些重要的物体保留下来。
那么 DontDestroyOnLoad() 可以实现这个要求
其作用是标记一个物体为“不被场景加载销毁的”
所以在想留下来的物体上加上下面一段代码就可
void Awake(){ DontDestroyOnLoad(gameObject);}简单存档
存档是什么?
几乎每个游戏或多或少都有自己的存档机制,所谓存档,其实是将当前游戏的 状态信息 (玩家进度、装备、位置等)以某种编码存储在本地中。下一次打开游戏时,程序会读取该存档并复原之前的状态。
一个简易的存档系统会做这么几件事:
- 当收到存档(Save)指令时,程序会获取当前的状态信息并写在一个文件里 假设我们存了角色的位置(vector3)
- 当收到读档(load)指令时,程序会逐个读取存档中的信息,并应用到当前场景中 这时我们读取到之前存的vector3的值,并把玩家的位置设为该值
那么现在最大的问题就是:如何将状态信息写入一个文件里
PlayerPrefs
其实不建议使用这个方法
Unity为我们提供了一个简易的保存数据的方法:PlayerPrefs
如果使用PlayerRrefs,我们不需要真的和读写文件打交道,只需要使用PlayerPrefs中几个方法即可。
可以把PlayerPrefs想象成一个存储在游戏外的Dictionary,我们可以通过key访问其中的value
API:
PlayerPrefs.SetString(String key , string value);PlayerPrefs.GetString(string key);以及对应的int、float版本显然和Dictionary很像
现在我们来试一试吧
[SerializeField] private Transform Player;
void Update() { if (Input.GetKeyDown(KeyCode.S)) { Save(); }
if (Input.GetKeyDown(KeyCode.L)) { Load(); } }
public void Save() { UnityEngine.PlayerPrefs.SetFloat("PlayerX", Player.position.x); UnityEngine.PlayerPrefs.SetFloat("PlayerY", Player.position.y); UnityEngine.PlayerPrefs.SetFloat("PlayerZ", Player.position.z); Debug.Log("Game Saved"+ UnityEngine.PlayerPrefs.GetFloat("PlayerX")); }
public void Load() { Debug.Log("Loaded Ready"+ UnityEngine.PlayerPrefs.GetFloat("PlayerX")); float x = UnityEngine.PlayerPrefs.GetFloat("PlayerX"); float y = UnityEngine.PlayerPrefs.GetFloat("PlayerY"); float z = UnityEngine.PlayerPrefs.GetFloat("PlayerZ"); Player.position = new Vector3(x, y, z); Debug.Log("Game Loaded"+ Player.transform.position.x); }PlayerPrefs的限制
虽然PlayerPrefs很容易使用也很好理解,但是我们并不推荐大家在自己的项目中去使用它
原因:
- PlayerPrefs是很不安全的 通过PlayerPrefs存储的数据是完全未经加密的,这使玩家可以非常轻易的在电脑中修改其数值。
- PayerPrefs支持的类型非常有限 它仅支持存储int、float、string类型的数值,更加复杂的数据结构(类、结构体)等并不能通过其进行存储
- playerPrefs的性能并不好 相比于一般的存储方式,PlayerPrefs在处理大文件和复杂文件的效率非常低下
- PlayerPrefs对多平台并没有足够的支持 在每种操作系统上其存储位置都是不一样的,可能导致跨平台的数据丢失
Json与序列化
为了将游戏中各式各样的数据转换成文本并存储进文件中,我们需要用到Json。
Json (Javascript Object Notation)是一种数据编码模式。它可以将一个数据结构(比如一个类)用一
篇文本文件来表示。这个操作叫做序列化(Serialization)。
相反地,我们也可以根据一个Json文件构造出原来的数据结构。这叫做反序列化(Deserialization)。
这时我们可能会联想到之前见到序列化这个概念的地方
Field!
之前我们说Serialize Field的作用是将private变量公开显示在引擎里,但其实它本质是“序列化(serialize)”了那个变量,使其加入了Unity自身的存档系统。public变量都会被Unity序列化,这也是为什么当我们在引擎中设定了public变量的数值后它会一直保存下来。(而private变量是不会的!)
使用Json进行存档
那么,我们该如何将需要存储的信息转换成一个Json文件呢?
存档:
- 第一步:我们需要将这些信息整合进一个类里,这样就可以一起存储/读取了。
- 第二步:我们需要将这个类序列化为Json格式。Unity提供了JsonUtility.ToJson()方法为我们实现这个需求。
- 第三步:我们需要指定一个文件路径并使用File.WriteAllText()方法将得到的Json文本写入该文件里。
读档:
- 第四步:读档时,我们首先使用File.ReadAllText()读取到存档文件里的文本。
- 第五步:读取到的文本是Json格式的,于是我们使用JsonUtility.FromJson()方法将其反序列化为Save类。
- 第六步:得到存档类后,我们逐个获取其中的数据并应用在场景中。
实现:
public class JsonSave: MonoBehaviour{ [SerializeField] Transform player;
void Update() { if (Input.GetKeyDown(KeyCode.S)) { Save(); }
if (Input.GetKeyDown(KeyCode.L)) { Load(); } }
public void Save() { SaveData saveData= new SaveData(); saveData.playerPosition = player.position; string json = JsonUtility.ToJson(saveData); File.WriteAllText(Application.persistentDataPath + "/savefile.json", json); Debug.Log("Game Saved"); }
public void Load() { string json = File.ReadAllText(Application.persistentDataPath + "/savefile.json"); SaveData saveData = JsonUtility.FromJson<SaveData>(json); player.position = saveData.playerPosition; Debug.Log("Game Loaded"); }}
public class SaveData{ public Vector3 playerPosition;}