Inspector controls
Control field visibility, replace one declarative field control with project-owned UI Toolkit, add validation, and reuse the built-in dropdown engine
Standard serialized fields and Runtime metadata cover most Workbench extensions. Editor-only registries can hide inherited fields, replace one field control, or add validation without replacing the complete content Inspector. The remaining fields continue through the shared schema renderer, so the same derived object still works in Workbench and the normal Unity Inspector.
Custom control and validation code belongs in an Editor assembly. The derived content class and its serialized data remain in the runtime assembly described by Inspector extensions.
The complete split is available in Samples~/InspectorExtensions/ inside GCS-Samples.zip. Extract the archive outside the Unity project, then copy the required Runtime and Editor directories into project-owned folders before adapting them.
Reference the Editor assembliesโ
Create an Editor-only Assembly Definition that references the project content assembly, GCS Editor, and Shared Editor:
{
"name": "YourGame.GCS.Content.Editor",
"references": [
"YourGame.GCS.Content",
"TinyGiants.GCS.Runtime",
"TinyGiants.GCS.Editor",
"TinyGiants.Shared.Editor"
],
"includePlatforms": [
"Editor"
]
}
This assembly is excluded from player builds. Runtime code must not reference GCSInspectorVisibilityRegistry, GCSInspectorControlRegistry, GCSInspectorValidationRegistry, TGDropdown, or UI Toolkit Editor controls.
Hide inherited fieldsโ
GCSInspectorVisibilityRegistry hides serialized fields for one content type without changing the field declaration or stored data. Register the exact field names from an Editor assembly after every Domain Reload:
[InitializeOnLoad]
public static class SampleInspectorRegistration
{
static SampleInspectorRegistration()
{
GCSInspectorVisibilityRegistry.Hide<SamplePlayer>(
nameof(SamplePlayer.MaxHp),
nameof(SamplePlayer.BaseEnergy),
nameof(SamplePlayer.EnergyDisplay));
}
}
Workbench omits these rows before creating their Header or Section, so a hidden group does not leave an empty foldout. The fields remain serialized and available to runtime code. SamplePlayer hides them without changing GamePlayerUnit or another player type.
A registration for a base content type also applies to its derived types. Remove registrations explicitly when an Editor integration is disabled or replaced:
GCSInspectorVisibilityRegistry.Unhide<SamplePlayer>(
nameof(SamplePlayer.MaxHp),
nameof(SamplePlayer.BaseEnergy),
nameof(SamplePlayer.EnergyDisplay));
Register one field controlโ
GCSInspectorControlRegistry matches a content type and serialized field name. Register during every Domain Reload with [InitializeOnLoad] or [InitializeOnLoadMethod].
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));
}
}
}
The registration hides three inherited fields on SamplePlayer, draws SampleCard.SpellLoadout with SampleSpellLoadoutField, and adds one validation rule for SampleCard. It does not change base GCS content or unrelated derived types. Registering the same content type and field name again replaces the earlier control factory. When both a base type and a derived type have control registrations, the closest matching type wins.
Use the serialized field contextโ
GCSInspectorFieldContext keeps the custom control inside the same serialized-data and Undo path as built-in controls.
| Member | Use |
|---|---|
Target | Current concrete ScriptableObject |
SerializedObject | Serialized wrapper for the current target |
PropertyPath | Exact path of the field being replaced |
Field | Reflected field metadata |
Label | Final displayed label after GCSLabel, InspectorName, or nicification |
GetProperty() | Updates the serialized object and resolves the current property |
Modify(undoLabel, mutation) | Applies a serialized change, records Undo, marks the target dirty, and refreshes Workbench |
RegisterRefresh(refresh) | Refreshes the control after Undo/Redo or an external serialized change |
UseFullWidth | Makes the returned element occupy the complete section width |
Do not keep a SerializedProperty reference and assume it remains valid after an array edit, Undo, or Inspector rebuild. Resolve it through GetProperty() when the value is needed. Use RegisterRefresh with SetValueWithoutNotify so an Undo refresh does not create another edit callback.
Set UseFullWidth before returning a compound control:
private static VisualElement BuildTimelineControl(
GCSInspectorFieldContext context)
{
context.UseFullWidth = true;
var timeline = new VisualElement();
timeline.AddToClassList("sample-timeline");
return timeline;
}
The registry expects a UI Toolkit VisualElement. When a factory returns null, Workbench continues with the field's declarative control or default serialized control. When a factory throws, Workbench logs the exception to the Console and follows the same fallback. IMGUI-only controls need their own IMGUIContainer, including their own focus and serialized-change handling.
Reuse GCS field controls directlyโ
Project factories can construct the same public controls used by the declarative renderer.
| Control | Purpose |
|---|---|
GCSSelectField | Selection-only string field |
GCSEditableSelectField | Editable string plus option dropdown |
GCSTagField | Comma-separated values plus multiple-selection dropdown |
GCSChipMultiSelectField | Ordered removable chips plus multiple-selection dropdown |
GCSEnumDropdown<T> | Enum picker using GCS dropdown presentation |
GCSBuffDebuffPicker | Two-segment Boolean control with custom labels |
GCSDescriptionField | GCS description editor with token and semantic-reference support |
GCSSubAssetField | Compact or card-style selector for registered GCS content |
GCSSubAssetListField | Serialized list editor for GCS content references |
GCSFlowGraphField | FlowGraph button and static entry or enemy-intent summary |
GCSDeckEntriesField | GameDeck card count picker and summary |
Prefer Runtime attributes when they already select the required control. A registry factory is appropriate when the field needs custom composition, conditional interaction, project-specific commands, or a value model that the declarative attributes do not express.
Add project validationโ
GCSInspectorValidationRegistry runs project validators together with built-in Workbench validation. Registration identity combines the content type and ID, so use a stable ID to make Domain Reload or repeated initialization replace the same validator instead of adding duplicates. A validator registered for a base type also checks derived objects.
GCSInspectorValidationRegistry.Register<SampleEncounter>(
"sample.encounter.rewards",
encounter => ValidateRewards(encounter));
A validator returns zero or more GCSInspectorValidationResult values:
| Value | Result in Workbench |
|---|---|
Warning | Adds a warning to the current database Issue Badge |
Error | Adds an error to the current database Issue Badge |
Category | Displays the project-defined issue category |
Message | Appears after the content object's display name |
PropertyPath | Exact SerializedProperty.propertyPath used first to jump to and flash one Inspector row |
FieldLabel | Displayed-label fallback when PropertyPath is empty or cannot be resolved |
Use the five-parameter constructor (severity, message, fieldLabel, category, propertyPath) when an issue belongs to one serialized field. For a top-level field, pass nameof(SampleCard.RequiredLevel). For a nested field, pass its complete relative path such as Stats.BaseDamage. Array and list paths must use Unity's exact serialized form, including segments such as Array.data[0].
Workbench resolves PropertyPath first. When the path is empty or no matching serialized property exists, it falls back to FieldLabel. FieldLabel is the displayed label, not necessarily the C# field name; use the value from [GCSLabel], then [InspectorName], or Unity's nicified field name. The existing four-parameter constructor (severity, message, fieldLabel, category) remains available and can locate a field only through fieldLabel. For an issue on the complete asset, leave fieldLabel empty with the four-parameter constructor, or leave both fieldLabel and propertyPath empty with the five-parameter constructor.
Validation reports problems but does not repair data, prevent saving, or enforce a player-build invariant. If one validator throws, Workbench logs the exception to the Console and continues with the remaining validators. Keep runtime invariants in runtime code. If a derived type overrides OnEnable or OnValidate, call the base implementation so GCS identity and base-type normalization continue to run.
Build entries for TGDropdownโ
TGDropdown is the Editor popup shared by GCS selection controls. Each entry supplies a stable identity, visible label, page path, and callback data:
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 member | Meaning |
|---|---|
Key | Unique stable row identity for multiple-selection state |
Label | Visible row text |
GroupPath | Explicit page path with any number of segments |
Payload | Object returned to callbacks; equal payloads share one Multiple-mode selection state |
Tooltip | Optional row tooltip |
Enabled | Whether the item can be selected |
Group remains available for legacy slash-delimited paths such as Combat/Magic/Fire. New code should use GroupPath; its segments are explicit, so a slash inside one segment does not create another page. A null GroupPath falls back to Group, while an empty list explicitly places the entry on the root page. Search covers labels and complete display paths. GroupOrder supplies preferred child-page names at every level, with unspecified names sorted ordinally.
Assign every row a unique stable Key when Multiple mode represents persisted data. If a key is omitted or collides, the dropdown creates an internal identity that is unique only for that popup lifetime. Rows with equal payloads act as aliases in Multiple mode: selecting or clearing either row updates all rows that represent that payload and invokes the callback once.
Open a single-selection dropdownโ
After the user selects an entry, Single mode passes its Payload to OnPicked and closes the popup. When ShowNoneOption is enabled, choosing None calls OnPicked with 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
});
}
When this callback edits an Inspector field, call context.Modify inside OnPicked rather than writing the target object directly.
Open a multiple-selection dropdownโ
Multiple mode reads initial state through IsSelected and reports every toggle through OnSelectionChanged. The following wrapper commits one ordered list when the popup closes:
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
});
}
For immediate persistence, call context.Modify from OnSelectionChanged. For a single Undo step, keep popup-local state as above and call context.Modify once from OnClose.
Open a count dropdownโ
Count mode displays a non-negative count for every entry. Left-click reports +1; right-click reports -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 mode keeps the popup open while values change. OnCountChanged receives the requested non-negative count, stores or clamps it, and returns the committed count that the popup displays. This return value applies a project-specific maximum without requiring the popup to read the backing model again.
Configure popup behavior consistentlyโ
TGDropdownConfig exposes the shared behavior without requiring another popup implementation.
| Setting | Effect |
|---|---|
Title, AnchorRect, Entries | Defines the popup heading, screen anchor, and data |
SelectionMode | Chooses Single, Multiple, or Count interaction |
ShowNoneOption, NoneLabel | Configures the optional Single-mode null row |
GroupOrder | Applies preferred child-page order at every path level |
Width, MinWidth, MaxHeight | Overrides or constrains popup dimensions |
SelectedItemsFirst | Moves selected Multiple-mode entries before unselected entries on each page |
ClearSearchOnSelection | Clears search after a Multiple-mode toggle |
CountHint | Replaces the Count-mode instruction text |
OnClose | Runs once for every popup close path |
The popup owns interaction and temporary visual state, not project serialization. Keep the project value in the serialized field, refresh the control through RegisterRefresh, and commit through context.Modify.
Respect the extension boundaryโ
-
Register controls and validation from an Editor assembly after every Domain Reload.
-
Keep serializable fields, option providers, and typed runtime access in a runtime assembly.
-
Use declarative attributes before replacing a field with a registry factory.
-
Use
context.Modifyfor every persisted custom-control change andRegisterRefreshfor Undo/Redo. -
Keep validation side-effect free; it reports an issue but does not mutate the target.
-
Treat
TGDropdownas an Editor authoring control, not a runtime game UI. -
Do not expect an Inspector control registered through the project API to change Workbench List rows, Preview, card-face layout, or gameplay automatically.
These boundaries preserve the common serialized Inspector while allowing a project to replace only the interaction that genuinely needs custom UI.