Unity 數據讀寫與存檔(1)——配置表初探

1.1 與策劃小夥伴協同工作

如果大家在使用Unity的遊戲公司工作,或者對遊戲公司的工作流程與技術有所知曉,相信一定會或多或少地聽說過“配置表”這個東西。

什麼是配置表呢?很簡單,配置表就是一些普通的Excel表格,即.xlsx文件;而使用配置表,則是一種在遊戲的團隊開發過程中十分常見的工作方式。

配置表是做什麼用的?一般來說,配置表與遊戲中的人物屬性、道具屬性等數值設定密切相關。

例如,遊戲中有100名不同的角色,每個角色都擁有各自的名字、生命值、攻擊力和移動速度,不同角色的以上數據各不相同。在遊戲的開發和更新過程中,策劃人員可能經常需要修改這些數據。

對於團隊合作的開發過程而言,怎樣讓策劃人員記錄和修改這些數據呢?很明顯,在代碼內或Unity編輯器內進行編輯是不合適的。理由如下:

(1)首先,C#代碼和Unity編輯器並非爲數據管理所設計。對於【100個不同角色的屬性】這樣的大批量數據,如果在代碼內或Unity界面上進行管理,那麼管理的效率恐怕和手動在txt文件內編輯文本沒有什麼區別;

(2)其次,遊戲的代碼在同一時刻只能有一個正確版本。一旦策劃部門開始編輯數據,那麼程序部門必須停止工作,等待策劃人員將代碼修改完畢並傳回,才能繼續寫新的代碼,這會使協同工作毫無效率可言;

(3)此外,遊戲的策劃人員不一定是計算機類專業出身,可能難以熟練地編輯代碼或操作Unity編輯器。

因此,我們必須找到辦法,在項目中使用Excel表格來管理大批量、有規律且經常需要編輯的數據;同時,必須爲Excel文件在Unity中尋求合適的讀、寫方式,來使程序部門能夠快速讀取並應用來自策劃部門的數值設定,從而實現開發過程中的良好協同性。

1.2 初識配置表

說了這麼多,配置表到底長什麼樣子呢?我們直接根據情境,來看一個簡單而典型的配置表!

假設遊戲中需要定義若干個人物(Unit)的屬性。每個人物具有以下屬性:ID、名稱、生命上限、攻擊力和移動速度。現在我們打開Excel或WPS軟件,新建一個.xlsx文件,來定義兩個遊戲人物:湯姆和傑瑞。

習慣上,我們使用的配置表,在格式和內容含義上滿足以下性質:

·表格中的第一行是表頭。表頭的每一格是一個字段,該字段規定了配置個體需要被定義的一項屬性;

·從第二行開始,每一行代表一個配置個體。依據表頭,每一行都標明瞭一名個體屬性的具體值;

·第一列是個體的ID。每個配置個體必須被賦予一個獨一無二的ID,這是我們對錶內個體進行查、刪、改的依據。

·*各個配置個體是沒有順序的。每個個體所在的行號可以任意變動,而不影響配置表的效力;每個配置個體的ID數值可以是任意值,不需要有任何規律性,也不需要在數值上連續。

*這對於大型項目的工作效率有着至關重要的意義。例如在擁有數萬種道具的大型遊戲中,如果策劃想要再新增一種道具,只需要在配置表末尾另起一個ID即可,並不需要在數萬行的表格內部尋找一個適合的位置和ID數值來插入該道具;想要刪除一個道具時,直接刪除個體所在行即可,其餘道具不需要修改ID來填補空位。

將前面創建的配置表命名爲Unit.xlsx,下面我們將學習在Unity中讀取它。

1.3 讀取Excel文件

【本小節知識主要出自《Unity3D遊戲開發——第2版》,人民郵電出版社,作者:宣雨松(博客:雨松MOMO)】

作者官網:https://www.xuanyusong.com/

建立一個新Unity項目,將Unit.xlsx文件導入Unity,會發現無法對其進行任何操作;因爲,Unity並不直接支持.xlsx這種資源格式,不能直接讀取配置表。C#所依賴的.NET FrameWork也沒有自帶對Excel文件的訪問功能,因此我們引入一個GitHub上的第三方dll庫: EPPlus.dll。在網上搜索,該文件隨處都可以下載到。

