From bb577f01a8aea616e79856c5305ea212ef73f779 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 13:09:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20DataHub=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=95=8C=E4=BA=BA=E8=83=BD=E5=8A=9B=E6=80=BB=E8=A7=88=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=EF=BC=8C=E8=A1=A5=E5=9B=9E=E5=88=A0=E6=97=A7=20Boss?= =?UTF-8?q?=20=E8=BD=A8=E6=97=B6=E4=B8=A2=E7=9A=84=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 BossSkillModule / BossSkillSequenceWindow 后能力资产没有任何编辑器总览 (spec §6.4 记为待补)。本次补上,但不照搬「列表 + 详情」的标准形态: 能力资产按敌人分散在 Data/Enemies/{敌人}/Abilities/ 下,逐个点开看不出问题; 真正需要的是横向对比——同一敌人的招之间射程/权重/冷却是否成比例、有没有 Attack 类却射程为 0 的死招。所以做成一张跨敌人的对比表,表格本身就是导航: 点行即选中资产,Inspector 出现在表格下方。列均可点击排序。 两点刻意的设计: - 问题列直接调资产自己的 Validate(),不在表格里另写一份判定规则。否则 SO 侧 改了规则,表格会继续按旧规则报。 - 不提供新建。能力有类型化子类且必须落到对应敌人目录,这两件事由角色向导保证 (CLAUDE.md 第 2 条不裸建);模块只给一个跳转向导的按钮。 取数与排序抽成纯函数(EnemyAbilityOverview)与 UI 分离:UIElements 构建不可单测, 而表格真正会出错的是「归属怎么从路径推导」「问题从哪来」「排序对不对」, 这三处都是纯逻辑,抽出来即可覆盖。 spec 里还提到「阶段一览」,本次不做且不打算做:阶段现在归 BossPhaseAbilityGate (预制体上的组件),不在 SO 上,配方/资产层看不到它——与 ApproachAttackEngagement 无法在配方层校验选招器是同一类边界。 验证:编译 0 错;EditMode 279/279(273 + 新增 6)。 UI 路径另做程序化冒烟(单测覆盖不到):模块被 DataHubWindow 自动发现并排在 「敌人」之后,两个面板构建无异常,表格 18 行 = 项目 18 个能力资产; 过滤实测 18 / 仅 Attack 5 / 仅有问题 0(与 SO 校验"全部合法"一致), 归属降序首行为 E006 符合序数排序。 Co-Authored-By: Claude Opus 5 --- .../EditMode/BaseGames.Tests.EditMode.asmdef | 1 + Assets/Tests/EditMode/EditorTools.meta | 8 + .../EditorTools/EnemyAbilityOverviewTests.cs | 146 ++++++++++ .../EnemyAbilityOverviewTests.cs.meta | 11 + .../Editor/Modules/EnemyAbilityModule.cs | 266 ++++++++++++++++++ .../Editor/Modules/EnemyAbilityModule.cs.meta | 11 + .../Editor/Modules/EnemyAbilityOverview.cs | 132 +++++++++ .../Modules/EnemyAbilityOverview.cs.meta | 11 + 8 files changed, 586 insertions(+) create mode 100644 Assets/Tests/EditMode/EditorTools.meta create mode 100644 Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs create mode 100644 Assets/Tests/EditMode/EditorTools/EnemyAbilityOverviewTests.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/EnemyAbilityModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs.meta 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: