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

Script GUIDs Remapping in Unity

Edit icon 沒有留言
Unity

需求,大量修改專案中的 GUIDs,並且保證 Assets 中參考不會遺失 (Reference missing)。

發現問題

剛開始時,只有一個 Unity 遊戲專案要弄,版本控制使用 Git,建立 Git repository 來放置專案所有資源 ,我們將專案架構分成下圖,分成下列三塊:

  • 自家共用程式 (Engine Code):共用的程式碼以及資源,使用 Subgit moudule 方式引用
  • 遊戲專案資源 (App Resources):該遊戲的音效,貼圖以及 Unity 資料 (Scenes, AnimationClips, Meshes, etc.) 等等資源
  • 程式碼邏輯 (App Code):該遊戲的邏輯以及自訂的組件 (Components) 程式碼
遊戲專案架構

遊戲專案架構

雖著時間的推移,我們依循這樣的架構,開了許多新的遊戲專案:

多個遊戲專案架構

建立多個遊戲專案架構

但突然有一天,冒出需求要建立大廳系統 (Lobby),便是要製作一個大廳程式(或是遊戲?),能夠動態加載 (Dynamic loading) 已經建立好的遊戲專案 (Game A, GameB, and GameC…)。因此設想大廳也應該依循相同的方式建置:

加入遊戲大廳專案架構

加入遊戲大廳專案架構

從 Unity 官方文件中,若要實現動態加載的功能需求,最好的方式就是使用 Assetbundles,多看看 Assetbundles 的技術文件以及教學說明,發現我們的目標平台 Mobile & WebGL,並不能使用 Assetbundles 來動態加載程式碼 (Scripts),那這樣要怎麼做下去?

稍微仔細思考推敲,不可能將所有遊戲全部打包在一起發佈,這樣會使得大廳遊戲容量暴大,會讓玩家根本不想要下載,且未來更新要更新這麼大一包,這怎麼會受得了。

但又有加載程式碼的限制,想想後,大概只能把架構改成下圖模式來製作吧。僅遊戲專案 GameA, GameB, and GameC 的遊戲程式碼引用到遊戲引擎 (使用 Symbolic link),遊戲資源自行打包成 Assetbundles,這樣可以大大縮小大廳遊戲容量(程式碼比遊戲資源小很多),又可以滿足動態加載的需求。

支援動態加載的遊戲大廳架構

支援動態加載的遊戲大廳架構

但事情沒有這麼美好,在大廳專案中,使用 Symbolic link 引用各個遊戲的 App Code 後,會發生 Script GUID 發生衝突 (GUID 定義於 *.cs.meta),因而 Unity Editor 會自動調整 Script GUID,如此一來原先專案中的 App Resources 內部所指向的 Scripts 就會不正常,重新開啟原先的遊戲專案後,會發生大量的 Component missing 錯誤,使得遊戲不能正常運作啊。

提示:在 Unity 專案中每個資源都擁有自己獨一無二的 GUID (Unique),作為其他資源使用該資源的參考
注意:整篇文章都基於 Unity Editor 設定,Visible meta files 以及 Force text asset serialization,可參考 Editor settings。先設定 Editor 參數後,Reimport 整個專案 (Reimport all),讓 Unity Editor 重新處理 Assets 儲存格式。
補充:如果以上設定沒有完成,其 Assets 採用 Binary 格式存檔,套用此 Script 將可能會造成 Assets 錯誤,導致資料全部遺失。其錯誤訊息可能是 Invalid serialized file header. File。 2017-07-25

解決方案

一種解決方式,經由 Unity Editor 自動調整完 Script GUID 衝突後,重新開啟遊戲專案手動修理 Component missing 的錯誤,這非常直覺,但當修改的 Script GUIDs 過多時,調整就相當的麻煩。尤其是遊戲專案 GameB & GameC 是複製 GameA 下去調整的,要這樣做根本累死。

另外一種解決方式,寫段程式碼來修改每個遊戲專案的 Scriot GUIDs,確保遊戲專案間的 App Code 不會有 GUID 重複的可能性後,再連結到大廳專案。例如 GameA 中的 App Code 程式碼 GUIDs,都以 00 開頭,GameB 以 01 開頭,GameC 以 02 開頭,如此一來確保所有 App Code GUIDs 都會是唯一不會重覆。

