前言
EPPlus更符合习惯,方便做扩展,dll放在文末链接
虽然有写好的插件,但还是自己写的方便扩展
2025/1/2 补充:不用看我的了,这里本质还是对话系统,找到两篇不错的博客,之后有空再重置
Unity从零做一个任务系统
可拓展性开放世界轻量级任务系统框架实
任务对话系统概览,后面演示的表格数据基于触发式任务
步骤
1. 自动生成结构类和容器类
主要针对单列表的SO数据的通用自动化导入,首先做好表格
约定第一行描述,第二行字段名,之后通过反射去找同名字段赋值。每个sheet都会生成一个新SO,我的预想是一个sheet对应一个角色
public static class ExcelTool
{
/// <summary>
/// excel文件存放路径
/// </summary>
public static string EXCEL_PATH = Application.dataPath + "/GameData/";
/// <summary>
/// 数据结构类存放路径
/// </summary>
public static string DATA_CLASS_PATH = Application.dataPath + "/Scripts/ExcelData/";
/// <summary>
/// 容器类
/// </summary>
public static string DATA_CONTAINER_PATH = Application.dataPath + "/Scripts/ExcelData/";
/// <summary>
/// 内容开始行
/// </summary>
public const int BEGINE_ROW = 4;
/// <summary>
/// 字段所在行
/// </summary>
public const int FIELD_ROW = 2;
/// <summary>
/// 类型所在行
/// </summary>
public const int TYPE_ROW = 3;
/// <summary>
/// 生成excel数据结构
/// </summary>
[MenuItem("Tools/生成excel数据结构")]
private static void GenerateExcelInfo()
{
DirectoryInfo dInfo = new DirectoryInfo(EXCEL_PATH);
FileInfo[] files = dInfo.GetFiles("*.xlsx", SearchOption.AllDirectories).Where(f => !f.Name.StartsWith("~$")).ToArray(); ;
foreach (var file in files)
{
using (var package = new ExcelPackage(file))
{
string fileName = file.Name.Replace(file.Extension, "");
foreach (var worksheet in package.Workbook.Worksheets)
{
GenerateClass(worksheet, fileName);
break;
}
string assetPath = "Assets" + file.FullName.Replace(Application.dataPath.Replace("/", "\\"), "").Replace("\\", "/");
GenerateContainer(fileName, assetPath);
}
}
}
private static void GenerateClass(ExcelWorksheet worksheet, string fileName)
{
string className = fileName;
string str = "[System.Serializable]\npublic class " + className + "\n{\n";
for (int i = 1; i <= worksheet.Dimension.End.Column; i++)
{
string fieldName = worksheet.Cells[FIELD_ROW, i].Value.ToString();
string fieldType = worksheet.Cells[TYPE_ROW, i].Value.ToString();
str += "\t public " + fieldType + " " + fieldName + ";\n";
}
str += "}";
string path = Path.Combine(DATA_CLASS_PATH, className + ".cs");
File.WriteAllText(path, str);
AssetDatabase.Refresh();
}
private static void GenerateContainer(string fileName, string excelFilePath)
{
string className = fileName + "DataSO";
string str = "using System.Collections.Generic;\nusing UnityEngine;\nusing UnityEditor;\n\n";
str += "[CreateAssetMenu(fileName = \"" + className + "\", menuName = \"ExcelData/" + className + "\")]\n";
str += "public class " + className + " : ScriptableObject\n{\n";
str += "\tpublic List<" + fileName + "> data = new List<" + fileName + "" + ">();\n\n";
str += "\t[MenuItem(\"Assets/导入表格数据\")]\n";
str += "\tpublic static void ImportData()\n";
str += "\t{\n";
str += "\t\tExcelTool.ImportExcelData<" + className + ", " + fileName + ">(\"" + excelFilePath + "\");\n";
str += "\t}\n";
str += "}";
string path = Path.Combine(DATA_CONTAINER_PATH, className + ".cs");
File.WriteAllText(path, str);
AssetDatabase.Refresh();
}
/// <summary>
/// 获取变量名所在行,应对不同规则
/// </summary>
/// <param name="table"></param>
/// <param name="index"></param>
/// <returns></returns>
private static DataRow GetVariableNameRow(DataTable table)
{
return table.Rows[FIELD_ROW];
}
/// <summary>
/// 获取变量类型所在行
/// </summary>
/// <param name="table"></param>
/// <returns></returns>
private static DataRow GetVariableTypeRow(DataTable table)
{
return table.Rows[TYPE_ROW];
}
}
生成好的结果如下
dialog要解析一些数据,所以重新写了
/// <summary>
/// 对话片段
/// </summary>
[Serializable]
public class DialogPiece
{
/// <summary>
/// 约定:id增序,若无isEnd,则读取下一条
/// </summary>
public string id;
[Header("对话内容")]
public int faceIndex;
// public Sprite face;
public string npcName;
/// <summary>
/// 以此作为节点,没有该标识,就按顺序往下读
/// </summary>
public bool isEnd = false;
/// <summary>
/// 是否直接触发任务
/// </summary>
public bool isQuestStart = false;
[TextArea]
public string dialogText;
public UnityEvent aftereTalkEvent;
/// <summary>
/// 任务
/// </summary>
public string questName;
public QuestDataSO quest;
// 回答选项
public List<DialogOption> options = new();
public string optionsString;
}
2. 根据表格数据自动生成SO
这里需要选中生成的so文件,右键生成即可
规则: 工作表sheet名字作为SO文件名,行首为空跳过
因为要解析一些数据,所以加了内容
直接在表格写选项也可以,单独重写生成SO的方法即可
生成SO的方法
public static class ExcelTool
{
/// <summary>
/// 导入excel 需要手动指定excel路径
/// </summary>
/// <typeparam name="T">最终保存的so类型</typeparam>
/// <typeparam name="U">List列表元素的类型</typeparam>
/// <param name="excelFilePath"></param>
public static void ImportExcelData<T, U>(string excelFilePath, Action<T> OnFinish = null) where T : ScriptableObject
{
if (string.IsNullOrEmpty(excelFilePath))
{
Debug.LogError("Excel file empty.");
return;
}
FileInfo fileInfo = new FileInfo(excelFilePath);
using (ExcelPackage package = new ExcelPackage(fileInfo))
{
foreach (ExcelWorksheet worksheet in package.Workbook.Worksheets)
{
// 如果整个sheet是空的,跳过
if (worksheet.Dimension == null)
{
continue;
}
string directoryPath = Path.GetDirectoryName(excelFilePath);
string soPath = Path.Combine(directoryPath, worksheet.Name + ".asset");
T excelData = AssetDatabase.LoadAssetAtPath<T>(soPath);
if (excelData == null)
{
excelData = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(excelData, soPath);
}
Type type = typeof(U);
IList list = null;
foreach (FieldInfo field in typeof(T).GetFields())
{
if (field.FieldType.IsGenericType && field.FieldType.GetGenericTypeDefinition() == typeof(List<>))
{
// 找到list泛型字段
list = (IList)field.GetValue(excelData);
list.Clear(); // 清空列表,以便重新填充
}
}
// 自定义开始列和 字段名所在列
for (int i = BEGINE_ROW; i <= worksheet.Dimension.End.Row; i++)
{
// 检查第一列是否为空
if (worksheet.Cells[i, 1].Value == null || string.IsNullOrEmpty(worksheet.Cells[i, 1].Value.ToString()))
{
continue;
}
object dataItem = Activator.CreateInstance(type);
for (int j = 1; j <= worksheet.Dimension.End.Column; j++)
{
string fieldName = worksheet.Cells[FIELD_ROW, j].Value.ToString();
FieldInfo property = type.GetField(fieldName);
if (property != null)
{
if (worksheet.Cells[i, j].Value == null || string.IsNullOrEmpty(worksheet.Cells[i, j].Value.ToString()))
continue;
string cellValue = worksheet.Cells[i, j].Value.ToString();
if (property.FieldType == typeof(int))
{
property.SetValue(dataItem, int.Parse(cellValue));
}
else if (property.FieldType == typeof(float))
{
property.SetValue(dataItem, float.Parse(cellValue));
}
else if (property.FieldType == typeof(string))
{
property.SetValue(dataItem, cellValue);
}
else if (property.FieldType == typeof(bool))
{
property.SetValue(dataItem, bool.Parse(cellValue));
}
else if (property.FieldType == typeof(double))
{
property.SetValue(dataItem, double.Parse(cellValue));
}
else if (property.FieldType == typeof(DateTime))
{
property.SetValue(dataItem, DateTime.Parse(cellValue));
}
else if (property.FieldType.IsEnum)
{
property.SetValue(dataItem, Enum.Parse(property.FieldType, cellValue));
}
else if (property.FieldType == typeof(long))
{
property.SetValue(dataItem, long.Parse(cellValue));
}
}
}
// 填充到so 的list
if (list != null) list.GetType().GetMethod("Add").Invoke(list, new[] {
dataItem });
}
OnFinish?.Invoke(excelData);
// 标记为脏,确保Unity保存更改 其实不需要这句
UnityEditor.EditorUtility.SetDirty(excelData);
}
AssetDatabase.SaveAssets();
}
}
}
DataSO
[CreateAssetMenu(fileName = "DialogDataSO", menuName = "dialog/DialogDataSO", order = 0)]
public class DialogDataSO : ScriptableObject
{
public List<DialogPiece> dialogPieceList = new List<DialogPiece>();
// public NativeHashMap<string, DialogPiece> dialogueIndex = new();
public Dictionary<string, DialogPiece> dialogueIndex = new();
public QuestDataSO GetQuest()
{
QuestDataSO currentQuest = null;
//循环对话中的任务,找到该任务并返回
foreach (var piece in dialogPieceList)
{
if (piece.quest != null)
currentQuest = piece.quest;
}
return currentQuest;
}
[MenuItem("Assets/导入表格数据")]
public static void ImportData()
{
ExcelTool.ImportExcelData<DialogDataSO, DialogPiece>("Assets/GameData/Farm/Dialog/dialog.xlsx", (dialogDataSO) =>
{
UpdateData(dialogDataSO);
});
}
public static void UpdateData(DialogDataSO dialogDataSO)
{
var dialogueIndex = dialogDataSO.dialogueIndex;
var dialogPieceList = dialogDataSO.dialogPieceList;
dialogueIndex.Clear();
//一旦信息有所更新,就会将信息存储在字典中
foreach (var piece in dialogPieceList)
{
if (!dialogueIndex.ContainsKey(piece.id))
dialogueIndex.Add(piece.id, piece);
// 自动将任务名转为对应so 考虑对话和任务放在同一文件夹,就无需深度搜索
if (!piece.questName.IsNullOrEmpty())
{
string[] guids = UnityEditor.AssetDatabase.FindAssets(piece.questName, new[] {
"Assets/GameData/Farm/Quest" }); // 搜索"Assets/So"及其子文件夹下的所有名为questName的资源
if (guids.Length > 0)
{
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]); // 将第一个匹配的资源的GUID转换为资源路径
piece.quest = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestDataSO>(path); // 加载资源
}
}
// 将optionsString解析
if (!piece.optionsString.IsNullOrEmpty())
{
piece.options = ParseOptionsString(piece.optionsString);
}
}
List<DialogOption> ParseOptionsString(string optionsString)
{
List<DialogOption> options = new List<DialogOption>();
// 分割字符串以获取单独的选项
string[] optionStrings = optionsString.Split(',');
foreach (string optionString in optionStrings)
{
// 进一步分割以获取属性值
string[] properties = optionString.Trim().Split('|');
if (properties.Length == 3)
{
DialogOption option = new DialogOption();
option.text = properties[0].Trim();
option.targetID = properties[1].Trim();
option.takeQuest = properties[2].Trim().ToUpper() == "TRUE";
options.Add(option);
}
}
return options;
}
}
//如果是在Unity编辑器中,则字典随时改变时则进行修改,如果是打包则字典信息不会更改
#if UNITY_EDITOR
void OnValidate()//一旦这个脚本中的数据被更改时会自动调用
{
UpdateData(this);
}
#else
void Awake()//保证在打包执行的游戏里第一时间获得对话的所有字典匹配
{
dialogueIndex.Clear();
foreach (var piece in dialoguePieces)
{
if (!dialogueIndex.ContainsKey(piece.ID))
dialogueIndex.Add(piece.ID, piece);
}
}
#endif
}
结尾
链接:https://pan.baidu.com/s/17w-Rss_ExtoDV63cgaeSUQ?pwd=8uly
提取码:8uly