Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/EnemyAbilityOverview.cs
T
joywayerandClaude Opus 5 bb577f01a8 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>
2026-08-03 13:09:05 +08:00

133 lines
6.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}