Unity游戏开发:基于Excel与JSON实现敌人属性动态管理与存档
1. 项目概述:为什么要在Unity里用Excel管理敌人属性?
做游戏开发,尤其是中小型项目,最头疼的事情之一就是数据管理。策划同学今天说“我们把哥布林的攻击力从15调到18吧”,明天又说“给所有Boss的血量统一加个20%的护盾系数”。如果你把属性硬编码在C#脚本里,每次改动都得重新编译、打包、测试,效率低得令人发指。如果写在JSON或者ScriptableObject里,虽然方便了,但策划可能不熟悉这些工具的编辑和批量操作。
这时候,一个几乎人人都能上手、功能强大且支持批量处理的神器就派上用场了——Excel。把enemy(敌人)的属性,比如生命值、攻击力、防御力、技能ID、掉落物品列表等,全部整理在一张Excel表格里,策划可以随心所欲地调整公式、排序筛选、批量修改。而我们开发者要做的,就是在Unity运行时,把这张表格“读”进来,转换成游戏里敌人实实在在的属性;同样,当游戏过程中属性发生动态变化(比如玩家给敌人上了个持续掉血的Debuff),我们也能把变化“写”回Excel,实现真正的动态存档。
听起来是不是比想象中更实用?这不仅仅是简单的数据存储,更是一套连接游戏设计(Excel)与游戏运行时(Unity)的轻量级数据驱动架构。接下来,我就带你从零开始,手把手实现这套系统,并分享我趟过的坑和总结的最佳实践。
2. 核心思路与方案选型:几种读写Excel方式的深度对比
在Unity里操作Excel,本质上是在操作一个外部文件。我们需要一个“翻译官”,把.xlsx或.xls文件里的行列数据,翻译成C#能理解的数据结构(比如List<EnemyData>)。主流方案有以下几种,我们来逐一拆解其优劣。
2.1 方案一:EPPlus / NPOI(直接读写Excel文件)
这是最强大、最灵活的方式。EPPlus(对于.xlsx)和NPOI(支持.xls和.xlsx)是.NET平台成熟的开源库,可以让你像在C#里直接创建、读取、修改Excel文件。
优点:
- 功能完整:支持单元格格式、公式计算、合并单元格、图表等几乎所有Excel特性。
- 无需安装Office:纯托管代码实现,不依赖用户电脑上是否安装了Microsoft Office。
- 精细控制:你可以精确到某个单元格进行读写,适合复杂表格。
缺点:
- 平台兼容性坑:Unity使用的是Mono或IL2CPP,以及特定的.NET API子集。EPPlus/NPOI的某些高级API(特别是涉及
System.Drawing的)在Unity(尤其是WebGL、iOS等平台)上可能无法运行或需要额外处理。 - 性能与体积:库本身有一定大小,对于简单数据读写来说稍显笨重。
- 移动端权限:在Android/iOS上,读写应用沙盒外的文件路径需要处理权限问题。
实操心得:如果你的项目是PC或主机平台,且需要复杂的Excel交互(如生成带格式的报表),EPPlus是首选。但对于移动端或简单的属性存储,我通常不首选它,因为潜在的兼容性问题调试起来很耗时。
2.2 方案二:将Excel另存为CSV/TXT/JSON(推荐给大多数项目)
这是我最推荐、也是实践中用得最多的方法。核心思想是:在编辑期(Editor-time)用Excel编辑,在运行期(Run-time)用文本格式读取。
工作流:
- 策划用Excel维护数据。
- 通过一个我们编写的Unity编辑器工具,一键将Excel文件导出为CSV(逗号分隔值)或JSON格式,放入项目的
Resources或StreamingAssets文件夹。 - 游戏运行时,Unity使用内置的
TextAsset、JsonUtility或第三方CSV解析库来读取这些文本文件。
优点:
- 零依赖,全平台兼容:Unity原生支持读取文本文件,没有任何第三方库的兼容性风险。
- 轻量高效:文本文件解析速度极快,内存占用小。
- 安全可控:分离了编辑环境和运行环境。策划动Excel不会影响打包后的游戏内容,除非重新导出。
- 易于版本管理:CSV/JSON是纯文本,可以用Git等版本控制系统很好地管理差异。
缺点:
- 需要额外工具:必须编写一个编辑器扩展来完成“Excel转文本”的步骤,增加了前期工作量。
- 失去Excel特性:运行时无法使用Excel的公式、样式等功能(这些本就不该在运行时存在)。
实操心得:对于95%的游戏数据配置需求,这个方案都是最优解。它完美遵循了“关注点分离”原则。本文将重点围绕这个方案展开,因为它最稳健、最实用。
2.3 方案三:使用ScriptableObject与编辑器工具结合
ScriptableObject是Unity用于存储数据资产的强大工具。我们可以创建一个EnemyDataSO的ScriptableObject类,然后写一个编辑器脚本,从Excel读取数据并生成或更新一系列的EnemyDataSO资产文件。
优点:
- Unity原生集成:ScriptableObject在Inspector中可视化编辑,支持引用其他Unity对象(如Prefab、AudioClip)。
- 类型安全:数据在C#中是有明确类型的类,编译时就能发现错误。
- 便于按需加载:可以作为独立资产进行管理。
缺点:
- 资产数量可能爆炸:如果敌人种类有几百上千个,会产生大量
.asset文件,管理不便。 - 批量更新稍复杂:虽然可以一键从Excel导入,但反向同步(游戏内修改写回Excel)的链路比直接操作文件要绕。
方案选型结论: 对于核心需求“用Excel存档和读档enemy属性”,尤其是“存档”意味着运行时可能修改并保存,方案二(Excel编辑 -> 导出文本 -> 运行时读写)在灵活性、兼容性和实现复杂度上取得了最佳平衡。因此,本教程将基于此方案构建。
3. 系统设计与数据结构定义
在动手写代码之前,我们必须把数据结构和系统流程设计清楚。一个好的设计能避免后续很多重构的麻烦。
3.1 敌人数据模型(EnemyData)设计
首先,定义一个C#类来表示一个敌人的所有属性。这个类需要可序列化,以便于转换成JSON或从CSV行映射。
// EnemyData.cs using System; using System.Collections.Generic; [Serializable] // 这个特性让这个类可以被JsonUtility序列化/反序列化 public class EnemyData { public string id; // 敌人唯一ID,如 "goblin_001", "boss_dragon" public string name; // 显示名称 public int hp; public int maxHp; public int attack; public int defense; public float moveSpeed; public string prefabPath; // 关联的预制体路径,如 "Prefabs/Enemies/Goblin" public List<string> skillIds; // 技能ID列表 public List<DropItem> dropTable; // 掉落物品表 // 一个嵌套的类,用于表示掉落物品 [Serializable] public class DropItem { public string itemId; public int minCount; public int maxCount; public float probability; // 掉落概率,0-1 } // 构造函数,用于初始化 public EnemyData(string id, string name) { this.id = id; this.name = name; skillIds = new List<string>(); dropTable = new List<DropItem>(); } }3.2 Excel表格结构设计
我们的Excel表格应该和这个EnemyData类对应。设计一个Enemies.xlsx文件,包含以下列:
| 列名 (A, B, C...) | 数据类型 | 说明 | 示例 |
|---|---|---|---|
| id | 文本 | 唯一标识符,必须 | goblin_warrior |
| name | 文本 | 敌人名称 | 哥布林战士 |
| hp | 数字 | 生命值 | 120 |
| maxHp | 数字 | 最大生命值 | 120 |
| attack | 数字 | 攻击力 | 25 |
| defense | 数字 | 防御力 | 10 |
| moveSpeed | 数字 | 移动速度 | 3.5 |
| prefabPath | 文本 | 预制体资源路径 | Prefabs/Enemy/GoblinWarrior |
| skillIds | 文本 | 技能ID,多个用分号;隔开 | slash;shout |
| dropTable | 文本 | 掉落表,格式:物品ID:最小数量:最大数量:概率;... | health_potion:1:1:0.5;gold_coin:5:10:1.0 |
注意事项:
- 第一行必须是列头,且名称建议与C#类的字段名相同或可映射,这样解析起来最方便。
- 复杂数据(列表、数组)需要约定一个分隔符在单个单元格内表示。这里我用分号
;分隔不同技能或不同掉落项,用冒号:分隔一项内的不同属性。这是处理Excel到简单文本转换的常用技巧。- id列是关键,它将作为游戏中查找和存档时匹配敌人的依据。
3.3 系统工作流程
整个系统的工作流程可以分为编辑期和运行期两个部分:
编辑期(开发/策划阶段):
- 策划在
Enemies.xlsx中维护数据。 - 点击Unity编辑器中的自定义菜单项(如
Tools/Data/Export Enemy Excel to JSON)。 - 编辑器脚本读取Excel文件,将每一行数据解析成一个
EnemyData对象。 - 将所有的
EnemyData对象序列化为一个JSON数组,保存到StreamingAssets/EnemyData.json。
运行期(游戏运行时):
- 读档(Load):游戏启动时,从
StreamingAssets或Application.persistentDataPath(对于存档)读取JSON文件,反序列化为List<EnemyData>,存入一个全局的EnemyDataManager进行管理。 - 游戏逻辑:当需要生成一个敌人时,根据
id从EnemyDataManager中获取对应的属性模板,实例化敌人并初始化其属性。 - 动态修改:游戏过程中,敌人属性发生变化(如HP减少)。
- 存档(Save):在游戏保存点(如退出游戏、进入检查点),将当前内存中所有敌人的最新状态(或关键变化)更新回对应的
EnemyData对象,然后将整个List<EnemyData>再次序列化为JSON,保存到玩家存档目录(Application.persistentDataPath)下的一个文件(如save01_enemy.json)。
这样,我们就实现了数据的双向流动:Excel是权威数据源,JSON是运行时载体,内存中的EnemyData对象是游戏实际使用的状态。
4. 核心实现:编辑器工具篇(Excel to JSON)
这是连接Excel和Unity的桥梁。我们需要引用一个能在Unity编辑器环境下读取Excel的库。这里我选择EPPlus,因为它功能强大且对于编辑器脚本来说兼容性问题较少。我们通过Unity的Package Manager从NuGet导入。
4.1 安装EPPlus(通过程序集引用)
由于Unity的Package Manager对传统.NET库支持有限,更可靠的方式是直接下载EPPlus的DLL。
- 从EPPlus的 GitHub Release 页面下载稳定版的
EPPlus.xxx.zip。 - 解压后,找到
netstandard2.0或net45文件夹下的EPPlus.dll。 - 在Unity项目内创建一个
Plugins文件夹(如果还没有),将EPPlus.dll复制进去。 - 在Unity编辑器中,选中该DLL,在Inspector面板中,确保仅为Editor平台启用(Platform Settings -> 取消勾选 Any Platform, 然后只勾选 Editor)。这是因为我们只在编辑器工具中使用它,不会打包到运行时游戏里,避免了平台兼容性问题。
4.2 编写Excel导出工具脚本
创建一个Editor文件夹,在里面创建脚本EnemyDataExporter.cs。
// EnemyDataExporter.cs using UnityEngine; using UnityEditor; using System.IO; using System.Collections.Generic; using OfficeOpenXml; // EPPlus的命名空间 public static class EnemyDataExporter { // 添加一个Unity编辑器菜单项 [MenuItem("Tools/Data/Export Enemy Excel to JSON")] public static void ExportExcelToJson() { // 1. 弹出文件选择框,让用户选择Excel文件 string excelPath = EditorUtility.OpenFilePanel("Select Enemy Excel File", Application.dataPath, "xlsx,xls"); if (string.IsNullOrEmpty(excelPath)) { Debug.LogWarning("No Excel file selected."); return; } // 2. 使用EPPlus打开Excel文件 FileInfo excelFile = new FileInfo(excelPath); using (ExcelPackage package = new ExcelPackage(excelFile)) { ExcelWorksheet worksheet = package.Workbook.Worksheets[0]; // 假设数据在第一个工作表 int rowCount = worksheet.Dimension.Rows; int colCount = worksheet.Dimension.Columns; if (rowCount <= 1) { Debug.LogError("Excel file is empty or has no data rows."); return; } // 3. 读取表头,建立列名到索引的映射 Dictionary<string, int> columnIndexMap = new Dictionary<string, int>(); for (int col = 1; col <= colCount; col++) { string header = worksheet.Cells[1, col].Text.Trim().ToLower(); // 转小写方便匹配 columnIndexMap[header] = col; } // 4. 遍历数据行,构建EnemyData列表 List<EnemyData> enemyDataList = new List<EnemyData>(); for (int row = 2; row <= rowCount; row++) // 从第2行开始是数据 { string id = GetCellValue(worksheet, row, columnIndexMap, "id"); if (string.IsNullOrEmpty(id)) continue; // 跳过空ID的行 EnemyData data = new EnemyData(id, GetCellValue(worksheet, row, columnIndexMap, "name")); // 解析基础数值属性 data.hp = ParseInt(GetCellValue(worksheet, row, columnIndexMap, "hp")); data.maxHp = ParseInt(GetCellValue(worksheet, row, columnIndexMap, "maxhp")); data.attack = ParseInt(GetCellValue(worksheet, row, columnIndexMap, "attack")); data.defense = ParseInt(GetCellValue(worksheet, row, columnIndexMap, "defense")); data.moveSpeed = ParseFloat(GetCellValue(worksheet, row, columnIndexMap, "movespeed")); data.prefabPath = GetCellValue(worksheet, row, columnIndexMap, "prefabpath"); // 解析技能ID列表(用分号分隔) string skillIdsStr = GetCellValue(worksheet, row, columnIndexMap, "skillids"); if (!string.IsNullOrEmpty(skillIdsStr)) { data.skillIds = new List<string>(skillIdsStr.Split(';')); } // 解析掉落表(格式:itemId:min:max:prob;...) string dropTableStr = GetCellValue(worksheet, row, columnIndexMap, "droptable"); if (!string.IsNullOrEmpty(dropTableStr)) { data.dropTable = new List<EnemyData.DropItem>(); string[] dropItems = dropTableStr.Split(';'); foreach (string itemStr in dropItems) { string[] parts = itemStr.Split(':'); if (parts.Length == 4) { EnemyData.DropItem dropItem = new EnemyData.DropItem { itemId = parts[0], minCount = ParseInt(parts[1]), maxCount = ParseInt(parts[2]), probability = ParseFloat(parts[3]) }; data.dropTable.Add(dropItem); } } } enemyDataList.Add(data); } // 5. 将列表序列化为JSON string jsonOutput = JsonUtility.ToJson(new SerializationWrapper<EnemyData>(enemyDataList), true); // 使用包装类,并美化输出 // 包装类是因为JsonUtility不能直接序列化List<T>,需要包装一下 // [System.Serializable] private class SerializationWrapper<T> { public List<T> items; } // 6. 保存JSON文件到StreamingAssets目录 string outputPath = Path.Combine(Application.streamingAssetsPath, "EnemyData.json"); File.WriteAllText(outputPath, jsonOutput); Debug.Log($"Successfully exported {enemyDataList.Count} enemies to: {outputPath}"); AssetDatabase.Refresh(); // 刷新Unity资源数据库,让新文件显示出来 } } // --- 一些辅助方法 --- private static string GetCellValue(ExcelWorksheet ws, int row, Dictionary<string, int> colMap, string key) { if (colMap.ContainsKey(key.ToLower())) { var cell = ws.Cells[row, colMap[key.ToLower()]]; return cell.Text; } return string.Empty; } private static int ParseInt(string str, int defaultValue = 0) { int result; return int.TryParse(str, out result) ? result : defaultValue; } private static float ParseFloat(string str, float defaultValue = 0f) { float result; return float.TryParse(str, out result) ? result : defaultValue; } // JsonUtility辅助包装类 [System.Serializable] private class SerializationWrapper<T> { public List<T> items; public SerializationWrapper(List<T> list) { items = list; } } }实操心得与避坑指南:
- EPPlus许可证:EPPlus 5.0+版本在非商业用途或特定条件下是免费的,但商业项目需要留意其Polyform Noncommercial License。对于商业项目,可以考虑使用NPOI或坚持使用我们推荐的“导出为文本”方案,编辑器工具用简单的CSV解析(
string.Split)可能更省事。- 列名匹配:代码中将列名转为小写进行匹配,这提高了容错性。确保Excel表头不要有额外的空格。
- 复杂结构解析:像
skillIds和dropTable这样的复杂字段,在Excel单元格内用分隔符存储是通用做法。解析逻辑需要健壮,处理好空单元格和格式错误的情况。- StreamingAssets路径:
Application.streamingAssetsPath在打包后是只读的。所以这里导出的是初始配置数据。玩家的存档应该写到Application.persistentDataPath。
运行这个工具,你就能在StreamingAssets文件夹下得到一个EnemyData.json文件,里面是所有敌人的初始属性。
5. 核心实现:运行时数据管理篇
有了JSON数据,我们需要在游戏运行时加载和管理它们。
5.1 创建数据管理器(EnemyDataManager)
这是一个单例或静态管理器,负责在游戏启动时加载初始数据,并提供根据ID查询数据的方法。
// EnemyDataManager.cs using UnityEngine; using System.Collections.Generic; using System.IO; public class EnemyDataManager : MonoBehaviour { public static EnemyDataManager Instance { get; private set; } // 存储所有敌人模板数据的字典,键为id private Dictionary<string, EnemyData> enemyTemplateDict = new Dictionary<string, EnemyData>(); // 存储当前游戏进程中,敌人可能发生变化的数据(存档数据) private Dictionary<string, EnemyData> enemyRuntimeDataDict = new Dictionary<string, EnemyData>(); void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // 通常数据管理器需要跨场景 LoadInitialEnemyData(); } // 加载初始配置数据(从StreamingAssets) private void LoadInitialEnemyData() { string filePath = Path.Combine(Application.streamingAssetsPath, "EnemyData.json"); string jsonString = ""; // 处理不同平台的StreamingAssets读取方式 #if UNITY_ANDROID && !UNITY_EDITOR // Android平台下,StreamingAssets在压缩的jar包里,需要用UnityWebRequest读取 UnityEngine.Networking.UnityWebRequest www = UnityEngine.Networking.UnityWebRequest.Get(filePath); www.SendWebRequest(); while (!www.isDone) { } // 简单等待,生产环境应用协程 if (www.result == UnityEngine.Networking.UnityWebRequest.Result.Success) { jsonString = www.downloadHandler.text; } else { Debug.LogError($"Failed to load enemy data: {www.error}"); return; } #else // 其他平台(Editor, Standalone, iOS等)可以直接用File.ReadAllText if (File.Exists(filePath)) { jsonString = File.ReadAllText(filePath); } else { Debug.LogError($"Enemy data file not found at: {filePath}"); return; } #endif if (!string.IsNullOrEmpty(jsonString)) { // 使用包装类反序列化 var wrapper = JsonUtility.FromJson<SerializationWrapper<EnemyData>>(jsonString); if (wrapper != null && wrapper.items != null) { enemyTemplateDict.Clear(); foreach (var data in wrapper.items) { if (!enemyTemplateDict.ContainsKey(data.id)) { enemyTemplateDict.Add(data.id, data); // 初始化运行时数据,从模板深拷贝一份 enemyRuntimeDataDict[data.id] = DeepCopy(data); } else { Debug.LogWarning($"Duplicate enemy ID found: {data.id}"); } } Debug.Log($"Loaded {enemyTemplateDict.Count} enemy templates."); } } } // 根据ID获取敌人的运行时数据(如果存档中有修改,则返回修改后的) public EnemyData GetEnemyRuntimeData(string id) { if (enemyRuntimeDataDict.TryGetValue(id, out EnemyData data)) { return data; } Debug.LogWarning($"Enemy runtime data not found for ID: {id}. Returning template."); return GetEnemyTemplate(id); // 降级返回模板数据 } // 根据ID获取敌人的初始模板数据(只读) public EnemyData GetEnemyTemplate(string id) { if (enemyTemplateDict.TryGetValue(id, out EnemyData data)) { return data; } Debug.LogError($"Enemy template not found for ID: {id}"); return null; } // 更新某个敌人的运行时数据(例如战斗后HP变化) public void UpdateEnemyRuntimeData(string id, EnemyData newData) { if (enemyRuntimeDataDict.ContainsKey(id)) { enemyRuntimeDataDict[id] = newData; } else { Debug.LogWarning($"Cannot update runtime data for unknown enemy ID: {id}. Adding new entry."); enemyRuntimeDataDict[id] = newData; } } // 深拷贝辅助方法,确保运行时数据和模板数据独立 private EnemyData DeepCopy(EnemyData source) { // 简单序列化反序列化实现深拷贝(对于这个数据结构有效) string json = JsonUtility.ToJson(source); return JsonUtility.FromJson<EnemyData>(json); } // 内部使用的序列化包装类 [System.Serializable] private class SerializationWrapper<T> { public List<T> items; } }5.2 敌人生成与属性绑定
创建一个简单的敌人控制器,在生成时从EnemyDataManager获取数据并初始化。
// EnemyController.cs using UnityEngine; public class EnemyController : MonoBehaviour { public string enemyId; // 在Inspector中指定,或由生成器传入 private EnemyData currentData; private int currentHp; void Start() { if (string.IsNullOrEmpty(enemyId)) { Debug.LogError("Enemy ID is not set!"); return; } // 从管理器获取运行时数据(包含可能的存档信息) currentData = EnemyDataManager.Instance.GetEnemyRuntimeData(enemyId); if (currentData == null) { Debug.LogError($"Failed to load data for enemy: {enemyId}"); return; } // 初始化属性 currentHp = currentData.hp; // 这里可以根据prefabPath动态加载预制体,本例假设预制体已挂载 // 初始化技能、AI等... Debug.Log($"Enemy {currentData.name} spawned. HP: {currentHp}/{currentData.maxHp}, Attack: {currentData.attack}"); } public void TakeDamage(int damage) { int actualDamage = Mathf.Max(damage - currentData.defense, 1); // 简单伤害计算 currentHp -= actualDamage; Debug.Log($"{currentData.name} takes {actualDamage} damage. HP now: {currentHp}"); // 更新运行时数据管理器中的HP currentData.hp = currentHp; EnemyDataManager.Instance.UpdateEnemyRuntimeData(enemyId, currentData); if (currentHp <= 0) { Die(); } } private void Die() { Debug.Log($"{currentData.name} died."); // 处理掉落逻辑 foreach (var drop in currentData.dropTable) { if (Random.value <= drop.probability) { int dropCount = Random.Range(drop.minCount, drop.maxCount + 1); Debug.Log($" Drops {dropCount} of {drop.itemId}"); // 实际生成掉落物... } } Destroy(gameObject); } }6. 核心实现:存档与读档篇
这是“动态存档”的关键。我们需要将运行时修改过的敌人数据(enemyRuntimeDataDict)保存到磁盘,并在下次游戏时加载回来。
6.1 存档(Save)实现
在EnemyDataManager中添加保存方法。存档应该保存在Application.persistentDataPath,这是操作系统指定的、应用有读写权限的目录。
// 在EnemyDataManager.cs中添加 public void SaveEnemyRuntimeData(string saveSlot = "save01") { // 准备要保存的数据列表 List<EnemyData> dataToSave = new List<EnemyData>(enemyRuntimeDataDict.Values); // 序列化为JSON var wrapper = new SerializationWrapper<EnemyData>(dataToSave); string jsonString = JsonUtility.ToJson(wrapper, true); // 确定存档路径 string saveDir = Path.Combine(Application.persistentDataPath, "Saves"); if (!Directory.Exists(saveDir)) { Directory.CreateDirectory(saveDir); } string filePath = Path.Combine(saveDir, $"{saveSlot}_enemy.json"); // 写入文件 File.WriteAllText(filePath, jsonString); Debug.Log($"Enemy runtime data saved to: {filePath}"); }6.2 读档(Load)实现
同样,在EnemyDataManager中添加加载方法。加载时,我们用存档数据覆盖掉从模板初始化的运行时数据。
// 在EnemyDataManager.cs中添加 public void LoadEnemyRuntimeData(string saveSlot = "save01") { string filePath = Path.Combine(Application.persistentDataPath, "Saves", $"{saveSlot}_enemy.json"); if (!File.Exists(filePath)) { Debug.LogWarning($"No enemy save file found at: {filePath}. Loading initial data."); // 可以选择在这里重新初始化运行时数据为模板数据 ResetRuntimeDataToTemplate(); return; } string jsonString = File.ReadAllText(filePath); var wrapper = JsonUtility.FromJson<SerializationWrapper<EnemyData>>(jsonString); if (wrapper != null && wrapper.items != null) { enemyRuntimeDataDict.Clear(); foreach (var data in wrapper.items) { enemyRuntimeDataDict[data.id] = data; } Debug.Log($"Loaded enemy runtime data from save slot: {saveSlot}, count: {enemyRuntimeDataDict.Count}"); } else { Debug.LogError($"Failed to parse enemy save file: {filePath}"); } } // 将运行时数据重置为模板数据(用于新游戏) private void ResetRuntimeDataToTemplate() { enemyRuntimeDataDict.Clear(); foreach (var kvp in enemyTemplateDict) { enemyRuntimeDataDict[kvp.Key] = DeepCopy(kvp.Value); } }6.3 集成到游戏流程中
在游戏中合适的时机调用存档和读档,例如:
- 游戏开始/主菜单:调用
LoadEnemyRuntimeData加载存档。 - 进入检查点、退出游戏、手动保存:调用
SaveEnemyRuntimeData保存当前状态。 - 开始新游戏:调用
ResetRuntimeDataToTemplate(或直接调用LoadEnemyRuntimeData但传入一个不存在的存档名,触发重置逻辑)。
你可以创建一个GameSaveManager来统一管理包括敌人数据在内的所有游戏存档。
7. 常见问题、优化与扩展
在实际使用这套系统时,你可能会遇到以下问题,这里给出我的解决方案和经验。
7.1 性能与内存优化
- 问题:敌人数量很多(成千上万),每次保存/加载都序列化整个字典,JSON文件很大,速度慢。
- 解决方案:采用增量存档。只保存发生变化的数据。为
EnemyData添加一个dirty标志,只在属性被修改时标记。存档时只保存dirty为true的数据。读档时,用存档数据覆盖模板数据,未覆盖的保持原样。
- 解决方案:采用增量存档。只保存发生变化的数据。为
- 问题:
EnemyData类字段很多,但每次存档可能只改了HP,序列化整个对象浪费。- 解决方案:设计一个更精简的
EnemySaveData类,只包含必要字段(如id,hp)。存档时只保存这个精简版。读档后,再合并到完整的EnemyData对象中。
- 解决方案:设计一个更精简的
7.2 数据版本管理与兼容性
- 问题:游戏更新后,Excel表格增加了新列(如
magicResist),旧的存档JSON文件没有这个字段,导致加载失败或数据丢失。- 解决方案:在
EnemyData类中为字段设置默认值。Unity的JsonUtility在反序列化时,如果JSON中缺少某个字段,会保留该字段在对象中的初始值(对于引用类型为null,值类型为默认值)。因此,确保你的字段都有合理的默认值。更健壮的做法是在加载存档后,执行一个“数据迁移”函数,检查并补全缺失的字段,用模板数据或默认值填充。
- 解决方案:在
7.3 扩展性设计
- 支持多种敌人类型:如果敌人类型差异很大(如飞行单位、法师单位),可以考虑使用继承。定义基类
EnemyData,然后派生出MeleeEnemyData、RangedEnemyData等。在Excel中增加一个type列,导出和加载时根据类型实例化不同的子类。这需要更复杂的序列化逻辑,可以考虑使用Json.NET(Newtonsoft.Json)库,它支持多态序列化。 - 与ScriptableObject结合:对于不变的基础属性(如预制体引用、图标、音效),可以仍然放在ScriptableObject中。Excel只管理数值和配置,运行时通过
id关联到对应的ScriptableObject资产,获取引用资源。这样既享受了Excel批量编辑的便利,又利用了Unity资源管理的优势。
7.4 编辑器体验提升
- 实时预览:在编辑器工具中,导出JSON后,可以自动在Unity中创建一个可视化的查看器窗口,显示解析出的敌人列表和属性,方便策划校验。
- 错误检查:在导出工具中增加验证逻辑,检查ID是否重复、数值是否在合理范围内、预制体路径是否存在等,并在Unity Console中输出明确的错误或警告信息。
这套基于Excel的Unity敌人属性存档/读档系统,核心思想是数据驱动和关注点分离。它将易变的数值平衡工作交给了更友好的Excel,而Unity负责执行和呈现。实现过程中,关键在于设计好数据结构和约定好Excel的格式规范。虽然前期搭建需要一些工作量,但对于项目长期的数据迭代和维护来说,收益是巨大的。我自己的项目从手动修改ScriptableObject切换到这套系统后,策划的反馈效率和我们的开发效率都得到了显著提升。希望这篇详细的实战指南能帮助你顺利实现自己的游戏数据管理系统。
