feat(editor): DataHub 新增敌人能力总览表格,补回删旧 Boss 轨时丢的编辑器视图

删除 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:09:05 +08:00
co-authored by Claude Opus 5
parent 1e3c060c8d
commit bb577f01a8
8 changed files with 586 additions and 0 deletions
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BaseGames.Core;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Editor.Modules
{
/// <summary>总览表格的可排序列。</summary>
public enum AbilityOverviewColumn
{
Owner, Id, Category, Range, Weight, Cooldown, Priority, Exclusion
}
/// <summary>
/// 总览表格的一行。只读快照,刷新时整体重建——能力资产改动频率低,
/// 不值得为增量更新引入订阅。
/// </summary>
public readonly struct AbilityOverviewRow
{
public EnemyAbilitySO Asset { get; }
/// <summary>归属敌人(由资产路径推导;不在规范目录下时为 <see cref="EnemyAbilityOverview.UnknownOwner"/>)。</summary>
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; }
/// <summary>资产自校验报出的首条问题;无问题为 null。</summary>
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;
}
}
/// <summary>
/// 能力总览表格的取数与排序(纯函数,不碰 UI)。
/// 抽出来的理由:UIElements 构建不可单测,而表格真正会出错的是
/// 「归属怎么推导」「问题从哪来」「排序对不对」这三处——它们都是纯逻辑。
/// </summary>
public static class EnemyAbilityOverview
{
/// <summary>资产不在 Data/Enemies/{敌人}/ 下时的归属占位符。</summary>
public const string UnknownOwner = "—";
private const string EnemiesSegment = "Enemies";
/// <summary>
/// 从资产路径推导归属敌人:取 Enemies 段之后的那一级目录名。
/// 按 AssetFolderSpec,能力资产落在 Data/Enemies/{敌人}/Abilities/ 下。
/// 不符合该布局时返回占位符,而不是猜一个名字——放错目录这件事本身
/// 应该在表里看得见。
/// </summary>
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;
}
/// <summary>
/// 把一个能力资产摊成一行。问题一列直接取资产自己的 <see cref="IValidatable.Validate"/>
/// 不在这里另写一份判定规则——否则 SO 侧改了规则,表格会继续按旧规则报。
/// </summary>
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);
}
/// <summary>
/// 按列排序。次序键固定为 abilityId:同值行的相对位置才不会每次刷新都跳动,
/// 也让「按归属排」自然变成「同一敌人的招挨在一起、组内按 id」。
/// </summary>
public static List<AbilityOverviewRow> Sort(
IReadOnlyList<AbilityOverviewRow> rows, AbilityOverviewColumn column, bool ascending = true)
{
IOrderedEnumerable<AbilityOverviewRow> 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<AbilityOverviewRow> By<TKey>(
IEnumerable<AbilityOverviewRow> src, Func<AbilityOverviewRow, TKey> key, bool ascending)
=> ascending ? src.OrderBy(key) : src.OrderByDescending(key);
// 文本列固定用序数比较:排序结果不随编辑器区域设置变化
private static IOrderedEnumerable<AbilityOverviewRow> ByText(
IEnumerable<AbilityOverviewRow> src, Func<AbilityOverviewRow, string> key, bool ascending)
=> ascending
? src.OrderBy(key, StringComparer.Ordinal)
: src.OrderByDescending(key, StringComparer.Ordinal);
}
}