經過一些前期研究,了解 [GUID] https://en.wikipedia.org/wiki/Globally_unique_identifier) 是怎麼樣的格式 (Hex string),使用 Notepad++ 開啟 meta file 以及 unity asset files,也明白這些檔案中如何參照 Script GUID。

修改流程大致上是,要求先輸入一組 Hex string,例如 fe,檢查專案中的所有 App Scripts meta,若 GUID prefex 不等於 fe,就修改其 Script GUID,並且將 Unity assets 參考到該 Script 的連結一併換掉成新的 GUID。

GUIDs 調整例子

GUIDs 調整例子

製作過程

取得輸入

製作輸入介面,要求使用者輸入 Hex string,作為新的 Script GUID prefix。在 Unity Editor 中,若不額外寫 Editor Windows 情況下,大概只能使用檔案儲存視窗,輸入檔名來取得 Hex string 吧:

var inputPath = EditorUtility.SaveFilePanel("Type prefix", string.Empty, "00", "hex");
var prefix = Path.GetFileNameWithoutExtension(inputPath).ToLower();

建立一組正規表示 (Regular expression) 來檢查使用者的輸入:

var prefixRegex =newRegex("[a-fA-F0-9]{1,15}");
if (!prefixRegex.Match(prefix).Success)
{
// TODO: Input is not valid
}

建立 GUID 對應表

接著取得專案中所有要修改的 Scrtipt GUIDs,使用 System.IO 來取得 Scrtipt 位置以及讀取 對應的 meta file 裡的 GUID,並先儲存在列表中(List)。

取得指定路徑下的所有 Script meta files,並且讀取內容,我們使用 C# 開發,因此僅考慮 .cs.meta:

// AppCode 放置路徑為 /Assets/App/Scripts
var workingPath = Path.GetFullPath(Application.dataPath + "/App/Scripts");
var scriptMetas = Directory.GetFiles(workingPath, "*.cs.meta", SearchOption.AllDirectories);
foreach (var scriptMeta in scriptMetas)
{
var metaContent = File.ReadAllText(scriptMeta);

// TODO: Read GUID
}

使用正規表示取得 GUID(爬蟲讀資料,正規表示實在是方便,Regular expression 不學嗎?):

var guidRegx = new Regex("guid:\\s?([a-fA-F0-9]+)");
var match = guidRegx.Match(metaContent);
if (match.Success)
{
var oldGuid = match.Groups[1].Value;

// TODO: Handle GUID
}

接著拿 prefix 以及原有的 GUID 來產生新的 GUID,若這組新的 GUID 已經存在在專案中,則重新產生一組 GUID。此外若發現新 GUID 與舊 GUID 一致,那就沒有意義調整,放棄 GUID 的修改。

var newGuid = prefix + oldGuid.Substring(prefix.Length);
if (newGuid == oldGuid)
{
// TODO: Same GUID, do nothing...
}

if (string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath(newGuid)))
{
// TODO: Exist same Guid assets...
newGuid = BuildUniqueGuid();
}

// TODO: Handle NewGuid

其中產生新的 GUID 且唯一的演算法相同簡單,使用暴力迴圈產生:

while (true)
{
newGuid = System.Guid.NewGuid().ToString("N");
newGuid = prefix + newGuid.Substring(prefix.Length);
if (string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath(newGuid)))
{
break;
}
}

執行 GUID 替換

開啟專案中所有會引用 Scripts 的資源檔,例如場景 (.unity),Prefab (.prefab) 等等,使用 Linq 來篩選條件:

var assets = Directory.GetFiles(Application.dataPath, "*.*", SearchOption.AllDirectories).Where(s => s.EndsWith(".assets") || s.EndsWith(".unity") || s.EndsWith(".prefab") || s.EndsWith(".controller") || s.EndsWith(".cs.meta"));

foreach (var asset in assets)
{
// TODO: Read file and replace Guids
}

因為大量修改 Assets,因此加入下列程式碼,僅在最後重新載入修改的 Assets (Reimport assets),而非每次讓 Unity Editor 發現有修改而自動重新載入一次:

AssetDatabase.StartAssetEditing();
// Read assets and replace GUIDs
AssetDatabase.StopAssetEditing();
AssetDatabase.Refresh(ImportAssetOptions.Default);

完整程式碼

using System.IO;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditor;
public static class Utility
{
[MenuItem("Utility/RemapScriptGuid")]
public static void RemapScriptGuid()
{
var workingPath = Path.GetFullPath(Application.dataPath + "/App");

var prefix = Path.GetFileNameWithoutExtension(EditorUtility.SaveFilePanel("Type prefix", string.Empty, "00", "hex")).ToLower();
var prefixRegex = new Regex("[a-fA-F0-9]{1,15}");
if (string.IsNullOrEmpty(prefix))
{
return;
}
else if (!prefixRegex.Match(prefix).Success)
{
EditorUtility.DisplayDialog("Remapping GUID", string.Format("Prefix '{0}' is not valid.", prefix), "OK");
return;
}

// Get script meta
var mappings = new List<ScriptMapping>();
var scriptMetas = Directory.GetFiles(workingPath, "*.cs.meta", SearchOption.AllDirectories);
var guidRegx = new Regex("guid:\\s?([a-fA-F0-9]+)");
foreach (var scriptMeta in scriptMetas)
{
var metaContent = File.ReadAllText(scriptMeta);
var match = guidRegx.Match(metaContent);
if (match.Success)
{
var guid = match.Groups[1].Value;
var mapping = new ScriptMapping(scriptMeta, guid);
mappings.Add(mapping);
}
}

// Assign new Guids
foreach (var mapping in mappings)
{
var newGuid = prefix + mapping.OldGuid.Substring(prefix.Length);
if (newGuid == mapping.OldGuid)
{
continue;
}
// Check new Guids is exist?
if (!string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath(newGuid)))
{
while (true)
{
// Generate new
newGuid = System.Guid.NewGuid().ToString("N");
newGuid = prefix + newGuid.Substring(prefix.Length);
if (string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath(newGuid)))
{
break;
}
}
}
mapping.NewGuid = newGuid;
}

// Find resources amd replace GUIDs
// *.assets ScriptableObject
// *unity Scene
// *.prefab Prefab
// *.controller Animator
// *.cs.meta Scripts
AssetDatabase.StartAssetEditing();
var assets = Directory.GetFiles(Application.dataPath, "*.*", SearchOption.AllDirectories).Where(s => s.EndsWith(".assets") || s.EndsWith(".unity") || s.EndsWith(".prefab") || s.EndsWith(".controller") || s.EndsWith(".cs.meta"));
var assetsCount = assets.Count();
var assetsIndex = 0;
foreach (var asset in assets)
{
EditorUtility.DisplayProgressBar("Remapping", string.Format("Current: {0}", Path.GetFileName(asset)), Mathf.InverseLerp(0, assetsCount, assetsIndex));
var content = File.ReadAllText(asset);
var replaced = false;
foreach (var mapping in mappings)
{
if (mapping.NewGuid == null)
{
continue;
}
var oldGuid = string.Format("guid: {0}", mapping.OldGuid);
var newGuid = string.Format("guid: {0}", mapping.NewGuid);
if (oldGuid.IndexOf(oldGuid) >= 0)
{
content = content.Replace(oldGuid, newGuid);
replaced = true;
}
}
if (replaced)
{
File.WriteAllText(asset, content);
}
assetsIndex += 1;
}
EditorUtility.ClearProgressBar();
AssetDatabase.StopAssetEditing();
AssetDatabase.Refresh(ImportAssetOptions.Default);
}

class ScriptMapping
{
public ScriptMapping(string path, string guid)
{
this.Path = path;
this.OldGuid = guid;
this.NewGuid = null;
}
public string Path
{
get;
set;
}
public string OldGuid
{
get;
set;
}
public string NewGuid
{
get;
set;
}
}
}

或是從 Github 取得程式碼。

Github

沒有留言: