跳到主要内容

Inspector 控件

Guide

为一个声明式字段替换项目自有 UI Toolkit 控件,加入 Workbench 校验,并复用内置创作字段使用的同一套下拉引擎

标准序列化字段与 Runtime 元数据可以覆盖大多数 Workbench 扩展,某个字段需要项目专属交互时,应当只为这个字段注册 Editor-only Factory,而不是替换整套内容 Inspector,其他字段仍由共享 Schema Renderer 处理,因此同一个派生对象可以继续同时用于 Workbench 与普通 Unity Inspector

自定义控件与校验代码属于 Editor 程序集,派生内容类及其序列化数据仍放在 Inspector 扩展定义的运行时程序集中

Samples~/InspectorExtensions/ 提供这套完整的程序集拆分,调整为正式项目代码前,应先把示例复制到项目自有目录,不要直接编辑包内副本

引用 Editor 程序集

创建一个 Editor-only Assembly Definition,引用项目内容程序集、GCS Editor 与 Shared Editor

{
"name": "YourGame.GCS.Content.Editor",
"references": [
"YourGame.GCS.Content",
"TinyGiants.GCS.Runtime",
"TinyGiants.GCS.Editor",
"TinyGiants.Shared.Editor"
],
"includePlatforms": [
"Editor"
]
}

该程序集不会进入 Player Build,运行时代码不能引用 GCSInspectorControlRegistryGCSInspectorValidationRegistryTGDropdown 或 UI Toolkit Editor 控件

注册一个字段控件

GCSInspectorControlRegistry 通过内容类型与序列化字段名匹配控件,应通过 [InitializeOnLoad][InitializeOnLoadMethod] 在每次 Domain Reload 后重新注册

using System.Collections.Generic;
using TinyGiants.GCS.Editor;
using TinyGiants.Shared.Editor;
using UnityEditor;
using UnityEngine.UIElements;

[InitializeOnLoad]
public static class SampleInspectorRegistrations
{
static SampleInspectorRegistrations()
{
GCSInspectorControlRegistry.Register<SampleCard>(
nameof(SampleCard.Element),
BuildElementControl);

GCSInspectorValidationRegistry.Register<SampleCard>(
"sample.card.rules",
ValidateCard);
}

private static VisualElement BuildElementControl(
GCSInspectorFieldContext context)
{
SerializedProperty property = context.GetProperty();
var field = GCSEditableSelectField.FromEntries(
property != null ? property.stringValue : string.Empty,
BuildElementEntries,
value => context.Modify(
"Edit Element",
current => current.stringValue = value ?? string.Empty));

context.RegisterRefresh(current =>
field.SetValueWithoutNotify(current.stringValue ?? string.Empty));

return field;
}

private static IList<TGDropdownEntry> BuildElementEntries()
{
return new List<TGDropdownEntry>
{
new TGDropdownEntry
{
Key = "fire",
Label = "Fire",
Payload = "fire",
Tooltip = "Fire-aligned rules"
},
new TGDropdownEntry
{
Key = "ice",
Label = "Ice",
Payload = "ice",
Tooltip = "Ice-aligned rules"
}
};
}

private static IEnumerable<GCSInspectorValidationResult> ValidateCard(
SampleCard card)
{
if (card.RequiredLevel < 0)
{
yield return new GCSInspectorValidationResult(
GCSInspectorValidationSeverity.Error,
"Required Level cannot be negative.",
fieldLabel: "Required Level",
category: "Sample",
propertyPath: nameof(SampleCard.RequiredLevel));
}
}
}

这项注册只替换 SampleCard.Element,基础 GameCard 与其他无关派生类型继续使用声明式控件,再次注册相同内容类型与字段名时会替换之前的 Factory,基础类型与更具体派生类型同时存在注册时,继承距离最近的类型优先

使用序列化字段上下文

GCSInspectorFieldContext 让自定义控件进入与内置控件相同的序列化数据和 Undo 链路

成员用途
Target当前具体 ScriptableObject
SerializedObject当前目标的序列化包装
PropertyPath被替换字段的准确路径
Field反射得到的字段元数据
Label经过 GCSLabelInspectorName 或自动美化后的最终显示标签
GetProperty()更新 SerializedObject 并解析当前属性
Modify(undoLabel, mutation)应用序列化修改、记录 Undo、标记目标 Dirty 并刷新 Workbench
RegisterRefresh(refresh)在 Undo/Redo 或外部序列化变化后刷新控件
UseFullWidth让返回元素占用完整 Section 宽度

不要长期缓存一个 SerializedProperty 并假设它在数组编辑、Undo 或 Inspector 重建后仍然有效,需要读取时应通过 GetProperty() 重新解析,使用 RegisterRefresh 时应调用 SetValueWithoutNotify,避免 Undo 刷新再次触发编辑回调

返回复合控件前可以启用 UseFullWidth

private static VisualElement BuildTimelineControl(
GCSInspectorFieldContext context)
{
context.UseFullWidth = true;
var timeline = new VisualElement();
timeline.AddToClassList("sample-timeline");
return timeline;
}

Registry 需要返回 UI Toolkit VisualElement,IMGUI-only 控件需要自行放进 IMGUIContainer,并负责焦点与序列化变化处理

直接复用 GCS 字段控件

项目 Factory 可以直接创建声明式 Renderer 使用的公开控件

控件用途
GCSSelectField纯选择字符串字段
GCSEditableSelectField可编辑字符串与选项下拉框
GCSTagField逗号分隔值与多选下拉框
GCSChipMultiSelectField有序、可移除胶囊项与多选下拉框
GCSEnumDropdown<T>使用 GCS 下拉表现的枚举 Picker
GCSBuffDebuffPicker带自定义标签的双段 Boolean 控件
GCSDescriptionField支持 Token 与语义引用的 GCS Description Editor
GCSSubAssetField从已注册 GCS 内容中选择紧凑行或卡片式引用
GCSSubAssetListFieldGCS 内容引用的序列化 List Editor
GCSFlowGraphFieldFlowGraph 按钮与入口或敌方意图静态摘要
GCSDeckEntriesFieldGameDeck 卡牌数量 Picker 与摘要

Runtime 属性已经能够选择目标控件时,应优先使用声明式方式,字段需要自定义组合、条件交互、项目命令或声明式属性无法表达的值模型时,再注册 Factory

添加项目校验

GCSInspectorValidationRegistry 会让项目 Validator 与内置 Workbench 校验一起执行,应使用稳定 ID,让 Domain Reload 或重复初始化替换同一个 Validator,而不是累积重复注册

GCSInspectorValidationRegistry.Register<SampleEncounter>(
"sample.encounter.rewards",
encounter => ValidateRewards(encounter));

Validator 返回零个或多个 GCSInspectorValidationResult

Workbench 结果
Warning向当前数据库 Issue Badge 加入 Warning
Error向当前数据库 Issue Badge 加入 Error
Category显示项目定义的问题分类
Message显示在内容对象名称之后
PropertyPath精确的 SerializedProperty.propertyPath,Workbench 会优先用它定位并闪烁一个 Inspector 字段行
FieldLabelPropertyPath 为空或无法解析时使用的显示标签回退值

问题属于某个序列化字段时,应使用五参数构造函数 (severity, message, fieldLabel, category, propertyPath),顶层字段传入 nameof(SampleCard.RequiredLevel),嵌套字段传入 Stats.RequiredLevel 这样的完整相对路径。数组和 List 路径必须使用 Unity 的准确序列化形式,包括 Array.data[0] 这样的 Segment

Workbench 会先解析 PropertyPath,路径为空或找不到对应序列化属性时再回退到 FieldLabelFieldLabel 是显示标签,不一定等于 C# 字段名,应依次使用 [GCSLabel][InspectorName] 或 Unity 自动美化后的字段名。原有四参数构造函数 (severity, message, fieldLabel, category) 继续可用,并采用这套标签回退行为;问题作用于完整资产时可以把两个值都留空

校验只报告问题,不会修复数据、阻止保存或强制 Player Build 不变量,运行时不变量仍应由 Runtime 代码保证,派生类型需要覆盖 OnEnableOnValidate 时,应调用基类实现,让 GCS Identity 与基类标准化继续执行

为 TGDropdown 建立选项

TGDropdown 是 GCS 选择控件使用的共享 TinyGiants Editor Popup,每个 Entry 都会分别定义身份、显示、分组与回调数据

using System.Collections.Generic;
using TinyGiants.Shared.Editor;

private static List<TGDropdownEntry> BuildAbilityEntries()
{
return new List<TGDropdownEntry>
{
new TGDropdownEntry
{
Key = "fireball",
Label = "Fireball",
GroupPath = new List<string> { "Combat", "Magic", "Fire" },
Payload = "fireball",
Tooltip = "Deals fire damage",
Enabled = true
},
new TGDropdownEntry
{
Key = "guard-break",
Label = "Guard Break",
GroupPath = new List<string> { "Combat", "Physical" },
Payload = "guard-break",
Tooltip = "Reduces armor"
}
};
}
Entry 成员含义
Key多选状态使用的唯一稳定行身份
Label可见行文本
GroupPath具有任意层级的显式页面路径
Payload传给回调的对象,相等 Payload 在 Multiple 模式共享同一选择状态
Tooltip可选的行 Tooltip
Enabled该项能否被选择

Group 仍兼容 Combat/Magic/Fire 这样的旧式斜杠分隔路径,新代码应使用 GroupPath,它的 Segment 是显式值,因此 Segment 中的斜杠不会再创建一层页面,GroupPath 为 Null 时会回退到 Group,显式空 List 则会把选项放在根页面,搜索会覆盖 Label 与完整显示路径,GroupOrder 在每一层提供优先子页面名称,其余名称按序排列

Multiple 模式表示持久数据时,应为每一行设置唯一且稳定的 Key,Key 缺失或冲突时,Dropdown 会生成只在当前 Popup 生命周期内唯一的内部身份,Payload 相等的行在 Multiple 模式中视为同一个值的别名,选择或清除其中任意一行会同步所有对应行,并只调用一次回调

打开单选下拉框

Single 模式返回一个 Payload,并在选中后关闭,启用 ShowNoneOption 时,选择 None 会把 null 传给 OnPicked

using System;
using TinyGiants.GCS.Editor;
using TinyGiants.Shared.Editor;
using UnityEngine.UIElements;

private static void OpenSingle(
Button anchor,
Action<string> commit)
{
TGDropdown.Open(new TGDropdownConfig
{
Title = "Ability",
AnchorRect = GCSVisualKit.ScreenRectOf(anchor),
Entries = BuildAbilityEntries(),
SelectionMode = TGDropdownSelectionMode.Single,
ShowNoneOption = true,
NoneLabel = "No Ability",
OnPicked = payload => commit(payload as string),
GroupOrder = new[] { "Combat", "Utility" },
MinWidth = 260f
});
}

该回调需要编辑 Inspector 字段时,应在 OnPicked 内调用 context.Modify,不要直接写目标对象

打开多选下拉框

Multiple 模式通过 IsSelected 读取初始状态,并通过 OnSelectionChanged 报告每次切换,下面的包装方法会在 Popup 关闭时提交一份有序 List

using System;
using System.Collections.Generic;
using TinyGiants.GCS.Editor;
using TinyGiants.Shared.Editor;
using UnityEngine.UIElements;

private static void OpenMultiple(
Button anchor,
IEnumerable<string> initial,
Action<List<string>> commit)
{
var selected = new List<string>();
if (initial != null)
{
foreach (string value in initial)
{
if (!string.IsNullOrEmpty(value) && !selected.Contains(value))
selected.Add(value);
}
}

TGDropdown.Open(new TGDropdownConfig
{
Title = "Granted Abilities",
AnchorRect = GCSVisualKit.ScreenRectOf(anchor),
Entries = BuildAbilityEntries(),
SelectionMode = TGDropdownSelectionMode.Multiple,
ShowNoneOption = false,
IsSelected = payload =>
payload is string id && selected.Contains(id),
OnSelectionChanged = (payload, isSelected) =>
{
if (!(payload is string id)) return;
if (isSelected && !selected.Contains(id)) selected.Add(id);
if (!isSelected) selected.Remove(id);
},
OnClose = () => commit(new List<string>(selected)),
SelectedItemsFirst = true,
ClearSearchOnSelection = true,
MaxHeight = 360f
});
}

需要立即持久化时,可以从 OnSelectionChanged 调用 context.Modify,需要把整次 Popup 操作合并为一个 Undo 步骤时,可以像上例一样保存 Popup 局部状态,再从 OnClose 调用一次 context.Modify

打开计数下拉框

Count 模式为每个 Entry 显示非负计数,左键报告 +1,右键报告 -1

using System;
using System.Collections.Generic;
using TinyGiants.GCS.Editor;
using TinyGiants.Shared.Editor;
using UnityEngine;
using UnityEngine.UIElements;

private static void OpenCounts(
Button anchor,
IDictionary<string, int> initial,
Action<Dictionary<string, int>> commit)
{
var counts = initial != null
? new Dictionary<string, int>(initial, StringComparer.Ordinal)
: new Dictionary<string, int>(StringComparer.Ordinal);

TGDropdown.Open(new TGDropdownConfig
{
Title = "Ability Copies",
AnchorRect = GCSVisualKit.ScreenRectOf(anchor),
Entries = BuildAbilityEntries(),
SelectionMode = TGDropdownSelectionMode.Count,
ShowNoneOption = false,
GetCount = payload =>
{
string id = payload as string;
return id != null && counts.TryGetValue(id, out int count)
? count
: 0;
},
OnCountChanged = (payload, delta) =>
{
if (!(payload is string id)) return;
counts.TryGetValue(id, out int current);
counts[id] = (int)Math.Max(0L, Math.Min(int.MaxValue, (long)current + delta));
},
OnClose = () =>
commit(new Dictionary<string, int>(counts, StringComparer.Ordinal)),
CountHint = "Left click +1 · Right click −1",
MaxHeight = 520f
});
}

Count 模式会在数值变化时保持 Popup 打开,回调只接收带符号 Delta,因此所有者仍需负责保存计数模型和实施最大数量规则。OnCountChanged 必须在返回前更新 GetCount 读取的 backing model,Popup 会立即再次调用 GetCount 来显示已提交的数值

统一配置 Popup 行为

TGDropdownConfig 提供共享行为,不需要再实现另一套 Popup

设置作用
TitleAnchorRectEntries定义 Popup 标题、屏幕锚点与数据
SelectionMode选择 SingleMultipleCount 交互
ShowNoneOptionNoneLabel配置 Single 模式的可选空值行
GroupOrder在每一层路径应用优先子页面顺序
WidthMinWidthMaxHeight覆盖或限制 Popup 尺寸
SelectedItemsFirst在每一页把 Multiple 模式已选项放到未选项之前
ClearSearchOnSelectionMultiple 模式切换后清空搜索
CountHint替换 Count 模式说明文本
OnClose在所有 Popup 关闭路径中执行一次

Popup 负责交互与临时视觉状态,不负责项目序列化,项目值应保存在序列化字段中,通过 RegisterRefresh 刷新控件,并通过 context.Modify 提交

遵守扩展边界

  • 每次 Domain Reload 后,从 Editor 程序集注册控件与校验

  • 把序列化字段、Option Provider 与强类型运行时访问保留在 Runtime 程序集

  • 优先使用声明式属性,再为确实需要的字段注册 Factory

  • 所有持久化自定义控件修改都使用 context.Modify,Undo/Redo 刷新使用 RegisterRefresh

  • Validator 保持无副作用,只报告问题,不修改目标

  • TGDropdown 是 Editor 创作控件,不是运行时游戏 UI

  • 通过 Registry 注册的 Inspector 控件不会自动改变 Workbench List 行、Preview、卡面布局或 Gameplay

保持这些边界后,项目可以只替换真正需要自定义 UI 的交互,同时保留统一序列化 Inspector 的其他部分