Unity可以非常好地支持第三方dll文件。將文件EPPlus.dll直接拖入到Unity資源的任意路徑,它會顯示爲一個拼圖形狀的圖標,代表插件類資源。選中它,將該插件的使用平臺設定爲Editor(編輯器),這個插件就設置完成啦。

設置完成後的EPPlus.dll在Unity中顯示如下圖。

在Unity項目的Assets目錄下建立名爲Excel的文件夾,將前面創建的Unit.xlsx文件放入其中。創建遊戲腳本ReadUnits.cs,代碼內容如下。

using UnityEngine;
using UnityEditor;
using System.IO;
using OfficeOpenXml;//啓用EPPlus插件

public class ReadUnits : MonoBehaviour
{
    [MenuItem("Excel/Read Excel")]//添加Unity編輯器菜單項用來讀表
    static void LoadExcel()
    {
        string path = Application.dataPath + "/Excel/Unit.xlsx";//指定待讀取表格的文件路徑。在編輯器模式下,Application.dataPath就是Assets文件夾

        FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);//建立文件流fs

        ExcelPackage excel = new ExcelPackage(fs);//這是來自第三方插件的功能,將文件流fs視爲Excel文件,開始訪問

        ExcelWorksheets workSheets = excel.Workbook.Worksheets;//查找到工作簿內的各工作表

        ExcelWorksheet workSheet = workSheets[1];//只看第一個工作表,餘者不看

        int colCount = workSheet.Dimension.End.Column;//工作表的列數
        int rowCount = workSheet.Dimension.End.Row;//工作表的行數

        for (int row = 1; row <= rowCount; row++)//從當前工作表的第一行遍歷到最後一行
        { 
            for (int col = 1; col <= colCount; col++)//從第一列遍歷到最後一列
            {
                string text = workSheet.Cells[row, col].Text;//讀取每個單元格中的數據
                Debug.LogFormat("表格座標:({0},{1}),表格內容:{2}", row, col, text);
            }
        }

        Debug.Log("complete");
        return;
    }
}

本段代碼調用UnityEditor功能,在Unity編輯器上提供自定義的選項卡和選項Excel/Read Excel。選中一次該選項,即可調用LoadExcel方法執行讀表操作。

編譯完成後,可以看到Unity編輯器的頂部出現了新的選項卡”Excel”。

現在,選中該選項卡內的Read Excel選項,執行代碼內的LoadExcel()靜態方法,進行讀表。

查看Console頁面,我們看到,Unit.xlsx文件已經被成功解讀,Console頁面中顯示出了表格中每一格的座標和文字內容。

到這裏,我們就在Unity中首次完成了對Excel表格的讀取,是不是很開心?

1.4 處理Excel數據的思路

實現了對Excel的讀取很讓人興奮,但在功能上還頗爲欠缺;我們前面僅僅是將Excel表格中的文字內容輸出到了頁面上——這就好像編程中的Hello World,離實現有用的功能還相距甚遠。

那麼,對於配置表的讀取,我們希望在功能上達到什麼樣的效果呢?

假設在項目中有若干個遊戲物體obj,它們每一個都代表着一名遊戲角色,但它們的具體屬性處於待定狀態。

現在我們希望,當策劃人員在配置表中寫入對遊戲內各角色的屬性設定後,我們通過爲每個obj指定配置表中的對應ID,就能在Unity中實現對該角色屬性的自動設置——將這個待定角色的各項角色屬性,設定成配置表中對應ID所記載的屬性值。這樣一來,我們就能形成順暢的工作流程,從而便捷地將策劃人員在配置表中敲定的屬性數值、名稱文案等內容快速應用到遊戲角色上。

而從策劃部門的體驗而言,只需要向程序人員提交更新過的配置表,即可實現對遊戲內數值、文案等內容的自主修正,而無需程序人員提供任何技術上的幫助。這無疑能大大提高策劃的工作效率。

於是,現在我們需要編寫代碼,來嘗試將我們從Excel文檔中讀取的數據應用到Unity編輯器中。

新建一個腳本文件UnitInfo.cs,該組件用於掛載到遊戲角色上,代表着遊戲內角色的屬性。假想我們的項目中管理着很多角色——數量多到我們不願意手動填寫UnitInfo組件中記載的角色各項屬性。

