unity 網絡遊戲架構設計(第09課:遊戲文件加載讀取)之美

第09課:遊戲文件加載讀取

遊戲中配置文件是必不可少的,它們主要是用於遊戲角色身上的基礎屬性值設置,Unity 遊戲開發一般會使用 JSON、XML、CSV、二進制等等。不論使用哪種文件格式,對於策劃填寫的表格項,都需要程序員使用 C# 腳本定表格文件的結構體,一旦文件表格項發生變換,比如增加一項、減少一項,這裏不是說的數值而是表頭項,腳本定義的文件結構體也要隨之發生變化,這對於程序員來說修改比較繁瑣,一是要保證文件修改的同步,否則,會發生策劃一方改了,另一方程序代碼手動沒改就容易出錯。這在程序開發中經常遇到。如何解決這種問題?

在本篇教程中,我們推出一種可以動態的自動批量生成結構體的腳本文件工具,利用該工具你就無需擔心結構體的改變了,只需要單擊按鈕重新生成腳本文件就可以了,避免程序員手動修改,再編寫工具之前,先把我們的文本文件工具的模塊框架展示如下:

enter image description here

首先要清楚 CSV 文件結構,因爲我們要操作 CSV 文件,下面先給出的是 Excel 文件的定義方式:

enter image description here

通過上圖可以看出,文件的第一行表頭是我們遊戲中要定義的項,文件中包括 string、int、string[]、int[] 等,除了文件中列舉的幾項,另外還有 float 和 float[],以及 bool 和 bool[],csv 文件的獲取其實就是 Excel 表格定義好數據後,將文件另存爲 csv 文件即可。

csv 文件存儲方式如下:

enter image description here

各個項是通過逗號的方式隔開的,文本文件格式搞清楚了,那我們開始工具的製作,我們根據前面框架上的模塊逐步給讀者介紹:

首先我們定義帶有結構體的腳本模板,爲什麼定義模板呢?因爲我們的配置文件會很多的,我們通過模板就可以生成對應的帶有結構體的腳本。否則手工定義對應的腳本文件費時費力。

模板文件的編寫思路跟我們的 C# 設計思想類似,也需要抽離出代碼公有的屬性和方法,怎麼設計模板文件呢?這就要從加載 csv 配置文件說起,程序要做的事情就是讀取加載它們,然後將它們賦值給遊戲中對應的對象,加載文件需要我們定義一個加載配置文件的方法 Load,再說說配置文件的加載,我們程序要提供配置文件的路徑,在模版中就要定義獲取配置文件的方法。每個配置文件對應的類都需要進行加載配置文件操作,下面我們編程實現模板文件,在這裏我們將模板文件定義成 txt 文本文件,接口模版內容如下:

    namespace Tool.Database
{
    public interface IDatabase
    {
        uint TypeID();
        string DataPath();
        void Load();
    }
}

注意文本文件的擴展名是 txt 文件,我們的主要工作是編寫腳本工具將其生成 cs 文件,下面我們再定義接口模版文件的子模板類,這個子模版文件是針對每個配置文件定義的,說了半天,我們還沒定義文件的結構體呢?下面的一句對應的就是配置文件模板的結構體:

        public class $DataClassName
    {
        $DataAttributes
    }

