using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; using BaseGames.UI; using BaseGames.Enemies; using BaseGames.Localization; using BaseGames.Editor.Localization; namespace BaseGames.Editor.UI { /// /// 图鉴条目同步 / 校验工具(策划友好)。从现有敌人数据自动补全 : /// /// 来源 A:Assets/_Game/Data/Enemies/{id}/ 下的 EnemyStatsSO(文件夹名即 enemyId,读 MaxHP) /// 来源 B:含 EnemyBase 的预制件(权威 _enemyId + StatsSO.MaxHP + Boss 判定),优先级高于 A /// /// 仅"补全缺失":已存在条目的策划字段(名/图标/描述/分级)一律保留,只在 displayMaxHP 为 0 时回填。 /// 新增条目自动生成 ENM_{id}_NAME / ENM_{id}_DESC 占位本地化(已存在不覆盖)。 /// 孤儿条目(enemyId 无对应敌人数据,常为手敲错字)仅警告,不删除。 /// /// 菜单:BaseGames/UI/控件库/同步图鉴条目(从敌人数据) · 校验图鉴条目 /// public static class BestiarySyncTool { private const string DbPath = "Assets/_Game/Data/UI/UI_BestiaryDatabase.asset"; private const string EnemiesDir = "Assets/_Game/Data/Enemies"; private struct Src { public int hp; public bool boss; public string from; } [MenuItem("BaseGames/UI/控件库/同步图鉴条目(从敌人数据)", priority = 300)] public static void Sync() { var db = LoadDb(); if (db == null) return; var discovered = Discover(out var srcLog); var so = new SerializedObject(db); var arr = so.FindProperty("_entries"); // 现有条目 id → 索引 var existing = new Dictionary(); for (int i = 0; i < arr.arraySize; i++) { string id = arr.GetArrayElementAtIndex(i).FindPropertyRelative("enemyId").stringValue; if (!string.IsNullOrEmpty(id) && !existing.ContainsKey(id)) existing[id] = i; } int added = 0, hpFilled = 0; var newLocKeys = new List(); foreach (var kv in discovered.OrderBy(k => k.Key)) { string id = kv.Key; if (existing.TryGetValue(id, out int idx)) { // 已存在:仅在 HP 为 0 时回填,其余策划字段保留 var el = arr.GetArrayElementAtIndex(idx); var hpProp = el.FindPropertyRelative("displayMaxHP"); if (hpProp.intValue == 0 && kv.Value.hp > 0) { hpProp.intValue = kv.Value.hp; hpFilled++; } continue; } // 新增 int n = arr.arraySize; arr.arraySize = n + 1; var nel = arr.GetArrayElementAtIndex(n); nel.FindPropertyRelative("enemyId").stringValue = id; nel.FindPropertyRelative("displayNameKey").stringValue = $"ENM_{id}_NAME"; nel.FindPropertyRelative("descKey").stringValue = $"ENM_{id}_DESC"; nel.FindPropertyRelative("icon").objectReferenceValue = null; nel.FindPropertyRelative("tier").enumValueIndex = kv.Value.boss ? 2 : 0; // Boss : Normal nel.FindPropertyRelative("region").stringValue = ""; nel.FindPropertyRelative("fullLoreKillCount").intValue = 1; nel.FindPropertyRelative("displayMaxHP").intValue = kv.Value.hp; newLocKeys.Add(id); added++; } so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(db); int locAdded = SeedLocalization(newLocKeys); // 孤儿(DB 有、敌人数据无) var orphans = existing.Keys.Where(id => !discovered.ContainsKey(id)).OrderBy(s => s).ToList(); AssetDatabase.SaveAssets(); Selection.activeObject = db; EditorGUIUtility.PingObject(db); var sb = new System.Text.StringBuilder(); sb.AppendLine($"[图鉴同步] 完成:新增 {added} 条 · 回填 HP {hpFilled} 条 · 本地化新增 {locAdded} 条 · 现有总计 {arr.arraySize} 条。"); sb.AppendLine($"发现敌人来源 {discovered.Count} 个:"); foreach (var l in srcLog) sb.AppendLine(" • " + l); if (orphans.Count > 0) sb.AppendLine($"⚠ 孤儿条目 {orphans.Count} 个(enemyId 无对应敌人数据,请核对拼写,未自动删除):{string.Join(", ", orphans)}"); Debug.Log(sb.ToString(), db); EditorUtility.DisplayDialog("图鉴同步", $"新增 {added} 条,回填 HP {hpFilled} 条。\n现有总计 {arr.arraySize} 条。\n" + (orphans.Count > 0 ? $"⚠ {orphans.Count} 个孤儿条目(见 Console)。" : "无孤儿条目。") + "\n\n详情见 Console;已选中 UI_BestiaryDatabase。", "好"); } [MenuItem("BaseGames/UI/控件库/校验图鉴条目", priority = 301)] public static void Validate() { var db = LoadDb(); if (db == null) return; var discovered = Discover(out _); var so = new SerializedObject(db); var arr = so.FindProperty("_entries"); var dbIds = new HashSet(); var dupes = new List(); var noKey = new List(); for (int i = 0; i < arr.arraySize; i++) { var el = arr.GetArrayElementAtIndex(i); string id = el.FindPropertyRelative("enemyId").stringValue; if (string.IsNullOrEmpty(id)) { noKey.Add($"#{i}"); continue; } if (!dbIds.Add(id)) dupes.Add(id); if (string.IsNullOrEmpty(el.FindPropertyRelative("displayNameKey").stringValue)) noKey.Add(id); } var orphans = dbIds.Where(id => !discovered.ContainsKey(id)).OrderBy(s => s).ToList(); var missing = discovered.Keys.Where(id => !dbIds.Contains(id)).OrderBy(s => s).ToList(); var sb = new System.Text.StringBuilder("[图鉴校验]\n"); sb.AppendLine($"DB 条目 {arr.arraySize} · 敌人数据来源 {discovered.Count} · 已覆盖 {dbIds.Count - orphans.Count}/{discovered.Count}"); sb.AppendLine(missing.Count > 0 ? $"✗ 缺失(有敌人数据无条目){missing.Count}:{string.Join(", ", missing)}" : "✓ 无缺失"); sb.AppendLine(orphans.Count > 0 ? $"⚠ 孤儿(条目无敌人数据){orphans.Count}:{string.Join(", ", orphans)}" : "✓ 无孤儿"); sb.AppendLine(dupes.Count > 0 ? $"✗ 重复 enemyId {dupes.Count}:{string.Join(", ", dupes)}" : "✓ 无重复"); sb.AppendLine(noKey.Count > 0 ? $"⚠ 缺 enemyId/名称 Key:{string.Join(", ", noKey)}" : "✓ id/名称 Key 齐全"); Debug.Log(sb.ToString(), db); EditorUtility.DisplayDialog("图鉴校验", $"DB {arr.arraySize} 条 / 敌人数据 {discovered.Count} 个\n" + $"缺失 {missing.Count} · 孤儿 {orphans.Count} · 重复 {dupes.Count}\n\n详情见 Console。", missing.Count == 0 && orphans.Count == 0 && dupes.Count == 0 ? "全部通过" : "好"); } // ── 发现敌人来源 ───────────────────────────────────────────────────── private static Dictionary Discover(out List log) { var map = new Dictionary(); log = new List(); // 来源 A:Data/Enemies/{id}/ 子文件夹 + EnemyStatsSO if (AssetDatabase.IsValidFolder(EnemiesDir)) { foreach (var folder in AssetDatabase.GetSubFolders(EnemiesDir)) { string id = folder.Substring(folder.LastIndexOf('/') + 1); EnemyStatsSO stats = null; foreach (var g in AssetDatabase.FindAssets("t:EnemyStatsSO", new[] { folder })) { stats = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(g)); if (stats != null) break; } if (stats == null) continue; // 无 EnemyStatsSO 的文件夹不算敌人(跳过 Stats 等杂项目录) map[id] = new Src { hp = stats.MaxHP, boss = false, from = "data" }; log.Add($"{id}(data, HP={stats.MaxHP})"); } } // 来源 B:含 EnemyBase 的预制件(权威 id,优先覆盖) foreach (var g in AssetDatabase.FindAssets("t:Prefab")) { var go = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(g)); if (go == null) continue; var eb = go.GetComponentInChildren(true); if (eb == null || string.IsNullOrEmpty(eb.EnemyId)) continue; int hp = eb.StatsSO != null ? eb.StatsSO.MaxHP : 0; bool boss = eb.GetType().Name.IndexOf("Boss", System.StringComparison.OrdinalIgnoreCase) >= 0; map[eb.EnemyId] = new Src { hp = hp, boss = boss, from = "prefab" }; log.Add($"{eb.EnemyId}(prefab{(boss ? ",BOSS" : "")}, HP={hp})"); } return map; } private static int SeedLocalization(List ids) { if (ids == null || ids.Count == 0) return 0; var zh = LocalizationFileIO.Read(Language.ChineseSimplified, LocalizationTable.UI); var en = LocalizationFileIO.Read(Language.English, LocalizationTable.UI); int added = 0; foreach (var id in ids) { string nk = $"ENM_{id}_NAME", dk = $"ENM_{id}_DESC"; if (!zh.ContainsKey(nk)) { zh[nk] = $"敌人 {id}"; added++; } if (!zh.ContainsKey(dk)) { zh[dk] = "(占位)该敌人的背景与弱点描述,由策划填写。"; added++; } if (!en.ContainsKey(nk)) { en[nk] = $"Enemy {id}"; added++; } if (!en.ContainsKey(dk)) { en[dk] = "(Placeholder) Lore and weakness, to be filled by design."; added++; } } if (added > 0) { LocalizationFileIO.Write(Language.ChineseSimplified, LocalizationTable.UI, zh); LocalizationFileIO.Write(Language.English, LocalizationTable.UI, en); } return added; } private static BestiaryDatabaseSO LoadDb() { var db = AssetDatabase.LoadAssetAtPath(DbPath); if (db == null) EditorUtility.DisplayDialog("图鉴同步", $"未找到 {DbPath}。\n请先运行「BaseGames/UI/控件库/生成图鉴(预制件 + 空表)」。", "好"); return db; } } }