1.按需创建对象

我们可以在游戏中任意创造物体,例如子弹发射,敌人,随机道具生成等,但当我们退出游戏再次进入时,Unity不会自动为我们记录过程当中的变化,需要我们自己去做。

本例中我们会创建一个非常简单的游戏,在按下一个键时随机生成一个立方体。只要我们能够跟踪不同游戏会话之间的立方体,就可以在此基础上增加游戏的复杂性。

1.1 准备工作

我们需要一个Game组件脚本控制生成立方体,因此它需要包含一个public字段来连接一个预置实例

1
public Transform cubePrefab;

创建一个空物体将脚本挂在上面,在创建一个cube的预制体,给脚本一个它的引用

1.2 玩家输入

游戏应该根据玩家的输入来生成立方体,所以必须要检测玩家的输入。这里可以利用Unity的输入系统来检测按键,我们设置按下C建时生成立方体,通过在脚本中添加一个KeyCode字段实现

1
2
3
4
5
public KeyCode creatKey;
private void Awake()
{
creatKey = KeyCode.C;
}

在Update方法中通过Input.GetKeyDown方法检测是否按下按键

该方法返回一个bool值,Input.GetKeyDown只在玩家按下按键的第一帧返回true,Input.GetKey每一帧都返回true,还有Input.GetKeyUp在玩家松开按键时返回true

1
2
3
4
5
6
7
private void Update()
{
if (Input.GetKeyDown(KeyCode.C))
{
Instantiate(cubePrefab);
}
}

1.3 随机立方体

上述方法生成立方体时只会在初始位置生成立方体,多个立方体叠加生成,我们希望随机化创建每一个立方体,大小位置旋转都不相同。

为了方便实例化多个对象,我们将初始化立方体单独写成一个方法

1
2
3
4
5
6
7
8
9
10
11
12
private void Update()
{
if (Input.GetKeyDown(KeyCode.C))
{
CreatCube();
}
}

private void CreatCube()
{
Transform t = Instantiate(cubePrefab);
}

使用静态Random.insideUnitSphere属性获取随机点,意为在单位圆中随机生成坐标,这里将半径设为5

使用静态Random.rotation属性设置随机旋转

使用Random.Range获取随机数,并将其与Vector3.one相乘,得到随机大小

1
2
3
4
5
6
7
private void CreatCube()
{
Transform t = Instantiate(cubePrefab);
t.localPosition = Random.insideUnitSphere * 5;
t.localRotation = Random.rotation;
t.localScale = Vector3.one * Random.Range(0.1f, 1);
}

image-20210223172640314

1.4 开始新游戏

开始新游戏我们需要退出游戏模式重新进入,但这仅在Unity编辑器里可行,玩家则需要退出应用,然后重新启动它才能开始新游戏。因此我们需要在保持游戏模式的同时开始新游戏。

我们可以通过重新加载场景开始新游戏,也可以通过销毁所有的立方体。游戏中我们通过按键检测,按下N键则开始新游戏,我们应该一次处理一个键,所有只有在C键没按下时才检测N键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public KeyCode newGame;
private void Awake()
{
creatKey = KeyCode.C;
newGame = KeyCode.N;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.C))
{
CreatCube();
}
else if (Input.GetKeyDown(KeyCode.N))
{
BeginNewGame();
}
}

1.5 持有物体的引用

现在游戏中可以生成随机数量的立方体,但要销毁立方体需要持有对立方体的引用,我们每生成一个立方体就将其加入List列表中,在Awake中初始化List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private List<Transform> cubeList;
private void Awake()
{
cubeList = new List<Transform>();
creatKey = KeyCode.C;
newGame = KeyCode.N;
}
private void CreatCube()
{
Transform t = Instantiate(cubePrefab);
t.localPosition = Random.insideUnitSphere * 5;
t.localRotation = Random.rotation;
t.localScale = Vector3.one * Random.Range(0.1f, 1);
cubeList.Add(t);
}

1.6 清空列表

在BeginNewGame中遍历cubeList,逐个销毁游戏对象,然后清空列表。由于List保存的时Transform对象,销毁时要带上.gameObject

1
2
3
4
5
6
7
8
private void BeginNewGame()
{
for(int i = 0; i < cubeList.Count; i++)
{
Destroy(cubeList[i].gameObject);
}
cubeList.Clear();
}

2.保存和加载

如果只是在单个场景进行保存和加载,那么将一系列转换数据保存在内存中就够了。在保存时复制所有立方体的位置,旋转和缩放,并在加载时使用记住的数据重置游戏的生成立方体。最简单的方法是将数据保存在文件中。

2.1 保存路径

游戏文件的存储位置取决于文件系统。Unity会通过Application.persistentDataPath属性提供具体路径地址。此路径是文件夹的位置,完整的路径还需要包含文件名。

路径使用正斜杠反斜杠取决操作系统,这里使用Path.Combine处理细节

1
2
3
private string savePath;
//在Awake里初始化
savePath = Path.Combine(Application.streamingAssetsPath + "saveFile");

2.2 打开文件以便写入

为了保存数据首先要打开文件,通过File.Open方法完成,该方法需要2个参数,文件路径和打开方式。我们为了写入数据,需要创建新文件或替换已有文件,通过FileMode.Create作为第二个参数指定它。File.Open返回一个文件流。

1
2
3
4
public void save()
{
FileStream f = File.Open(savePath, FileMode.Create);
}

我们使用二进制数据写入,需提供文件流作为参数

1
2
3
BinaryWriter writer = new BinaryWriter(f);
//也可以用var关键字
//var writer = new BinaryWriter(f);

2.3 关闭文件

我们可以用Close方法关闭文件,但这不安全。如果打开和关闭文件之间出现问题可能会引发异常,可以用try,finally捕获异常,也可以用Using语句的语法糖简化代码(没有实现IDisposable不能使用using语法糖)

1
2
3
using (
BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create))
){}

2.4写数据

游戏要知道我们何时保存,通过key控制此操作,默认按下S保存

1
2
3
public KeyCode saveGame;
//在Awake中初始化
saveGame = KeyCode.S;

通过调用Write方法将数据写入文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using (
BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create)))
{
//先保存物体个数
writer.Write(cubeList.Count);
for(int i = 0; i < cubeList.Count; i++)
{
Transform t = cubeList[i];
writer.Write(t.localPosition.x);
writer.Write(t.localPosition.y);
writer.Write(t.localPosition.z);
writer.Write(t.localRotation.eulerAngles.x);
writer.Write(t.localRotation.eulerAngles.y);
writer.Write(t.localRotation.eulerAngles.z);
writer.Write(t.localScale.x);
writer.Write(t.localScale.y);
writer.Write(t.localScale.z);
}
}

2.5 加载数据

加载数据要读取文件里的数据,创建一个新方法Load实现此功能

1
2
3
4
5
6
7
8
private void Load()
{
using (
var reader = new BinaryReader(File.Open(savePath, FileMode.Open)))
{

}
}

由于写入的第一个数据是count属性,通过Reader的ReadInt32实现此操作。读取数据后实例化新的立方体并将其添加到列表中。Vector3向量使用float类型,通过ReadSingle读取。Read每读取一个字符都会提升当前流的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int count = reader.ReadInt32();
for(int i = 0; i < count; i++)
{
Vector3 p;
p.x = reader.ReadSingle();
p.y = reader.ReadSingle();
p.z = reader.ReadSingle();
Vector3 r;
r.x = reader.ReadSingle();
r.y = reader.ReadSingle();
r.z = reader.ReadSingle();
Vector3 s;
s.x = reader.ReadSingle();
s.y = reader.ReadSingle();
s.z = reader.ReadSingle();
Transform t = Instantiate(cubePrefab);
t.localPosition = p;
t.localRotation = Quaternion.Euler(r);
t.localScale = s;
cubeList.Add(t);
}

在加载保存的游戏前需要重置当前游戏,设置按下L键调用Load方法

这里需要注意的是,Unity中物体的旋转是由四元数控制的,保存的时候要保存欧拉角的三个值,读取的时候再转化成四元数。

3.抽象存储

以上方式虽能读取二进制数细节,但编写单个3D向量需要三个Write调用。保存和加载对象时,若能在更高层次上进行工作,只需一次方法调用就可以读写整个3D向量。数据以什么方式储存都没有关系。游戏不需要知道这些细节。

3.1 游戏数据的读取器和写入器

为了隐藏读取和写入数据的细节,我们将自己创建读取器和写入器

以写入器为例,GameDataWriter不需要继承MonoBehaviour,因为我们不会把他附加到游戏对象上,它将充当BinaryWriter的包装器。

1
2
3
4
5
6
7
8
9
public class GameDataWrite
{
BinaryWriter writer;

public GameDataWrite (BinaryWriter writer)
{
this.writer = writer;
}
}

重载Write方法写入不同类型的数据。这里旋转选择利用四元数存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void Write(float value)
{
writer.Write(value);
}
public void Write(int value)
{
writer.Write(value);
}
public void Write(Quaternion value)
{
writer.Write(value.x);
writer.Write(value.y);
writer.Write(value.z);
writer.Write(value.w);
}
public void Write(Vector3 value)
{
writer.Write(value.x);
writer.Write(value.y);
writer.Write(value.z);
}

同样的完成一个读取器,充当BinaryReaderd的包装器

1
2
3
4
5
6
7
8
9
public class GameDataReader 
{
BinaryReader reader;

public GameDataReader(BinaryReader reader)
{
this.reader = reader;
}
}