上面的代碼是我們定義的結構體模板,$DataClassName是類名字,我們使用了特殊符號$標註,在工具中會對其做解釋,結構體成員用$DataAttributes表示,也是用了特殊符號$進行標註,結構體定義完了,下面再定義類的實現,我們之所以定義模板文件,主要是因爲我們的配置文件太多,否則我們需要定義多個文件腳本,而定義模版類後,我們只需要用一個模板再通過工具就可以生成文本文件對應的腳本類,其實通過上面定義的父模板類可以看出,子模板文件首先要實現父類定義的方法。除了父類模版定義的三個函數外,我們還需要實現配置文件中的數據存儲,以及數據查找方法,下面是實現的配置文件模板類:

    public class $DataTypeName:IDatabase
    {
        public const uint TYPE_ID = $DataID;
        public const string DATA_PATH = $DataPath;

        private $DataClassName m_tempData = new $DataClassName();
        private string[][] m_datas;

        public $DataTypeName(){}

        public uint TypeID()
        {
            return TYPE_ID;
        }

        public string DataPath()
        {
            return DATA_PATH;
        }

        public void Load()
        {
            TextAsset textData = Resources.Load<TextAsset>(DataPath());
            m_datas = CSVConverter.SerializeCSVData(textData);
        }

        public $DataClassName GetDataByKey(string key)
        {
            for(int cnt = 0; cnt < m_datas.Length; cnt++)
            {
                if(m_datas[cnt][0] == key)
                {
                    $CsvSerialize

                    return m_tempData;
                }
            }

            return null;
        }

        public int GetCount()
        {
            return m_datas.Length;
        }
    }

以上是具體的配置文件模板類,模板文件的擴展名也是 txt,它繼承我們先前定義的父模版類 IDataBase 類,同樣文件中定義了很多$特殊符號,也是爲了做標註使用的,在工具中會針對$這個符號進行解釋。大家知道我們的配置文件是很多的,同樣我們也需要一個對外的統一接口供開發者作爲管理類使用,其實這麼設計跟我們的工廠模式設計思路是一致的。下面進行管理類模板的設計,這種設計思路用得多了可以形成一個思維定式,管理類首先做的事情是註冊存儲所有的文件類,大家看到了我們的每個文件類都有 Load 加載函數,管理類就是通過遍歷的方式去統一加載處理,加載的目的是爲了獲取文件數據,同時也爲我們避免了重複寫多個文本文件類,因爲我們可以使用模板代替。囉嗦了這麼多,下面我們開始實現我們的模板管理類:

    public class DatabaseManager : Singleton<DatabaseManager>
    {
        private Dictionary<uint, IDatabase> m_databases;

        public DatabaseManager()
        {
            m_databases = new Dictionary<uint, IDatabase>();

            $RegisterList

            Load();
        }

        public void Load()
        {
            foreach(KeyValuePair<uint, IDatabase> data in m_databases)
            {
                data.Value.Load();
            }
        }


        public T GetDatabase<T>() where T : IDatabase, new()
        {
            T result = new T();
            if(m_databases.ContainsKey(result.TypeID()))
            {
                return (T)m_databases[result.TypeID()];
            }

            return default(T);
        }

        private void RegisterDataType(IDatabase database)
        {
            m_databases[database.TypeID()] = database;
        }
    }

這樣我們的管理類模版就完成了,關於文件的編寫我們就完成了,下面開始把我們上面寫的模版轉化成腳本,首先我們要定義生成 C# 文件的路徑,便於我們找到它們,還有我們的配置文件存放路徑,我們要通過配置文件生成腳本這些都是需要定義的,先定義幾個常用路徑內容如下所示:

     /*腳本路徑以及模板文件定義*/
    private static string GENERATE_SCRIPT_PATH = Application.dataPath + "/Scripts/Config/GenerateScripts/";

    private static string EDITOR_PATH = Application.dataPath + "/Editor/CSVTool";

    private static string TEMPLATE_IDATABASE_PATH = "Assets/Editor/CSVTool/Template_IDatabase.txt";

    private static string TEMPLATE_DATABASE_PATH = "Assets/Editor/CSVTool/Template_Database.txt";

    private static string TEMPLATE_DATABASEMANAGER_PATH = "Assets/Editor/CSVTool/Template_DatabaseManager.txt";

    private static string CSV_PATH = Application.dataPath + "/Config/";

    private static int DATA_ID;

    private static string REGISTER_LIST;

    private static string CONVERT_LIST;

