跳到主要内容

Inspector 控件

Guide

控制字段可见性、使用项目自有 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,运行时代码不能引用 GCSInspectorVisibilityRegistryGCSInspectorControlRegistryGCSInspectorValidationRegistryTGDropdown 或 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经过 GCSLabelInspectorName 或自动美化后的最终显示标签
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 内容中选择紧凑行或卡片式引用
GCSSubAssetListFieldGCS 内容引用的序列化 List Editor
GCSFlowGraphFieldFlowGraph 按钮与入口或敌方意图静态摘要
GCSDeckEntriesFieldGameDeck 卡牌数量 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 字段行
FieldLabelPropertyPath 为空或无法解析时使用的显示标签回退值

问题属于某个序列化字段时,使用五参数构造函数 (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 留空,使用五参数构造函数时则同时将 fieldLabelpropertyPath 留空

提示

校验只报告问题,不会修复数据、阻止保存,也不会保证相同约束在 Player Build 中继续成立;某个 Validator 抛出异常时,Workbench 会把异常写入 Console,并继续运行其余 Validator

运行时必须成立的条件仍应由 Runtime 代码保证;派生类型覆盖 OnEnableOnValidate 时,还应调用基类实现,让 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,其中每个元素都是独立的路径层级,元素内部的 / 不会再创建子页面;GroupPathnull 时会回退到 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

设置作用
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

遵守这些边界后,自定义控件只会接管确实需要特殊交互的字段,其余字段继续使用统一的序列化 Inspector