Unity编辑器扩展实战:5分钟构建自定义Excel数据导入工具
1. 项目概述:为什么我们需要一个自定义的Excel工具窗口?
在Unity项目开发中,尤其是涉及大量配置数据驱动的游戏(如RPG、策略、模拟经营等),策划同学最常用的工具就是Excel。数值平衡、关卡配置、道具属性、对话文本……这些海量数据最初都躺在Excel表格里。传统的流程是:策划改表 -> 导出为CSV/JSON -> 程序写解析代码 -> 导入Unity。这个过程繁琐、容易出错,且每次微调都需要重复操作,沟通成本极高。
一个集成在Unity编辑器内的自定义Excel工具窗口,就能彻底改变这个局面。它允许策划或开发者直接在Unity中打开、预览甚至编辑Excel数据,并实时将结构化数据映射到游戏内的ScriptableObject或预制体上。这不仅仅是“读取Excel”,而是打造一个连接数据生产端(Excel)与游戏运行时(Unity)的高效数据管道。想象一下,策划调整了一个角色的攻击力数值,你只需要在Unity里点一下“导入”按钮,游戏内所有引用该角色的地方立即生效,无需重启游戏,甚至无需重新打包。这种效率提升,对于追求快速迭代的项目来说,是质的飞跃。
我经历过太多因为数据配置错误导致的Bug排查到深夜的情况。一个字段名对不上、一个数据类型转换出错,都可能导致游戏逻辑崩溃。自己动手做一个定制化的工具窗口,不仅能固化数据导入规范,还能加入数据校验、版本对比等高级功能,把问题扼杀在摇篮里。今天,我就带你用大约5分钟的核心骨架时间,搭建一个属于你自己的、功能可无限扩展的Excel工具窗口。我们将从零开始,涵盖窗口创建、Excel解析、UI绘制到数据应用的完整闭环,并提供可直接复用的完整代码。
2. 核心思路与架构设计
在动手写代码之前,我们先厘清核心思路。我们的目标不是做一个通用的、大而全的Excel编辑器(那是Office的事),而是做一个高度定制化、服务于特定游戏数据格式的导入工具。因此,架构设计上要遵循“轻量、聚焦、可扩展”的原则。
2.1 技术选型与依赖分析
首先,我们需要选择在Unity中读取Excel文件的方式。Unity本身并不直接支持.xlsx或.xls文件。主流方案有以下几种:
- EPPlus、NPOI等.NET库:功能强大,能完整读写Excel文件,但需要处理DLL兼容性,在部分Unity版本或平台上可能遇到问题。
- 第三方Unity Asset Store插件(如ExcelDataReader、SimpleJSON):封装较好,开箱即用,但可能需要付费,且灵活性受插件限制。
- 将Excel另存为CSV后读取:这是最轻量、兼容性最好的方案。策划依然用Excel维护数据,最终通过“另存为CSV”提供数据源。Unity内置的
StreamReader可以轻松读取CSV。
对于这个“5分钟实战”的目标,我强烈推荐方案3:CSV路径。理由如下:
- 零依赖:无需引入任何第三方DLL或插件,项目最干净。
- 简单可靠:CSV是纯文本格式,解析逻辑简单,不易出错。
- 流程可控:可以要求策划将最终版Excel“另存为CSV”到指定项目目录,这本身就是一个简单的发布和版本管理步骤。
- 性能好:文本解析速度快。
当然,它的缺点是失去了Excel中原生的公式、多工作表、单元格格式等高级特性。但对于游戏配置数据导入这个核心场景,我们99%的时间只需要纯数据。所以,我们的架构将基于CSV解析来构建。
2.2 工具窗口的基本结构
一个标准的Unity编辑器工具窗口,继承自EditorWindow。其核心生命周期和UI构建依赖于OnGUI()方法。我们的窗口结构规划如下:
- 顶部工具栏:包含“选择CSV文件”、“导入”、“验证数据”、“保存为Asset”等按钮。
- 数据预览区域:以表格或列表形式展示从CSV解析出来的原始数据。
- 数据映射与配置区域:让用户定义CSV的每一列对应到游戏数据类(如
ItemData)的哪个字段,并选择数据类型(int, float, string等)。 - 生成结果预览区域:展示即将生成的ScriptableObject数组或Prefab列表。
- 日志/状态输出区域:显示导入过程中的操作日志、错误或警告信息。
整个数据流是:选择CSV -> 解析为原始字符串矩阵 -> 配置映射关系 -> 转换为强类型对象列表 -> 序列化为Unity资产。
2.3 数据流向设计
清晰的数据流向是工具稳定的关键:
[策划的Excel] --(另存为)--> [项目内的CSV文件] | v [工具窗口] --(读取/解析)--> [List<List<string>>原始数据] | v [用户配置映射] --(转换)--> [List<T> 强类型数据列表] (T如: ItemData) | v [序列化] --> [ScriptableObject数组或Prefab] --> [被游戏逻辑使用]这个流程中,最关键的转换步骤发生在“用户配置映射”到“强类型数据列表”之间。我们需要一个灵活且类型安全的反射或代码生成机制来处理它。
3. 实战第一步:创建自定义编辑器窗口骨架
让我们打开Unity,创建一个新的C#脚本,开始搭建窗口的骨架。这是最快能见到效果的一步。
3.1 创建基础窗口类
在项目的Editor文件夹下(如果没有就创建一个,这是Unity的约定,Editor文件夹下的代码不会被打进游戏包体),新建一个C#脚本,命名为ExcelDataImporterWindow.cs。
using UnityEngine; using UnityEditor; using System.Collections.Generic; // 注意:暂时不需要引入Excel相关库 public class ExcelDataImporterWindow : EditorWindow { // 单例模式,方便从菜单栏打开 private static ExcelDataImporterWindow window; // 添加一个菜单项 [MenuItem("Tools/Excel数据导入工具")] public static void ShowWindow() { window = GetWindow<ExcelDataImporterWindow>(); window.titleContent = new GUIContent("Excel导入工具"); window.Show(); } // 窗口的核心UI绘制方法 private void OnGUI() { GUILayout.Label("Excel/CSV 数据导入工具", EditorStyles.boldLabel); EditorGUILayout.Space(); // 1. 文件选择区域 DrawFileSelectionArea(); // 2. 数据预览区域 (当有数据时显示) if (rawData != null && rawData.Count > 0) { DrawDataPreviewArea(); } // 3. 映射配置区域 (当有数据且未配置时显示) if (rawData != null && rawData.Count > 1) // 假设第一行是表头 { DrawMappingConfigurationArea(); } // 4. 操作按钮区域 DrawActionButtonsArea(); } // 临时变量,用于存储选中的文件路径和解析的原始数据 private string csvFilePath = ""; private List<List<string>> rawData = null; // 二维列表,存储行和列 private void DrawFileSelectionArea() { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("CSV文件路径:", csvFilePath, EditorStyles.wordWrappedLabel); if (GUILayout.Button("浏览...", GUILayout.Width(60))) { // 弹出系统文件选择框 string path = EditorUtility.OpenFilePanel("选择CSV文件", Application.dataPath, "csv"); if (!string.IsNullOrEmpty(path)) { csvFilePath = path; // 选择新文件后,清空旧数据 rawData = null; // 可以在这里自动触发一次解析,为了简单我们先不自动解析 } } EditorGUILayout.EndHorizontal(); } private void DrawDataPreviewArea() { /* 稍后实现 */ } private void DrawMappingConfigurationArea() { /* 稍后实现 */ } private void DrawActionButtonsArea() { /* 稍后实现 */ } }保存脚本后,回到Unity编辑器,顶部菜单栏就会出现Tools -> Excel数据导入工具。点击它,一个空的工具窗口就弹出来了!虽然现在除了一个标题和一个浏览按钮,什么都做不了,但骨架已经搭好。这里有个实操心得:EditorWindow的OnGUI方法每帧都会调用,类似于游戏对象的Update,所以不要在OnGUI里执行耗时的操作(如解析大文件),否则会卡住编辑器界面。文件解析这类操作,最好在按钮点击的事件响应里做。
3.2 解析CSV数据
接下来,我们实现DrawActionButtonsArea中的一个“解析”按钮,并填充DrawDataPreviewArea来展示数据。CSV解析的核心在于处理引号和逗号。这里我们实现一个简单但够用的解析器。
private void DrawActionButtonsArea() { EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("解析CSV")) { if (string.IsNullOrEmpty(csvFilePath)) { EditorUtility.DisplayDialog("错误", "请先选择CSV文件", "确定"); return; } ParseCSVFile(csvFilePath); } // 后续可以在这里添加“生成数据”、“保存”等按钮 EditorGUILayout.EndHorizontal(); } private void ParseCSVFile(string filePath) { if (!System.IO.File.Exists(filePath)) { Debug.LogError($"文件不存在: {filePath}"); return; } rawData = new List<List<string>>(); try { // 使用StreamReader读取文件 using (var reader = new System.IO.StreamReader(filePath)) { while (!reader.EndOfStream) { string line = reader.ReadLine(); if (string.IsNullOrWhiteSpace(line)) continue; List<string> row = ParseCSVLine(line); rawData.Add(row); } } Debug.Log($"CSV解析成功,共{rawData.Count}行数据。"); // 强制重绘窗口,以显示预览区域 Repaint(); } catch (System.Exception e) { Debug.LogError($"解析CSV文件时出错: {e.Message}"); rawData = null; } } // 一个简单的CSV行解析器,处理带引号的字段 private List<string> ParseCSVLine(string line) { List<string> result = new List<string>(); bool inQuotes = false; System.Text.StringBuilder currentField = new System.Text.StringBuilder(); for (int i = 0; i < line.Length; i++) { char c = line[i]; if (c == '\"') // 遇到引号 { // 判断是否是转义的引号(即两个连续的引号) if (inQuotes && i + 1 < line.Length && line[i + 1] == '\"') { currentField.Append('\"'); i++; // 跳过下一个引号 } else { inQuotes = !inQuotes; // 切换引号状态 } } else if (c == ',' && !inQuotes) // 遇到逗号且不在引号内,表示一个字段结束 { result.Add(currentField.ToString()); currentField.Clear(); } else { currentField.Append(c); } } // 添加最后一个字段 result.Add(currentField.ToString()); return result; }现在,我们实现DrawDataPreviewArea,用一个ScrollView来展示原始数据,这对于检查CSV格式是否正确非常有用。
private Vector2 dataPreviewScrollPos; private void DrawDataPreviewArea() { EditorGUILayout.Space(); GUILayout.Label("原始数据预览:", EditorStyles.boldLabel); dataPreviewScrollPos = EditorGUILayout.BeginScrollView(dataPreviewScrollPos, GUILayout.Height(200)); if (rawData != null) { for (int i = 0; i < rawData.Count; i++) { EditorGUILayout.BeginHorizontal(); GUILayout.Label($"行{i}:", GUILayout.Width(40)); for (int j = 0; j < rawData[i].Count; j++) { // 显示每个单元格的内容,第一行(表头)可以高亮显示 var style = (i == 0) ? EditorStyles.boldLabel : EditorStyles.label; EditorGUILayout.LabelField(rawData[i][j], style, GUILayout.MinWidth(80)); } EditorGUILayout.EndHorizontal(); } } EditorGUILayout.EndScrollView(); }至此,你已经拥有了一个能选择CSV文件并展示其内容的基础工具窗口。选择你的测试CSV文件,点击“解析CSV”,数据就会以行列形式展示出来。注意事项:这个简易解析器假设CSV是用逗号分隔的。如果你的数据里包含逗号,必须用双引号将整个字段括起来(这是标准CSV格式)。例如:"Hello, World", 123。
4. 核心难点:动态数据映射与类型转换
这是整个工具最核心、也最具挑战性的部分。我们需要让用户指定CSV的每一列(对应表头)映射到哪个C#类的哪个属性,并且要进行正确的类型转换(string -> int/float/bool等)。
4.1 定义数据模型与映射配置
首先,我们定义一个简单的数据模型,例如一个ItemData类,它最终可以生成ScriptableObject。
// 这是一个运行时可能用到的数据类,放在非Editor文件夹下 [System.Serializable] public class ItemData { public int id; public string name; public string description; public float price; public bool isConsumable; }然后,在编辑器工具中,我们需要一个类来保存“映射配置”。这个配置需要知道:CSV的列索引、目标字段名、目标字段类型。
// 在ExcelDataImporterWindow类内部或单独定义 [System.Serializable] public class ColumnMapping { public string csvHeaderName; // CSV表头名称 public string targetFieldName; // 目标类中的字段名 public System.Type fieldType; // 字段类型,如int, string, float // 注意:System.Type无法直接序列化,我们需要一个变通方法,比如存类型名称字符串 public string fieldTypeName; } // 在ExcelDataImporterWindow中 private List<ColumnMapping> columnMappings = new List<ColumnMapping>(); private System.Type selectedTargetType = typeof(ItemData); // 默认目标类型4.2 构建映射配置界面
我们需要一个界面,让用户为CSV的每一列(通常是第一行)创建映射。DrawMappingConfigurationArea方法将变得复杂一些。
private Vector2 mappingScrollPos; private void DrawMappingConfigurationArea() { EditorGUILayout.Space(); GUILayout.Label("数据映射配置:", EditorStyles.boldLabel); // 1. 选择目标数据类型(可以通过反射获取所有符合条件的类) EditorGUILayout.BeginHorizontal(); GUILayout.Label("目标数据类:", GUILayout.Width(80)); // 这里简化处理,手动输入类名。更高级的做法是下拉菜单选择程序集中所有[Serializable]的类。 string typeName = EditorGUILayout.TextField(selectedTargetType.FullName); // 尝试根据输入更新类型 if (GUILayout.Button("刷新类型")) { System.Type newType = System.Type.GetType(typeName); if (newType != null) selectedTargetType = newType; } EditorGUILayout.EndHorizontal(); // 2. 自动生成映射建议(基于表头名称和目标类字段名的模糊匹配) if (GUILayout.Button("智能匹配映射")) { AutoGenerateMappings(); } // 3. 显示和编辑每一列的映射 mappingScrollPos = EditorGUILayout.BeginScrollView(mappingScrollPos, GUILayout.Height(250)); if (rawData.Count > 0) { List<string> headers = rawData[0]; // 第一行是表头 for (int i = 0; i < headers.Count; i++) { EditorGUILayout.BeginHorizontal(); // 显示CSV列名 EditorGUILayout.LabelField($"[{i}] {headers[i]}", GUILayout.Width(120)); // 查找或创建该列的映射配置 ColumnMapping mapping = columnMappings.Find(m => m.csvHeaderName == headers[i]); bool isNewMapping = mapping == null; if (isNewMapping) { mapping = new ColumnMapping { csvHeaderName = headers[i] }; } // 下拉菜单选择目标字段 mapping.targetFieldName = EditorGUILayout.TextField(mapping.targetFieldName, GUILayout.Width(100)); // 下拉菜单选择字段类型 string[] typeOptions = new string[] { "string", "int", "float", "bool" }; int currentTypeIndex = System.Array.IndexOf(typeOptions, mapping.fieldTypeName ?? "string"); int newTypeIndex = EditorGUILayout.Popup(currentTypeIndex, typeOptions, GUILayout.Width(80)); mapping.fieldTypeName = typeOptions[newTypeIndex]; if (isNewMapping && !string.IsNullOrEmpty(mapping.targetFieldName)) { columnMappings.Add(mapping); } // 可以添加一个“删除映射”按钮 if (GUILayout.Button("X", GUILayout.Width(20))) { columnMappings.Remove(mapping); } EditorGUILayout.EndHorizontal(); } } EditorGUILayout.EndScrollView(); } private void AutoGenerateMappings() { if (rawData.Count == 0 || selectedTargetType == null) return; columnMappings.Clear(); var fields = selectedTargetType.GetFields(); // 获取公共字段 List<string> headers = rawData[0]; foreach (var header in headers) { // 简单的大小写不敏感匹配 var matchedField = System.Array.Find(fields, f => f.Name.ToLower() == header.ToLower().Replace(" ", "")); if (matchedField != null) { columnMappings.Add(new ColumnMapping { csvHeaderName = header, targetFieldName = matchedField.Name, fieldTypeName = matchedField.FieldType.Name.ToLower() // 如 "int32" -> "int" }); } } Debug.Log($"智能匹配生成了{columnMappings.Count}条映射。"); }这个界面允许用户为每一列手动指定目标字段和类型,并提供了一个“智能匹配”按钮来根据名称自动匹配。实操心得:在实际项目中,字段名和CSV表头名往往不完全一致(比如CSV用“ID”,代码用“id”),所以智能匹配逻辑可以做得更复杂,比如支持同义词映射表(“ItemID” -> “id”, “Price” -> “price”),这能极大提升策划的体验。
4.3 执行数据转换
配置好映射后,我们需要一个“生成数据”按钮,来执行最终的转换。这是类型安全和错误处理的关键环节。
private List<object> convertedDataList = new List<object>(); // 存储转换后的对象 private void DrawActionButtonsArea() { EditorGUILayout.BeginHorizontal(); // ... 之前的解析按钮 ... if (GUILayout.Button("生成数据")) { if (rawData == null || rawData.Count < 2) { EditorUtility.DisplayDialog("错误", "请先解析包含表头和数据行的CSV文件", "确定"); return; } if (columnMappings.Count == 0) { EditorUtility.DisplayDialog("警告", "未配置任何数据映射,将无法生成数据。", "确定"); return; } ConvertData(); } if (GUILayout.Button("保存为ScriptableObject")) { SaveAsScriptableObject(); } EditorGUILayout.EndHorizontal(); } private void ConvertData() { convertedDataList.Clear(); List<string> headers = rawData[0]; // 从第二行开始是数据行 for (int rowIndex = 1; rowIndex < rawData.Count; rowIndex++) { var row = rawData[rowIndex]; // 动态创建目标类型的实例 object dataItem = System.Activator.CreateInstance(selectedTargetType); bool rowHasError = false; for (int colIndex = 0; colIndex < Mathf.Min(row.Count, headers.Count); colIndex++) { string header = headers[colIndex]; string cellValue = row[colIndex]; // 找到该列对应的映射配置 ColumnMapping mapping = columnMappings.Find(m => m.csvHeaderName == header); if (mapping == null || string.IsNullOrEmpty(mapping.targetFieldName)) { continue; // 该列未映射,跳过 } // 通过反射找到目标字段 var fieldInfo = selectedTargetType.GetField(mapping.targetFieldName); if (fieldInfo == null) { Debug.LogWarning($"第{rowIndex+1}行,列'{header}':目标类中未找到字段 '{mapping.targetFieldName}'"); continue; } // 类型转换并赋值 try { object convertedValue = ConvertStringToType(cellValue, mapping.fieldTypeName, fieldInfo.FieldType); fieldInfo.SetValue(dataItem, convertedValue); } catch (System.Exception e) { Debug.LogError($"第{rowIndex+1}行,列'{header}'(值:'{cellValue}')转换失败: {e.Message}"); rowHasError = true; } } if (!rowHasError) { convertedDataList.Add(dataItem); } } Debug.Log($"数据转换完成,成功{convertedDataList.Count}条,失败{rawData.Count - 1 - convertedDataList.Count}条。"); // 转换完成后,可以刷新一个结果预览区域 } // 核心转换函数 private object ConvertStringToType(string str, string typeName, System.Type targetType) { if (string.IsNullOrEmpty(str)) str = ""; switch (typeName.ToLower()) { case "string": return str; case "int": if (int.TryParse(str, out int intVal)) return intVal; throw new System.FormatException($"无法将 '{str}' 转换为整数。"); case "float": if (float.TryParse(str, out float floatVal)) return floatVal; throw new System.FormatException($"无法将 '{str}' 转换为浮点数。"); case "bool": // 支持"1/0", "true/false", "是/否"等多种布尔表示 str = str.ToLower().Trim(); if (str == "1" || str == "true" || str == "是" || str == "yes" || str == "y") return true; if (str == "0" || str == "false" || str == "否" || str == "no" || str == "n") return false; throw new System.FormatException($"无法将 '{str}' 转换为布尔值。"); default: // 尝试其他基础类型或枚举 if (targetType.IsEnum) { try { return System.Enum.Parse(targetType, str); } catch { throw new System.FormatException($"无法将 '{str}' 转换为枚举 {targetType.Name}。"); } } // 更复杂的类型(如Vector3)可以在这里扩展 throw new System.NotSupportedException($"不支持的类型转换: {typeName}"); } }这段代码是工具的心脏。它遍历每一行数据,根据映射配置,通过反射将字符串值转换为目标类型并赋值给新创建的对象实例。注意事项:反射在编辑器工具中使用是可以接受的,但如果转换的数据量极大(数万行),可能需要考虑性能优化,比如为常用类型(int, float, string)写死转换方法,或者使用System.ComponentModel.TypeDescriptor或FastMember这类库。对于常规的策划配置表(几百上千行),这个性能完全足够。
5. 数据落地:生成Unity资产
转换后的数据还在内存里,我们需要将其持久化为Unity可以使用的资产,最常用的就是ScriptableObject。
5.1 创建或更新ScriptableObject
假设我们为ItemData准备一个容器ItemDatabase,它继承自ScriptableObject并包含一个ItemData列表。
// 运行时脚本 public class ItemDatabase : ScriptableObject { public List<ItemData> items = new List<ItemData>(); }在工具窗口中,我们实现SaveAsScriptableObject方法。
private void SaveAsScriptableObject() { if (convertedDataList.Count == 0) { EditorUtility.DisplayDialog("错误", "没有已转换的数据可供保存。请先点击‘生成数据’。", "确定"); return; } // 让用户选择保存路径和文件名 string defaultName = System.IO.Path.GetFileNameWithoutExtension(csvFilePath) + "_Database"; string path = EditorUtility.SaveFilePanelInProject("保存数据资产", defaultName, "asset", "请选择保存ScriptableObject的路径"); if (string.IsNullOrEmpty(path)) return; // 创建或加载现有的数据库 ItemDatabase database = AssetDatabase.LoadAssetAtPath<ItemDatabase>(path); bool isNew = database == null; if (isNew) { database = ScriptableObject.CreateInstance<ItemDatabase>(); } else { // 如果是覆盖,清空旧数据 database.items.Clear(); } // 将转换后的数据添加到数据库 foreach (var obj in convertedDataList) { // 注意:convertedDataList里存储的是object,需要转换为ItemData // 因为我们目前只支持一种类型,所以直接转换。更通用的做法需要类型判断。 if (obj is ItemData item) { database.items.Add(item); } } // 保存资产 if (isNew) { AssetDatabase.CreateAsset(database, path); } else { EditorUtility.SetDirty(database); // 标记为已修改 } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"数据已保存至: {path}"); EditorUtility.DisplayDialog("成功", $"数据资产已保存!\n路径: {path}", "确定"); }现在,整个流程就打通了:选择CSV -> 解析 -> 配置映射 -> 转换 -> 保存为Asset。你可以在Project窗口中双击生成的.asset文件,在Inspector里查看导入的数据。
5.2 扩展:支持多种数据模型和导出方式
一个强大的工具不应该只支持一种数据类型。我们可以通过反射枚举所有符合条件的类(比如带有[System.Serializable]特性的类),让用户在工具窗口的下拉菜单中选择。导出方式也可以多样化:
- 单个ScriptableObject列表:如上所示。
- 多个独立的ScriptableObject资产:每一行数据生成一个.asset文件,适用于角色、物品等独立配置。
- 生成Prefab:将数据附加到GameObject上,然后保存为Prefab。这对于需要关联模型、音效等复杂游戏对象的数据非常有用。
- 直接生成C#代码或JSON:为热更新或服务器通信准备数据。
实现这些扩展,核心在于将selectedTargetType和生成逻辑抽象出来,根据用户的选择调用不同的“生成器”(Generator)类。这会让工具代码量增加,但架构会清晰得多。
6. 打磨体验:错误处理、批量操作与效率技巧
一个专业的工具,稳定性和用户体验至关重要。下面分享几个提升工具可靠性和效率的实战技巧。
6.1 健壮的错误处理与数据验证
在ConvertStringToType函数中,我们进行了基本的类型转换错误处理。但数据验证可以更前置、更丰富:
- 表头校验:解析CSV后,立即检查表头是否包含空值或重复值,并给出明确警告。
- 空行/空列处理:在解析或转换时,跳过完全为空的行或列,避免生成无效数据。
- 数据范围校验:在映射配置中,可以为数值类型字段添加范围约束(如
int字段要求值在1-100之间)。在转换过程中进行校验,将超出范围的行记录为错误。 - 唯一性校验:对于ID类字段,检查转换后的数据列表中是否有重复值。
- 引用校验:如果某字段的值需要引用另一个配置表(如
itemId需要存在于ItemTable中),可以在这里进行关联性检查。
可以在工具窗口中增加一个“验证数据”按钮,专门执行这些校验,并生成一份详细的报告,用不同颜色(如红色错误、黄色警告)在日志区域显示。
6.2 支持批量导入与自动化
策划可能有几十张配置表。我们可以扩展工具,支持批量处理:
- 监视文件夹:指定一个
Resources/Config文件夹,工具自动监视该文件夹下的所有CSV文件变化(通过FileSystemWatcher或定时检查),一旦有文件更新,自动触发导入流程。 - 批处理模式:创建一个
Editor脚本,通过命令行或菜单项一次性导入所有配置表,这在打包前进行资源构建时非常有用。 - 预设映射配置:将
columnMappings序列化为JSON或ScriptableObject保存下来。对于同一张表结构,下次打开工具时可以直接加载预设,无需重新配置。
6.3 编辑器性能优化
当数据量很大时,在OnGUI中绘制所有数据预览会非常卡顿。
- 虚拟列表:只绘制可视区域内的行,这是大型列表控件的标准优化方案。Unity的
EditorGUILayout没有原生支持,但可以基于GUI系统手动计算位置实现,或者使用第三方编辑器插件(如Odin Inspector)的表格功能。 - 分页显示:在预览区域实现简单的上一页/下一页按钮。
- 异步操作:将文件解析、数据转换等耗时操作放在后台线程(通过
Task.Run或ThreadPool),然后在主线程更新UI。注意,Unity的API(如AssetDatabase)必须在主线程调用,需要妥善处理线程间通信。 - 进度条:对于批量操作,使用
EditorUtility.DisplayProgressBar显示进度,提升用户体验。
7. 完整代码整合与使用指南
由于篇幅限制,无法将超过500行的完整代码全部贴出,但以上部分已经构成了工具的所有核心模块。我将提供一个整合后的简化版入口代码框架,并说明如何组织你的项目。
项目文件结构建议:
Assets/ ├── Editor/ │ └── ExcelDataImporterWindow.cs (主窗口) │ └── CSVUtility.cs (CSV解析工具类) │ └── DataMapper.cs (数据映射与转换核心类) │ └── ScriptableObjectGenerator.cs (资产生成器) ├── Scripts/ │ └── DataModels/ (数据模型) │ └── ItemData.cs │ └── SkillData.cs │ └── ItemDatabase.cs │ └── Runtime/ (游戏运行时逻辑) └── Resources/ └── Config/ (存放CSV文件) └── ItemTable.csv └── SkillTable.csv简化版ExcelDataImporterWindow.cs整合框架:
using UnityEngine; using UnityEditor; using System.Collections.Generic; public class ExcelDataImporterWindow : EditorWindow { #region 变量与属性 private string csvFilePath = ""; private List<List<string>> rawData; private List<ColumnMapping> columnMappings = new List<ColumnMapping>(); private System.Type selectedTargetType = typeof(ItemData); // 需引用你的数据模型 private List<object> convertedDataList = new List<object>(); private Vector2 previewScroll, mappingScroll; #endregion [MenuItem("Tools/Excel数据导入工具")] public static void ShowWindow() { /* 同上 */ } private void OnGUI() { DrawHeader(); DrawFileSelection(); if (rawData != null && rawData.Count > 0) DrawDataPreview(); if (rawData != null && rawData.Count > 1) DrawMappingConfig(); DrawActionButtons(); } // 将之前章节的各个Draw方法实现放在这里 private void DrawHeader() { /* ... */ } private void DrawFileSelection() { /* ... */ } private void DrawDataPreview() { /* ... */ } private void DrawMappingConfig() { /* ... */ } private void DrawActionButtons() { /* ... */ } // 核心功能方法 private void ParseCSVFile(string path) { /* 调用CSVUtility.Parse */ } private void AutoGenerateMappings() { /* ... */ } private void ConvertData() { /* 调用DataMapper.Convert */ } private void SaveAsScriptableObject() { /* 调用ScriptableObjectGenerator.CreateAsset */ } // 辅助类定义 [System.Serializable] private class ColumnMapping { /* ... */ } }使用步骤:
- 将上述代码模块分别创建到
Editor文件夹下的对应文件中。 - 在
Scripts/DataModels/下定义你的数据类(如ItemData)和数据库类(如ItemDatabase)。 - 将策划提供的Excel文件另存为CSV格式,放入
Resources/Config/或任何你喜欢的目录。 - 在Unity中打开Tools -> Excel数据导入工具。
- 点击“浏览”选择你的CSV文件,点击“解析CSV”。
- 在“数据映射配置”区域,检查并调整每一列对应的字段名和类型。可以点击“智能匹配映射”快速匹配。
- 点击“生成数据”,在Console窗口查看转换日志。
- 点击“保存为ScriptableObject”,选择保存路径,生成.asset文件。
- 在游戏脚本中,通过
Resources.Load<ItemDatabase>("路径")加载并使用这些数据。
这个工具窗口从零到一搭建完成,它不仅是一个简单的数据导入器,更是一个可扩展的编辑器扩展框架。你可以基于它,轻松定制出符合自己项目需求的、各种强大的数据管理工具,将策划、美术和程序的工作流无缝衔接起来,真正实现“5分钟搞定”数据配置的承诺。