下面開始解釋模版內容的編寫工作,爲了幫助讀者理清思路,在這裏主要分爲以下幾步。

第一步:創建目錄,進行初始化工作,函數代碼如下:

    private static void Initialize()
    {
        DATA_ID = 0;
        REGISTER_LIST = string.Empty;
        CONVERT_LIST = string.Empty;

        if (Directory.Exists (GENERATE_SCRIPT_PATH))
            Directory.Delete (GENERATE_SCRIPT_PATH,true);

        Directory.CreateDirectory (GENERATE_SCRIPT_PATH);
    }

第二步:獲取定義的模版文件:

       private static string GetTemplate(string path)
    {
        TextAsset txt = (TextAsset)AssetDatabase.LoadAssetAtPath (path, typeof(TextAsset));
        return txt.text;
}

第三步:生成腳本文件函數:

 private static void GenerateScript(string dataName, string data)
    {
        dataName = GENERATE_SCRIPT_PATH + dataName + ".cs";

        if (File.Exists (dataName))
            File.Delete (dataName);

        StreamWriter sr = File.CreateText (dataName);
        sr.WriteLine (data);
        sr.Close ();
    }

第四步:生成文本文件父類腳本,因爲我們定義的文件模板父類其實只是擴展名字不一樣,可以直接生成腳本文件,不需要做特殊處理:

        private static void CreateIDatabaseScript()
    {
        string template = GetTemplate (TEMPLATE_IDATABASE_PATH);
        GenerateScript ("IDatabase", template);
    }

第五步:加載 csv 配置文件,同時生成對應的腳本文件,每張表格對應一個配置文件腳本,在這裏先加載 csv 配置文件:

        private static void CreateDatabaseScript()
    {
        string[] csvPaths = Directory.GetFiles (CSV_PATH, "*.csv", SearchOption.AllDirectories);
        string assetPath = "";

        TextAsset textAsset = null;

        for (int cnt = 0; cnt < csvPaths.Length; cnt++) {
            assetPath = "Assets" + csvPaths [cnt].Replace (Application.dataPath, "").Replace ('\\', '/');

            textAsset = (TextAsset)AssetDatabase.LoadAssetAtPath (assetPath, typeof(TextAsset));

            REGISTER_LIST += string.Format ("RegisterDataType(new {0}Database());\n", textAsset.name);

            if(cnt != csvPaths.Length - 1)
                REGISTER_LIST += "\t\t\t";
            CONVERT_LIST += string.Format ("CsvToJsonConverter.Convert<{0}Data>(\"{0}\"); \n", textAsset.name);

            if(cnt != csvPaths.Length - 1)
                CONVERT_LIST += "\t\t\t";

            CreateDatabaseScript (textAsset);

        }
    }

第六步:我們在上述函數中調用了函數接口 CreateDatabaseScript(TextAsset textAsset),這個函數是用於實現對應配置文件的腳本 cs 文件,也就是解釋我們的模板文件:

        private static void CreateDatabaseScript(TextAsset textAsset)
    {
        DATA_ID++;
        string template = GetTemplate (TEMPLATE_DATABASE_PATH);
        template = template.Replace ("$DataClassName",textAsset.name + "Data");
        template = template.Replace ("$DataAttributes",GetClassParameters(textAsset));
        template = template.Replace ("$CsvSerialize",GetCsvSerialize(textAsset));
        template = template.Replace ("$DataTypeName",textAsset.name + "Database");
        template = template.Replace ("$DataID", DATA_ID.ToString());
        template = template.Replace ("$DataPath", "\"/" + textAsset.name + "\"");

        GenerateScript (textAsset.name + "Database", template);

    }

第七步:在上述函數中實現了兩個函數的調用:GetClassParameters 和 GetCsvSerialize,下面把兩個函數的實現展示如下:

    private static string GetClassParameters(TextAsset textAsset)
    {
        string[] csvParameter = CSVConverter.SerializeCSVParameter (textAsset);
        int keyCount = csvParameter.Length;

        string classParameters = string.Empty;

        for (int cnt = 0; cnt < keyCount; cnt++) {
            string[] attributes = csvParameter [cnt].Split (new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
            classParameters += string.Format ("public {0} {1};", attributes[0], attributes[1]);

            if (cnt != keyCount - 1) {
                classParameters += "\n";
                classParameters +="\t\t";
            }
        }
        return classParameters;
    }

    private static string GetCsvSerialize(TextAsset textAsset)
    {
        string[] csvParameter = CSVConverter.SerializeCSVParameter (textAsset);

        int keyCount = csvParameter.Length;

        string csvSerialize = string.Empty;

        for (int cnt = 0; cnt < keyCount; cnt++) {
            string[] attributes = csvParameter [cnt].Split (new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);

            if (attributes [0] == "string") {
                csvSerialize += string.Format ("m_tempData.{0} = m_datas[cnt][{1}];", attributes [1], cnt);
            }
            else if (attributes [0] == "bool") {
                csvSerialize += GetCsvSerialize (attributes, cnt, "0");
            }
            else if (attributes [0] == "int") {
                csvSerialize += GetCsvSerialize (attributes, cnt, "0");
            }
            else if (attributes [0] == "float") {
                csvSerialize += GetCsvSerialize (attributes, cnt, "0.0f");
            }
            else if (attributes [0] == "string[]") {
                csvSerialize += string.Format ("m_tempData.{0} = CSVConverter.ConvertToArray<string>(m_datas[cnt][{1}]);",
                    attributes [1], cnt);
            }
            else if (attributes [0] == "bool[]") {
                csvSerialize += string.Format ("m_tempData.{0} = CSVConverter.ConvertToArray<bool>(m_datas[cnt][{1}]);",
                    attributes [1], cnt);
            }
            else if (attributes [0] == "int[]") {
                csvSerialize += string.Format ("m_tempData.{0} = CSVConverter.ConvertToArray<int>(m_datas[cnt][{1}]);",
                    attributes [1], cnt);
            }
            else if (attributes [0] == "float[]") {
                csvSerialize += string.Format ("m_tempData.{0} = CSVConverter.ConvertToArray<float>(m_datas[cnt][{1}]);",
                    attributes [1], cnt);
            }

            if (cnt != keyCount - 1) {
                csvSerialize += "\n";
                csvSerialize +="\t\t";
            }
        }

        return csvSerialize;
    }

以上兩個函數就是生成對應的腳本文件,它裏面主要是實現了對模板文件的解釋,它調用函數 GetCsvSerialize 用於對 csv 配置文件進行序列化操作:

        private static string GetCsvSerialize(string[] attributes, int arrayCount, string defaultValue)
    {
        string csvSerialize = "";
        csvSerialize += string.Format ("\n\t\t\tif(!{0}.TryParse(m_datas[cnt][{1}], out m_tempData.{2}))\n", attributes [0], arrayCount, attributes [1]);

        csvSerialize += "\t\t\t{\n";
        csvSerialize += string.Format ("\t\t\t\tm_tempData.{0} = {1};\n",attributes[1], defaultValue);
        csvSerialize += "\t\t\t}\n";

        return csvSerialize;

    }

第八步:完成對管理類的模板轉化函數實現,這個相對來說比較簡單,代碼如下:

private static void CreateDatabaseManagerScript()
{
    string template = GetTemplate (TEMPLATE_DATABASEMANAGER_PATH);
    template = template.Replace ("$RegisterList", REGISTER_LIST);
    GenerateScript ("DatabaseManager", template);
}

第九步:將我們實現的函數彙總在一起就實現了腳本工具如下所示:

    //生成工具
[MenuItem("CSVTool/Database/Generate Script")]
public static void GenerateScript()
{
    Initialize (); 
    CreateIDatabaseScript ();
    CreateDatabaseScript ();
    CreateDatabaseManagerScript ();

    AssetDatabase.Refresh ();
}

第十步:我們在上面的函數中調用了類 CSVConverter 中的方法,這個類主要是解釋 csv 文件的,代碼如下:

    public class CSVConverter 
{
    public static string[] SerializeCSVParameter(TextAsset csvData)
    {
        string[] lineArray = csvData.text.Replace ("\n", string.Empty).Split("\r"[0]);
        return lineArray [0].Split (',');
    }

    public static string[][] SerializeCSVData(TextAsset csvData)
    {
        string[][] csv;
        string[] lineArray = csvData.text.Replace ("\n", string.Empty).Split("\r"[0]);
        csv =new string[lineArray.Length - 1][];
        for(int i = 0; i < lineArray.Length - 1; i++)
        {
            csv[i] = lineArray[i + 1].Split(',');
        }

        return csv;

    }

    public static T[] ConvertToArray<T>(string value)
    {
        string[] temp = value.Split (';');
        int arrayLength = 0;

        for (int cnt = 0; cnt < temp.Length; cnt++) {
            if (string.IsNullOrEmpty (temp [cnt])) {
                continue;
            }
            arrayLength++;
        }

        T[] array = new T[arrayLength];
        int pointer = 0;
        for (int cnt = 0; cnt < temp.Length; cnt++) {
            if (string.IsNullOrEmpty (temp [cnt]))
                continue;
            array [pointer] = (T)Convert.ChangeType (temp [cnt], typeof(T));
            pointer++;
        }

        return array;

    }

}

下面給讀者介紹如何使用我們的工具生成代碼,先把配置文件和模板文件放到工程中,對應的目錄如下:

enter image description here

接下來利用編寫的工具生成與配置文件對應的代碼腳本,操作如下:

enter image description here

點擊按鈕後,生成與對應的代碼腳本如下所示:

enter image description here

現在我們通過案例把測試代碼展示如下所示:

    public class TestCSV : MonoBehaviour {
    void Awake()
    {   
        DatabaseManager.Instance.Load();

        PrintPlayerData ();
        PrintWeaponData ();
    }

    private void PrintPlayerData()
    {
        PlayerDatabase playerDatabase = DatabaseManager.Instance.GetDatabase<PlayerDatabase>();

        PlayerData playerData = null;

        for (int cnt = 0; cnt < playerDatabase.GetCount (); cnt++) {
            playerData = playerDatabase.GetDataByKey (cnt.ToString());
            Debug.Log (string.Format("PlayerData_0{0}: key = {1}, level = {2}, Hp = {3}, Exp = {4}",
                cnt, playerData.Key, playerData.Level, playerData.Hp, playerData.Exp));

        }
    }

    private void PrintWeaponData()
    {
        WeaponDatabase weaponDatabase = DatabaseManager.Instance.GetDatabase<WeaponDatabase>();

        WeaponData weaponData = null;

        for (int cnt = 1; cnt < weaponDatabase.GetCount (); cnt++) {
            weaponData = weaponDatabase.GetDataByKey (cnt.ToString ());
            Debug.Log(string.Format("WeaponData_{0}: Key = {1}, Name = {2}",cnt, weaponData.Key, weaponData.Name));

            for (int lv = 0; lv < weaponData.Atk.Length; lv++) {
                Debug.Log (string.Format("Lv.{0}, Atk = {1}", lv + 1, weaponData.Atk[lv]));
            }

            for (int lv = 0; lv < weaponData.Rarity.Length; lv++) {
                Debug.Log (string.Format("Lv.{0}, Rarity = {1}", lv + 1, weaponData.Rarity[lv]));
            }
        }

    }
}

測試代碼會把打印的 Log 展示出來,讀者可以按照測試代碼去使用,非常方便。在這裏也是起到拋磚引玉的作用,如讀者有更好的做法可以一起交流分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章