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:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 敌人能力模块 —— 全部 <see cref="EnemyAbilitySO"/> 的对比表格。
|
||||
///
|
||||
/// 本模块刻意不做成「列表 + 详情」的标准形态:能力资产按敌人分散在
|
||||
/// Data/Enemies/{敌人}/Abilities/ 下,逐个点开看不出问题;真正需要的是横向对比
|
||||
/// —— 同一敌人的招之间射程/权重/冷却是否成比例、有没有 Attack 类却射程为 0 的死招。
|
||||
/// 所以表格本身就是导航:点行即选中资产,Inspector 出现在表格下方。
|
||||
///
|
||||
/// 新建不在此模块内做:能力有类型化子类(EnemyAbilitySO 的各子类),且必须落到
|
||||
/// 对应敌人的目录,这两件事由角色向导保证(CLAUDE.md 第 2 条:不裸建)。
|
||||
/// </summary>
|
||||
public class EnemyAbilityModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
public string ModuleId => "enemyability";
|
||||
public string DisplayName => "敌人能力";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 35; // 紧随「敌人」(30)
|
||||
|
||||
private readonly List<AbilityOverviewRow> _rows = new List<AbilityOverviewRow>();
|
||||
|
||||
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<UnityEngine.Object> _onSelected;
|
||||
|
||||
// ── IDataModule ──────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() => Reload();
|
||||
|
||||
public void OnActivated()
|
||||
{
|
||||
Reload();
|
||||
RebuildTable();
|
||||
UpdateSummary();
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> 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<EnemyAbilitySO>())
|
||||
_rows.Add(EnemyAbilityOverview.BuildRow(so, AssetDatabase.GetAssetPath(so)));
|
||||
}
|
||||
|
||||
private IEnumerable<AbilityOverviewRow> 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<MouseDownEvent>(_ =>
|
||||
{
|
||||
_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。请从菜单打开角色向导创建能力资产。", "确定");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e88e43dbf88ab84a99a5469669ceb0d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e8a611379a854f4fb285f91fad810a1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user