Inspector 控件
隐藏字段、注册自定义 UI Toolkit 控件与校验规则,复用 GCS 的字段控件和下拉菜单
Workbench 会根据序列化字段和 Runtime 属性选择控件,通过 Editor 注册表可以隐藏字段、替换单个控件或添加校验规则,其余字段继续由 Workbench 绘制
自定义控件与校验代码属于 Editor 程序集,派生内容类及其序列化数据仍放在Inspector 扩展定义的运行时程序集中
GCS-Samples.zip 中的 Samples~/InspectorExtensions/ 包含 Runtime 和 Editor 扩展示例,在 Unity 项目外解压,再把需要的目录复制到项目自有位置
引用 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));
}
}
SamplePlayer 的三个字段会从 Workbench 中隐藏,整组字段被隐藏时也不会留下空折叠区,字段仍可保存和读取,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));
}
}
}
SampleCard.SpellLoadout 由 SampleSpellLoadoutField 绘制,RequiredLevel 小于 1 时报告校验错误,注册只作用于指定类型及其派生类型
重复注册同一类型的同一字段会替换原有控件工厂,基类和派生类都注册了该字段时,优先使用最接近当前类型的注册
使用序列化字段上下文
通过 GCSInspectorFieldContext 读取和修改字段,自定义控件也能使用 Workbench 的序列化、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 设置界面值,避免再次触发编辑回调,需要整行显示的复合控件可设置 UseFullWidth
private static VisualElement BuildTimelineControl(
GCSInspectorFieldContext context)
{
context.UseFullWidth = true;
var timeline = new VisualElement();
timeline.AddToClassList("sample-timeline");
return timeline;
}
控件工厂返回 UI Toolkit VisualElement,返回 null 或抛出异常时,Workbench 改用字段属性指定的控件或普通序列化控件,异常会写入 Console
IMGUI 控件需要放进 IMGUIContainer,并自行处理焦点和序列化变化
直接复用 GCS 字段控件
控件工厂也可以直接创建以下 GCS 内置控件
| 控件 | 用途 |
|---|---|
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 属性,需要组合控件、条件交互或项目命令时,再注册控件工厂
添加项目校验
通过 GCSInspectorValidationRegistry 注册校验函数,Workbench 会与内置校验一起执行,每项注册使用内容类型和 ID 标识,重复注册会替换原项,注册到基类的规则也会检查派生对象
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),通过 propertyPath 指定位置,顶层字段使用 nameof(SampleCard.RequiredLevel),嵌套字段使用 Stats.BaseDamage,数组和 List<T> 使用包含 Array.data[0] 的完整 Unity 属性路径
Workbench 优先使用 PropertyPath 定位字段,路径为空或无法解析时使用 FieldLabel,该标签来自 [GCSLabel]、[InspectorName] 或 Unity 格式化后的字段名,可能与 C# 名称不同
四参数构造函数 (severity, message, fieldLabel, category) 仍可使用,通过 fieldLabel 定位字段,问题属于整个资产时,将 fieldLabel 和适用的 propertyPath 留空
校验只报告问题,不修改数据或阻止保存,某个校验函数抛出异常时,Workbench 会记录异常并继续执行其他校验
运行时约束需要由 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 | 该项能否被选择 |
GroupPath 的每个元素对应一层页面,元素中的 / 按普通字符显示,Group 仍支持 Combat/Magic/Fire 这样的旧式路径,GroupPath 为 null 时使用 Group,空列表表示根页面
搜索同时匹配 Label 和完整路径,GroupOrder 指定每层优先显示的子页面,其余页面按名称排序
Multiple 模式下,为每行设置唯一且稳定的 Key,缺失或重复的 Key 会被替换为仅在本次 Popup 中有效的内部标识
相同 Payload 的选项共享选择状态,选中或清除其中一项会同步其他项,并调用一次回调
打开单选下拉框
Single 模式选中条目后,将 Payload 传给 OnPicked 并关闭 Popup,启用 ShowNoneOption 后,选择 None 会传入 null
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
});
}
在 OnPicked 中调用 context.Modify 保存 Inspector 字段修改
打开多选下拉框
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,需要将整次操作合并为一个 Undo 步骤时,先保存到局部变量,再在 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 行为
通过 TGDropdownConfig 设置三种模式的标题、尺寸与关闭回调
| 设置 | 作用 |
|---|---|
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 不会自动保存项目数据,通过 context.Modify 写入序列化字段,并通过 RegisterRefresh 注册控件刷新逻辑
遵守扩展边界
- 每次 Domain Reload 后,从 Editor 程序集注册控件与校验
- 把序列化字段、Option Provider 与强类型运行时访问保留在 Runtime 程序集
- 优先使用声明式属性,再为确实需要的字段注册 Factory
- 所有持久化自定义控件修改都使用
context.Modify,Undo/Redo 刷新使用RegisterRefresh - Validator 保持无副作用,只报告问题,不修改目标
TGDropdown是 Editor 创作控件,不是运行时游戏 UI- 通过 Registry 注册的 Inspector 控件不会自动改变 Workbench List 行、Preview、卡面布局或 Gameplay