删除 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>
133 lines
6.1 KiB
C#
133 lines
6.1 KiB
C#
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);
|
||
}
|
||
}
|