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); }
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); } } }
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(); }