UnitInfo.cs代碼內容如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}

public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;
}

在遊戲中*隨意建立幾個空物體(或者方塊、圓球、膠囊體......),將UnitInfo組件掛載上去。容易看出,UnitInfo是一個遊戲角色數據的記錄器,其上的各項數據處於未設定狀態。

*這些空物體用來代表遊戲中的各個遊戲角色。由於本篇目講解的是Excel讀表,所以我們不需要讓遊戲角色具有模型、動畫等遊戲性元素,只要能掛上組件就可以了。

我們應該怎樣做,才能從Excel表格中讀出數據,然後應用到UnitInfo組件上呢?

現在,問題就變成了一個編程思路問題。在上一節中,我們已經知道如何獲取表格內各個格子的內容;我們要想將這些內容應用到遊戲角色上,應當以什麼爲操作對象呢?

容易想到,如果Unit表變得很長,例如有200行;每一行代表一份角色數據,那麼此時這個表記載了199份不同的遊戲角色數據。將每一份數據想象成一個球,那麼199份數據放在一起就好像一個海洋球泳池——需要取出一份數據應用到特定遊戲角色時,只要撈出一個特定ID的球即可。

於是我們知道,讀取配表的過程最好以“小球”爲操作對象,也就是說,應當以Excel表的“行”爲操作單元。每一行代表一組數據,這組數據可以定義一個遊戲角色的屬性。

1.5  將表格拆分爲基礎單元

創建腳本文件BaseExcel.cs, 作爲後續功能的基礎支持模塊,用來定義和描述Excel表中以行爲單位的基礎單元。這段代碼非常簡短,僅僅定義了一個IndividualData類,用來描述Excel表格中的一行數據。IndividualData類在創建時會根據配置表的列數,來決定存儲數據字段的數組長度;例如配置表有5列,則數組也應能存儲5個字段。

Tips-1:從這裏開始,我們有關配表讀取的腳本都將使用或引用XlsWork命名空間,從而實現協同工作。

BaseExcel.cs內容如下:

using System;

namespace XlsWork
{
    public class IndividualData
    {
        public string[] Values;
        public IndividualData(int Columns)
        {
            Values = new string[Columns];
        }
    }
}

然後,編寫讀取配置表的核心模塊。新建一個腳本文件UnitXls.cs

根據先前的思路,不難猜出此模塊的功能——將Excel配表文件按行拆成一個個“小球”,然後將拆散之後的各行數據輸出爲一個海洋球池。在本模塊中,這個海洋球池是一個以首列的ID爲鍵,單行全部數據爲值的C#字典;這就好像給每個小球貼上了各自的ID作爲標籤。字典生成後,我們只要向字典輸入ID,即可查詢到具有對應ID的小球,也就是該ID對應的那份遊戲角色數據。

UnitXls.cs內容如下:

using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
using System.IO;
using OfficeOpenXml;

namespace XlsWork
{
    namespace UnitsXls
    {
        public class UnitXls : MonoBehaviour
        {
            /// <summary>
            /// 配表中屬性字段的數量
            /// </summary>
            public static int CountOfAttributes = 5;

            public static Dictionary<int, IndividualData> LoadExcelAsDictionary()
            {
                Dictionary<int, IndividualData> ItemDictionary = new Dictionary<int, IndividualData>();//新建字典,用於存儲以行爲單位的各個操作單元

                string path = Application.dataPath + "/Excel/Unit.xlsx";//指定表格的文件路徑。在編輯器模式下,Application.dataPath就是Assets文件夾

                FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);//建立文件流fs

                ExcelPackage excel = new ExcelPackage(fs);

                ExcelWorksheets workSheets = excel.Workbook.Worksheets;//獲取全部工作表

                ExcelWorksheet workSheet = workSheets[1];//只看第一個工作表,餘者不看

                int colCount = workSheet.Dimension.End.Column;//工作表的列數
                int rowCount = workSheet.Dimension.End.Row;//工作表的行數

                for (int row = 2; row <= rowCount; row++)//從當前工作表的第二行遍歷到最後一行(第一行是表頭,所以不讀取)
                {
                    IndividualData item = new IndividualData(CountOfAttributes);//新建一個操作單元,開始接收本行數據

                    for (int col = 1; col <= colCount; col++)//從第一列遍歷到最後一列
                    {
                        //讀取每個單元格中的數據
                        item.Values[col - 1] = workSheet.Cells[row, col].Text;//將單元格中的數據寫入操作單元
                    }

                    int itemID = Convert.ToInt32(item.Values[0].ToString());//獲取操作單元的ID

                    ItemDictionary.Add(itemID, item);//將ID和操作單元寫入字典
                }

                Debug.Log("complete");
                return ItemDictionary;
            }
        }
    }
}

1.6 查找並應用數據單元

很明顯,我們已經向最終的效果前進了一大步。通過上一節內容,我們成功地編寫了LoadExcelAsDictionary()方法,該方法能夠將Excel文檔逐行拆散,並將各行數據重組爲易於在C#中操作的字典。但是,這項功能還未能與單個的遊戲角色建立聯繫,因此還不能將讀出的數據應用到單個的UnitInfo組件上。現在,我們需要爲UnitInfo加入一些新功能,讓每個UnitInfo組件從配置表中讀取指定ID的數據單元,並將數據應用到自身。

修改UnitInfo組件,引入XlsWork和XlsWork.UnitXls(在UnitXls.cs中定義)命名空間並補充功能。

修改後的UnitInfo.cs如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using XlsWork;
using XlsWork.UnitsXls;


[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}

public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;

    [Header("配表內ID")]
    public int InitFromID;


    public void InitSelf()
    {
        Action init;

        var dictionary = UnitXls.LoadExcelAsDictionary();//調用讀表方法並獲取生成的字典

        //如果字典中沒有查到所需的ID,說明表內沒有相應ID的數據,報出異常
        if (!dictionary.ContainsKey(InitFromID))
        {
            Debug.LogErrorFormat("未能在配表中找到指定的ID:{0}", InitFromID);
            return;
        }
        IndividualData item = dictionary[InitFromID];//如果字典中查到了所需的數據,則將該操作單元記錄下來


        //將操作單元內的數據應用到自身
        //System.Convert在這裏用於實現表格內文本對代碼內數據類型的自適應,將Excel單元格中的字符串轉換成int或其它類型
        init = (() =>
        {
            Settings.ID = Convert.ToInt32(item.Values[0]);
            Settings.Name = Convert.ToString(item.Values[1]);
            Settings.HitPointLimit = Convert.ToInt32(item.Values[2]);
            Settings.Damage = Convert.ToInt32(item.Values[3]);
            Settings.MoveSpeed = Convert.ToInt32(item.Values[4]);
        });

        init();
    }
}

修改之後的UnitInfo組件在Inspector中的外觀如圖。InitFromID屬性要求你填入一個ID——依據這個ID,UnitInfo中新加入的InitSelf方法就可以呼叫UnitXls.LoadExcelAsDictionary()方法來讀表,然後獲取返回的字典,並將指定ID的行數據應用到自身。

1.7  Inspector自定義按鈕

到這裏,準備工作已經萬事大吉,只差最後一步——我們要再次利用UnityEditor提供的自定義編輯器功能,爲單個角色的UnitInfo組件賦予一個自定義的Inspector按鈕。這樣我們就可以對每個UnitInfo組件下達最終的指令,執行讀表操作。

創建腳本UnitInfo_Editor.cs。代碼如下:

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(UnitInfo))]//將本模塊指定爲UnitInfo組件的編輯器自定義模塊
public class UnitXls_Editor : Editor
{
    public override void OnInspectorGUI()//對UnitInfo在Inspector中的繪製方式進行接管
    {
        DrawDefaultInspector();//繪製常規內容

        if(GUILayout.Button("從配表ID刷新"))//添加按鈕和功能——當組件上的按鈕被按下時
        {
            UnitInfo unitInfo = (UnitInfo)target;
            unitInfo.InitSelf();//令組件調用自身的InitSelf方法
        }
    }
}

此腳本可以理解爲UnitInfo.cs的附屬掛件;它的作用是改變UnitInfo組件在Inspector中的顯示內容,爲該組件在Inspector上添加一個按鈕。在編輯模式下單擊按鈕,即可調用InitSelf方法,執行讀表的全過程。

編譯代碼,返回Unity編輯器,可以看到UnitInfo組件的外觀發生了變化:

組件上多出了一個自定義按鈕!

現在,我們只要在InitFromID中填寫爲表格內已有的ID,按下按鈕,就可以對UnitInfo的屬性進行設置。

根據表格的內容,我們填入1試一下。按下按鈕,UnitInfo組件的屬性數值,立即變成了表格內記載的角色“湯姆”的數值:

將Init From ID從1改爲0,以獲取“傑瑞”的數據。再點擊按鈕刷新一次,結果如下:

至此,我們的任務終於大功告成!在艱苦的努力下,Excel文件終於在Unity中摘下了高冷難及的面紗;現在,我們可以使用配置表來管理遊戲中的批量數據,爲開發中的重複性工作和團隊協作提供一種強力的保障。

*1.8  架構優化與擴展(可選內容)

擁有較強編程實力,或有大型項目開發經驗的小夥伴們請往下看。

1.8.1 消除耦合

在這個時候,我們最好重寫一下UnitInfo.csUnitInfo_Editor.cs,將InitSelf方法從UnitInfo.cs轉移到UnitInfo_Editor.cs中。轉移之後,UnitInfo.cs不需要再引入Excel相關命名空間,在完全脫離Excel相關模塊的情況下也能單獨運作,從而極大地降低模塊之間的耦合度。Excel讀配表與遊戲的運行模式完全無關,因此我們在項目的發佈階段,可能需要把整個Excel讀表模塊移除掉。所以,最好不要讓遊戲的主要邏輯與讀表部分產生依賴性。

優化之後的代碼如下:

(1)UnitInfo_Editor.cs(優化版)

using UnityEngine;
using UnityEditor;
using System;
using XlsWork;
using XlsWork.UnitsXls;

[CustomEditor(typeof(UnitInfo))]//將本模塊指定爲UnitInfo組件的編輯器自定義模塊
public class UnitInfo_Editor : Editor
{
    public override void OnInspectorGUI()//對UnitInfo在Inspector中的繪製方式進行接管
    {
        DrawDefaultInspector();//繪製常規內容

        if(GUILayout.Button("從配表ID刷新"))//添加按鈕和功能——當組件上的按鈕被按下時
        {
            UnitInfo unitInfo = (UnitInfo)target;
            Init(unitInfo);
        }
    }

    public void Init(UnitInfo instance)
    {
        Action init;

        var dictionary = UnitXls.LoadExcelAsDictionary();

        if (!dictionary.ContainsKey(instance.InitFromID))
        {
            Debug.LogErrorFormat("未能在配表中找到指定的ID:{0}", instance.InitFromID);
            return;
        }
        IndividualData item = dictionary[instance.InitFromID];

        init = (() =>
        {
            instance.Settings.ID = Convert.ToInt32(item.Values[0]);
            instance.Settings.Name = Convert.ToString(item.Values[1]);
            instance.Settings.HitPointLimit = Convert.ToInt32(item.Values[2]);
            instance.Settings.Damage = Convert.ToInt32(item.Values[3]);
            instance.Settings.MoveSpeed = Convert.ToInt32(item.Values[4]);
        });

        init();
    }
}

(2)UnitInfo.cs只需要退回最初的版本即可。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}

public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;
}

優化之後,我們已經將與Excel有關的配置表相關代碼與遊戲的主邏輯部分完全剝離開。此時,不妨將讀表相關模塊在項目中統一放到單獨的文件夾內,作爲一個“大插件”進行管理。

不需要讀配表時,將黃色框內的內容整體刪除,不會引發任何故障。

1.8.2 模塊可擴展性

在項目中,可能有不止一個地方需要讀取配置表;除了前面展示的角色屬性管理,還可能在道具、商店等更多地方用到配置表。

如果你很細心,或許已經發現,前面的UnitXls模塊被做成了Excel主模塊(即XlsWork)命名空間的一個分支。如果我們需要引入新的配置表讀取系統,只需要將1.8.1圖中Unit文件夾內的模塊另起一份,引入另一個分支命名空間XlsWork.xxx,然後寫入新的讀表邏輯即可。

例如,如果你想要加入一個道具表(Item)系統,那麼你的架構應該是這樣:

黃色框內是配置表系統,藍色框內是遊戲的主邏輯。在此架構下,你可以擴展出任意多個配置表分支,且配置表系統始終不會與遊戲主幹代碼產生相互依賴。

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