思考并回答以下问题:
游戏中配置文件是必不可少的,它们主要是用于游戏角色身上的基础属性值设置,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 文本文件,接口模版内容如下:1
2
3
4
5
6
7
8
9 namespace Tool.Database
{
public interface IDatabase
{
uint TypeID();
string DataPath();
void Load();
}
}
注意文本文件的扩展名是 txt 文件,我们的主要工作是编写脚本工具将其生成 cs 文件,下面我们再定义接口模版文件的子模板类,这个子模版文件是针对每个配置文件定义的,说了半天,我们还没定义文件的结构体呢?下面的一句对应的就是配置文件模板的结构体:1
2
3
4 public class $DataClassName
{
$DataAttributes
}
上面的代码是我们定义的结构体模板,$DataClassName是类名字,我们使用了特殊符号$标注,在工具中会对其做解释,结构体成员用$DataAttributes表示,也是用了特殊符号$进行标注,结构体定义完了,下面再定义类的实现,我们之所以定义模板文件,主要是因为我们的配置文件太多,否则我们需要定义多个文件脚本,而定义模版类后,我们只需要用一个模板再通过工具就可以生成文本文件对应的脚本类,其实通过上面定义的父模板类可以看出,子模板文件首先要实现父类定义的方法。除了父类模版定义的三个函数外,我们还需要实现配置文件中的数据存储,以及数据查找方法,下面是实现的配置文件模板类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46public 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 加载函数,管理类就是通过遍历的方式去统一加载处理,加载的目的是为了获取文件数据,同时也为我们避免了重复写多个文本文件类,因为我们可以使用模板代替。啰嗦了这么多,下面我们开始实现我们的模板管理类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38public 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# 文件的路径,便于我们找到它们,还有我们的配置文件存放路径,我们要通过配置文件生成脚本这些都是需要定义的,先定义几个常用路径内容如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 /*脚本路径以及模板文件定义*/
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;
下面开始解释模版内容的编写工作,为了帮助读者理清思路,在这里主要分为以下几步。
第一步:创建目录,进行初始化工作,函数代码如下:1
2
3
4
5
6
7
8
9
10
11private 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);
}
第二步:获取定义的模版文件:1
2
3
4
5 private static string GetTemplate(string path)
{
TextAsset txt = (TextAsset)AssetDatabase.LoadAssetAtPath (path, typeof(TextAsset));
return txt.text;
}
第三步:生成脚本文件函数:1
2
3
4
5
6
7
8
9
10
11private 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 ();
}
第四步:生成文本文件父类脚本,因为我们定义的文件模板父类其实只是扩展名字不一样,可以直接生成脚本文件,不需要做特殊处理:1
2
3
4
5 private static void CreateIDatabaseScript()
{
string template = GetTemplate (TEMPLATE_IDATABASE_PATH);
GenerateScript ("IDatabase", template);
}
第五步:加载 csv 配置文件,同时生成对应的脚本文件,每张表格对应一个配置文件脚本,在这里先加载 csv 配置文件:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 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 文件,也就是解释我们的模板文件:1
2
3
4
5
6
7
8
9
10
11
12
13
14 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,下面把两个函数的实现展示如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67private 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 配置文件进行序列化操作:1
2
3
4
5
6
7
8
9
10
11
12 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;
}
第八步:完成对管理类的模板转化函数实现,这个相对来说比较简单,代码如下:1
2
3
4
5
6private static void CreateDatabaseManagerScript()
{
string template = GetTemplate (TEMPLATE_DATABASEMANAGER_PATH);
template = template.Replace ("$RegisterList", REGISTER_LIST);
GenerateScript ("DatabaseManager", template);
}
第九步:将我们实现的函数汇总在一起就实现了脚本工具如下所示:1
2
3
4
5
6
7
8
9
10
11 //生成工具
[ ]
public static void GenerateScript()
{
Initialize ();
CreateIDatabaseScript ();
CreateDatabaseScript ();
CreateDatabaseManagerScript ();
AssetDatabase.Refresh ();
}
第十步:我们在上面的函数中调用了类 CSVConverter 中的方法,这个类主要是解释 csv 文件的,代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48 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
现在我们通过案例把测试代码展示如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44 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 展示出来,读者可以按照测试代码去使用,非常方便。在这里也是起到抛砖引玉的作用,如读者有更好的做法可以一起交流分享。