向量和四元数根据写入顺序读取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public float ReadFloat()
{
return reader.ReadSingle();
}
public int ReadInt()
{
return reader.ReadInt32();
}
public Quaternion ReadQuaternion()
{
Quaternion value;
value.x = reader.ReadSingle();
value.y = reader.ReadSingle();
value.z = reader.ReadSingle();
value.w = reader.ReadSingle();
return value;
}
public Vector3 ReadVector3()
{
Vector3 value;
value.x = reader.ReadSingle();
value.y = reader.ReadSingle();
value.z = reader.ReadSingle();
return value;
}

3.2 持久化对象

在Game中写入立方体的transform数据要简单得多,但我们需要代码看起来更加简洁一些,如果可以简单地调用writer.Write(objects [i])将非常方便,但这需要GameDataWriter自己区分transform的细节。

换个思路,Game不需要知道如何保存对象,这是对象自己的责任,Game可以使用对象[i] .Save(writer)保存数据。

我们创建一个PersistableObject组件脚本,该脚本知道如何保存和加载该数据,这个脚本将挂载到预制体上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class PersistableObject : MonoBehaviour
{
public void Save(GameDataWrite write)
{
write.Write(transform.localPosition);
write.Write(transform.localRotation);
write.Write(transform.localScale);
}
public void Load(GameDataReader reader)
{
transform.localPosition = reader.ReadVector3();
transform.localRotation = reader.ReadQuaternion();
transform.localScale = reader.ReadVector3();
}
}

通过预制体创建的对象会附加一个PersistableObject组件。具有多个这样的组件是没有意义的。我们可以通过向类添加DisallowMultipleComponent属性来强制执行此操作。

1
2
3
4
5
[DisallowMultipleComponent]
public class PersistableObject : MonoBehaviour
{
......
}

3.3 持久化存储

现在我们有了一个持久化对象类型,接下来创建一个PersistentStorage类来保存这样的对象。它包含和Game相同的保存和加载逻辑,不同之处在于它仅保存和加载单个PersistableObject实例。将其设为MonoBehaviour,这样我们就可以将其附加到游戏对象上,并且可以初始化其保存路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PersistentStorage : MonoBehaviour
{
private string savePath;

private void Awake()
{
savePath = Path.Combine(Application.streamingAssetsPath + "saveFile");
}

public void Save(PersistableObject o)
{
using (
BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create)))
{
o.Save(new GameDataWrite(writer));
}
}
public void Load(PersistableObject o)
{
using(
BinaryReader reader=new BinaryReader(File.Open(savePath, FileMode.Open)))
{
o.Load(new GameDataReader(reader));
}
}
}

附加此组件到场景中的一个空物体上,它代表了游戏的持久化存储。理论上将可以有多个这样的存储对象,用于存储不同的事物或提供对不同存储类型的访问。

3.4 可持久化游戏

为了利用新的可持久对象方法,我们必须重写Game。将预置的对象的内容类型更改为PersistableObject。调整CreateObject使其可以处理此类型更改。然后删除读取写入的方法。

1
2
3
4
5
6
7
8
9
10
11
12
   public PersistableObject cubePrefab;
private List<PersistableObject> cubeList;
......
private void CreatCube()
{
PersistableObject o = Instantiate(cubePrefab);
Transform t = o.transform;
t.localPosition = Random.insideUnitSphere * 5;
t.localRotation = Random.rotation;
t.localScale = Vector3.one * Random.Range(0.1f, 1);
cubeList.Add(o);
}

让Game依赖于PersistentStorage实例来处理存储数据的细节。添加此类型的公共存储字段,以便我们可以为Game提供对存储对象的引用。为了再次保存和加载游戏状态,我们让Game本身继承了PersistableObject。然后,它可以使用存储加载并保存自身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Game : PersistableObject{
......
public PersistentStorage storage;
......
private void Update()
{
......
else if (Input.GetKeyDown(KeyCode.S))
{
storage.Save(this);
}
else if (Input.GetKeyDown(KeyCode.L))
{
BeginNewGame();
storage.Load(this);
}
}
}

image-20210302170845044

3.5 重新方法

现在我们保存和加载游戏的时候只是通过PersistentStorage调用了Game继承于PersistableObject的Save和Load方法,即保存自身的transform属性。所以我们需要在Game中重写这两个方法来保存对象列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public override void Save(GameDataWrite write)
{
write.Write(cubeList.Count);
for(int i = 0; i < cubeList.Count; i++)
{
cubeList[i].Save(write);
}
}

public override void Load(GameDataReader reader)
{
int count = reader.ReadInt();
for(int i = 0; i < count; i++)
{
PersistableObject o = Instantiate(cubePrefab);
o.Load(reader);
cubeList.Add(o);
}
}

在此之前要将PersistableObject中的Save和Load方法加上virtual关键字

1
2
3
4
5
6
7
8
9
10
11
12
public virtual void Save(GameDataWrite write)
{
write.Write(transform.localPosition);
write.Write(transform.localRotation);
write.Write(transform.localScale);
}
public virtual void Load(GameDataReader reader)
{
transform.localPosition = reader.ReadVector3();
transform.localRotation = reader.ReadQuaternion();
transform.localScale = reader.ReadVector3();
}