diff --git a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
index 4b76c97e..29161e8f 100644
--- a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
+++ b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
@@ -11,6 +11,7 @@
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.EventChain",
+ "BaseGames.Editor",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
diff --git a/Assets/Tests/EditMode/EditorTools.meta b/Assets/Tests/EditMode/EditorTools.meta
new file mode 100644
index 00000000..66d20cd1
--- /dev/null
+++ b/Assets/Tests/EditMode/EditorTools.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b6d8451861294544a83ef2430a0932fe
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs b/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs
new file mode 100644
index 00000000..4765904b
--- /dev/null
+++ b/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs
@@ -0,0 +1,146 @@
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using UnityEngine;
+using BaseGames.Editor.Modules;
+using BaseGames.Enemies.Abilities;
+
+namespace BaseGames.Tests.EditMode.EditorTools
+{
+ ///
+ /// 能力总览表格的取数与排序。UI 构建不可测,所以把「一行长什么样」「怎么排序」
+ /// 「问题从哪来」抽成纯函数放这里——表格真正会出错的也正是这三处。
+ ///
+ public class EnemyAbilityOverviewTests
+ {
+ private readonly List _created = new List();
+
+ [TearDown]
+ public void TearDown()
+ {
+ // 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
+ // 清理必须放在 TearDown,否则失败一次就往内存里漏一个 SO。
+ foreach (var so in _created) if (so != null) Object.DestroyImmediate(so);
+ _created.Clear();
+ }
+
+ private EnemyAbilitySO MakeAbility(
+ string id, AbilityCategory category = AbilityCategory.None,
+ float range = 1f, float weight = 1f, float cooldown = 1f,
+ int priority = 0, string exclusion = "")
+ {
+ var so = ScriptableObject.CreateInstance();
+ so.abilityId = id;
+ so.category = category;
+ so.rangeRadius = range;
+ so.weight = weight;
+ so.cooldown = cooldown;
+ so.priority = priority;
+ so.exclusionGroup = exclusion;
+ _created.Add(so);
+ return so;
+ }
+
+ // ── 归属提取 ─────────────────────────────────────────────────────
+
+ [Test]
+ public void OwnerFromPath_TakesEnemyFolderName()
+ {
+ // 能力资产按 AssetFolderSpec 落在 Data/Enemies/{敌人}/Abilities/ 下,
+ // 归属列靠路径推导——表格要横向对比,必须能一眼看出这招是谁的。
+ Assert.AreEqual("ChaoFeng", EnemyAbilityOverview.OwnerFromPath(
+ "Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset"));
+ Assert.AreEqual("E004", EnemyAbilityOverview.OwnerFromPath(
+ "Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset"));
+ }
+
+ [Test]
+ public void OwnerFromPath_OffSpecLayout_ReturnsPlaceholder()
+ {
+ // 放错目录的资产不该让整张表崩掉,但也不能假装它有归属——显示占位符,
+ // 让"这个资产没按规范放"本身在表里可见。
+ Assert.AreEqual(EnemyAbilityOverview.UnknownOwner,
+ EnemyAbilityOverview.OwnerFromPath("Assets/Random/ABL_Stray.asset"));
+ Assert.AreEqual(EnemyAbilityOverview.UnknownOwner,
+ EnemyAbilityOverview.OwnerFromPath(""));
+ }
+
+ // ── 行取数 ───────────────────────────────────────────────────────
+
+ [Test]
+ public void BuildRow_CarriesTunableFields()
+ {
+ var so = MakeAbility("blink_strike", AbilityCategory.Attack,
+ range: 3.5f, weight: 2f, cooldown: 4.25f,
+ priority: 7, exclusion: "melee");
+
+ var row = EnemyAbilityOverview.BuildRow(
+ so, "Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Blink.asset");
+
+ Assert.AreEqual("E004", row.Owner);
+ Assert.AreEqual("blink_strike", row.Id);
+ Assert.AreEqual(AbilityCategory.Attack, row.Category);
+ Assert.AreEqual(3.5f, row.Range);
+ Assert.AreEqual(2f, row.Weight);
+ Assert.AreEqual(4.25f, row.Cooldown);
+ Assert.AreEqual(7, row.Priority);
+ Assert.AreEqual("melee", row.Exclusion);
+ Assert.AreSame(so, row.Asset, "行必须持有资产引用,点选才能跳到它");
+ }
+
+ [Test]
+ public void BuildRow_SurfacesIssue_FromAssetsOwnValidate()
+ {
+ // 问题判定不在表格里另写一份规则,而是调资产自己的 Validate()。
+ // 这条断言守住"规则只有一处"——否则 SO 侧改了规则,表格会继续按旧规则报。
+ var broken = MakeAbility("bad_attack", AbilityCategory.Attack, range: 0f);
+ var ok = MakeAbility("good_attack", AbilityCategory.Attack, range: 2f);
+
+ var brokenRow = EnemyAbilityOverview.BuildRow(broken, "Assets/x/ABL_A.asset");
+ var okRow = EnemyAbilityOverview.BuildRow(ok, "Assets/x/ABL_B.asset");
+
+ Assert.IsTrue(brokenRow.HasIssue,
+ "Attack 类但射程为 0 的招永远选不中,表格必须标出来");
+ Assert.IsFalse(string.IsNullOrEmpty(brokenRow.Issue), "要带上原因文本,不能只给个红点");
+ Assert.IsFalse(okRow.HasIssue);
+ }
+
+ // ── 排序 ─────────────────────────────────────────────────────────
+
+ [Test]
+ public void Sort_ByCooldown_BothDirections()
+ {
+ var rows = new List
+ {
+ EnemyAbilityOverview.BuildRow(MakeAbility("b", cooldown: 5f), "Assets/x/B.asset"),
+ EnemyAbilityOverview.BuildRow(MakeAbility("a", cooldown: 1f), "Assets/x/A.asset"),
+ EnemyAbilityOverview.BuildRow(MakeAbility("c", cooldown: 3f), "Assets/x/C.asset"),
+ };
+
+ var asc = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Cooldown, ascending: true);
+ CollectionAssert.AreEqual(new[] { "a", "c", "b" }, asc.Select(r => r.Id).ToArray());
+
+ var desc = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Cooldown, ascending: false);
+ CollectionAssert.AreEqual(new[] { "b", "c", "a" }, desc.Select(r => r.Id).ToArray());
+ }
+
+ [Test]
+ public void Sort_ByOwnerThenId_IsStableAcrossEnemies()
+ {
+ // 默认视图按归属分组才有对比意义:同一敌人的招要挨在一起,组内按 id 稳定排列。
+ var rows = new List
+ {
+ EnemyAbilityOverview.BuildRow(MakeAbility("bite"), "Assets/_Game/Data/Enemies/E005/Abilities/ABL_1.asset"),
+ EnemyAbilityOverview.BuildRow(MakeAbility("acid"), "Assets/_Game/Data/Enemies/E004/Abilities/ABL_2.asset"),
+ EnemyAbilityOverview.BuildRow(MakeAbility("appear"),"Assets/_Game/Data/Enemies/E004/Abilities/ABL_3.asset"),
+ };
+
+ var sorted = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Owner, ascending: true);
+
+ CollectionAssert.AreEqual(
+ new[] { "acid", "appear", "bite" },
+ sorted.Select(r => r.Id).ToArray(),
+ "同归属内按 id 排,跨归属按归属名排");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs.meta b/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs.meta
new file mode 100644
index 00000000..816c4a05
--- /dev/null
+++ b/Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4d191f982c5f0394192ac0f2a7d1172a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs
new file mode 100644
index 00000000..f725e05e
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+using BaseGames.Enemies.Abilities;
+
+namespace BaseGames.Editor.Modules
+{
+ ///
+ /// DataHub 敌人能力模块 —— 全部 的对比表格。
+ ///
+ /// 本模块刻意不做成「列表 + 详情」的标准形态:能力资产按敌人分散在
+ /// Data/Enemies/{敌人}/Abilities/ 下,逐个点开看不出问题;真正需要的是横向对比
+ /// —— 同一敌人的招之间射程/权重/冷却是否成比例、有没有 Attack 类却射程为 0 的死招。
+ /// 所以表格本身就是导航:点行即选中资产,Inspector 出现在表格下方。
+ ///
+ /// 新建不在此模块内做:能力有类型化子类(EnemyAbilitySO 的各子类),且必须落到
+ /// 对应敌人的目录,这两件事由角色向导保证(CLAUDE.md 第 2 条:不裸建)。
+ ///
+ public class EnemyAbilityModule : IDataModule, IDataModuleOrdered
+ {
+ public string ModuleId => "enemyability";
+ public string DisplayName => "敌人能力";
+ public string IconName => null;
+ public int DisplayOrder => 35; // 紧随「敌人」(30)
+
+ private readonly List _rows = new List();
+
+ private AbilityOverviewColumn _sortColumn = AbilityOverviewColumn.Owner;
+ private bool _sortAscending = true;
+ private bool _onlyIssues;
+ private bool _onlyAttack;
+
+ private EnemyAbilitySO _selected;
+ private Label _summaryLabel;
+ private VisualElement _tableHost; // 表格容器:过滤/排序变化时只重建它
+ private Action _onSelected;
+
+ // ── IDataModule ──────────────────────────────────────────────────
+
+ public void Initialize() => Reload();
+
+ public void OnActivated()
+ {
+ Reload();
+ RebuildTable();
+ UpdateSummary();
+ }
+
+ public void BuildListPane(VisualElement container, Action onSelected)
+ {
+ _onSelected = onSelected;
+
+ _summaryLabel = new Label();
+ _summaryLabel.style.fontSize = 10;
+ _summaryLabel.style.opacity = 0.7f;
+ _summaryLabel.style.paddingLeft = 10;
+ _summaryLabel.style.paddingTop = 8;
+ _summaryLabel.style.whiteSpace = WhiteSpace.Normal;
+ container.Add(_summaryLabel);
+ UpdateSummary();
+
+ var hint = new Label("能力资产按敌人分目录存放,本页把它们并到一张表里横向对比。\n点表格中的行即可选中并编辑该资产。");
+ hint.style.fontSize = 10;
+ hint.style.opacity = 0.45f;
+ hint.style.whiteSpace = WhiteSpace.Normal;
+ hint.style.paddingLeft = 10;
+ hint.style.paddingRight = 8;
+ hint.style.paddingTop = 6;
+ container.Add(hint);
+
+ var refreshBtn = new Button(() => { Reload(); RebuildTable(); UpdateSummary(); }) { text = "刷新" };
+ refreshBtn.style.marginTop = 10;
+ refreshBtn.style.marginLeft = 8;
+ refreshBtn.style.marginRight = 8;
+ container.Add(refreshBtn);
+
+ // 新建走向导:能力有类型化子类且必须落到对应敌人目录,裸建两者都保证不了
+ var wizardBtn = new Button(OpenCharacterWizard) { text = "用角色向导新建…" };
+ wizardBtn.style.marginTop = 4;
+ wizardBtn.style.marginLeft = 8;
+ wizardBtn.style.marginRight = 8;
+ container.Add(wizardBtn);
+ }
+
+ public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
+ {
+ _selected = selected as EnemyAbilitySO;
+
+ // 过滤
+ var filterRow = new VisualElement();
+ filterRow.style.flexDirection = FlexDirection.Row;
+ filterRow.style.flexWrap = Wrap.Wrap;
+ filterRow.style.paddingLeft = 6;
+ filterRow.style.paddingTop = 6;
+ filterRow.style.paddingBottom = 4;
+ filterRow.Add(DataHubEditorKit.MakeFilterChip("仅有问题",
+ v => { _onlyIssues = v; RebuildTable(); UpdateSummary(); }));
+ filterRow.Add(DataHubEditorKit.MakeFilterChip("仅 Attack 类",
+ v => { _onlyAttack = v; RebuildTable(); UpdateSummary(); }));
+ container.Add(filterRow);
+
+ _tableHost = new VisualElement();
+ container.Add(_tableHost);
+ RebuildTable();
+
+ if (_selected == null) return;
+
+ container.Add(SkillModule.MakeDivider());
+ container.Add(new InspectorElement(_selected));
+ }
+
+ // ── 表格 ─────────────────────────────────────────────────────────
+
+ private void Reload()
+ {
+ _rows.Clear();
+ foreach (var so in AssetOperations.FindAll())
+ _rows.Add(EnemyAbilityOverview.BuildRow(so, AssetDatabase.GetAssetPath(so)));
+ }
+
+ private IEnumerable VisibleRows()
+ {
+ var rows = _rows.AsEnumerable();
+ if (_onlyIssues) rows = rows.Where(r => r.HasIssue);
+ if (_onlyAttack) rows = rows.Where(r => r.Category == AbilityCategory.Attack);
+ return EnemyAbilityOverview.Sort(rows.ToList(), _sortColumn, _sortAscending);
+ }
+
+ private void RebuildTable()
+ {
+ if (_tableHost == null) return;
+ _tableHost.Clear();
+ _tableHost.Add(BuildHeaderRow());
+ foreach (var row in VisibleRows())
+ _tableHost.Add(BuildDataRow(row));
+ }
+
+ // 列宽(px);归属与 id 用弹性宽度,数值列固定以便纵向对齐比较
+ private const int WIssue = 18, WOwner = 90, WId = 150, WCat = 70,
+ WNum = 58, WExcl = 90;
+
+ private VisualElement BuildHeaderRow()
+ {
+ var header = new VisualElement();
+ header.style.flexDirection = FlexDirection.Row;
+ header.style.alignItems = Align.Center;
+ header.style.paddingLeft = 8;
+ header.style.paddingRight = 8;
+ header.style.paddingBottom = 3;
+ header.style.borderBottomWidth = 1;
+ header.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.35f));
+
+ header.Add(FixedLabel("", WIssue, bold: true));
+ header.Add(SortButton("归属", AbilityOverviewColumn.Owner, WOwner));
+ header.Add(SortButton("能力 Id", AbilityOverviewColumn.Id, WId));
+ header.Add(SortButton("类别", AbilityOverviewColumn.Category, WCat));
+ header.Add(SortButton("射程", AbilityOverviewColumn.Range, WNum));
+ header.Add(SortButton("权重", AbilityOverviewColumn.Weight, WNum));
+ header.Add(SortButton("冷却", AbilityOverviewColumn.Cooldown, WNum));
+ header.Add(SortButton("优先级", AbilityOverviewColumn.Priority, WNum));
+ header.Add(SortButton("互斥组", AbilityOverviewColumn.Exclusion, WExcl));
+ return header;
+ }
+
+ private Button SortButton(string text, AbilityOverviewColumn column, int width)
+ {
+ string arrow = _sortColumn == column ? (_sortAscending ? " ▲" : " ▼") : "";
+ var btn = new Button(() =>
+ {
+ if (_sortColumn == column) _sortAscending = !_sortAscending;
+ else { _sortColumn = column; _sortAscending = true; }
+ RebuildTable();
+ })
+ { text = text + arrow };
+
+ btn.style.width = width;
+ btn.style.flexShrink = 0;
+ btn.style.fontSize = 10;
+ btn.style.marginLeft = 0;
+ btn.style.marginRight = 0;
+ btn.style.paddingLeft = 2;
+ btn.style.paddingRight = 2;
+ btn.style.backgroundColor = StyleKeyword.None;
+ btn.style.borderTopWidth = btn.style.borderBottomWidth = 0;
+ btn.style.borderLeftWidth = btn.style.borderRightWidth = 0;
+ btn.style.unityTextAlign = TextAnchor.MiddleLeft;
+ return btn;
+ }
+
+ private VisualElement BuildDataRow(AbilityOverviewRow r)
+ {
+ bool isSelected = _selected != null && r.Asset == _selected;
+
+ var row = new VisualElement();
+ row.style.flexDirection = FlexDirection.Row;
+ row.style.alignItems = Align.Center;
+ row.style.paddingTop = 2;
+ row.style.paddingBottom = 2;
+ row.style.paddingLeft = 8;
+ row.style.paddingRight = 8;
+ row.style.backgroundColor = isSelected
+ ? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f))
+ : StyleKeyword.None;
+
+ var issue = FixedLabel(r.HasIssue ? "⚠" : "", WIssue);
+ if (r.HasIssue)
+ {
+ issue.style.color = new StyleColor(new Color(1f, 0.45f, 0.2f));
+ issue.tooltip = r.Issue; // 悬停看原因,不占列宽
+ }
+ row.Add(issue);
+
+ row.Add(FixedLabel(r.Owner, WOwner));
+ row.Add(FixedLabel(string.IsNullOrEmpty(r.Id) ? "(未命名)" : r.Id, WId));
+ row.Add(FixedLabel(r.Category.ToString(), WCat));
+ row.Add(FixedLabel(Num(r.Range), WNum));
+ row.Add(FixedLabel(Num(r.Weight), WNum));
+ row.Add(FixedLabel(Num(r.Cooldown), WNum));
+ row.Add(FixedLabel(r.Priority.ToString(), WNum));
+ row.Add(FixedLabel(string.IsNullOrEmpty(r.Exclusion) ? "-" : r.Exclusion, WExcl));
+
+ row.RegisterCallback(_ =>
+ {
+ _selected = r.Asset;
+ _onSelected?.Invoke(r.Asset);
+ });
+ return row;
+ }
+
+ private static string Num(float v) => v.ToString("0.##");
+
+ private static Label FixedLabel(string text, int width, bool bold = false)
+ {
+ var lbl = new Label(text);
+ lbl.style.width = width;
+ lbl.style.flexShrink = 0;
+ lbl.style.fontSize = 11;
+ if (bold) lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
+ return lbl;
+ }
+
+ private void UpdateSummary()
+ {
+ if (_summaryLabel == null) return;
+ int total = _rows.Count;
+ int issues = _rows.Count(r => r.HasIssue);
+ int shown = VisibleRows().Count();
+ _summaryLabel.text = issues > 0
+ ? $"共 {total} 个能力 · 有问题 {issues} 个 · 当前显示 {shown}"
+ : $"共 {total} 个能力 · 无问题 · 当前显示 {shown}";
+ }
+
+ private static void OpenCharacterWizard()
+ {
+ // 与脚手架保持单一入口:能力的类型化子类与目标目录都由向导保证
+ var type = typeof(EnemyAbilityModule).Assembly.GetType("BaseGames.Editor.CharacterWizardWindow");
+ if (type != null) { EditorWindow.GetWindow(type).Show(); return; }
+ EditorUtility.DisplayDialog("未找到角色向导",
+ "没有找到 CharacterWizardWindow。请从菜单打开角色向导创建能力资产。", "确定");
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs.meta
new file mode 100644
index 00000000..dae9b016
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0e88e43dbf88ab84a99a5469669ceb0d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs
new file mode 100644
index 00000000..e31fa5aa
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using BaseGames.Core;
+using BaseGames.Enemies.Abilities;
+
+namespace BaseGames.Editor.Modules
+{
+ /// 总览表格的可排序列。
+ public enum AbilityOverviewColumn
+ {
+ Owner, Id, Category, Range, Weight, Cooldown, Priority, Exclusion
+ }
+
+ ///
+ /// 总览表格的一行。只读快照,刷新时整体重建——能力资产改动频率低,
+ /// 不值得为增量更新引入订阅。
+ ///
+ public readonly struct AbilityOverviewRow
+ {
+ public EnemyAbilitySO Asset { get; }
+ /// 归属敌人(由资产路径推导;不在规范目录下时为 )。
+ public string Owner { get; }
+ public string Id { get; }
+ public AbilityCategory Category { get; }
+ public float Range { get; }
+ public float Weight { get; }
+ public float Cooldown { get; }
+ public int Priority { get; }
+ public string Exclusion { get; }
+ /// 资产自校验报出的首条问题;无问题为 null。
+ public string Issue { get; }
+
+ public bool HasIssue => !string.IsNullOrEmpty(Issue);
+
+ public AbilityOverviewRow(
+ EnemyAbilitySO asset, string owner, string id, AbilityCategory category,
+ float range, float weight, float cooldown, int priority, string exclusion, string issue)
+ {
+ Asset = asset; Owner = owner; Id = id; Category = category;
+ Range = range; Weight = weight; Cooldown = cooldown;
+ Priority = priority; Exclusion = exclusion; Issue = issue;
+ }
+ }
+
+ ///
+ /// 能力总览表格的取数与排序(纯函数,不碰 UI)。
+ /// 抽出来的理由:UIElements 构建不可单测,而表格真正会出错的是
+ /// 「归属怎么推导」「问题从哪来」「排序对不对」这三处——它们都是纯逻辑。
+ ///
+ public static class EnemyAbilityOverview
+ {
+ /// 资产不在 Data/Enemies/{敌人}/ 下时的归属占位符。
+ public const string UnknownOwner = "—";
+
+ private const string EnemiesSegment = "Enemies";
+
+ ///
+ /// 从资产路径推导归属敌人:取 Enemies 段之后的那一级目录名。
+ /// 按 AssetFolderSpec,能力资产落在 Data/Enemies/{敌人}/Abilities/ 下。
+ /// 不符合该布局时返回占位符,而不是猜一个名字——放错目录这件事本身
+ /// 应该在表里看得见。
+ ///
+ public static string OwnerFromPath(string assetPath)
+ {
+ if (string.IsNullOrEmpty(assetPath)) return UnknownOwner;
+
+ var parts = assetPath.Split('/');
+ for (int i = 0; i < parts.Length - 1; i++)
+ if (parts[i] == EnemiesSegment)
+ return string.IsNullOrEmpty(parts[i + 1]) ? UnknownOwner : parts[i + 1];
+
+ return UnknownOwner;
+ }
+
+ ///
+ /// 把一个能力资产摊成一行。问题一列直接取资产自己的 ,
+ /// 不在这里另写一份判定规则——否则 SO 侧改了规则,表格会继续按旧规则报。
+ ///
+ public static AbilityOverviewRow BuildRow(EnemyAbilitySO asset, string assetPath)
+ {
+ string issue = null;
+ foreach (var r in asset.Validate()) { issue = r.Message; break; }
+
+ return new AbilityOverviewRow(
+ asset,
+ OwnerFromPath(assetPath),
+ asset.abilityId,
+ asset.category,
+ asset.rangeRadius,
+ asset.weight,
+ asset.cooldown,
+ asset.priority,
+ asset.exclusionGroup,
+ issue);
+ }
+
+ ///
+ /// 按列排序。次序键固定为 abilityId:同值行的相对位置才不会每次刷新都跳动,
+ /// 也让「按归属排」自然变成「同一敌人的招挨在一起、组内按 id」。
+ ///
+ public static List Sort(
+ IReadOnlyList rows, AbilityOverviewColumn column, bool ascending = true)
+ {
+ IOrderedEnumerable ordered = column switch
+ {
+ AbilityOverviewColumn.Owner => ByText(rows, r => r.Owner, ascending),
+ AbilityOverviewColumn.Id => ByText(rows, r => r.Id, ascending),
+ AbilityOverviewColumn.Exclusion => ByText(rows, r => r.Exclusion, ascending),
+ AbilityOverviewColumn.Category => By(rows, r => (int)r.Category, ascending),
+ AbilityOverviewColumn.Range => By(rows, r => r.Range, ascending),
+ AbilityOverviewColumn.Weight => By(rows, r => r.Weight, ascending),
+ AbilityOverviewColumn.Cooldown => By(rows, r => r.Cooldown, ascending),
+ AbilityOverviewColumn.Priority => By(rows, r => r.Priority, ascending),
+ _ => ByText(rows, r => r.Id, ascending),
+ };
+
+ return ordered.ThenBy(r => r.Id, StringComparer.Ordinal).ToList();
+ }
+
+ private static IOrderedEnumerable By(
+ IEnumerable src, Func key, bool ascending)
+ => ascending ? src.OrderBy(key) : src.OrderByDescending(key);
+
+ // 文本列固定用序数比较:排序结果不随编辑器区域设置变化
+ private static IOrderedEnumerable ByText(
+ IEnumerable src, Func key, bool ascending)
+ => ascending
+ ? src.OrderBy(key, StringComparer.Ordinal)
+ : src.OrderByDescending(key, StringComparer.Ordinal);
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs.meta b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs.meta
new file mode 100644
index 00000000..a23f7bfe
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9e8a611379a854f4fb285f91fad810a1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: