Unity场景加载及简单存档系统

3004 字
15 分钟
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这个类型自带的属性和方法

  1. name
SceneManager.GetActiveScene().name;
  • 场景名(.unity 文件名)

  1. buildIndex
SceneManager.GetActiveScene().buildIndex;
  • Build Settings 中的索引

  1. path
SceneManager.GetActiveScene().path;
  • 场景文件在 Assets 中的路径

    例子:

Assets/Scenes/Level1.unity

用途:

  • 调试
  • 编辑器工具
  • 区分同名场景

  1. isLoaded
SceneManager.GetActiveScene().isLoaded;
  • 是否已经被加载到内存

  • 返回 bool

    常用于 多场景加载(Additive)

if (SceneManager.GetActiveScene().isLoaded)
{
Debug.Log("场景已加载");
}

  1. IsValid()
SceneManager.GetActiveScene().IsValid();
  • 判断 Scene 是否有效
  • 防止空引用 / 非法场景
if (!SceneManager.GetActiveScene().IsValid())
{
return;
}
  1. rootCount
int count = SceneManager.GetActiveScene().rootCount;
  • 当前场景中 根物体(没有父物体的 GameObject)数量

  1. GetRootGameObjects()

    GameObject[] roots = SceneManager.GetActiveScene().GetRootGameObjects();
    1. 获取所有根物体 典型用途:
    2. 场景扫描
    3. 管理器查找
    4. 编辑器工具
    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 中有以下常用的成员:

  1. isDone
bool done = asyncOp.isDone;
  • 是否完成
  • true 表示异步任务结束(不一定已经显示)
if (asyncOp.isDone)
{
Debug.Log("加载完成");
}

  1. progress
float p = asyncOp.progress;
  • 进度值:0 ~ 1

    注意如果将 allowSceneActivation设为false,progress会卡在0.9,即**场景加载时最大只到 **0.9

Debug.Log(asyncOp.progress);

为什么到 0.9?

剩下 0.1 等待 激活场景


  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;

  1. completed(事件)
asyncOp.completed += OnLoaded;
void OnLoaded(AsyncOperation op)
{
Debug.Log("异步任务完成");
}
  • 异步完成时自动触发
  • 推荐事件方式 ,比 while 更优雅

  1. priority
asyncOp.priority = 100;
  • 加载优先级

  • 数值越大,优先级越高(默认 0)

    用于:

  • 后台加载

  • 多资源同时异步加载时调度顺序

那么,完成一个较为完整的异步加载的步骤是:

  1. 使用 SceneManager.LoadSceneAsync() 发出异步加载的指令并得到 AsyncOperation
  2. allowSceneActivation设为false
  3. 用进度条将progress展现出来
  4. 当加载完成时将 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);
}

简单存档#

存档是什么?#

几乎每个游戏或多或少都有自己的存档机制,所谓存档,其实是将当前游戏的 状态信息 (玩家进度、装备、位置等)以某种编码存储在本地中。下一次打开游戏时,程序会读取该存档并复原之前的状态。

一个简易的存档系统会做这么几件事:

  1. 当收到存档(Save)指令时,程序会获取当前的状态信息并写在一个文件里 假设我们存了角色的位置(vector3)
  2. 当收到读档(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很容易使用也很好理解,但是我们并不推荐大家在自己的项目中去使用它

原因:

  1. PlayerPrefs是很不安全的 通过PlayerPrefs存储的数据是完全未经加密的,这使玩家可以非常轻易的在电脑中修改其数值。
  2. PayerPrefs支持的类型非常有限 它仅支持存储int、float、string类型的数值,更加复杂的数据结构(类、结构体)等并不能通过其进行存储
  3. playerPrefs的性能并不好 相比于一般的存储方式,PlayerPrefs在处理大文件和复杂文件的效率非常低下
  4. 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;
}
Unity场景加载及简单存档系统
https://blog.rouming-fei.top/posts/unity-scene-loading-and-simple-save-system/
作者
废江流
发布于
2025-10-12
许可协议
CC BY-NC-SA 4.0
unity-lesson
第 3 篇 / 共 3 篇
已是最后一篇

评论区

Profile Image of the Author
废江流
提灯寻影,灯到影灭。
分类
标签
站点统计
文章
15
分类
4
标签
18
总字数
37,248
运行时长
0
最后活动
0 天前

文章目录