Unity 遊戲存檔機制淺談,從序列化 (Serialization) 到儲存裝置 (Storage)
之前在社團看到的有趣問題,因此綜合以前的開發經驗,整理成這份筆記。
通常來說,一個遊戲存檔與讀取流程可以拆分如下:
- Object:遊戲物件狀態,任何存檔需要紀錄的物件狀態,也許是遊戲世界關卡進度、角色屬性等級、持有道具狀態等等
- Serialized data:序列化資料,將遊戲物件狀態轉換成可儲存的格式(可儲存於檔案或是網路傳輸)
- Encrypted data:加密資料,使用可逆加密演算法(reversible encryption)將資料加密保護,避免私自竄改遊戲存檔(可選擇不加密)
- Compressed data:壓縮資料,減少資料儲存所需的空間,尤其是在有限儲存空間特別需要考慮(可選擇不壓縮)
- Data storage:可永久儲存的空間,可以是本機硬碟 (local disk)、雲端伺服器的空間 (cloud storage)、或是其他儲存設備
上述的加密與壓縮順序可對調,甚至可任意忽略該些步驟,加密或是壓縮可以說是儲存的擴充步驟,本文僅探討最核心的流程:
關於加密與壓縮,可參考之後的文章:
序列化與反序列化 (Serialization and Deserialization)
關於資料序列化 (Serialization),是將遊戲物件或是狀態轉換成一個可儲存格式的過程,反序列化 (Deserialization) 則是將可儲存格式還原成原本的遊戲物件或是狀態。
首先考慮範例資料結構,記錄玩家狀態以及持有道具資料:
[System.Serializable]
public class PlayerData {
public string name;
public int lv;
public int exp;
public ItemData[] items = new ItemData[0];
}
[System.Serializable]
public struct ItemData
{
public int id;
public int count;
}
其序列化可選擇方案有很多種,就已知理解整理如下:
- 自幹方案,全部得自己來安排
- 使用 .Netframework 提供的 BinaryFormatter
- 標準序列化格式:XML
- 更輕量的標準序列化格式:JSON
- 人好閱讀且更強大特性的標準序列化格式:YAML
- 支援變數 (support variables)
- 其他選擇方案 Google Protocol Buffers
- 使用
.proto
格式先寫訊息結構後,用其編譯器建立目標語言的程式碼,再用呼叫該些程式碼函式來執行序列化 & 反序列化 - 相較於其他方案會比較麻煩些,沒有準備範例程式碼,請參考官方文件說明
- 使用
序列化程式碼範例
在 Unity 實作各種格式的程式碼,參考以下範例。
using System.IO;
using System.Text;
public partial class PlayerData
{
public byte[] Serialize()
{
using (var ms = new MemoryStream())
{
using (var w = new BinaryWriter(ms, Encoding.UTF8))
{
w.Write(this.name);
w.Write(this.lv);
w.Write(this.exp);
w.Write(this.items == null ? 0 : this.items.Length);
if (this.items != null)
{
foreach (var item in this.items)
{
w.Write(item.Serialize());
}
}
return ms.ToArray();
}
}
}
public void Deserialize(byte[] b)
{
using (var ms = new MemoryStream(b))
{
using (var r = new BinaryReader(ms, Encoding.UTF8))
{
this.name = r.ReadString();
this.lv = r.ReadInt32();
this.exp = r.ReadInt32();
this.items = new ItemData[r.ReadInt32()];
for (var i = 0; i < this.items.Length; i++)
{
var item = new ItemData();
item.Deserialize(r);
this.items[i] = item;
}
}
}
}
}
public partial struct ItemData
{
public byte[] Serialize()
{
using (var ms = new MemoryStream(8))
{
using (var w = new BinaryWriter(ms))
{
w.Write(this.id);
w.Write(this.count);
return ms.ToArray();
}
}
}
public void Deserialize(byte[] b)
{
using (var ms = new MemoryStream(b))
{
using (var r = new BinaryReader(ms))
{
this.id = r.ReadInt32();
this.count = r.ReadInt32();
}
}
}
public void Deserialize(BinaryReader r)
{
this.id = r.ReadInt32();
this.count = r.ReadInt32();
}
}
public static void CustomExample(PlayerData playerData)
{
// Serialization
var serializedData = playerData.Serialize();
// Deserialization
var playerObject = new PlayerData();
playerObject.Deserialize(serializedData);
}
using System.Runtime.Serialization.Formatters.Binary;
public static void BinaryFormatterExample(PlayerData playerData)
{
// Serialization
var serializedData = (byte[])null;
var formatter = new BinaryFormatter();
using (var ms = new MemoryStream())
{
formatter.Serialize(ms, playerData);
serializedData = ms.ToArray();
}
// Deserialization
using (var ms = new MemoryStream(serializedData))
{
playerData = (PlayerData)formatter.Deserialize(ms);
}
}
using System.Text;
using System.Xml.Serialization;
public static void XMLExample(PlayerData playerData)
{
// Serialization
var serializedData = (byte[])null;
var serializer = new XmlSerializer(typeof(PlayerData));
using (var ms = new MemoryStream())
{
using (var sw = new StreamWriter(ms, Encoding.UTF8))
{
serializer.Serialize(sw, playerData);
serializedData = ms.ToArray();
}
}
// Deserialization
using (var ms = new MemoryStream(serializedData))
{
using (var sr = new StreamReader(ms, Encoding.UTF8))
{
playerData = (PlayerData)serializer.Deserialize(sr);
}
}
}
using UnityEngine;
public static void JSONExample(PlayerData playerData)
{
// Serialization
var serializedData = JsonUtility.ToJson(playerData);
// Deserialization
playerData = JsonUtility.FromJson<PlayerData>(serializedData);
}
// 注意採用 YamlDotNet Version 4.3.1 來測試,其序列化規則與 Unity 不同,僅處理 Properties,若使用範例資料 PlayerData 進行序列化,應不會有任何資料被序列化。但可自建 ITypeInspectors 使得支援 Fields 資料序列化,更多細節請參考該 repository 的討論。
using YamlDotNet.Serialization;
public static void YAMLExample(PlayerData playerData)
{
// Serialization
var serializer = new SerializerBuilder().Build();
var serializedData = serializer.Serialize(playerData);
// Deserialization
var deserializer = new DeserializerBuilder().Build();
playerData = deserializer.Deserialize<PlayerData>(serializedData);
}
序列化結果範例
考慮以下這組遊戲狀態:
var p = new PlayerData{
name = "Siyuan",
lv = 10,
exp = 1000,
items = new ItemData[]{
new ItemData(){ id = 1, count = 1, },
new ItemData(){ id = 2, count = 3, },
},
};
序列化成各種格式的資料:
# in hash string
0653697975616E0A000000E80300000200000001000000010000000200000003000000
# in hash string
0001000000FFFFFFFF01000000000000000C020000000F417373656D626C792D43536861727005010000000A506C617965724461746104000000046E616D65026C7603657870056974656D730100000408080A4974656D446174615B5D020000000200000006030000000653697975616E0A000000E80300000904000000070400000000010000000200000004084974656D44617461020000000505000000084974656D446174610200000002696405636F756E740000080802000000010000000100000001060000000500000002000000030000000B
<?xml version="1.0" encoding="utf-8"?>
<PlayerData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<name>Siyuan</name>
<lv>10</lv>
<exp>1000</exp>
<items>
<ItemData>
<id>1</id>
<count>1</count>
</ItemData>
<ItemData>
<id>2</id>
<count>3</count>
</ItemData>
</items>
</PlayerData>
// Original{"name":"Siyuan","lv":10,"exp":1000,"items":[{"id":1,"count":1},{"id":2,"count":3}]}
// After adjusting text indents{ "name":"Siyuan", "lv":10, "exp":1000, "items":[ { "id":1, "count":1 }, { "id":2, "count":3 } ] }
name: Siyuan
lv: 10
exp: 1000
items:
- id: 1
count: 1
- id: 2
count: 3
序列化格式選擇
因應需求來選用序列化格式,通常來說,如果需要資料交換,到伺服器進行資料驗證,會建議選用標準序列化格式 (JSON or YAML),畢竟伺服器端會有現成的序列化函式庫;如果儲存空間有限,會建議選擇輕量化的序列化格式(e.g. 不會選擇 XML)。
若就一個不需要儲存大量資料的 Unity 單機遊戲來說,會直接使用已有內建函式庫的 JSON 格式,或是擁有更多設定的第三方 JSON 函式庫(例如 Json.NET);若需要人工編輯則會考慮好讀的 YAML 格式;若需要考慮序列化資料量要夠小最小,會考慮使用 Protocol Buffers。不選擇自幹模式自己重新噪輪子。
如果整理一個簡單表格進行比較,在 Unity 中使用相同遊戲狀態物件進行序列化成各式格式比較,數字越小表示排名越前面:
所佔容量 | 處理速度* | 實作複雜度 | 標準格式** | 附註 | |
---|---|---|---|---|---|
Custom | 1 | 1 | 複雜 | No | 沒事不會硬幹 |
BinaryFormatter | 2 | 2 | 內建 | .Net 專用 | 不會想跟 .Net 綁定 |
XML | 5 | 5 | 內建 | Yes | 序列化後的格式太大 |
JSON | 3 | 3 | 內建 | Yes | 一般人的選擇 |
YAML | 4 | 4 | 需找第三方函式庫 | Yes | 如果需要人編輯 |
- * 依照序列化檔案格式來猜測速度(實際應當以各個函式庫實作為準)
- ** 表示能在各種語言找到實作該格式序列化以及反序列化之函式庫
關於序列化函式庫可參考 Github 的整理,Awesome-dotnet 或者是 Awesome-dotnet Core。
儲存與讀取 (Save and Load)
儲存,即是將遊戲資料儲存在可永久紀錄的空間,可能是本機的硬碟檔案 (local)、雲端伺服器空間等 (remote)、或者是其他儲存設備。讀取,便是將該些遊戲資料從儲存設備中取出。
若要本機端硬碟儲存資料大致上有以下幾種方式:
- PlayerPrefs (in String)
- 需考慮在各個平台實作的容量限制
- Windows 平台,若是 standard format 僅有 1MB 容量 (see Registry Element Size Limits)
- 若序列化檔案格式非字串,則須考慮使用 Base64 轉換成字串後儲存(需要考慮
\0
空字符問題)
void PlayerPrefsExample(byte[] serializedData) { var s = System.Convert.ToBase64String(serializedData); PlayerPrefsExample(s); } void PlayerPrefsExample(string serializedData) { const string key = "gamesave"; // Save UnityEngine.PlayerPrefs.SetString(key, serializedData); // Load serializedData = UnityEngine.PlayerPrefs.GetString(key); }
- 寫入檔案
- 遊戲存檔最適合方案
- 一律寫入到 Unity 所提供長期儲存的資料夾路徑
Application.persistentDataPath
- 要注意上述路徑在 Android & iOS 會根據 Bundle Identifier 來產生(改變 Bundle Identifier 將無法讀取到之前得檔案)
using UnityEngine; using System.IO; using System.Text; void FileExample(string serizliedData) { var raws = Encoding.UTF8.GetBytes(serizliedData); FileExample(raws); } void FileExample(byte[] serizliedData) { const string fileName = "gamesave.dat"; var filePath = Application.persistentDataPath + "/" + fileName; // Save try { File.WriteAllBytes(filePath, serizliedData); } catch (System.Exception e) { // TODO: Handle exception } // Load try { serizliedData = File.ReadAllBytes(filePath); } catch (System.Exception e) { // TODO: Handle exception } }
如果是雲端伺服器空間,那就得看雲端伺服器的 API 文件來進行操作,例如 Firebase 可參考其文件:Firebase: Saving Data,串接 Steam 可參考其文件:Steamworks: Steam Cloud,如果是自架伺服器?問實作該功能的工程師吧。
完整存檔讀取範例,採用 JSON 格式寫入至硬碟檔案
public static void Save(object gameState, string fileName = "gamesave.dat")
{
var serializedData = JsonUtility.ToJson(gameState);
var filePath = Application.persistentDataPath + "/" + fileName;
File.WriteAllBytes(filePath, serizliedData);
}
public static T Load<T>(string fileName = "gamesave.dat")
{
var filePath = Application.persistentDataPath + "/" + fileName;
var serizliedData = ([]byte)(null)
try
{
serizliedData = File.ReadAllBytes(filePath);
}
catch (System.IO.FileNotFoundException)
{
return null;
}
return JsonUtility.FromJson<T>(serializedData);
}
沒有留言: