關於 web service, unity, blogger 等軟體工程筆記

Unity 遊戲存檔機制淺談,從序列化 (Serialization) 到儲存裝置 (Storage)

Edit icon 沒有留言
Unity 遊戲存檔機制淺談

之前在社團看到的有趣問題,因此綜合以前的開發經驗,整理成這份筆記。

提示:程式碼在 Unity 2018.1.1 中測試

通常來說,一個遊戲存檔與讀取流程可以拆分如下:

遊戲資料存取架構,考慮加密與壓縮

遊戲資料存取架構,考慮加密與壓縮

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

Reference

沒有留言: