游戏现在变得越来越长,有些游戏已经超过了 100 个小时的内容。不可能让玩家一次就玩完整个游戏。允许玩家保存游戏是游戏最基本的一个功能——哪怕仅仅保存玩家的得分记录。
但如何创建一个存档文件,以及需要在里面保存什么东西?你必须在存档中保存玩家的设置吗?以及如何将存档保存到 web 上允许玩家在不同设备上下载呢?
在这篇教程中,你将学习:
- 什么是是序列化以及反序列化。
- 什么是 PlayerPrefs 以及如何用它保存玩家设定。
- 如何创建游戏存档并将它保存到磁盘。
- 如何加载游戏存档。
- 什么是 JSON 以及如何使用它。
假定你具备简单的 Unity 使用知识(比如创建和打开脚本),但不需要什么东西都懂,本教程很容易上手的。哪怕你是一个 C# 新手,你也能上手,只不过有几个概念你需要进一步的阅读而已。
注意:如果你没有接触过 Unity 或者想了解更多的 Unity 技巧,你可以参考我们的其它 Unity 教程,其中你可以学到大量 Unity 主题,从 C# 到如何编写 UI。
开始
从这里下载开始项目。你将实现保存游戏和加载的代码,以及保存玩家设定的逻辑。
主要的存档概念
在 Unity 中关于保存,有 4 个关键的概念:
- PlayerPrefs: 这是一个特殊的缓存系统,在两次游戏过程中为玩家保存简单设定。许多新手程序员误以为他们能够用它来保存游戏存档,但这不是什么好的做法。它只允许你保存简单的东西,比如图形声音设置、登录信息或者其它基本的用户相关的数据。
- 序列化:这是 Unity 真正用到的关键点。序列化将对象转换成字节流。听起来有点抽象,你可以看看这张图:
什么是“对象”?在这里“对象”是 Unity 中的任意脚本或文件。事实上,当你创建一个 MonoBehaviour 脚本时,Unity 会通过序列化/反序列化将文件转换成 C++ 代码,然后变成你在检查器窗口中看见的 C# 代码。如果你曾经添加过 [SerializeField] 让某些东西在检查器中显示出来,你就知道怎么回事了。
注意:如果你是一个 Java 或者 web 开发者,你可能熟悉 marshalling (编码解码)的概念。序列化和 marshalling 是几乎是同义词,如果你硬要说它们有什么不同,序列化是将对象从一种对象转换成另一种形式(比如,对象->字节),而 marshalling 是让参数从一个地方传到另一个地方。
- 反序列化:就是它的名字所暗示的。它是序列化反面,将一个字节流转换成对象。
- JSON:全称是 JavaScript Object Notation,是一种语言无关的发送和接收数据的常用格式。例如,你可能有一个运行在 Java 或者 PHP 下面的 web 服务器。你不能发送 C# 对象给它,但你可以发送这个对象的 JSON 形式给它,服务器会用它来重新创建一个本地版本的对象。后面的内容中你会学习更多关于这方面的内容,现在你只需要理解它是一种简单的格式化数据让它能够跨平台的方法(就像 XML)。当进行“对象->JSON”和“JSON->对象”转换时,可以分别将这两种称作称作 JSON 序列化和JSON 反序列化。
Player Prefs
我们创建了一个项目,以便你能将注意力集中在保存、加载游戏的逻辑上。但是,如果你想了解它是怎样运作的,不妨打开所有脚本,看看都是怎样实现的,如果需要帮助尽可到论坛中进行提问。
打开项目,打开名为 Game 的场景,点击 play。
要开始游戏,点击 New Game 按钮。玩游戏时,直接用鼠标进行移动,枪会自动跟随你的动作。点击鼠标左键开火,打中目标一次(它会以不同的时间间隔上下翻转)就会计分。试玩一下,看看在 30 秒内能拿到多少分。按 esc 键可以弹出这个菜单。
同这个游戏一样搞笑的是,它没有音乐,显得有点枯燥。你可能看到了有一个 music 开关,但它是关着的。点击 play 启动游戏,这次点击 Music 开关将它设置为 On,你会在开始游戏后听到音乐。确保你的扬声器已打开!
修改音乐设定很简单,但在此点击 play 按钮会出现一个问题:音乐开关又关闭了。在修改音乐设定时,没有保存这种修改。这就是 PlayerPrefs 负责的事情。
在 Scripts 文件夹中新建脚本 PlayerSettings。因为需要用到某些 UI 元素,在文件头部引用其他命名空间:
using UnityEngine.UI;
然后,新建几个变量:
[SerializeField]
private Toggle toggle;
[SerializeField]
private AudioSource myAudio;
这些变量用于保存开关和 AudioSource 对象。
然后新建一个函数:
public void Awake ()
{
// 1
if (!PlayerPrefs.HasKey("music"))
{
PlayerPrefs.SetInt("music", 1);
toggle.isOn = true;
myAudio.enabled = true;
PlayerPrefs.Save ();
}
// 2
else
{
if (PlayerPrefs.GetInt ("music") == 0)
{
myAudio.enabled = false;
toggle.isOn = false;
}
else
{
myAudio.enabled = true;
toggle.isOn = true;
}
}
}
在初始化时,这将:
- 判断 PlayerPrefs 是否有一个缓存了的 key 为 music 的值存在。如果没有,新建一个键值对,用 music 作为键,1 作为值。同时将开关设置为 on,并打开 AudioSource。这应当是玩家第一次运行游戏。值为 1 是因为不允许保存 Boolean 值(但你可以用 0 表示 false,1 表示 true)。
- 检查 PlayerPrefs 中的 music 键。如果它的值为 1,玩家的声音是打开的,因此打开音乐,设置开关为 On。否则,将音乐关闭,开关也 Off。
保存脚本,返回 Unity。
将 PlayerSettings 脚本附加到 Game 游戏对象。然后展开 UI 游戏对象下面的 Menu,显示它的子对象。然后将 Music 游戏对象拖到 PlayerSettings 脚本的 Toggle 字段。然后,选择 Game 游戏对象,将 AudioSource 拖到 MyAudio 字段。
当游戏运行时,音乐响了(因为 Awake 函数中的代码),但你仍然需要添加玩家在游戏过程中改变对设置进行改变的代码。打开 PlayerSettings 脚本,添加函数:
public void ToggleMusic()
{
if (toggle.isOn)
{
PlayerPrefs.SetInt ("music", 1);
myAudio.enabled = true;
}
else
{
PlayerPrefs.SetInt ("music", 0);
myAudio.enabled = false;
}
PlayerPrefs.Save ();
}
这里做的和上一段代码基本一样,除了一个关键的地方。这里它检查了音乐开关的状态,并根据情况修改和保存设置。为了让这个方法能够被调用和派上用场,你必须设置 Toggle 游戏对象的回调方法。选中 Music 游戏对象,将 Game 游戏对象拖到 OnValueChanged 节的 Object 一栏:
在下拉框中当前值为 No Functions,修改为 PlayerSettings -> ToggleMusic()。当菜单中的开关按钮被点击,它会调用 ToggleMusic 函数。
现在你已经用某些东西去记录音乐设定了。点击 play,试着打开/关闭 music 开关,结束游戏并重新启动游戏。
音乐设置现在已经能保存了!干得不错——但你只是用到了序列化的皮毛而已。
保存游戏
PlayerPrefs 的用法很简单吧?通过它,你能够简单保存某些设置如玩家的图形设置、登录信息(比如 Facebook 或者 Twitter 的 token),以及需要保存的玩家其它设置。但是,PlayerPrefs 不是为了游戏存档而设计的。因此,你需要用到序列化。
创建一个游戏存档文件的第一步是创建一个存档文件类。新建一个 Save 脚本,取消 MonoBehaviour 继承。删掉默认的 Start() 方法和 Update() 方法。
添加下列变量:
public List<int> livingTargetPositions = new List<int>();
public List<int> livingTargetsTypes = new List<int>();
public int hits = 0;
public int shots = 0;
为了保存游戏,你必须记录还活着的机器人在什么位置以及它们的种类。这要用到两个 List。hits 和 shots 则用 int 来保存。
还需要添加一句非常重要的代码。在类声明之上,添加:
[System.Serializable]
这是一个属性,它标明了代码的一个元数据。它告诉 Unity 这个类可以被序列化,这意味着你可以将它转换成字节流并保存到磁盘文件中。
注意:属性的使用非常广泛,它允许你在类、方法和变量上附加数据(这种数据叫做元数据)。甚至你可以定义自己的元数据并用于你的代码。序列化中使用了 [SerializeField] 和 [System.Serializable] 属性,这样在序列化对象时它知道要写入什么。其它属性还包括用于单元测试和依赖注入的设置,则不再本文范围之内,但你也可以研究一番。
完整的 Save 脚本应该是这样子:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class Save
{
public List<int> livingTargetPositions = new List<int>();
public List<int> livingTargetsTypes = new List<int>();
public int hits = 0;
public int shots = 0;
}
然后,打开 Game 脚本,添加一个方法:
private Save CreateSaveGameObject()
{
Save save = new Save();
int i = 0;
foreach (GameObject targetGameObject in targets)
{
Target target = targetGameObject.GetComponent<Target>();
if (target.activeRobot != null)
{
save.livingTargetPositions.Add(target.position);
save.livingTargetsTypes.Add((int)target.activeRobot.GetComponent<Robot>().type);
i++;
}
}
save.hits = hits;
save.shots = shots;
return save;
}
这段代码创建了一个 Save 类实例,根据存活的机器人来进行赋值。同时还保存了玩家的 shots 和 hits。
保存按钮已经链接到 Game 脚本的 SaveGame 方法,但里面没代码。将 SaveGame 函数修改为:
public void SaveGame()
{
// 1
Save save = CreateSaveGameObject();
// 2
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Create(Application.persistentDataPath + "/gamesave.save");
bf.Serialize(file, save);
file.Close();
// 3
hits = 0;
shots = 0;
shotsText.text = "Shots: " + shots;
hitsText.text = "Hits: " + hits;
ClearRobots();
ClearBullets();
Debug.Log("Game Saved");
}
代码解释如下:
- 创建了一个 Save 对象,同时当前游戏的所有数据都会保存到这个对象中。
- 创建了一个 BinaryFormatter,然后创建一个 FileStream,在创建时指定文件路径和要保存的 Save 对象。它会序列化数据(转换成字节),然后写磁盘,关闭 FileStream。现在在电脑上会多一个名为 gamesave.save 的文件。.save 后缀只是一个示例,你可以使用任意扩展名。
- 重置游戏,以便玩家保存后所有东西都处于默认状态。
要保存游戏,在玩的过程中按下 esc 键,然后点击 Save 按钮。你会注意到所有东西都被重置了,控制台会显示一条消息,说游戏已经被保存。
在 Game 脚本中 LoadGame 方法已经连接到 Load 按钮了。打开 Game 脚本,找到 LoadGame 函数。将它修改为:
public void LoadGame()
{
// 1
if (File.Exists(Application.persistentDataPath + "/gamesave.save"))
{
ClearBullets();
ClearRobots();
RefreshRobots();
// 2
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Open(Application.persistentDataPath + "/gamesave.save", FileMode.Open);
Save save = (Save)bf.Deserialize(file);
file.Close();
// 3
for (int i = 0; i < save.livingTargetPositions.Count; i++)
{
int position = save.livingTargetPositions[i];
Target target = targets[position].GetComponent<Target>();
target.ActivateRobot((RobotTypes)save.livingTargetsTypes[i]);
target.GetComponent<Target>().ResetDeathTimer();
}
// 4
shotsText.text = "Shots: " + save.shots;
hitsText.text = "Hits: " + save.hits;
shots = save.shots;
hits = save.hits;
Debug.Log("Game Loaded");
Unpause();
}
else
{
Debug.Log("No game saved!");
}
}
细细过一遍:
- 判断存档文件是否存在。如果存在,它会清空机器人和分数。否则,打印控制台“没有存档文件”。
- 和保存文件时类似,又创建了一个 FileStream,只不过和写入不同,这次是让它读取字节流。因此直接传入存档文件的路径。然后生成一个 Save 对象并关闭 FileStream。
- 尽管你已经拥有了存档信息,你仍然需要将它转换成游戏状态。这个循环遍历了保存的机器人位置(或者的机器人),天后在那个位置添加一个机器人。并正确设置机器人的种类。为了简单起见,定时器被重置,但如果你愿意的话也可以删除这句。这防止机器人立马消失,让玩家有几秒钟的时间反应。同时,为了简单起见,机器人的上移动画被设置为已结束,这样当你存档时机器人只上移了一部分,但在加载游戏之后却显示了完全上移。
- 刷新 UI 上的 hits 和 shotsp,同时也设置了本地变量,这样当玩家开火或者击中目标时会继续从原来的分数值叠加。如果你不做这个步骤,当玩家下次开火或者击中目标时值会显示为 1。
点击 play,玩一小会游戏并存档。点击 Load 按你会看到它会将你保存游戏时的敌人都加载进来。它还会正确地设置你的积分和射击次数。
将数据保存为 JSON
在保存数据时有一个技巧——那就是 JSON。你可以创建一个本地 JSON 对象用于代表你的游戏存档,将它发送给服务器,然后将这个 JSON(字符串)下载到另一台设备,将它从字符串转换回 JSON。本教程不会介绍如何向服务器发送接收数据,而是想介绍 JSON 的使用以及它是如何的简单。
JSON 格式和你在 C# 代码中的对象稍有不同,但它也很简单。这是一个简单 JSON:
{
"message":"hi",
"age":22
"items":
[
"Broadsword",
"Bow"
]
}
外边的大括号表示了父对象,即这个 JSON。如果你了解字典的数据结构,那么 JSON 就跟它一样。一个 JSON 文件就是对键值对进行的一个映射。因此在上面的示例中,有 3 个键值对。在 JSON 中,键总是字符串,当值可以是对象(比如子 JSON 对象)、数组、数字或者字符串。message 键中存储的值是 hi,age 键中的值是数字 22,items 键的值是一个带两个字符串的数组。
JSON 对象自身是以字符串类型存储的。通过将数组转换成字符串,任何语言都能简单地以一个字符串作为构造参数重建 JSON 对象。很方便,也很简单。
每种语言都有自己的从这种格式创建对象的方法。从 Unity 5.3 开始,就有一个原生方法,可以用一个 JSON 字符串创建 JSON 对象。你可以创建一个 JSON 对象用于保存玩家的得分记录并打印到控制台。但你可以将这个逻辑扩展到发送 JSON 到服务器。
在 Game 脚本中有一个 SaveAsJSON 方法,它已经连接到 Save As JSON 按钮。修改这个 SaveAsJSON 方法为:
public void SaveAsJSON()
{
Save save = CreateSaveGameObject();
string json = JsonUtility.ToJson(save);
Debug.Log("Saving as JSON: " + json);
}
这里和前面一样创建了一个 Save 对象。然后用 JsonUtility 类的 ToJSON 方法创建了一个 JSON 字串。然后打印到控制台。
开始游戏,打死几个敌人,按下 esc 键打开菜单。点击 Save As JSON 按钮,你会看到你创建的 JSON 字符串:
如果你想将这个 JSON 转换成一个 Save 对象,你可以这样:
Save save = JsonUtility.FromJson<Save>(json);
如果你想从 web 下载一个存档然并加载到你的游戏中,这就是你要做的工作。但创建一个 web 服务器是另外一个课程了!现在,给自己一个奖励,因为你刚刚学完了几个技术,在你下一个游戏(叹气)中会省心不少!