Inspector 控件
控制字段可见性、使用项目自有 UI Toolkit 替换单个字段控件、加入校验,并复用内置下拉引擎
Workbench 会根据序列化字段与 Runtime 属性自动选择控件;Editor-only 注册表可以隐藏继承字段、替换单个字段控件或加入校验,同时保留其余字段的共享绘制链路,因此不必重写整套内容 Inspector
自定义控件与校验代码属于 Editor 程序集,派生内容类及其序列化数据仍放在Inspector 扩展定义的运行时程序集中
GCS-Samples.zip 中的 Samples~/InspectorExtensions/ 提供这套完整拆分,在 Unity 项目外解压后,只把需要的 Runtime 与 Editor 目录复制到项目自有位置再进行修改
引用 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,运行时代码不能引用 GCSInspectorVisibilityRegistry、GCSInspectorControlRegistry、GCSInspectorValidationRegistry、TGDropdown 或 UI Toolkit Editor 控件
隐藏继承字段
GCSInspectorVisibilityRegistry 可以为指定内容类型隐藏序列化字段,不需要修改字段声明,也不会删除已经保存的数据;应在 Editor 程序集中于每次 Domain Reload 后注册准确字段名
[InitializeOnLoad]
public static class SampleInspectorRegistration
{
static SampleInspectorRegistration()
{
GCSInspectorVisibilityRegistry.Hide<SamplePlayer>(
nameof(SamplePlayer.MaxHp),
nameof(SamplePlayer.BaseEnergy),
nameof(SamplePlayer.EnergyDisplay));
}
}
Workbench 会在创建对应 Header 或 Section 之前跳过这些字段,因此隐藏整组字段不会留下空白折叠区;字段仍会正常序列化,运行时代码也可以继续读取。以上注册只改变 SamplePlayer,不会修改 GamePlayerUnit 或其他玩家类型
为基础内容类型注册的隐藏规则也会作用于其派生类型;Editor 集成被停用或替换时,可以显式移除注册
GCSInspectorVisibilityRegistry.Unhide<SamplePlayer>(
nameof(SamplePlayer.MaxHp),
nameof(SamplePlayer.BaseEnergy),
nameof(SamplePlayer.EnergyDisplay));
注册一个字段控件
GCSInspectorControlRegistry 根据内容类型与序列化字段名查找控件,应使用 [InitializeOnLoad] 或 [InitializeOnLoadMethod] 在每次 Domain Reload 后重新注册
using System.Collections.Generic;
using TinyGiants.GCS.Editor;
using UnityEditor;
using UnityEngine.UIElements;
[InitializeOnLoad]
public static class SampleInspectorRegistration
{
static SampleInspectorRegistration()
{
GCSInspectorVisibilityRegistry.Hide<SamplePlayer>(
nameof(SamplePlayer.MaxHp),
nameof(SamplePlayer.BaseEnergy),
nameof(SamplePlayer.EnergyDisplay));
GCSInspectorControlRegistry.Register<SampleCard>(
nameof(SampleCard.SpellLoadout),
CreateSpellLoadoutControl);
GCSInspectorValidationRegistry.Register<SampleCard>(
"sample.card.progression",
ValidateProgression);
}
private static VisualElement CreateSpellLoadoutControl(
GCSInspectorFieldContext context)
{
context.UseFullWidth = true;
return new SampleSpellLoadoutField(context);
}
private static IEnumerable<GCSInspectorValidationResult> ValidateProgression(
SampleCard card)
{
if (card.RequiredLevel < 1)
{
yield return new GCSInspectorValidationResult(
GCSInspectorValidationSeverity.Error,
"Required Level must be at least 1.",
fieldLabel: "Required Level",
category: "Sample Progression",
propertyPath: nameof(SampleCard.RequiredLevel));
}
}
}
这项注册在 SamplePlayer 上隐藏三个继承字段,使用 SampleSpellLoadoutField 绘制 SampleCard.SpellLoadout,并为 SampleCard 加入一条校验规则;基础 GCS 内容和其他无关派生类型不受影响。再次注册相同的内容类型与字段名会替换原有控件 Factory;如果基础类型与派生类型都为同一字段注册了控件,Workbench 会选择与当前对象继承距离最近的注册
使用序列化字段上下文
GCSInspectorFieldContext 让自定义控件进入与内置控件相同的序列化数据和 Undo 链路
| 成员 | 用途 |
|---|---|
Target | 当前具体 ScriptableObject |
SerializedObject | 当前目标的序列化包装 |
PropertyPath | 被替换字段的准确路径 |
Field | 反射得到的字段元数据 |
Label | 经过 GCSLabel、InspectorName 或自动美化后的最终显示标签 |
GetProperty() | 更新 SerializedObject 并解析当前属性 |
Modify(undoLabel, mutation) | 应用序列化修改、记录 Undo、标记目标 Dirty 并刷新 Workbench |
RegisterRefresh(refresh) | 在 Undo/Redo 或外部序列化变化后刷新控件 |
UseFullWidth | 让返回元素占用完整 Section 宽度 |
数组编辑、Undo 或 Inspector 重建后,原有的 SerializedProperty 可能已经失效,因此每次读取字段前都应通过 GetProperty() 重新解析;通过 RegisterRefresh 刷新控件时,应使用 SetValueWithoutNotify 写入界面值,避免 Undo 刷新再次触发编辑回调;返回复合控件前,可以设置 UseFullWidth 让它占用完整的 Section 宽度
private static VisualElement BuildTimelineControl(
GCSInspectorFieldContext context)
{
context.UseFullWidth = true;
var timeline = new VisualElement();
timeline.AddToClassList("sample-timeline");
return timeline;
}
Factory 应返回 UI Toolkit VisualElement;如果返回 null,Workbench 会继续为该字段选择声明式控件或普通序列化控件,如果 Factory 抛出异常,Workbench 会先把异常写入 Console,再执行相同的回退流程;IMGUI-only 控件需要放进 IMGUIContainer,并自行处理焦点与序列化变化
直接复用 GCS 字段控件
项目 Factory 可以直接创建声明式 Renderer 使用的公开控件
| 控件 | 用途 |
|---|---|
GCSSelectField | 纯选择字符串字段 |
GCSEditableSelectField | 可编辑字符串与选项下拉框 |
GCSTagField | 逗号分隔值与多选下拉框 |
GCSChipMultiSelectField | 有序、可移除胶囊项与多选下拉框 |
GCSEnumDropdown<T> | 使用 GCS 下拉表现的枚举 Picker |
GCSBuffDebuffPicker | 带自定义标签的双段 Boolean 控件 |
GCSDescriptionField | 支持 Token 与语义引用的 GCS Description Editor |
GCSSubAssetField | 从已注册 GCS 内容中选择紧凑行或卡片式引用 |
GCSSubAssetListField | GCS 内容引用的序列化 List Editor |
GCSFlowGraphField | FlowGraph 按钮与入口或敌方意图静态摘要 |
GCSDeckEntriesField | GameDeck 卡牌数量 Picker 与摘要 |
Runtime 属性已经能够选择目标控件时,应优先使用声明式方式;只有字段需要自定义组合、条件交互、项目命令或声明式属性无法表达的值模型时,才需要注册 Factory
添加项目校验
GCSInspectorValidationRegistry 会让项目 Validator 与内置 Workbench 校验一起执行,每项注册由内容类型与 ID 共同标识;保持 ID 稳定后,Domain Reload 或重复初始化会替换原有 Validator,不会累积重复注册,注册到基础类型的 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 字段行 |
FieldLabel | PropertyPath 为空或无法解析时使用的显示标签回退值 |
问题属于某个序列化字段时,使用五参数构造函数 (severity, message, fieldLabel, category, propertyPath) 指定准确位置:顶层字段传入 nameof(SampleCard.RequiredLevel),嵌套字段传入 Stats.BaseDamage 这样的完整相对路径,数组和 List<T> 则使用 Unity 的序列化路径,例如 Array.data[0]
Workbench 会优先使用 PropertyPath 定位字段,路径为空或无法解析时,再使用 FieldLabel 匹配显示标签;FieldLabel 不一定等于 C# 字段名,它可能来自 [GCSLabel]、[InspectorName] 或 Unity 自动美化后的字段名
原有四参数构造函数 (severity, message, fieldLabel, category) 继续可用,但此时 Workbench 只能通过 fieldLabel 定位字段;如果问题作用于完整资产,使用四参数构造函数时将 fieldLabel 留空,使用五参数构造函数时则同时将 fieldLabel 与 propertyPath 留空
校验只报告问题,不会修复数据、阻止保存,也不会保证相同约束在 Player Build 中继续成立;某个 Validator 抛出异常时,Workbench 会把异常写入 Console,并继续运行其余 Validator
运行时必须成立的条件仍应由 Runtime 代码保证;派生类型覆盖 OnEnable 或 OnValidate 时,还应调用基类实现,让 GCS Identity 与基类标准化继续执行
为 TGDropdown 建立选项
TGDropdown 是 GCS 内置选择控件共用的 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,其中每个元素都是独立的路径层级,元素内部的 / 不会再创建子页面;GroupPath 为 null 时会回退到 Group,显式传入空 List 则会把选项放在根页面;搜索会同时匹配 Label 与完整显示路径,GroupOrder 可以在每一层指定优先显示的子页面,其余子页面按名称排序
Multiple 模式用于编辑持久数据时,应为每一行设置唯一且稳定的 Key,Key 缺失或冲突时,Dropdown 只能生成在当前 Popup 生命周期内有效的内部标识;多个选项使用相同的 Payload 时,Multiple 模式会把它们视为同一个值,选择或清除其中任意一项都会同步其他对应项,并且只调用一次回调
打开单选下拉框
选择一个选项后,Single 模式会将该选项的 Payload 传给 OnPicked 并关闭 Popup;启用 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 报告每次切换,示例中的 selected 列表会按选择顺序保存这些变化,并在 Popup 关闭时提交
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, count) =>
{
if (!(payload is string id)) return 0;
if (count == 0) counts.Remove(id);
else counts[id] = count;
return count;
},
OnClose = () =>
commit(new Dictionary<string, int>(counts, StringComparer.Ordinal)),
CountHint = "Left click +1 · Right click −1",
MaxHeight = 520f
});
}
Count 模式会在数值变化时保持 Popup 打开,OnCountChanged 接收请求的非负数量,项目可以直接保存该值,也可以先按自己的规则限制数值;回调返回的数量就是 Popup 随后显示的数量,因此最大数量等限制可以在这次回调中完成,不需要 Popup 再次读取底层数据
统一配置 Popup 行为
TGDropdownConfig 提供共享行为,不需要再实现另一套 Popup
| 设置 | 作用 |
|---|---|
Title、AnchorRect、Entries | 定义 Popup 标题、屏幕锚点与数据 |
SelectionMode | 选择 Single、Multiple 或 Count 交互 |
ShowNoneOption、NoneLabel | 配置 Single 模式的可选空值行 |
GroupOrder | 在每一层路径应用优先子页面顺序 |
Width、MinWidth、MaxHeight | 覆盖或限制 Popup 尺寸 |
SelectedItemsFirst | 在每一页把 Multiple 模式已选项放到未选项之前 |
ClearSearchOnSelection | Multiple 模式切换后清空搜索 |
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
遵守这些边界后,自定义控件只会接管确实需要特殊交互的字段,其余字段继续使用统一的序列化 Inspector