diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md index 5b241f97..ef04a70a 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md @@ -17,6 +17,7 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) - [SerializedProperty Extensions](#serializedproperty-extensions) @@ -273,7 +274,8 @@ namespace Aspid.FastTools.Types.Editors Type[] types = null, string currentAqn = "", TypeAllow allow = TypeAllow.None, - Action onSelected = null); + Action onSelected = null, + Func filter = null); } } ``` @@ -285,6 +287,7 @@ namespace Aspid.FastTools.Types.Editors | `currentAqn` | Assembly-qualified name of the currently selected type, used to pre-navigate to its location. Pass `null` or empty to start at the root. | | `allow` | Which special type kinds (abstract classes, interfaces) are included in addition to concrete classes. Default: `TypeAllow.None`. | | `onSelected` | Callback invoked with the assembly-qualified name of the selected type, or `null` if the user chose ``. | +| `filter` | Optional predicate applied to each candidate type after the base-type and `allow` checks. Return `false` to hide a type. Pass `null` to keep every match. | ### ComponentTypeSelector @@ -325,6 +328,63 @@ public sealed class TankEnemy : EnemyBase --- +## SerializeReference Selector + +A drop-in dropdown for `[SerializeReference]` fields. Add `[SerializeReferenceSelector]` next to `[SerializeReference]` and the Inspector replaces the default managed-reference UI with the same searchable, hierarchical type picker used by `SerializableType` — letting you choose which concrete implementation of the field's declared type is instantiated. + +- Lists every concrete, non-`UnityEngine.Object` class assignable to the field's declared interface / base type. +- Picking a type instantiates it; `` clears the reference. +- The assigned instance's serialized fields are drawn inline under a foldout. +- A stored type that no longer resolves (renamed or deleted) is surfaced as a missing-type warning instead of silently clearing. +- Open generic implementations (e.g. `Modifier`) are offered too: arguments are inferred from a closed-generic field, or picked in a follow-up window (validated against the field type) before instantiation. +- Switching the selected type preserves matching data — fields shared by the old and new implementation (by name and serialized shape) carry over instead of resetting to defaults. +- Right-click the header for a Copy / Paste context menu: it copies the managed-reference value and pastes it as an independent instance into any compatible field (paste is disabled when the clipboard type is not assignable to the target). +- A missing type can be repaired in place: the warning is a compact yellow notice whose underlined **Fix** word opens the type picker — choose the correct type and the reference is re-pointed while keeping its stored data; hover the notice for the full missing-type detail. Works for saved assets (ScriptableObjects and prefab assets) selected in the Project **and for objects open in Prefab Mode** — saved assets are rewritten in their YAML, while a Prefab Mode object is repaired on the live instance, recovering the data Unity still holds for the missing type. The repair also reaches nested references — through nested managed references and through plain `[Serializable]` containers (a struct/class field or a `List` of them) — so a missing type buried in a slot or list element is fixed inline too. +- For missing references the Inspector cannot surface in the moment — components on child objects when the asset is not open in Prefab Mode, plus bulk repair and orphaned entries no field points at — the **Repair Missing References** window (`Tools → Aspid 🐍`) scans the whole asset file and lists every one with its own **Fix** picker, no Prefab Mode required. +- An aliased reference (two fields sharing one instance, e.g. after duplicating a list element) is flagged by the same compact notice, whose underlined **Make unique** word (also a right-click → **Make Unique Reference** action) splits it into an independent copy. +- Works on single fields, arrays, and `List`, in both IMGUI and UIToolkit inspectors. + +```csharp +using System; +using UnityEngine; +using System.Collections.Generic; +using Aspid.FastTools.SerializeReferences; + +public interface IWeapon +{ + void Fire(); +} + +[Serializable] +public sealed class Pistol : IWeapon +{ + [SerializeField] [Min(0)] private int _damage = 10; + + public void Fire() => Debug.Log($"Pistol: {_damage} dmg"); +} + +[Serializable] +public sealed class Railgun : IWeapon +{ + [SerializeField] [Min(0)] private float _chargeTime = 1.5f; + + public void Fire() => Debug.Log($"Railgun charged for {_chargeTime}s"); +} + +public sealed class Loadout : MonoBehaviour +{ + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _primary; + + [SerializeReference] [SerializeReferenceSelector] + private List _sidearms; +} +``` + +The attribute is editor-only (`[Conditional("UNITY_EDITOR")]`) and carries no runtime cost. + +--- + ## Enum System Provides serializable enum-to-value mappings configurable from the Inspector. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md index 54ad4964..bfbeee0b 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md @@ -17,6 +17,7 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) - [SerializedProperty Extensions](#serializedproperty-extensions) @@ -273,7 +274,8 @@ namespace Aspid.FastTools.Types.Editors Type[] types = null, string currentAqn = "", TypeAllow allow = TypeAllow.None, - Action onSelected = null); + Action onSelected = null, + Func filter = null); } } ``` @@ -285,6 +287,7 @@ namespace Aspid.FastTools.Types.Editors | `currentAqn` | Assembly-qualified имя текущего выбранного типа: окно сразу откроется на его уровне иерархии. Передайте `null` или пустую строку, чтобы стартовать с корня. | | `allow` | Какие специальные категории (абстрактные классы, интерфейсы) включаются в список в дополнение к конкретным классам. По умолчанию: `TypeAllow.None`. | | `onSelected` | Callback с assembly-qualified именем выбранного типа или `null`, если пользователь выбрал ``. | +| `filter` | Необязательный предикат, применяемый к каждому типу-кандидату после проверок базового типа и `allow`. Верните `false`, чтобы скрыть тип. Передайте `null`, чтобы оставить все совпадения. | ### ComponentTypeSelector @@ -325,6 +328,63 @@ public sealed class TankEnemy : EnemyBase --- +## SerializeReference Selector + +Готовый выпадающий список для полей с `[SerializeReference]`. Добавьте `[SerializeReferenceSelector]` рядом с `[SerializeReference]`, и Inspector заменит стандартный UI managed-ссылки тем же иерархическим выбором типа с поиском, что используется в `SerializableType` — позволяя выбрать, какая конкретная реализация объявленного типа поля будет создана. + +- Показывает каждый конкретный, не наследующий `UnityEngine.Object` класс, совместимый с объявленным интерфейсом / базовым типом поля. +- Выбор типа создаёт его экземпляр; `` очищает ссылку. +- Сериализуемые поля назначенного экземпляра рисуются вложенно под foldout. +- Сохранённый тип, который больше не разрешается (переименован или удалён), показывается как предупреждение о потерянном типе, а не очищается молча. +- Открытые generic-реализации (например, `Modifier`) тоже предлагаются: аргументы выводятся из закрытого generic-поля либо выбираются в дополнительном окне (с проверкой на присваиваемость полю) перед созданием экземпляра. +- При смене типа совпадающие данные сохраняются — поля, общие у старой и новой реализации (по имени и сериализуемой форме), переносятся, а не сбрасываются в значения по умолчанию. +- По правому клику на заголовке доступно контекстное меню Copy / Paste: оно копирует значение managed-ссылки и вставляет его как независимый экземпляр в любое совместимое поле (вставка недоступна, если тип из буфера нельзя присвоить полю). +- Потерянный тип можно починить на месте: предупреждение показано компактным жёлтым лейблом, подчёркнутое слово **Fix** открывает селектор типов — выбранный тип переназначается с сохранением данных, а при наведении на лейбл показывается полная информация о потерянном типе. Работает для сохранённых ассетов (ScriptableObject и префаб-ассетов), выделенных в Project, **и для объектов, открытых в Prefab Mode**: сохранённые ассеты переписываются в YAML, а объект в Prefab Mode чинится прямо на живом экземпляре — с восстановлением данных, которые Unity всё ещё хранит для потерянного типа. Починка работает на любой глубине — через вложенные managed-ссылки и через обычные `[Serializable]`-контейнеры (поле-struct/class или `List` из них), так что потерянный тип внутри слота или элемента списка чинится инлайном тоже. +- До потерянных ссылок, которые инспектор не показывает в моменте — компоненты дочерних объектов, когда ассет не открыт в Prefab Mode, плюс массовая починка и осиротевшие записи, на которые не указывает ни одно поле, — есть окно **Repair Missing References** (`Tools → Aspid 🐍`): оно сканирует весь файл ассета и выводит каждую со своим **Fix**, без Prefab Mode. +- Общая ссылка (два поля делят один экземпляр, например после дублирования элемента списка) помечается тем же компактным лейблом; подчёркнутое слово **Make unique** (а также действие правой кнопкой → **Make Unique Reference**) расщепляет её в независимую копию. +- Работает с одиночными полями, массивами и `List`, в инспекторах IMGUI и UIToolkit. + +```csharp +using System; +using UnityEngine; +using System.Collections.Generic; +using Aspid.FastTools.SerializeReferences; + +public interface IWeapon +{ + void Fire(); +} + +[Serializable] +public sealed class Pistol : IWeapon +{ + [SerializeField] [Min(0)] private int _damage = 10; + + public void Fire() => Debug.Log($"Pistol: {_damage} dmg"); +} + +[Serializable] +public sealed class Railgun : IWeapon +{ + [SerializeField] [Min(0)] private float _chargeTime = 1.5f; + + public void Fire() => Debug.Log($"Railgun charged for {_chargeTime}s"); +} + +public sealed class Loadout : MonoBehaviour +{ + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _primary; + + [SerializeReference] [SerializeReferenceSelector] + private List _sidearms; +} +``` + +Атрибут существует только в редакторе (`[Conditional("UNITY_EDITOR")]`) и не несёт стоимости в рантайме. + +--- + ## Enum System Предоставляет сериализуемые отображения enum → значение, настраиваемые через Inspector. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta new file mode 100644 index 00000000..9d6b8820 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5dca98ae8db24794bbb8b57f9074d562 +timeCreated: 1780775167 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta new file mode 100644 index 00000000..edaf3f9d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cc14437c0afa46b8843c58f14cdbb07c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab new file mode 100644 index 00000000..f81eedb1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab @@ -0,0 +1,64 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6600000000000000001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6600000000000000002} + - component: {fileID: 6600000000000000003} + m_Layer: 0 + m_Name: IMGUILoadout + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6600000000000000002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6600000000000000001} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &6600000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6600000000000000001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e6cd8f6abf02422a92c5f183b53afa29, type: 3} + m_Name: + m_EditorClassIdentifier: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.IMGUILoadout + _primaryWeapon: + rid: 2001 + _sidearms: [] + _onHitEffect: + rid: 2002 + references: + version: 2 + RefIds: + - rid: 2001 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 10 + _magazineSize: 12 + - rid: 2002 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta new file mode 100644 index 00000000..c71e4a92 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 78c0fa006a244ee78b5a2dfd9c6618a6 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab new file mode 100644 index 00000000..3ff1a95b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab @@ -0,0 +1,82 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6500000000000000001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6500000000000000002} + - component: {fileID: 6500000000000000003} + m_Layer: 0 + m_Name: Loadout + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6500000000000000002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &6500000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1003 + _onHitEffect: + rid: 1004 + references: + version: 2 + RefIds: + - rid: 1001 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 2 + _chargeEffect: + rid: 1005 + - rid: 1002 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 15 + _magazineSize: 12 + - rid: 1003 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 8 + _spreadAngle: 25 + - rid: 1004 + type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 2.5 + _slowPercent: 40 + - rid: 1005 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta new file mode 100644 index 00000000..7b7dd168 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 90d26bbfdf2e48fba5c42d214308e9fd +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab new file mode 100644 index 00000000..625467eb --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab @@ -0,0 +1,82 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6500000000000000001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6500000000000000002} + - component: {fileID: 6500000000000000003} + m_Layer: 0 + m_Name: LoadoutMissingType + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6500000000000000002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &6500000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1003 + _onHitEffect: + rid: 1004 + references: + version: 2 + RefIds: + - rid: 1001 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 2 + _chargeEffect: + rid: 1005 + - rid: 1002 + type: {class: GhostPistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 15 + _magazineSize: 12 + - rid: 1003 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 8 + _spreadAngle: 25 + - rid: 1004 + type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 2.5 + _slowPercent: 40 + - rid: 1005 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta new file mode 100644 index 00000000..8ee70796 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2d0dd2d64d8644d082fcf7019c422955 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab new file mode 100644 index 00000000..92bde9e6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab @@ -0,0 +1,77 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6500000000000000001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6500000000000000002} + - component: {fileID: 6500000000000000003} + m_Layer: 0 + m_Name: LoadoutSharedRef + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6500000000000000002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &6500000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1002 + _onHitEffect: + rid: 1004 + references: + version: 2 + RefIds: + - rid: 1001 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 2 + _chargeEffect: + rid: 1005 + - rid: 1002 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 15 + _magazineSize: 12 + - rid: 1004 + type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 2.5 + _slowPercent: 40 + - rid: 1005 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta new file mode 100644 index 00000000..468f737d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 16d87b1e935f4ebbae70d89f7b57f84e +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab new file mode 100644 index 00000000..a580bffe --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab @@ -0,0 +1,84 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6166294228952064148 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8734551161022389016} + - component: {fileID: 1474488742748373929} + m_Layer: 0 + m_Name: SlottedLoadout + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8734551161022389016 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6166294228952064148} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1474488742748373929 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6166294228952064148} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 251896effa22341e7981b29000d77094, type: 3} + m_Name: + m_EditorClassIdentifier: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.SlottedLoadout + _primarySlot: + label: Primary + priority: 0 + _weapon: + rid: 2699798180063346814 + _slots: + - label: Backup + priority: 1 + _weapon: + rid: 2699798180063346816 + - label: Heavy + priority: 2 + _weapon: + rid: 2699798180063346817 + references: + version: 2 + RefIds: + - rid: 2699798180063346814 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 1.5 + _chargeEffect: + rid: 2699798180063346815 + - rid: 2699798180063346815 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 + - rid: 2699798180063346816 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 10 + _magazineSize: 12 + - rid: 2699798180063346817 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 8 + _spreadAngle: 25 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta new file mode 100644 index 00000000..4fe1f8a5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9c2056f6e14fb4adba50391b71ae9757 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta new file mode 100644 index 00000000..393093ec --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8e15c8d83f394218957f70a4d3126ac7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset new file mode 100644 index 00000000..da1a4c28 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b7874533c7294db1b8aa77e7d4102c9f, type: 3} + m_Name: BrokenWeaponPreset + m_EditorClassIdentifier: + _weapon: + rid: 7000 + _alternates: [] + references: + version: 2 + RefIds: + - rid: 7000 + type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 25 + _magazineSize: 8 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta new file mode 100644 index 00000000..fd2843ff --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a4cbc7edeb6449ca04211e456655406 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md new file mode 100644 index 00000000..fda849c7 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md @@ -0,0 +1,67 @@ +# SerializeReferences Sample + +A tiny loadout system that demonstrates `[SerializeReferenceSelector]` — a searchable, hierarchical type dropdown for `[SerializeReference]` fields. You pick which concrete implementation of a polymorphic field is instantiated, directly in the Inspector. + +Look at: + +- `Scripts/Loadout.cs` — single (`IWeapon`), `List`, and abstract-base (`StatusEffect`) `[SerializeReference]` fields, each annotated with `[SerializeReferenceSelector]`. +- `Scripts/Weapons/` — `IWeapon` interface and its implementations (`Pistol`, `Shotgun`, `Railgun`). `Railgun` nests another `[SerializeReferenceSelector]` field, showing recursive polymorphic editing. +- `Scripts/Effects/` — abstract `StatusEffect` base with `BurnEffect` / `FreezeEffect`. The dropdown offers only the concrete subclasses; the abstract base is never listed. +- `Scripts/Modifiers/` — generic hierarchy: a non-abstract `Modifier` generic class (`IModifier`) with closed-generic subclasses `DamageModifier : Modifier`, `AmmoModifier : Modifier`, `NameModifier : Modifier`. An `IModifier` field offers all three subclasses **and** the open generic `Modifier` — picking `Modifier` opens a second window to choose the argument `T`. A `Modifier` field offers only the candidates assignable to it (`DamageModifier`, and `Modifier` with `T` inferred to `float`). +- `Scripts/WeaponPreset.cs` + `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `_weapon` points at a type that no longer exists, used to demonstrate the missing-type repair flow (see *Maintenance features* below). + +The drawer ships both a UIToolkit and an IMGUI rendering path. The `IMGUILoadout` variant forces the IMGUI path so you can compare them or migrate IMGUI-only projects: + +- `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` — the same fields rendered via `OnInspectorGUI` (`SerializeReferenceIMGUIPropertyDrawer`). + +## How to run + +Two ready-made prefabs live in `Prefabs/` — double-click to open in Prefab Mode, or drag either into any scene: + +- **Loadout** (`Prefabs/Loadout.prefab`) — UIToolkit path. Pre-filled: `Primary Weapon = Railgun` (with a nested `BurnEffect` charge effect), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. +- **IMGUILoadout** (`Prefabs/IMGUILoadout.prefab`) — IMGUI path. Pre-filled: `Primary Weapon = Pistol`, `On Hit Effect = BurnEffect`. + +Then experiment with the dropdowns: + +1. Click any type dropdown and pick another implementation — the instance is created and its serialized fields appear inline under the foldout. +2. Expand `Railgun` and change its nested `Charge Effect` to see recursive polymorphic editing. +3. Press **+** on `Sidearms` and give each element its own weapon type. +4. Open `On Hit Effect` — note only `BurnEffect` / `FreezeEffect` are offered (the abstract `StatusEffect` is hidden). +5. Open `Modifier` — the three concrete subclasses (`DamageModifier`, `AmmoModifier`, `NameModifier`) are offered alongside the open generic `Modifier`. Pick `Modifier` and a second window opens to choose the argument `T` (try `string`, then `float`) before the instance is created. Open `Float Modifier` — only candidates assignable to `Modifier` are offered (`DamageModifier`, and `Modifier` whose `T` is inferred to `float` without the extra window). +6. Right-click the component header → **Log Loadout** to print the configured loadout to the Console. + +Prefer building from scratch? Add an empty GameObject and attach the **Loadout** (UIToolkit) or **IMGUILoadout** (IMGUI) component. + +Switching a field back to `` clears the reference. If a stored type is later renamed or deleted, the dropdown shows a `` caption and a warning instead of silently clearing. + +## Maintenance features + +The drawer also helps recover from the two ways a managed reference goes wrong in practice. + +### Copy / Paste & keep-data + +- **Right-click** any selector header → **Copy Serialize Reference** / **Paste Serialize Reference**. Paste rebuilds an *independent* instance in the target field and is greyed out when the copied type does not fit the field. +- **Switching the type** keeps the fields the old and new implementation share. Set `Sidearms[0]` to `Pistol`, give it a damage value, then switch it to `Shotgun` and back — the `Pistol` value is still there. + +### Repair a missing type — `BrokenWeaponPreset.asset` & `LoadoutMissingType.prefab` + +Two assets ship pre-broken, pointing at classes that do not exist: + +- `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` references a missing `GhostWeapon`. +- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`. + +Select either **in the Project window**. The missing field shows a `` caption, a **Missing type** warning, and a **Fix** button: + +1. Click **Fix** — the usual searchable type picker opens. Choose `Pistol`. +2. The reference is restored to a `Pistol` with its preserved data (the prefab keeps `_damage = 15`, `_magazineSize = 12`; the asset keeps `_damage = 25`, `_magazineSize = 8`). Picking the type rewrites the stored type in the asset file rather than recreating the instance, so the values survive. + +> The repair reads and rewrites the asset file directly — Unity does not expose a missing type through its serialization API (and on GameObjects/prefabs even drops it from the live object, UUM-129100), so the orphaned type and data are recovered straight from the YAML. It therefore needs a **saved asset file**: it works for ScriptableObjects and prefab assets selected in the Project, but not for objects edited in Prefab Mode or instances living in a scene (no backing asset to rewrite). +> +> When a missing reference is nested inside another value or sits on a child object the Inspector can't reach, use **`Tools → Aspid 🐍 → Repair Missing References FastTools`** instead: it scans the whole asset file and lists every missing reference (any depth, any child) with its own **Fix** picker. + +### Un-share an aliased reference — `LoadoutSharedRef.prefab` + +`Prefabs/LoadoutSharedRef.prefab` has both `Sidearms` elements backed by the **same** instance (a state you can also reach by duplicating an array element). + +1. Open it — both elements show a **shared reference** notice; editing one changes the other. +2. **Right-click** one element → **Make Unique Reference**. It gets its own copy of the data and the two fields become independent. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta new file mode 100644 index 00000000..e0f714a1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 28ef597d95ad4acda660bc86c164648b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md new file mode 100644 index 00000000..d9027909 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md @@ -0,0 +1,67 @@ +# Пример SerializeReferences + +Маленькая система снаряжения, демонстрирующая `[SerializeReferenceSelector]` — иерархический выпадающий список с поиском для полей `[SerializeReference]`. Вы прямо в Inspector выбираете, какая конкретная реализация полиморфного поля будет создана. + +Смотрите: + +- `Scripts/Loadout.cs` — одиночное поле (`IWeapon`), `List` и поле с абстрактным базовым типом (`StatusEffect`), каждое с `[SerializeReference]` и `[SerializeReferenceSelector]`. +- `Scripts/Weapons/` — интерфейс `IWeapon` и его реализации (`Pistol`, `Shotgun`, `Railgun`). `Railgun` вкладывает ещё одно поле `[SerializeReferenceSelector]` — показывает рекурсивное полиморфное редактирование. +- `Scripts/Effects/` — абстрактный базовый `StatusEffect` с `BurnEffect` / `FreezeEffect`. В списке предлагаются только конкретные подтипы; абстрактный базовый класс никогда не показывается. +- `Scripts/Modifiers/` — generic-иерархия: неабстрактный generic-класс `Modifier` (`IModifier`) с закрытыми подтипами `DamageModifier : Modifier`, `AmmoModifier : Modifier`, `NameModifier : Modifier`. Поле `IModifier` предлагает все три подтипа **и** сам открытый `Modifier` — при его выборе открывается второе окно для выбора аргумента `T`. Поле `Modifier` предлагает только присваиваемых кандидатов (`DamageModifier` и `Modifier` с выведенным `T = float`). +- `Scripts/WeaponPreset.cs` + `Presets/BrokenWeaponPreset.asset` — `ScriptableObject`, у которого поле `_weapon` указывает на несуществующий тип; используется для демонстрации починки потерянного типа (см. *Сервисные функции* ниже). + +Drawer поддерживает и UIToolkit, и IMGUI. Вариант `IMGUILoadout` принудительно использует IMGUI-путь — удобно для сравнения или миграции IMGUI-проектов: + +- `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` — те же поля, отрисованные через `OnInspectorGUI` (`SerializeReferenceIMGUIPropertyDrawer`). + +## Как запустить + +В `Prefabs/` лежат два готовых префаба — дважды кликните, чтобы открыть в Prefab Mode, или перетащите любой в сцену: + +- **Loadout** (`Prefabs/Loadout.prefab`) — путь UIToolkit. Предзаполнено: `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. +- **IMGUILoadout** (`Prefabs/IMGUILoadout.prefab`) — путь IMGUI. Предзаполнено: `Primary Weapon = Pistol`, `On Hit Effect = BurnEffect`. + +Дальше поэкспериментируйте с выпадающими списками: + +1. Кликните по любому дропдауну типа и выберите другую реализацию — экземпляр создастся, а его сериализуемые поля появятся вложенно под foldout. +2. Разверните `Railgun` и смените вложенный `Charge Effect` — увидите рекурсивное полиморфное редактирование. +3. Нажмите **+** на `Sidearms` и задайте каждому элементу свой тип оружия. +4. Откройте `On Hit Effect` — обратите внимание, что предлагаются только `BurnEffect` / `FreezeEffect` (абстрактный `StatusEffect` скрыт). +5. Откройте `Modifier` — рядом с тремя конкретными подтипами (`DamageModifier`, `AmmoModifier`, `NameModifier`) предлагается и открытый `Modifier`. Выберите `Modifier` — откроется второе окно для выбора аргумента `T` (попробуйте `string`, затем `float`), и только потом создастся экземпляр. Откройте `Float Modifier` — предлагаются только присваиваемые к `Modifier` кандидаты (`DamageModifier` и `Modifier` с выведенным `T = float`, без дополнительного окна). +6. ПКМ по заголовку компонента → **Log Loadout**, чтобы вывести настроенное снаряжение в Console. + +Хотите собрать с нуля? Добавьте пустой GameObject и прикрепите компонент **Loadout** (UIToolkit) или **IMGUILoadout** (IMGUI). + +Переключение поля обратно на `` очищает ссылку. Если сохранённый тип позже переименуют или удалят, в списке появится подпись `` и предупреждение, вместо тихой очистки. + +## Сервисные функции + +Drawer также помогает восстановиться после двух типичных поломок managed-ссылки. + +### Copy / Paste и сохранение данных + +- **ПКМ** по заголовку любого селектора → **Copy Serialize Reference** / **Paste Serialize Reference**. Вставка создаёт *независимый* экземпляр в целевом поле и неактивна, если скопированный тип не подходит полю. +- **Смена типа** сохраняет поля, общие у старой и новой реализации. Поставьте `Sidearms[0] = Pistol`, задайте урон, переключите на `Shotgun` и обратно — значение `Pistol` сохранится. + +### Починка потерянного типа — `BrokenWeaponPreset.asset` и `LoadoutMissingType.prefab` + +Два ассета поставляются заранее сломанными, со ссылками на несуществующие классы: + +- `Presets/BrokenWeaponPreset.asset` — `ScriptableObject`, поле `Weapon` ссылается на потерянный `GhostWeapon`. +- `Prefabs/LoadoutMissingType.prefab` — префаб, `Sidearms → Element 0` ссылается на потерянный `GhostPistol`. + +Выделите любой **в окне Project**. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix**: + +1. Нажмите **Fix** — откроется привычный селектор типов с поиском. Выберите `Pistol`. +2. Ссылка восстановится в `Pistol` с сохранёнными данными (префаб сохранит `_damage = 15`, `_magazineSize = 12`; ассет — `_damage = 25`, `_magazineSize = 8`). Выбор типа переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново — поэтому значения сохраняются. + +> Починка читает и переписывает файл ассета напрямую — Unity не отдаёт потерянный тип через свой serialization API (а на GameObject/префабах ещё и обнуляет его в живом объекте, UUM-129100), поэтому осиротевшие тип и данные восстанавливаются прямо из YAML. Значит нужен **сохранённый файл ассета**: работает для ScriptableObject и префаб-ассетов, выделенных в Project, но не для объектов в Prefab Mode или экземпляров в сцене (нет файла ассета для перезаписи). +> +> Если потерянная ссылка вложена в другое значение или лежит на дочернем объекте, до которого не добраться в инспекторе — используйте **`Tools → Aspid 🐍 → Repair Missing References FastTools`**: окно сканирует весь файл ассета и выводит все потерянные ссылки (любой глубины, на любом дочернем объекте), каждую со своим **Fix**. + +### Расцепление общей ссылки — `LoadoutSharedRef.prefab` + +В `Prefabs/LoadoutSharedRef.prefab` оба элемента `Sidearms` ссылаются на **один и тот же** экземпляр (это же состояние получается дублированием элемента массива). + +1. Откройте его — оба элемента показывают пометку **shared reference**; редактирование одного меняет другой. +2. **ПКМ** по элементу → **Make Unique Reference**. Он получит собственную копию данных, и поля станут независимыми. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta new file mode 100644 index 00000000..64992c02 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ed2b6607ff347f6ae540ace02dbc14c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta new file mode 100644 index 00000000..d3d21526 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0c6859af7d0f4560a27fc9398c16cac2 +timeCreated: 1780775167 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef new file mode 100644 index 00000000..6aeeefc0 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.FastTools.Samples.SerializeReferences", + "rootNamespace": "", + "references": [ + "Aspid.FastTools.Unity" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta new file mode 100644 index 00000000..2202ab8f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9fad5519270642308b608484e669eeaf +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta new file mode 100644 index 00000000..29604cb6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5ae54c703d8f4ad4a074367f0614d47c +timeCreated: 1780775167 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef new file mode 100644 index 00000000..672a61e1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Aspid.FastTools.Samples.SerializeReferences.Editor", + "rootNamespace": "", + "references": [ + "Aspid.FastTools.Samples.SerializeReferences" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta new file mode 100644 index 00000000..721adc5e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 97608434520c4d078f28ab868e7f3e06 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs new file mode 100644 index 00000000..299dbb4c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs @@ -0,0 +1,22 @@ +using UnityEditor; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences.Editors +{ + // Forces IMGUI rendering for the IMGUILoadout inspector. + // + // Unity picks IMGUI vs UIToolkit at the Editor level: when CreateInspectorGUI is NOT + // overridden but OnInspectorGUI is, the whole inspector — including every nested + // PropertyDrawer — falls back to IMGUI. That routes [SerializeReferenceSelector] fields + // through SerializeReferenceIMGUIPropertyDrawer.OnGUI instead of CreatePropertyGUI. + [CustomEditor(typeof(IMGUILoadout))] + internal sealed class IMGUILoadoutEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + DrawPropertiesExcluding(serializedObject, "m_Script"); + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta new file mode 100644 index 00000000..0d589592 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49097153234d4b66b7eacaff0ba4d88d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta new file mode 100644 index 00000000..2efe7f26 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0f9e11c2bbf345a0a2895fc744afba61 +timeCreated: 1780775167 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs new file mode 100644 index 00000000..24dc603f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs @@ -0,0 +1,14 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + [Serializable] + public sealed class BurnEffect : StatusEffect + { + [SerializeField] [Min(0f)] private float _damagePerSecond = 5f; + + public override string Describe() => $"Burn — {_damagePerSecond} dps for {Duration}s"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta new file mode 100644 index 00000000..1c4aba5a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7dd6bde21faf4019b74c28d087f07570 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs new file mode 100644 index 00000000..c5e91e6f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs @@ -0,0 +1,14 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + [Serializable] + public sealed class FreezeEffect : StatusEffect + { + [SerializeField] [Range(0f, 100f)] private float _slowPercent = 40f; + + public override string Describe() => $"Freeze — {_slowPercent}% slow for {Duration}s"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta new file mode 100644 index 00000000..f5bdb149 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95af46da00c94957a8642c716ab6aabb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs new file mode 100644 index 00000000..465d1a6a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs @@ -0,0 +1,21 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Abstract base for a second polymorphic hierarchy. + // + // When a field is declared as StatusEffect, [SerializeReferenceSelector] offers only the + // concrete subclasses (BurnEffect, FreezeEffect) — the abstract base itself is never listed, + // because it cannot be instantiated. + [Serializable] + public abstract class StatusEffect + { + [SerializeField] [Min(0f)] private float _duration = 3f; + + protected float Duration => _duration; + + public abstract string Describe(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta new file mode 100644 index 00000000..2574018b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aea8b9d6094b41bf80cab9036bf88bbe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs new file mode 100644 index 00000000..c88d0f10 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.SerializeReferences; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Same fields as Loadout, but the companion editor (IMGUILoadoutEditor) overrides + // OnInspectorGUI without CreateInspectorGUI, forcing the entire inspector — and every + // nested [SerializeReferenceSelector] field — through the IMGUI path + // (SerializeReferenceIMGUIPropertyDrawer) instead of the UIToolkit one. + // + // Use this to verify both rendering paths stay visually and behaviourally aligned. + public sealed class IMGUILoadout : MonoBehaviour + { + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _primaryWeapon; + + [SerializeReference] [SerializeReferenceSelector] + private List _sidearms = new(); + + [SerializeReference] [SerializeReferenceSelector] + private StatusEffect _onHitEffect; + + [SerializeReference] [SerializeReferenceSelector] + private IModifier _modifier; + + [SerializeReference] [SerializeReferenceSelector] + private Modifier _floatModifier; + + [SerializeReference] [SerializeReferenceSelector] + private List _modifiers = new(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta new file mode 100644 index 00000000..ba18fad5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6cd8f6abf02422a92c5f183b53afa29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs new file mode 100644 index 00000000..60118ddd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.SerializeReferences; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates [SerializeReferenceSelector] through the default (UIToolkit) Inspector. + // + // Add [SerializeReferenceSelector] next to [SerializeReference] and the field renders as a + // searchable, hierarchical type dropdown: + // - single field → pick one IWeapon implementation + // - List / array → each element is its own polymorphic picker + // - abstract base type → only concrete subclasses are offered + // - generic hierarchy → concrete closed-generic subclasses are offered; the open generic + // Modifier is also offered and opens a second window to pick T; + // a closed-generic field type (Modifier) constrains candidates + // by assignability and infers T directly + // + // Picking a type instantiates it, clears the reference, and the assigned instance's + // serialized fields appear inline under the foldout. Nested [SerializeReference] fields + // (e.g. Railgun's charge effect) get their own dropdown recursively. + public sealed class Loadout : MonoBehaviour + { + // Interface-typed field: lists every IWeapon implementation (Pistol, Shotgun, Railgun). + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _primaryWeapon; + + // Each list element is its own independent picker. + [SerializeReference] [SerializeReferenceSelector] + private List _sidearms = new(); + + // Abstract-base field: the picker offers BurnEffect / FreezeEffect, never StatusEffect. + [SerializeReference] [SerializeReferenceSelector] + private StatusEffect _onHitEffect; + + // Generic hierarchy. Non-generic IModifier field: the picker offers the concrete subclasses + // (DamageModifier, AmmoModifier, NameModifier) AND the open generic Modifier — choosing the + // latter opens a second window to pick T (e.g. string vs float). + [SerializeReference] [SerializeReferenceSelector] + private IModifier _modifier; + + // Closed-generic field type: only types assignable to Modifier are offered — + // DamageModifier (Modifier) and Modifier (its T is inferred to float, no extra window). + // AmmoModifier (int) and NameModifier (string) are excluded. + [SerializeReference] [SerializeReferenceSelector] + private Modifier _floatModifier; + + // Polymorphic list mixing different closed-generic subclasses. + [SerializeReference] [SerializeReferenceSelector] + private List _modifiers = new(); + + [ContextMenu("Log Loadout")] + private void LogLoadout() + { + Debug.Log($"Primary: {_primaryWeapon?.Describe() ?? "none"}"); + + for (var i = 0; i < _sidearms.Count; i++) + Debug.Log($"Sidearm {i}: {_sidearms[i]?.Describe() ?? "none"}"); + + Debug.Log($"On-hit effect: {_onHitEffect?.Describe() ?? "none"}"); + Debug.Log($"Modifier: {_modifier?.Describe() ?? "none"}"); + Debug.Log($"Float modifier: {_floatModifier?.Describe() ?? "none"}"); + + for (var i = 0; i < _modifiers.Count; i++) + Debug.Log($"Modifier {i}: {_modifiers[i]?.Describe() ?? "none"}"); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta new file mode 100644 index 00000000..00265ea2 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 884d53b5154744d3af6948b1eef02505 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta new file mode 100644 index 00000000..18cfcbda --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9100055084444cdf8e2f37ff3b613c02 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs new file mode 100644 index 00000000..1f33dc51 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs @@ -0,0 +1,14 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Concrete subclass closing Modifier over int. + // Offered for an IModifier field, but NOT for a Modifier field — + // it is Modifier, which is not assignable to Modifier. + [Serializable] + public sealed class AmmoModifier : Modifier + { + public override string Describe() => $"+{Value} ammo"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta new file mode 100644 index 00000000..63c3ed5e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 350225ab4907402aa855efd9c953246f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs new file mode 100644 index 00000000..9d31af86 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs @@ -0,0 +1,13 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Concrete subclass closing Modifier over float. + // Offered wherever the field type is IModifier or Modifier. + [Serializable] + public sealed class DamageModifier : Modifier + { + public override string Describe() => $"Damage ×{Value}"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta new file mode 100644 index 00000000..68f58708 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b5d6a98f98bf40aa8f7e296f383e265b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs new file mode 100644 index 00000000..b30c90e3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs @@ -0,0 +1,15 @@ +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Non-generic entry point for the generic [SerializeReference] sample. + // + // A field typed as IModifier lets [SerializeReferenceSelector] offer both: + // - every concrete subclass that closes Modifier over a real type argument + // (DamageModifier : Modifier, AmmoModifier : Modifier, NameModifier : Modifier), and + // - the open generic Modifier itself — picking it opens a second window to choose the argument T, + // then instantiates Modifier / Modifier / etc. + public interface IModifier + { + string Describe(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta new file mode 100644 index 00000000..04f0fbe3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3864ea1917414755add52a58178a20e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs new file mode 100644 index 00000000..1a34f946 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs @@ -0,0 +1,26 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Non-abstract generic base for the [SerializeReferenceSelector] generic test. + // + // Because it is a concrete open generic, [SerializeReferenceSelector] lists it as "Modifier". + // - On a non-generic IModifier field, picking it opens a second window to choose the argument T + // (e.g. string in one case, float in another), then instantiates Modifier / Modifier. + // - On a closed-generic field such as Modifier, the argument is inferred from the field, so it + // is created directly as Modifier without the extra window. + // + // The typed _value field verifies that Unity's generic serialization handles a bare type-parameter + // field (for float/int/string) and renders it inline under the dropdown. + [Serializable] + public class Modifier : IModifier + { + [SerializeField] private T _value; + + protected T Value => _value; + + public virtual string Describe() => $"Modifier<{typeof(T).Name}> = {_value}"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta new file mode 100644 index 00000000..d8e7bd3c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dcde013178d3400892d1f76d1b8a1cb2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs new file mode 100644 index 00000000..2c618c88 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs @@ -0,0 +1,13 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Concrete subclass closing Modifier over string. + // Offered for an IModifier field; excluded from a Modifier field. + [Serializable] + public sealed class NameModifier : Modifier + { + public override string Describe() => $"Renamed to \"{Value}\""; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta new file mode 100644 index 00000000..32be9d14 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1557d1630f2245b289641510ed2e6021 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs new file mode 100644 index 00000000..341c34e2 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.SerializeReferences; + +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates [SerializeReferenceSelector] on references that live INSIDE plain [Serializable] + // containers — a single container field and a List of them — instead of directly on the component. + // + // Everything works at this depth exactly as for a top-level field: the type-picker dropdown, the inline + // child properties of the chosen type, and the missing-type warning with its inline Fix. A renamed or + // removed weapon type nested in a slot is detected and re-pointed in place (keeping its data), so the + // asset-level Repair window is only needed for things the Inspector cannot reach at all. + public sealed class SlottedLoadout : MonoBehaviour + { + // A plain [Serializable] container (NOT a managed reference itself) pairing a polymorphic weapon with + // some metadata. The [SerializeReference] weapon inside it is still a full hierarchical picker. + [Serializable] + public sealed class WeaponSlot + { + public string label; + + [Min(0)] public int priority; + + // Polymorphic weapon nested one level inside the container — picker, inline fields and Fix all apply. + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _weapon; + } + + // A reference nested inside a single container field (path "_primarySlot._weapon"). + [SerializeField] private WeaponSlot _primarySlot = new(); + + // References nested inside each element of a List of containers (path "_slots.Array.data[i]._weapon"). + [SerializeField] private List _slots = new(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta new file mode 100644 index 00000000..ca22612b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 251896effa22341e7981b29000d77094 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs new file mode 100644 index 00000000..ccc7b91d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.SerializeReferences; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // ScriptableObject host for [SerializeReferenceSelector], used to demonstrate the missing-type repair flow. + // + // Why a ScriptableObject and not the Loadout MonoBehaviour? Unity preserves a managed reference whose type + // went missing (renamed / moved / deleted) only on ScriptableObject assets — on GameObjects and prefabs the + // reference is silently dropped to null on load (Unity bug UUM-129100). The "Edit Type" action that rewrites + // the stored type therefore only has something to repair on assets like this one. + // + // See the bundled BrokenWeaponPreset.asset: its _weapon points at a type that does not exist (GhostWeapon), + // so the Inspector shows a "Missing type" warning with an "Edit Type" button — set the class back to "Pistol" + // to recover the reference and its data. + [CreateAssetMenu(menuName = "Aspid/FastTools Samples/Weapon Preset", fileName = "WeaponPreset")] + public sealed class WeaponPreset : ScriptableObject + { + [SerializeReference] [SerializeReferenceSelector] + private IWeapon _weapon; + + [SerializeReference] [SerializeReferenceSelector] + private List _alternates = new(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta new file mode 100644 index 00000000..cdacb9eb --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7874533c7294db1b8aa77e7d4102c9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta new file mode 100644 index 00000000..0e76adff --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b9b0ae5ee78a4faa9a79767c9f22771b +timeCreated: 1780775167 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs new file mode 100644 index 00000000..14f1039f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs @@ -0,0 +1,12 @@ +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Base interface for the polymorphic [SerializeReference] sample. + // + // [SerializeReferenceSelector] lists every concrete, non-UnityEngine.Object class + // assignable to the field's declared type — here, every IWeapon implementation. + public interface IWeapon + { + string Describe(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta new file mode 100644 index 00000000..b7e5c0aa --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4559c2a45a7844d28bee4f6935696eea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs new file mode 100644 index 00000000..fec54fdd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs @@ -0,0 +1,17 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Concrete IWeapon. Its serialized fields are drawn inline under the dropdown's foldout + // once it is assigned. [Serializable] is conventional for managed-reference payloads. + [Serializable] + public sealed class Pistol : IWeapon + { + [SerializeField] [Min(0)] private int _damage = 10; + [SerializeField] [Min(0)] private int _magazineSize = 12; + + public string Describe() => $"Pistol — {_damage} dmg, {_magazineSize}-round mag"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta new file mode 100644 index 00000000..cce63b4b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9acccfdd901f4e38b86499e3577cf2b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs new file mode 100644 index 00000000..db4bc8b4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs @@ -0,0 +1,24 @@ +using System; +using UnityEngine; +using Aspid.FastTools.SerializeReferences; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates a nested [SerializeReference] inside a managed-reference payload: + // the charge effect is itself polymorphic and gets its own inline dropdown. + [Serializable] + public sealed class Railgun : IWeapon + { + [SerializeField] [Min(0f)] private float _chargeTime = 1.5f; + + [SerializeReference] [SerializeReferenceSelector] + private StatusEffect _chargeEffect; + + public string Describe() + { + var effect = _chargeEffect is null ? "none" : _chargeEffect.Describe(); + return $"Railgun — {_chargeTime}s charge, effect: {effect}"; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta new file mode 100644 index 00000000..c81685d5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf44f2297b1c4276a53ffe31f331254e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs new file mode 100644 index 00000000..c93cb855 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs @@ -0,0 +1,15 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + [Serializable] + public sealed class Shotgun : IWeapon + { + [SerializeField] [Min(1)] private int _pellets = 8; + [SerializeField] [Range(0f, 90f)] private float _spreadAngle = 25f; + + public string Describe() => $"Shotgun — {_pellets} pellets, {_spreadAngle}° spread"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta new file mode 100644 index 00000000..97569737 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b6a4b841bbf4765a143eaa74ca1d6a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta new file mode 100644 index 00000000..fe9608c5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31a60714a36b461385fa435ea880b724 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss new file mode 100644 index 00000000..383c6d8b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -0,0 +1,251 @@ +:root { + margin: 0; +} + +/* Lay the foldout header out as a row and centre its items so the dropdown and the open-script + button line up vertically, mirroring the single-row SerializableType field. */ +.aspid-fasttools-serialize-reference .unity-foldout__toggle { + flex-direction: row; + align-items: center; +} + +/* Keep the expand arrow at its natural width on the far left of the header. */ +.aspid-fasttools-serialize-reference .unity-foldout__toggle > .unity-foldout__input { + flex-grow: 0; + flex-shrink: 0; +} + +/* The type dropdown fills the header row up to the open-script button. Its left margin is set + in code to offset the arrow so the dropdown begins at the inspector value column. */ +.aspid-fasttools-serialize-reference__dropdown { + flex-grow: 1; +} + +/* Cancel the EnumField caption's built-in -2px left margin so the text indents from the box + border like SerializableType's field instead of hugging the edge. */ +.aspid-fasttools-serialize-reference__dropdown .unity-enum-field__text { + margin-left: 0; +} + +/* Hide the expand arrow when the reference is empty — there is nothing to expand. */ +.aspid-fasttools-serialize-reference--empty .unity-foldout__toggle .unity-foldout__checkmark { + visibility: hidden; +} + +/* Open-script button mirrors the SerializableType drawer affordance. */ +.aspid-fasttools-serialize-reference Button { + padding: 0; + min-width: 18px; + max-width: 18px; + min-height: 18px; + max-height: 18px; + margin: 0 0 0 1px; +} + +.aspid-fasttools-serialize-reference Button > VisualElement { + width: 100%; + height: 100%; + background-image: resource("d_Folder Icon"); +} + +.aspid-fasttools-serialize-reference Button > VisualElement:enabled:hover { + background-image: resource("d_FolderOpened Icon"); +} + +/* --- Compact inline notice (missing type / shared reference) --- + Replaces the bulky help-box + button: a small warning icon, a terse yellow message and an + underlined, clickable action word. The full explanation rides the element's tooltip on hover. */ +.aspid-fasttools-serialize-reference-notice { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + margin: 2px 0 1px 0; +} + +.aspid-fasttools-serialize-reference-notice__icon { + width: 14px; + height: 14px; + min-width: 14px; + margin-right: 5px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + background-image: resource("d_console.warnicon"); +} + +/* Both states use the same yellow text per the design — warning, not info. */ +.aspid-fasttools-serialize-reference-notice__message { + color: var(--aspid-colors-status-warning-text-light); + -unity-font-style: normal; + white-space: normal; +} + +.aspid-fasttools-serialize-reference-notice__action { + margin-left: 4px; + color: var(--aspid-colors-status-warning-text-light); + -unity-font-style: bold; + border-bottom-width: 1px; + border-bottom-color: var(--aspid-colors-status-warning-text-light); + cursor: link; +} + +.aspid-fasttools-serialize-reference-notice__action:hover { + color: var(--aspid-colors-status-warning-text-lightness); + border-bottom-color: var(--aspid-colors-status-warning-text-lightness); +} + +/* --- Repair References window --- + Mirrors the Welcome window: an animated black dotted canvas behind a boxed asset-picker card (Aspid header + with the signature green divider, full-width picker, Rescan trailing it), then either a centred hero for the + terminal states (info = pick an asset, success = nothing to repair) or a warning-accented results group with + one full-width amber gradient row per orphaned managed reference (the whole row is the Fix action — stored + type as the label, dimmed rid, a "Fix" cue). */ +.aspid-fasttools-repair-references { + --aspid-fasttools-colors-bg: rgb(0, 0, 0); +} + +.aspid-fasttools-repair-references__background { + background-color: var(--aspid-fasttools-colors-bg); +} + +.aspid-fasttools-repair-references__content { + flex-grow: 1; + padding: 12px; +} + +/* The AspidBox supplies the rounded darkness panel; the hairline border lifts it off the pure-black canvas. + The box's own stylesheet sets `:root { flex-grow: 1; }` on the element itself, which outweighs a single + class from this ancestor sheet — the compound selector restores enough specificity to pin the card to its + content height. */ +.aspid-fasttools-repair-references__card.aspid-fasttools-background { + flex-grow: 0; + margin-bottom: 10px; + border-width: 1px; + border-color: var(--aspid-colors-shade-darkness); +} + +.aspid-fasttools-repair-references__card-header { + margin-bottom: 8px; +} + +.aspid-fasttools-repair-references__field-row { + flex-direction: row; + align-items: center; +} + +.aspid-fasttools-repair-references__asset { + flex-grow: 1; +} + +.aspid-fasttools-repair-references__rescan { + min-width: 92px; + margin-left: 8px; + margin-bottom: 0; +} + +/* Centre the Rescan label (the gradient button left-aligns its label by default). */ +.aspid-fasttools-repair-references__rescan .aspid-fasttools-gradient-button__label { + flex-grow: 0; + -unity-text-align: middle-center; +} + +/* Centred hero for the two terminal states: package icon in the status colour, headline, dimmed explanation. */ +.aspid-fasttools-repair-references__empty { + flex-grow: 1; + padding: 20px; + align-items: center; + justify-content: center; +} + +.aspid-fasttools-repair-references__empty--hidden { + display: none; +} + +.aspid-fasttools-repair-references__empty-icon { + width: 96px; + height: 96px; + opacity: 0.9; + margin-bottom: 14px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.aspid-fasttools-repair-references__empty-icon--info { + background-image: var(--aspid-icons-status-info); +} + +.aspid-fasttools-repair-references__empty-icon--success { + background-image: var(--aspid-icons-status-success); +} + +.aspid-fasttools-repair-references__empty-title { + margin-bottom: 4px; +} + +.aspid-fasttools-repair-references__empty-message { + max-width: 420px; + color: var(--aspid-colors-text-dark); + white-space: normal; + -unity-text-align: middle-center; +} + +.aspid-fasttools-repair-references__results { + flex-grow: 1; +} + +.aspid-fasttools-repair-references__results--hidden { + display: none; +} + +.aspid-fasttools-repair-references__results-header { + margin-bottom: 6px; +} + +.aspid-fasttools-repair-references__results-hint { + margin-bottom: 10px; + color: var(--aspid-colors-text-dark); + white-space: normal; +} + +.aspid-fasttools-repair-references__scroll { + flex-grow: 1; +} + +/* Amber-tinted gradient rows — a broken reference reads as a warning — with a matching hover accent. */ +.aspid-fasttools-repair-references__entry { + --aspid-fasttools-colors-gradient_button-bg: var(--aspid-colors-status-warning-darkness); + --aspid-fasttools-colors-gradient_button-accent: var(--aspid-colors-status-warning-text-light); +} + +/* The dimmed rid rides between the type label and the trailing "Fix" cue; reset the row's bold weight. */ +.aspid-fasttools-repair-references__entry-rid { + margin-left: 10px; + color: var(--aspid-colors-text-dark); + -unity-font-style: normal; + -unity-text-align: middle-right; +} + +/* Inline type picker: the dropdown's selector view boxed in the window's dark style, expanded as an accordion + directly below the row being fixed. Fixed height — the selector's ListView needs bounds to virtualize. The + 5px padding matches the selector's own edge spacing so its full-bleed header strip reaches the box border; + overflow keeps that strip inside the rounded corners. */ +.aspid-fasttools-repair-references__picker.aspid-fasttools-background { + flex-grow: 0; + height: 300px; + padding: 5px; + overflow: hidden; + margin: 0 0 8px 0; + border-width: 1px; + border-color: var(--aspid-colors-status-warning-shade-darkness); +} + +/* Re-tint the selector list's default editor-blue hover/selection to the window's palette. */ +.aspid-fasttools-repair-references__picker .unity-collection-view__item:hover { + background-color: var(--aspid-colors-bg-dark); +} + +.aspid-fasttools-repair-references__picker .unity-collection-view__item--selected, +.aspid-fasttools-repair-references__picker .unity-collection-view__item--selected:hover { + background-color: var(--aspid-colors-status-success-dark); +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta new file mode 100644 index 00000000..261dd60c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 80c610f029234224a2ab1568059370a3 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss similarity index 80% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss index 6a812724..c696e5dc 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss @@ -1,10 +1,10 @@ -:root { +.aspid-fasttools-type-selector { padding: 5px; flex-grow: 1; flex-direction: column; } -.aspid-fasttools-type-selector-header { +.aspid-fasttools-type-selector__header { padding-left: 10; height: 20px; min-height: 20px; @@ -15,7 +15,7 @@ } /* -------------------------------------------------- Back Button --------------------------------------------------- */ -.aspid-fasttools-type-selector-header > Button { +.aspid-fasttools-type-selector__header > Button { margin: 0; width: 20px; height: 16px; @@ -25,8 +25,8 @@ color: var(--aspid-colors-text-lightness); } -.aspid-fasttools-type-selector-header > Button:enabled:hover, -.aspid-fasttools-type-selector-header > Button:enabled:focus { +.aspid-fasttools-type-selector__header > Button:enabled:hover, +.aspid-fasttools-type-selector__header > Button:enabled:focus { border-width: 1px; color: var(--aspid-colors-status-info-text-lightness); border-color: var(--aspid-colors-status-info-shade-lightness); @@ -34,19 +34,19 @@ /* ------------------------------------------------------------------------------------------------------------------ */ /* ----------------------------------------------------- Title ------------------------------------------------------ */ -.aspid-fasttools-type-selector-header > Label { +.aspid-fasttools-type-selector__header > Label { flex-grow: 1; -unity-font-style: bold; } /* ------------------------------------------------------------------------------------------------------------------ */ -ToolbarSearchField { +.aspid-fasttools-type-selector ToolbarSearchField { width: auto; margin-bottom: 5px; } /* ------------------------------------------------------ Item ------------------------------------------------------ */ -.unity-collection-view__item { +.aspid-fasttools-type-selector .unity-collection-view__item { height: 20px; align-items: center; padding-left: 5px; @@ -54,12 +54,12 @@ ToolbarSearchField { flex-direction: row; } -.aspid-fasttools-type-selector-item-title { +.aspid-fasttools-type-selector__item-title { color: var(--aspid-colors-text-lightness); flex-grow: 1; } -.aspid-fasttools-type-selector-item-arrow { +.aspid-fasttools-type-selector__item-arrow { color: var(--aspid-colors-text-light); } /* ------------------------------------------------------------------------------------------------------------------ */ diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss.meta similarity index 100% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss.meta rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss.meta diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta new file mode 100644 index 00000000..e3cfd0d3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee1385cfb3fb4d79b8831e0de0102dcf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta new file mode 100644 index 00000000..dac523a3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4765d7eeba4748d3acd9142e2d2dca03 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs new file mode 100644 index 00000000..ec2cc59c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs @@ -0,0 +1,276 @@ +using System; +using UnityEditor; +using UnityEngine; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using Aspid.FastTools.Types.Editors; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// IMGUI rendering for the [SerializeReferenceSelector] drawer: a foldout-and-dropdown header + /// row, an optional missing-type warning, and the nested properties of the assigned instance. + /// + internal static class SerializeReferenceIMGUIPropertyDrawer + { + public static float GetHeight(SerializedProperty property) + { + var spacing = EditorGUIUtility.standardVerticalSpacing; + var height = EditorGUIUtility.singleLineHeight; + + if (SerializeReferenceHelpers.IsMissingType(property)) + height += spacing + EditorGUIUtility.singleLineHeight; + + if (SerializeReferenceHelpers.HasSharedReference(property)) + height += spacing + EditorGUIUtility.singleLineHeight; + + if (property.managedReferenceValue is not null && property.isExpanded) + height += GetChildrenHeight(property, spacing); + + return height; + } + + public static void Draw(Rect position, GUIContent label, SerializedProperty property, params Type[] types) + { + var spacing = EditorGUIUtility.standardVerticalSpacing; + var currentType = SerializeReferenceHelpers.GetCurrentType(property); + var hasValue = currentType is not null; + + var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); + + var contextEvent = Event.current; + if (contextEvent.type == EventType.ContextClick && line.Contains(contextEvent.mousePosition)) + { + var fieldType = types.Length > 0 ? types[0] : typeof(object); + ShowContextMenu(property, fieldType); + contextEvent.Use(); + } + + var labelRect = new Rect(line.x, line.y, EditorGUIUtility.labelWidth, line.height); + if (hasValue) property.isExpanded = EditorGUI.Foldout(labelRect, property.isExpanded, label, toggleOnLabelClick: true); + else EditorGUI.LabelField(labelRect, label); + + var dropdownRect = new Rect( + line.x + EditorGUIUtility.labelWidth + 2f, + line.y, + line.width - EditorGUIUtility.labelWidth - 2f, + line.height); + + var openRect = Rect.zero; + if (hasValue) + { + var openSize = line.height; + openRect = new Rect(dropdownRect.xMax - openSize, dropdownRect.y, openSize, openSize); + dropdownRect.width -= openSize + 1f; + } + + var caption = GetCaption(property, currentType); + if (EditorGUI.DropdownButton(dropdownRect, new GUIContent(caption), FocusType.Passive)) + ShowSelector(property, types, currentType, dropdownRect); + + if (hasValue) + TypeIMGUIPropertyDrawer.DrawOpenScriptButton(openRect, currentType); + + var y = line.yMax + spacing; + + if (SerializeReferenceHelpers.IsMissingType(property)) + { + var noticeRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + var typeName = SerializeReferenceHelpers.GetMissingTypeDisplayName(property); + var canFix = SerializeReferenceHelpers.TryGetRepairLocation(property, out _, out _, out _); + + DrawNotice( + noticeRect, + "Missing type —", + canFix ? "Fix" : null, + canFix + ? $"Missing type: {typeName}.\nClick Fix to re-point this reference to an existing type, keeping its data." + : $"Missing type: {typeName}.\nOpen this asset from the Project window to repair it.", + canFix + ? () => + { + var screenPosition = GUIUtility.GUIToScreenPoint(new Vector2(noticeRect.x, noticeRect.yMax)); + var screenRect = new Rect(screenPosition.x, screenPosition.y, noticeRect.width, EditorGUIUtility.singleLineHeight); + SerializeReferenceHelpers.ShowFixTypeSelector(property.Persistent(), screenRect, null); + } + : null); + + y += EditorGUIUtility.singleLineHeight + spacing; + } + + if (SerializeReferenceHelpers.HasSharedReference(property)) + { + var noticeRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + var persistent = property.Persistent(); + + DrawNotice( + noticeRect, + "Shared reference —", + "Make unique", + "This reference is shared with another field — editing one changes both.\n" + + "Click Make unique to give this field its own independent copy.", + () => SerializeReferenceHelpers.MakeReferenceUnique(persistent)); + + y += EditorGUIUtility.singleLineHeight + spacing; + } + + if (!hasValue || !property.isExpanded) return; + + EditorGUI.indentLevel++; + DrawChildren(property, position.x, position.width, spacing, ref y); + EditorGUI.indentLevel--; + } + + private static void DrawChildren(SerializedProperty property, float x, float width, float spacing, ref float y) + { + var iterator = property.Copy(); + var end = property.GetEndProperty(); + var enterChildren = true; + + while (iterator.NextVisible(enterChildren) && !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + + var height = EditorGUI.GetPropertyHeight(iterator, includeChildren: true); + EditorGUI.PropertyField(new Rect(x, y, width, height), iterator, includeChildren: true); + y += height + spacing; + } + } + + private static float GetChildrenHeight(SerializedProperty property, float spacing) + { + var height = 0f; + var iterator = property.Copy(); + var end = property.GetEndProperty(); + var enterChildren = true; + + while (iterator.NextVisible(enterChildren) && !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + height += EditorGUI.GetPropertyHeight(iterator, includeChildren: true) + spacing; + } + + return height; + } + + private static void ShowSelector(SerializedProperty property, Type[] types, Type currentType, Rect dropdownRect) + { + var persistent = property.Persistent(); + var fieldType = types.Length > 0 ? types[0] : typeof(object); + var screenPosition = GUIUtility.GUIToScreenPoint(new Vector2(dropdownRect.x, dropdownRect.y)); + var screenRect = new Rect(screenPosition.x, screenPosition.y, dropdownRect.width, dropdownRect.height); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: types, + currentAqn: currentType?.AssemblyQualifiedName ?? string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => Apply(string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false)), + filter: SerializeReferenceHelpers.IsAssignableManagedReference, + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(fieldType), + argumentFilter: SerializeReferenceHelpers.IsValidGenericArgument); + + return; + + void Apply(Type type) + { + var previous = persistent.managedReferenceValue; + persistent.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, previous)); + persistent.isExpanded = type is not null; + } + } + + private static void ShowContextMenu(SerializedProperty property, Type fieldType) + { + var persistent = property.Persistent(); + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Copy Serialize Reference"), false, + () => SerializeReferenceClipboard.Copy(persistent.managedReferenceValue)); + + var pasteLabel = new GUIContent("Paste Serialize Reference"); + if (SerializeReferenceClipboard.CanPasteInto(fieldType)) + menu.AddItem(pasteLabel, false, () => Paste(persistent)); + else + menu.AddDisabledItem(pasteLabel); + + if (SerializeReferenceHelpers.HasSharedReference(property)) + menu.AddItem(new GUIContent("Make Unique Reference"), false, + () => SerializeReferenceHelpers.MakeReferenceUnique(persistent)); + + menu.ShowAsContext(); + + void Paste(SerializedProperty target) + { + var value = SerializeReferenceClipboard.CreateInstance(); + target.SetManagedReferenceAndApply(value); + target.isExpanded = value is not null; + } + } + + + private static string GetCaption(SerializedProperty property, Type currentType) + { + if (currentType is not null) + return TypeSelectorHelpers.GetTypeSelectorTitle(currentType); + + var missingName = SerializeReferenceHelpers.IsMissingType(property) + ? SerializeReferenceHelpers.GetMissingTypeDisplayName(property) + : null; + + return TypeSelectorHelpers.GetTypeSelectorTitle(null, missingName); + } + + // Warning yellow mirrors the UIToolkit notice palette: + // --aspid-colors-status-warning-text-light / -lightness. + private static readonly Color NoticeColor = new(245f / 255f, 185f / 255f, 85f / 255f); + private static readonly Color NoticeColorHover = new(255f / 255f, 235f / 255f, 175f / 255f); + + private static GUIStyle _messageStyle; + private static GUIStyle _actionStyle; + + /// + /// Draws a compact single-row warning: a small warning icon, a terse yellow message and an + /// optional underlined, clickable action word. The full rides the + /// hover tooltip, mirroring the UIToolkit . + /// + private static void DrawNotice(Rect rect, string message, string actionText, string detail, Action onClick) + { + _messageStyle ??= new GUIStyle(EditorStyles.label) { wordWrap = false }; + _actionStyle ??= new GUIStyle(EditorStyles.label) { fontStyle = FontStyle.Bold }; + _messageStyle.normal.textColor = NoticeColor; + + const float iconSize = 16f; + var iconRect = new Rect(rect.x, rect.y + (rect.height - iconSize) * 0.5f, iconSize, iconSize); + GUI.Label(iconRect, EditorGUIUtility.IconContent("console.warnicon")); + + var messageContent = new GUIContent(message, detail); + var messageWidth = _messageStyle.CalcSize(messageContent).x; + var messageRect = new Rect(iconRect.xMax + 4f, rect.y, messageWidth, rect.height); + GUI.Label(messageRect, messageContent, _messageStyle); + + if (string.IsNullOrEmpty(actionText) || onClick is null) return; + + var actionContent = new GUIContent(actionText, detail); + var actionWidth = _actionStyle.CalcSize(actionContent).x; + var actionRect = new Rect(messageRect.xMax + 4f, rect.y, actionWidth, rect.height); + + var hover = actionRect.Contains(Event.current.mousePosition); + var actionColor = hover ? NoticeColorHover : NoticeColor; + _actionStyle.normal.textColor = actionColor; + _actionStyle.hover.textColor = actionColor; + + EditorGUIUtility.AddCursorRect(actionRect, MouseCursor.Link); + var clicked = GUI.Button(actionRect, actionContent, _actionStyle); + + // Underline the action word — IMGUI styles have no text-decoration, so draw the rule manually. + var underline = new Rect(actionRect.x, actionRect.center.y + EditorGUIUtility.singleLineHeight * 0.35f, actionWidth, 1f); + EditorGUI.DrawRect(underline, actionColor); + + if (clicked) onClick(); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta new file mode 100644 index 00000000..0473bc6d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c73abd645ce47b8b2d711c39909eaa8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs new file mode 100644 index 00000000..e1468f20 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs @@ -0,0 +1,52 @@ +using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Property drawer for . Delegates rendering to the + /// IMGUI and UIToolkit helpers, constraining the candidate list to the field's declared managed-reference type. + /// + [CustomPropertyDrawer(typeof(SerializeReferenceSelectorAttribute))] + internal sealed class SerializeReferenceSelectorPropertyDrawer : PropertyDrawer + { + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + ThrowExceptionIfInvalidProperty(property); + + SerializeReferenceIMGUIPropertyDrawer.Draw( + position: position, + label: label, + property: property, + types: SerializeReferenceHelpers.GetFieldType(property)); + } + + public override float GetPropertyHeight(SerializedProperty property, GUIContent label) + { + if (property.propertyType != SerializedPropertyType.ManagedReference) + return EditorGUIUtility.singleLineHeight; + + return SerializeReferenceIMGUIPropertyDrawer.GetHeight(property); + } + + public override VisualElement CreatePropertyGUI(SerializedProperty property) + { + ThrowExceptionIfInvalidProperty(property); + + return SerializeReferenceUIToolkitPropertyDrawer.Draw( + label: preferredLabel, + property: property); + } + + private static void ThrowExceptionIfInvalidProperty(SerializedProperty property) + { + if (property.propertyType != SerializedPropertyType.ManagedReference) + throw new ArgumentException( + "[SerializeReferenceSelector] can only be applied to a [SerializeReference] field.", + nameof(property)); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs.meta new file mode 100644 index 00000000..d586cb43 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceSelectorPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 928e59b0c8ab460cade339ce4e824b17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs new file mode 100644 index 00000000..a0929ca6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs @@ -0,0 +1,15 @@ +using UnityEditor; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + internal static class SerializeReferenceUIToolkitPropertyDrawer + { + public static VisualElement Draw(string label, SerializedProperty property) + { + label = string.IsNullOrWhiteSpace(label) ? null : label; + return new SerializeReferenceField(label, property); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta new file mode 100644 index 00000000..2bfcbff4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cbfa5b504864624a596b92a76a09ec1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions.meta new file mode 100644 index 00000000..672e73b8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b6da85b53a545ed8bb4d2d6bed835b7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs new file mode 100644 index 00000000..efd034ce --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs @@ -0,0 +1,58 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Editor-session clipboard backing the Copy/Paste context-menu entries of the + /// [SerializeReferenceSelector] drawers. Stores the copied managed-reference value as JSON plus its + /// concrete , so a paste reconstructs an independent instance (rather than aliasing the + /// source object) and survives across different fields, inspectors, and target objects within the session. + /// + internal static class SerializeReferenceClipboard + { + private static bool _hasContent; + private static string _json; + private static Type _type; + + /// The concrete type of the copied value, or when an empty reference was copied. + public static Type Type => _type; + + /// + /// Captures into the clipboard. Copying is meaningful — a + /// subsequent paste clears the target field. + /// + public static void Copy(object value) + { + _hasContent = true; + _type = value?.GetType(); + _json = value is null ? null : JsonUtility.ToJson(value); + } + + /// + /// Returns when the clipboard holds content that can be pasted into a field whose + /// declared managed-reference type is (an empty reference always pastes — + /// it clears the field). + /// + public static bool CanPasteInto(Type fieldType) + { + if (!_hasContent) return false; + if (_type is null) return true; + return fieldType is null || fieldType.IsAssignableFrom(_type); + } + + /// + /// Reconstructs a fresh instance from the clipboard contents for assignment to a managed reference, or + /// when an empty reference was copied. The result is independent of the copied object. + /// + public static object CreateInstance() + { + if (!_hasContent || _type is null) return null; + + return string.IsNullOrEmpty(_json) + ? SerializeReferenceHelpers.CreateInstance(_type) + : JsonUtility.FromJson(_json, _type); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta new file mode 100644 index 00000000..30baf539 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9667c30ec60b8473bacad6570addcffe \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs new file mode 100644 index 00000000..ddace906 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs @@ -0,0 +1,528 @@ +using System; +using System.Text; +using UnityEngine; +using UnityEditor; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using System.Collections.Generic; +using Aspid.FastTools.Types.Editors; +using UnityEditor.SceneManagement; +using System.Runtime.Serialization; +using System.Text.RegularExpressions; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Shared helpers for the [SerializeReferenceSelector] drawers: resolving the declared + /// managed-reference field type, filtering candidate types, instantiating the selected type, and + /// parsing Unity's managed-reference type-name format. The open-generic argument flow itself lives in + /// the shared / + /// ; + /// is supplied to the selector as its argument filter. + /// + internal static class SerializeReferenceHelpers + { + /// + /// Resolves the declared element type of a managed-reference property — the base type that + /// constrains the candidate list. Uses , + /// which already reports the element type for array/list entries. + /// + public static Type GetFieldType(SerializedProperty property) => + GetTypeFromTypename(property.managedReferenceFieldTypename) ?? typeof(object); + + /// + /// Resolves the concrete type currently stored in the managed reference, or + /// when the reference is empty or its stored type can no longer be loaded. + /// + public static Type GetCurrentType(SerializedProperty property) => + property.managedReferenceValue?.GetType(); + + /// + /// Returns when this property holds a managed reference whose type can no longer be + /// loaded (renamed / moved / deleted). Unity does not expose a missing type through the per-property API + /// (the value reads back and + /// is empty) and even drops it from the live object on prefabs / GameObjects, so detection reads the stored + /// reference straight from the asset YAML: a null value whose recorded type cannot be resolved is missing. + /// + public static bool IsMissingType(SerializedProperty property) => + TryGetMissingType(property, out _, out _); + + // Core missing-type probe shared by the public helpers: reads the property's stored id and type from the + // asset YAML and reports it missing when the recorded type no longer resolves to a loadable Type. + private static bool TryGetMissingType(SerializedProperty property, out long referenceId, out ManagedTypeName storedType) + { + referenceId = 0; + storedType = default; + + if (property.propertyType != SerializedPropertyType.ManagedReference) return false; + if (property.managedReferenceValue is not null) return false; + if (!TryGetRepairLocation(property, out var assetPath, out var fileId, out _)) return false; + if (!SerializeReferenceYamlEditor.TryReadStoredType(assetPath, fileId, property.propertyPath, out referenceId, out storedType)) + return false; + + return !storedType.IsEmpty && !StoredTypeResolves(storedType); + } + + // True when the YAML-recorded type identity can be loaded — i.e. the reference is intact, not missing. + public static bool StoredTypeResolves(ManagedTypeName name) + { + if (string.IsNullOrEmpty(name.Class)) return false; + + var className = name.Class.Replace('/', '+'); + var fullName = string.IsNullOrEmpty(name.Namespace) ? className : $"{name.Namespace}.{className}"; + var assemblyQualified = string.IsNullOrEmpty(name.Assembly) ? fullName : $"{fullName}, {name.Assembly}"; + + return Type.GetType(assemblyQualified, throwOnError: false) is not null; + } + + /// + /// Predicate identifying types that can legally be assigned to a [SerializeReference] field: + /// concrete reference types that are neither , open generics, strings, nor delegates. + /// + public static bool IsAssignableManagedReference(Type type) => + type is { IsClass: true, IsAbstract: false, ContainsGenericParameters: false } && + type != typeof(string) && + !typeof(Object).IsAssignableFrom(type) && + !typeof(Delegate).IsAssignableFrom(type); + + /// + /// Creates an instance of for assignment to a managed reference. + /// Prefers a (public or non-public) parameterless constructor so field initializers run, and + /// falls back to an uninitialized instance for types that expose no parameterless constructor. + /// + public static object CreateInstance(Type type) + { + if (type is null) return null; + + try + { + return Activator.CreateInstance(type, nonPublic: true); + } + catch (MissingMethodException) + { + return FormatterServices.GetUninitializedObject(type); + } + } + + /// + /// Creates an instance of and carries over the data of + /// for every field the two types share by name and serialized shape. Mirrors Unity's own type-change + /// behaviour: the old value is serialized to JSON and overwritten onto the new instance, so matching fields + /// survive a type switch (e.g. a shared _radius) while the rest fall back to the new type's defaults. + /// A structural mismatch simply leaves the new instance untouched. + /// + public static object CreateInstancePreservingData(Type newType, object previous) + { + var instance = CreateInstance(newType); + if (instance is null || previous is null) return instance; + + try + { + var json = JsonUtility.ToJson(previous); + if (!string.IsNullOrEmpty(json) && json != "{}") + JsonUtility.FromJsonOverwrite(json, instance); + } + catch (Exception) + { + // Best effort: incompatible layouts just mean nothing is carried over. + } + + return instance; + } + + /// + /// Parses Unity's managed-reference type-name format ("AssemblyName Namespace.TypeName") + /// into a , or when it is empty or cannot be loaded. + /// + public static Type GetTypeFromTypename(string typename) + { + if (string.IsNullOrEmpty(typename)) return null; + + var separator = typename.IndexOf(' '); + if (separator < 0) return Type.GetType(typename, throwOnError: false); + + var assembly = typename[..separator]; + var fullName = typename[(separator + 1)..]; + return Type.GetType($"{fullName}, {assembly}", throwOnError: false); + } + + /// + /// Predicate identifying types usable as a generic argument of a serialized managed reference: + /// concrete, non-generic types Unity can serialize as a field value (primitives, , + /// enums, -derived references, or [Serializable] structs/classes). Passed to + /// as the argument filter. + /// + public static bool IsValidGenericArgument(Type type) + { + if (type is null) return false; + if (type.IsAbstract || type.IsInterface || type.ContainsGenericParameters) return false; + if (typeof(Delegate).IsAssignableFrom(type)) return false; + + return type.IsPrimitive || + type.IsEnum || + type == typeof(string) || + typeof(Object).IsAssignableFrom(type) || + (type.IsValueType && type.IsSerializable) || + (type.IsClass && type.IsSerializable); + } + + #region Missing-type repair + /// + /// Resolves the stored (now unloadable) type identity of this property's missing managed reference, read from + /// the asset YAML, for display in the caption / warning. Returns when the property + /// is not a recognised missing reference. + /// + public static ManagedTypeName GetMissingTypeName(SerializedProperty property) => + TryGetMissingType(property, out _, out var storedType) ? storedType : default; + + /// + /// Human-readable Namespace.Class of this property's missing type, for the dropdown caption and the + /// warning message, or an empty string when the property is not a recognised missing reference. + /// + public static string GetMissingTypeDisplayName(SerializedProperty property) + { + var name = GetMissingTypeName(property); + if (name.IsEmpty) return string.Empty; + return string.IsNullOrEmpty(name.Namespace) ? name.Class : $"{name.Namespace}.{name.Class}"; + } + + /// + /// Resolves the on-disk asset path and the target object's local file id (the YAML document anchor) backing + /// this property. Returns for scene objects and prefab instances, which have no + /// editable asset file — the YAML repair flow only applies to saved assets (ScriptableObjects, prefabs). + /// + public static bool TryGetAssetLocation(SerializedProperty property, out string assetPath, out long fileId) + { + fileId = 0; + var target = property.serializedObject.targetObject; + assetPath = AssetDatabase.GetAssetPath(target); + + if (string.IsNullOrEmpty(assetPath)) return false; + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(target, out _, out fileId); + } + + /// + /// Resolves the YAML document backing this property's stored managed reference and reports whether the repair + /// must be applied in memory. Saved assets (ScriptableObjects, prefab assets selected in the Project) resolve + /// directly and are repaired by rewriting the file. Objects open in Prefab Mode have no asset path of + /// their own — the path comes from the prefab stage and the document id is matched back to the asset on disk — + /// and must be repaired in memory ( = ), because the open + /// stage holds a separate copy that does not refresh on reimport and would overwrite a file rewrite on save. + /// + public static bool TryGetRepairLocation(SerializedProperty property, out string assetPath, out long fileId, out bool inMemory) + { + inMemory = false; + if (TryGetAssetLocation(property, out assetPath, out fileId)) return true; + + assetPath = null; + fileId = 0; + + var target = property.serializedObject.targetObject; + var go = target as GameObject ?? (target as Component)?.gameObject; + if (go is null) return false; + + var stage = PrefabStageUtility.GetPrefabStage(go); + if (stage is null || !TryMatchAssetFileId(stage, target, go, out fileId)) return false; + + assetPath = stage.assetPath; + inMemory = true; + return true; + } + + // A Prefab Mode object is a copy in a preview scene and carries no file id of its own, so the matching + // persisted object is located in the asset by replaying its child path from the stage root, and the document + // id is read from the asset's component (or GameObject) there. + private static bool TryMatchAssetFileId(PrefabStage stage, Object target, GameObject stageGo, out long fileId) + { + fileId = 0; + + var indices = new List(); + var transform = stageGo.transform; + var root = stage.prefabContentsRoot.transform; + while (transform != root) + { + if (transform.parent is null) return false; // object is not under the stage root + indices.Insert(0, transform.GetSiblingIndex()); + transform = transform.parent; + } + + var assetRoot = AssetDatabase.LoadAssetAtPath(stage.assetPath); + if (assetRoot is null) return false; + + var assetTransform = assetRoot.transform; + foreach (var index in indices) + { + if (index < 0 || index >= assetTransform.childCount) return false; + assetTransform = assetTransform.GetChild(index); + } + + if (target is not Component component) + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(assetTransform.gameObject, out _, out fileId); + + // Disambiguate by component index in case the object carries several components of the same type. + var stageComponents = stageGo.GetComponents(component.GetType()); + var componentIndex = Array.IndexOf(stageComponents, component); + var assetComponents = assetTransform.GetComponents(component.GetType()); + if (componentIndex < 0 || componentIndex >= assetComponents.Length) return false; + + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(assetComponents[componentIndex], out _, out fileId); + } + + /// + /// Finds the RefIds id of the missing managed reference this property points at, read from the asset + /// YAML. Detection is strict and per-property: only a field whose own recorded type fails to resolve counts + /// as missing, so legitimately-empty fields are never flagged. + /// + public static bool TryGetMissingReferenceId(SerializedProperty property, out long referenceId) => + TryGetMissingType(property, out referenceId, out _); + + /// + /// Opens the same hierarchical type picker the dropdown uses, anchored at , to + /// choose the existing type a missing reference should resolve to. The chosen type is written into the asset + /// YAML (re-pointing the reference and keeping its stored data); runs on success. + /// + public static void ShowFixTypeSelector(SerializedProperty property, Rect screenRect, Action onFixed) + { + var fieldType = GetFieldType(property); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: new[] { fieldType }, + currentAqn: string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => + { + var type = string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false); + + if (type is not null && TryFixMissingType(property, type)) + onFixed?.Invoke(); + }, + filter: IsAssignableManagedReference, + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(fieldType), + argumentFilter: IsValidGenericArgument); + } + + /// + /// Re-points this property's missing managed reference to , keeping its stored data. + /// Saved assets are repaired by rewriting the type in the YAML and reimporting; objects open in Prefab Mode are + /// repaired in memory (see ). Returns on success; the + /// caller refreshes the inspector. + /// + public static bool TryFixMissingType(SerializedProperty property, Type newType) + { + if (newType is null) return false; + if (!TryGetRepairLocation(property, out var assetPath, out var fileId, out var inMemory)) return false; + if (!TryGetMissingReferenceId(property, out var referenceId)) return false; + + bool repaired; + if (inMemory) + { + repaired = TryFixMissingTypeInMemory(property, newType, referenceId); + } + else + { + repaired = SerializeReferenceYamlEditor.TryRewriteType(assetPath, fileId, referenceId, ManagedTypeName.FromType(newType)); + // ForceUpdate reloads the asset and invalidates the live SerializedObject, so the property must not be + // touched afterwards — the inspector is rebuilt below from a fresh selection instead. + if (repaired) AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + } + + if (repaired) ScheduleInspectorRebuild(); + return repaired; + } + + // Forces the inspector to rebuild after a repair. Unity's object-level "contains missing SerializeReference + // types" banner is drawn from a flag cached when the editor is built and does not react to + // ClearManagedReferenceWithMissingType or an inspector reload — it only clears on a genuine reselection. + // A reimport (saved-asset path) likewise leaves the live SerializedObject stale. Deselecting and reselecting + // the current objects on the next ticks tears the editors down and recreates them from scratch — exactly what + // a manual reselect does — so the banner clears and the resolved field shows through. + private static void ScheduleInspectorRebuild() + { + var selection = Selection.objects; + if (selection is null || selection.Length == 0) return; + + EditorApplication.delayCall += () => + { + Selection.objects = Array.Empty(); + EditorApplication.delayCall += () => Selection.objects = selection; + }; + } + + // Prefab Mode objects cannot be repaired by rewriting the asset file: the open stage holds its own copy that + // does not refresh on reimport and overwrites the change on save. Instead the reference is reassigned on the + // live object — recovering the orphaned field data Unity still keeps for the missing type — and the now-unused + // missing-type entry is cleared so the object stops being flagged. + private static bool TryFixMissingTypeInMemory(SerializedProperty property, Type newType, long referenceId) + { + var target = property.serializedObject.targetObject; + var instance = CreateInstance(newType); + if (instance is null) return false; + + foreach (var entry in SerializationUtility.GetManagedReferencesWithMissingTypes(target)) + { + if (entry.referenceId != referenceId) continue; + RecoverManagedReferenceData(entry.serializedData, instance); + break; + } + + property.SetManagedReferenceAndApply(instance); + ClearMissingSubtree(target, referenceId); + EditorUtility.SetDirty(target); + property.serializedObject.Update(); + + // Mark the owning scene (the prefab stage's preview scene, or a regular scene) dirty so the in-memory + // repair is offered for save — a file rewrite that the open stage would otherwise discard is avoided. + var scene = (target as Component)?.gameObject.scene ?? (target as GameObject)?.scene ?? default; + if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene); + + return true; + } + + // Clears the fixed missing-type entry and any missing-type entries it transitively referenced. The in-memory + // repair replaces the reference with a fresh instance, dropping the orphaned payload's nested references — so a + // missing child it carried (e.g. a missing effect nested inside a missing weapon) would otherwise linger as an + // unreachable orphan and keep Unity's object-level missing-types flag (and its banner) raised. + private static void ClearMissingSubtree(Object target, long rootReferenceId) + { + var dataByRid = new Dictionary(); + foreach (var entry in SerializationUtility.GetManagedReferencesWithMissingTypes(target)) + dataByRid[entry.referenceId] = entry.serializedData; + + var pending = new Stack(); + var visited = new HashSet(); + pending.Push(rootReferenceId); + + while (pending.Count > 0) + { + var rid = pending.Pop(); + if (!visited.Add(rid)) continue; + if (!dataByRid.TryGetValue(rid, out var data)) continue; // a resolvable reference, or already cleared + + foreach (Match match in Regex.Matches(data ?? string.Empty, @"rid:\s*(-?\d+)")) + if (long.TryParse(match.Groups[1].Value, out var child) && child != rid) + pending.Push(child); + + SerializationUtility.ClearManagedReferenceWithMissingType(target, rid); + } + } + + // Best-effort recovery of a missing reference's stored data onto the replacement instance. Unity surfaces the + // orphaned payload as the field block of YAML scalars (e.g. "_damage: 15"); the flat top-level scalars are + // mapped to JSON and overwritten onto the instance, so a renamed-type fix keeps its values. Nested mappings + // and sequences are skipped and left at the new type's defaults. + private static void RecoverManagedReferenceData(string serializedData, object instance) + { + if (string.IsNullOrEmpty(serializedData)) return; + + try + { + var json = new StringBuilder("{"); + var first = true; + + foreach (var raw in serializedData.Split('\n')) + { + var line = raw.TrimEnd('\r'); + // Only top-level scalars: skip blanks, indented (nested) lines and sequence items. + if (line.Length == 0 || char.IsWhiteSpace(line[0]) || line[0] == '-') continue; + + var separator = line.IndexOf(':'); + if (separator <= 0) continue; + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + + // Empty value = a mapping/array header (e.g. "_nested:"); complex flow values are not flat scalars. + if (key.Length == 0 || value.Length == 0 || value[0] is '{' or '[') continue; + + if (!first) json.Append(','); + first = false; + + json.Append('"').Append(key).Append("\":"); + json.Append(IsJsonNumber(value) ? value : Quote(UnquoteYaml(value))); + } + + json.Append('}'); + if (!first) JsonUtility.FromJsonOverwrite(json.ToString(), instance); + } + catch (Exception) + { + // Best effort: an unparseable payload simply leaves the new instance at its defaults. + } + } + + private static bool IsJsonNumber(string value) => Regex.IsMatch(value, @"^-?\d+(\.\d+)?$"); + + // Unity single-quotes YAML scalars that contain reserved characters, doubling embedded quotes. + private static string UnquoteYaml(string value) => + value.Length >= 2 && value[0] == '\'' && value[^1] == '\'' + ? value[1..^1].Replace("''", "'") + : value; + + private static string Quote(string value) => + $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + #endregion + + #region Cross references + /// + /// Returns when another managed-reference property in the same object aliases this + /// one (shares its ) — which happens after duplicating an + /// array element or pasting, leaving two fields backed by a single instance so edits to one bleed into the other. + /// + public static bool HasSharedReference(SerializedProperty property) + { + if (property.managedReferenceValue is null) return false; + + var id = property.managedReferenceId; + var shared = false; + var path = property.propertyPath; + + TraverseManagedReferences(property.serializedObject, other => + { + if (other.propertyPath != path && other.managedReferenceId == id) + { + shared = true; + return true; + } + + return false; + }); + + return shared; + } + + /// + /// Breaks an aliased managed reference by replacing it with an independent clone that carries the same data + /// (a fresh instance gets a new on assignment), so the + /// two formerly shared fields no longer affect each other. + /// + public static void MakeReferenceUnique(SerializedProperty property) + { + var persistent = property.Persistent(); + var current = persistent.managedReferenceValue; + if (current is null) return; + + persistent.SetManagedReferenceAndApply(CreateInstancePreservingData(current.GetType(), current)); + } + + // Visits every managed-reference property in the object, descending into nested values; stops early when + // the visitor returns true. + private static void TraverseManagedReferences(SerializedObject serializedObject, Func visit) + { + using var iterator = serializedObject.GetIterator(); + if (!iterator.Next(enterChildren: true)) return; + + do + { + if (iterator.propertyType == SerializedPropertyType.ManagedReference && visit(iterator)) + return; + } + while (iterator.Next(enterChildren: true)); + } + #endregion + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta new file mode 100644 index 00000000..a5d12ec0 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81c62c61701e4f6d88b678ddae8380ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs new file mode 100644 index 00000000..62b14797 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs @@ -0,0 +1,616 @@ +using System; +using System.IO; +using System.Linq; +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Identity of a managed-reference type as it is stored in Unity's serialized YAML + /// (type: {class: …, ns: …, asm: …}). Used to repair a reference whose type went missing by + /// rewriting that line directly, since Unity's serialization API cannot reassign a missing type. + /// + internal readonly struct ManagedTypeName + { + public readonly string Assembly; + public readonly string Namespace; + public readonly string Class; + + public ManagedTypeName(string assembly, string @namespace, string className) + { + Assembly = assembly ?? string.Empty; + Namespace = @namespace ?? string.Empty; + Class = className ?? string.Empty; + } + + public bool IsEmpty => + string.IsNullOrEmpty(Assembly) && string.IsNullOrEmpty(Namespace) && string.IsNullOrEmpty(Class); + + /// + /// Builds the YAML type identity for a resolved , including the + /// Name`N[[arg, asm],…] shape Unity uses for closed generics. + /// + public static ManagedTypeName FromType(Type type) + { + if (type is null) return default; + + var root = type.IsGenericType ? type.GetGenericTypeDefinition() : type; + return new ManagedTypeName(root.Assembly.GetName().Name, root.Namespace, BuildClassName(type)); + } + + private static string BuildClassName(Type type) + { + if (!type.IsGenericType) return type.Name; + + var definition = type.GetGenericTypeDefinition(); + var arguments = type.GetGenericArguments().Select(BuildGenericArgumentName); + return $"{definition.Name}[[{string.Join("],[", arguments)}]]"; + } + + private static string BuildGenericArgumentName(Type type) => + $"{BuildFullClassName(type)}, {type.Assembly.GetName().Name}"; + + private static string BuildFullClassName(Type type) + { + if (!type.IsGenericType) return type.FullName; + + var definition = type.GetGenericTypeDefinition(); + var prefix = string.IsNullOrEmpty(definition.Namespace) ? string.Empty : $"{definition.Namespace}."; + return $"{prefix}{BuildClassName(type)}"; + } + + /// Renders the inline YAML mapping Unity writes for a managed-reference type entry. + public string ToYamlType() => + $"{{class: {EscapeInline(Class)}, ns: {EscapeInline(Namespace)}, asm: {EscapeInline(Assembly)}}}"; + + // A flow-scalar containing any of , [ ] { } would break the inline mapping, so single-quote it + // (doubling embedded quotes) exactly as Unity does for generic class names like Foo`1[[…]]. + private static string EscapeInline(string value) + { + if (string.IsNullOrEmpty(value) || value.IndexOfAny(YamlReservedChars) < 0) + return value ?? string.Empty; + + return $"'{value.Replace("'", "''")}'"; + } + + private static readonly char[] YamlReservedChars = { ',', '[', ']', '{', '}' }; + } + + /// + /// Rewrites the stored type of a managed reference directly in an asset's YAML text. This is the only way + /// to re-point a [SerializeReference] whose type can no longer be loaded (renamed / moved / deleted), + /// because Unity drops missing references to through the serialization API and never + /// exposes them for reassignment. Parser-free: the document and the target RefIds entry are located by + /// line scanning, and only the inline { … } on the entry's type: line is replaced. + /// + /// + /// A single orphaned managed-reference entry found in an asset's YAML: the document it lives in + /// (), its RefIds id and the stored (unresolvable) type. Surfaced by the + /// asset-level repair tool, which finds every such entry regardless of nesting depth or child object. + /// + internal readonly struct MissingReferenceEntry + { + public readonly long FileId; + public readonly long Rid; + public readonly ManagedTypeName StoredType; + + public MissingReferenceEntry(long fileId, long rid, ManagedTypeName storedType) + { + FileId = fileId; + Rid = rid; + StoredType = storedType; + } + } + + internal static class SerializeReferenceYamlEditor + { + // "--- !u!114 &11400000" — object document header carrying the local file id as its YAML anchor. + private static readonly Regex DocumentHeader = new(@"^--- !u!\d+ &(\d+)", RegexOptions.Compiled); + + /// + /// Scans every object document in the asset and returns each RefIds entry whose stored type fails + /// the predicate. Because RefIds is a flat per-object list, this finds + /// missing references at any nesting depth and on any child object — without navigating the Inspector. + /// + public static List FindMissingReferences(string assetPath, Func resolves) + { + var result = new List(); + + try + { + if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) return result; + + var lines = File.ReadAllLines(assetPath); + + var headers = new List<(long fileId, int start)>(); + for (var i = 0; i < lines.Length; i++) + { + var match = DocumentHeader.Match(lines[i]); + if (match.Success && long.TryParse(match.Groups[1].Value, out var fileId)) + headers.Add((fileId, i)); + } + + var ridPattern = new Regex(@"^\s*-\s+rid:\s*(-?\d+)\s*$"); + var typePattern = new Regex(@"^\s*type:\s*\{(?.*)\}\s*$"); + + for (var h = 0; h < headers.Count; h++) + { + var (fileId, start) = headers[h]; + var end = h + 1 < headers.Count ? headers[h + 1].start : lines.Length; + + var refIdsStart = FindRefIdsStart(lines, start, end); + if (refIdsStart < 0) continue; + + for (var i = refIdsStart + 1; i < end; i++) + { + var ridMatch = ridPattern.Match(lines[i]); + if (!ridMatch.Success || !long.TryParse(ridMatch.Groups[1].Value, out var rid)) continue; + + for (var j = i + 1; j < end && j <= i + 4; j++) + { + var typeMatch = typePattern.Match(lines[j]); + if (!typeMatch.Success) continue; + + if (TryParseInlineType(typeMatch.Groups["body"].Value, out var type) && + !type.IsEmpty && !resolves(type)) + { + result.Add(new MissingReferenceEntry(fileId, rid, type)); + } + + break; + } + } + } + } + catch (Exception) + { + // Best effort — a parse failure simply yields no repair candidates. + } + + return result; + } + + /// + /// Replaces the type: mapping of the RefIds entry identified by within + /// the object document anchored at . Returns when the file + /// was rewritten; the caller is responsible for reimporting the asset. + /// + public static bool TryRewriteType(string assetPath, long fileId, long rid, ManagedTypeName newType) + { + try + { + if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) return false; + + var lines = File.ReadAllLines(assetPath); + var (start, end) = FindDocumentRange(lines, fileId); + if (start < 0) return false; + + // Field pointers ("_sidearms:\n - rid: 1002") share the "- rid:" shape with RefIds entries, so + // confine the search to the RefIds block — the entries are the only ones with a following type:. + var refIdsStart = FindRefIdsStart(lines, start, end); + if (refIdsStart < 0) return false; + + // Match the list item " - rid: " (the leading dash distinguishes a RefIds entry from a + // nested data "rid:" scalar). + var ridPattern = new Regex($@"^\s*-\s+rid:\s*{rid}\s*$"); + var typePattern = new Regex(@"^(?\s*type:\s*)\{.*\}\s*$"); + + for (var i = refIdsStart; i < end; i++) + { + if (!ridPattern.IsMatch(lines[i])) continue; + + // The type mapping follows the rid line; scan a few lines to tolerate formatting variance. + for (var j = i + 1; j < end && j <= i + 4; j++) + { + var match = typePattern.Match(lines[j]); + if (!match.Success) continue; + + lines[j] = match.Groups["indent"].Value + newType.ToYamlType(); + File.WriteAllLines(assetPath, lines); + return true; + } + + return false; + } + + return false; + } + catch (Exception exception) + { + Debug.LogError($"[SerializeReferenceSelector] Failed to rewrite managed-reference type in '{assetPath}': {exception}"); + return false; + } + } + + /// + /// Reads the managed-reference id (rid) stored at within the object + /// document anchored at . Needed because Unity reports an invalid id for a property + /// whose type is missing — the real id only survives in the YAML. Resolves the path at any depth, walking each + /// segment either into a managed reference's RefIds data block (a rid: pointer) or down through a + /// plain serializable container (a nested struct/class mapping or a List<T> of them), so paths + /// such as _weapon._chargeEffect, _config._weapon and _slots.Array.data[0]._weapon all + /// resolve. An unresolvable segment returns so the caller can fall back. + /// + public static bool TryReadReferenceId(string assetPath, long fileId, string propertyPath, out long rid) + { + rid = 0; + try + { + if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) return false; + + var lines = File.ReadAllLines(assetPath); + var (start, end) = FindDocumentRange(lines, fileId); + if (start < 0) return false; + + // Field pointers (and the object's inline serializable data) live before the "references:" block; the + // RefIds entries and the nested data each managed reference stores live after it. + var fieldsEnd = end; + var references = new Regex(@"^\s*references:\s*$"); + for (var i = start; i < end; i++) + if (references.IsMatch(lines[i])) { fieldsEnd = i; break; } + + var segments = ParsePathSegments(propertyPath.Replace(".Array.data", string.Empty)); + if (segments is null) return false; + + var refIdsStart = FindRefIdsStart(lines, start, end); + + // Cursor over the lines the current segment is resolved against. It starts on the object's own field + // block, then for each segment either descends into a plain serializable container (a nested mapping + // or sequence item, by indent) or jumps into a managed reference's RefIds data block (by rid). + var cursorStart = start; + var cursorEnd = fieldsEnd; + var cursorIndent = -1; // the object's top-level fields: match at any indent + + for (var s = 0; s < segments.Count; s++) + { + var kind = ResolveSegment(lines, cursorStart, cursorEnd, cursorIndent, segments[s], + out var segmentRid, out var valueStart, out var valueEnd, out var valueIndent); + + if (kind == SegmentKind.NotFound) return false; + + if (s == segments.Count - 1) + { + if (kind != SegmentKind.Reference) return false; + rid = segmentRid; + return true; + } + + if (kind == SegmentKind.Reference) + { + if (refIdsStart < 0) return false; + if (!TryGetDataBlockRange(lines, refIdsStart, end, segmentRid, out cursorStart, out cursorEnd, out cursorIndent)) + return false; + } + else + { + cursorStart = valueStart; + cursorEnd = valueEnd; + cursorIndent = valueIndent; + } + } + + return false; + } + catch (Exception) + { + return false; + } + } + + // A single managed-reference path segment: a field name with an optional sequence index ("_alternates[3]"). + private readonly struct PathSegment + { + public readonly string Name; + public readonly bool HasIndex; + public readonly int Index; + + public PathSegment(string name, bool hasIndex, int index) + { + Name = name; + HasIndex = hasIndex; + Index = index; + } + } + + // Splits a normalised property path ("_weapon._chargeEffect", "_alternates[3]") into ordered segments. + private static List ParsePathSegments(string path) + { + var result = new List(); + foreach (var raw in path.Split('.')) + { + var match = Regex.Match(raw, @"^(?[^\[\]\.]+)(\[(?\d+)\])?$"); + if (!match.Success) return null; + + var hasIndex = match.Groups["idx"].Success; + result.Add(new PathSegment(match.Groups["name"].Value, hasIndex, hasIndex ? int.Parse(match.Groups["idx"].Value) : -1)); + } + + return result.Count > 0 ? result : null; + } + + // What a resolved segment points at: a managed reference (a "rid:" pointer) or a plain serializable container + // (a nested mapping/sequence to descend into), or nothing. + private enum SegmentKind { NotFound, Reference, Container } + + // Resolves one path segment within [rangeStart, rangeEnd). For a plain field the value is either a managed + // reference (its sole child is a "rid:" scalar) or a container to descend into; for an indexed field it is the + // segment.Index-th sequence item, itself either a "- rid:" reference or a "- field:" mapping container. When + // requiredIndent is non-negative only a field at exactly that effective indent matches (a leading "- " counts + // toward the indent so a sequence-of-mappings item's fields align with their dashless siblings), which keeps + // resolution on a block's direct children instead of descending into a deeper object that reuses the name. + private static SegmentKind ResolveSegment(string[] lines, int rangeStart, int rangeEnd, int requiredIndent, + PathSegment segment, out long rid, out int valueStart, out int valueEnd, out int valueIndent) + { + rid = 0; + valueStart = valueEnd = valueIndent = -1; + + var fieldPattern = new Regex($@"^(?\s*)(?-\s+)?{Regex.Escape(segment.Name)}:\s*(?.*)$"); + + for (var i = rangeStart; i < rangeEnd; i++) + { + var field = fieldPattern.Match(lines[i]); + if (!field.Success) continue; + + var fieldIndent = field.Groups["lead"].Length + field.Groups["dash"].Length; + if (requiredIndent >= 0 && fieldIndent != requiredIndent) continue; + + return segment.HasIndex + ? ResolveSequenceItem(lines, i + 1, rangeEnd, segment.Index, out rid, out valueStart, out valueEnd, out valueIndent) + : ClassifyValue(lines, i, fieldIndent, field.Groups["inline"].Value, rangeEnd, out rid, out valueStart, out valueEnd, out valueIndent); + } + + return SegmentKind.NotFound; + } + + // Classifies the value of a plain (non-indexed) field at line i with effective indent fieldIndent: a managed + // reference when the value is a lone "rid:" scalar (inline or as the only following child), otherwise the + // indented mapping block to descend into. + private static SegmentKind ClassifyValue(string[] lines, int i, int fieldIndent, string inline, int rangeEnd, + out long rid, out int valueStart, out int valueEnd, out int valueIndent) + { + rid = 0; + valueStart = valueEnd = valueIndent = -1; + + var inlineMatch = Regex.Match(inline, @"rid:\s*(-?\d+)"); + if (inlineMatch.Success) + return long.TryParse(inlineMatch.Groups[1].Value, out rid) ? SegmentKind.Reference : SegmentKind.NotFound; + + // Gather the indented value block (lines more indented than the field). + var blockStart = i + 1; + var blockEnd = rangeEnd; + var firstChild = -1; + for (var j = blockStart; j < rangeEnd; j++) + { + if (lines[j].Trim().Length == 0) continue; + if (IndentOf(lines[j]) <= fieldIndent) { blockEnd = j; break; } + if (firstChild < 0) firstChild = j; + } + + if (firstChild < 0) return SegmentKind.NotFound; // scalar/empty field: no managed reference here + + // A managed reference's value block is exactly a "rid:" scalar; anything else is a container. + var ridScalar = Regex.Match(lines[firstChild].Trim(), @"^rid:\s*(-?\d+)$"); + if (ridScalar.Success) + return long.TryParse(ridScalar.Groups[1].Value, out rid) ? SegmentKind.Reference : SegmentKind.NotFound; + + valueStart = blockStart; + valueEnd = blockEnd; + valueIndent = IndentOf(lines[firstChild]); + return SegmentKind.Container; + } + + // Locates the index-th "- " item of a sequence whose items begin at [itemsStart, rangeEnd). A "- rid: N" item + // is a managed reference; a "- field: …" item is a mapping container whose fields begin on the dash line. + private static SegmentKind ResolveSequenceItem(string[] lines, int itemsStart, int rangeEnd, int index, + out long rid, out int valueStart, out int valueEnd, out int valueIndent) + { + rid = 0; + valueStart = valueEnd = valueIndent = -1; + + var itemPattern = new Regex(@"^(?\s*)-\s"); + var itemIndent = -1; + var count = 0; + + for (var j = itemsStart; j < rangeEnd; j++) + { + if (lines[j].Trim().Length == 0) continue; + + var item = itemPattern.Match(lines[j]); + if (!item.Success) + { + if (itemIndent >= 0 && IndentOf(lines[j]) <= itemIndent) break; // dedented out of the sequence + continue; + } + + var indent = item.Groups["lead"].Length; + if (itemIndent < 0) itemIndent = indent; + else if (indent < itemIndent) break; // dedented out of the sequence + else if (indent > itemIndent) continue; // item of a nested sequence, not ours + + if (count == index) + { + var ridMatch = Regex.Match(lines[j].TrimStart(), @"^-\s+rid:\s*(-?\d+)\s*$"); + if (ridMatch.Success) + return long.TryParse(ridMatch.Groups[1].Value, out rid) ? SegmentKind.Reference : SegmentKind.NotFound; + + // Mapping item: it runs until the next sibling "- " or a dedent; its fields start one "- " past + // the item indent. + var itemEnd = rangeEnd; + for (var k = j + 1; k < rangeEnd; k++) + { + if (lines[k].Trim().Length == 0) continue; + var ind = IndentOf(lines[k]); + if (ind < itemIndent || (ind == itemIndent && itemPattern.IsMatch(lines[k]))) { itemEnd = k; break; } + } + + valueStart = j; + valueEnd = itemEnd; + valueIndent = itemIndent + 2; + return SegmentKind.Container; + } + + count++; + } + + return SegmentKind.NotFound; + } + + // Locates the RefIds entry for rid and returns the line range of its "data:" block plus the indent of that + // block's direct children, so a nested segment can be resolved within the right scope. + private static bool TryGetDataBlockRange(string[] lines, int refIdsStart, int docEnd, long rid, out int blockStart, out int blockEnd, out int childIndent) + { + blockStart = blockEnd = childIndent = -1; + var ridPattern = new Regex($@"^(?\s*)-\s+rid:\s*{rid}\s*$"); + var dataPattern = new Regex(@"^\s*data:\s*$"); + + for (var i = refIdsStart; i < docEnd; i++) + { + var match = ridPattern.Match(lines[i]); + if (!match.Success) continue; + + // The entry runs until the next list item at its own indent, or until the block dedents out of it. + var entryIndent = match.Groups["indent"].Length; + var entryEnd = docEnd; + for (var j = i + 1; j < docEnd; j++) + { + if (lines[j].Trim().Length == 0) continue; + var indent = IndentOf(lines[j]); + if (indent < entryIndent || (indent == entryIndent && lines[j].TrimStart().StartsWith("- "))) { entryEnd = j; break; } + } + + for (var j = i + 1; j < entryEnd; j++) + { + if (!dataPattern.IsMatch(lines[j])) continue; + + blockStart = j + 1; + blockEnd = entryEnd; + for (var k = blockStart; k < blockEnd; k++) + if (lines[k].Trim().Length > 0) { childIndent = IndentOf(lines[k]); break; } + + return blockStart < blockEnd && childIndent >= 0; + } + + return false; + } + + return false; + } + + private static int IndentOf(string line) + { + var count = 0; + while (count < line.Length && line[count] == ' ') count++; + return count; + } + + /// + /// Reads the managed-reference id stored at and the type recorded for it in + /// the RefIds block, in a single pass over the asset YAML. This is how a missing reference is found + /// even when Unity has dropped it from the live object (notably on prefabs / GameObjects): the orphaned + /// id, type identity and payload all survive in the file. + /// + public static bool TryReadStoredType(string assetPath, long fileId, string propertyPath, out long rid, out ManagedTypeName type) + { + rid = 0; + type = default; + + if (!TryReadReferenceId(assetPath, fileId, propertyPath, out rid)) return false; + + try + { + var lines = File.ReadAllLines(assetPath); + var (start, end) = FindDocumentRange(lines, fileId); + if (start < 0) return false; + + var refIdsStart = FindRefIdsStart(lines, start, end); + if (refIdsStart < 0) return false; + + var ridPattern = new Regex($@"^\s*-\s+rid:\s*{rid}\s*$"); + var typePattern = new Regex(@"^\s*type:\s*\{(?.*)\}\s*$"); + + for (var i = refIdsStart; i < end; i++) + { + if (!ridPattern.IsMatch(lines[i])) continue; + + for (var j = i + 1; j < end && j <= i + 4; j++) + { + var match = typePattern.Match(lines[j]); + if (!match.Success) continue; + + return TryParseInlineType(match.Groups["body"].Value, out type); + } + + return false; + } + + return false; + } + catch (Exception) + { + return false; + } + } + + // Parses the inline "class: X, ns: Y, asm: Z" body of a RefIds type mapping, honouring single-quoted values + // (Unity quotes generic class names such as 'Modifier`1[[…]]'). + private static bool TryParseInlineType(string body, out ManagedTypeName type) + { + type = default; + + var match = Regex.Match(body, + @"class:\s*(?:'(?(?:[^']|'')*)'|(?[^,}]*?))\s*,\s*ns:\s*(?[^,}]*?)\s*,\s*asm:\s*(?[^,}]*?)\s*$"); + if (!match.Success) return false; + + var className = match.Groups["class"].Value.Replace("''", "'"); + type = new ManagedTypeName(match.Groups["asm"].Value, match.Groups["ns"].Value, className); + return !type.IsEmpty; + } + + // Returns the [start, end) line range of the document whose anchor equals fileId. Falls back to the single + // document of a one-object asset (the common ScriptableObject case) when the anchor cannot be matched. + private static (int start, int end) FindDocumentRange(string[] lines, long fileId) + { + var start = -1; + var end = lines.Length; + var headerCount = 0; + var firstHeader = -1; + + for (var i = 0; i < lines.Length; i++) + { + var match = DocumentHeader.Match(lines[i]); + if (!match.Success) continue; + + headerCount++; + if (firstHeader < 0) firstHeader = i; + + if (start >= 0) + { + end = i; + break; + } + + if (long.TryParse(match.Groups[1].Value, out var anchor) && anchor == fileId) + start = i; + } + + if (start >= 0) return (start, end); + if (headerCount == 1) return (firstHeader, lines.Length); + return (-1, -1); + } + + // Index of the "RefIds:" key line within [start, end), or -1 when the document has no managed references. + private static int FindRefIdsStart(string[] lines, int start, int end) + { + var refIds = new Regex(@"^\s*RefIds:\s*$"); + for (var i = start; i < end; i++) + if (refIds.IsMatch(lines[i])) + return i; + + return -1; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs.meta new file mode 100644 index 00000000..d9fa1447 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceYamlEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 365226302fbb14e7b9e4fda086d9f0a5 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements.meta new file mode 100644 index 00000000..9a07bca8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 70b1c58023de4500a33542b7e00b8346 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs new file mode 100644 index 00000000..47a055de --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs @@ -0,0 +1,343 @@ +using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using UnityEditor.UIElements; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using Aspid.FastTools.UIElements; +using Aspid.FastTools.Types.Editors; +using Aspid.FastTools.UIElements.Editors.Internal; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// UIToolkit field for a [SerializeReference] property: a foldout whose header carries an + /// EnumField-style type dropdown (backed by ) and an open-script button, + /// whose content hosts the assigned instance's nested properties, and which surfaces a missing-type + /// warning when the stored type can no longer be resolved. + /// + /// + /// Always bound to a managed-reference ; created by the + /// , not from UXML. The field keeps the live + /// inspector property so child fields round-trip through Unity's binding (apply/Undo) and only rebuilds + /// the nested properties when the assigned type actually changes. + /// + internal sealed class SerializeReferenceField : VisualElement + { + private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference"; + + private const string BlockClass = "aspid-fasttools-serialize-reference"; + private const string EmptyClass = BlockClass + "--empty"; + private const string DropdownClass = BlockClass + "__dropdown"; + + // Unity's BaseField input class — applied to the dropdown's inner input so it picks up the + // same flex/indent the EnumField theme rules target on a real field's visualInput. + private const string BaseFieldInputClass = "unity-base-field__input"; + + // Small gap kept between the value column and the dropdown's left edge. + private const float DropdownGap = 2f; + + private readonly Foldout _foldout; + private readonly TextElement _caption; + private readonly VisualElement _dropdown; + private readonly Button _openButton; + private readonly VisualElement _content; + private readonly SerializedProperty _property; + private readonly Type[] _types; + + private SerializeReferenceNotice _missingNotice; + private SerializeReferenceNotice _sharedNotice; + private Type _currentType; + private bool _contentBuilt; + private float _arrowInset = float.NaN; + + public SerializeReferenceField(string label, SerializedProperty property) + { + _property = property; + _types = new[] { SerializeReferenceHelpers.GetFieldType(_property) }; + + this.AddClass(BlockClass) + .AddClass(PropertyField.ussClassName) + .AddStyleSheetsFromResource(StyleSheetPath) + .AddStyleSheetsFromResource(AspidStyles.DefaultStyleSheet); + + _foldout = new Foldout(); + _foldout.RegisterValueChangedCallback(OnFoldoutToggled); + _content = _foldout.contentContainer; + + _caption = new TextElement() + .AddClass(EnumField.textUssClassName) + .SetPickingMode(PickingMode.Ignore); + + // Mirror SerializableType's TypeField structure: an enum-field "root" wrapping a separate + // "__input" child. Unity's theme indents the caption through descendant selectors + // (".unity-enum-field .unity-enum-field__input"), which only match when the input is a + // child of the field — collapsing both classes onto one element drops that indent. + var dropdownInput = new VisualElement() + .AddClass(BaseFieldInputClass) + .AddClass(EnumField.inputUssClassName) + .AddChild(_caption) + .AddChild(new VisualElement() + .AddClass(EnumField.arrowUssClassName) + .SetPickingMode(PickingMode.Ignore)); + + _dropdown = new VisualElement() + .AddClass(EnumField.ussClassName) + .AddClass(DropdownClass) + .AddChild(dropdownInput); + + _dropdown.RegisterCallback(OnDropdownClicked); + + _openButton = new Button() + .AddChild(new VisualElement()) + .AddClicked(() => SerializeReferenceHelpers.GetCurrentType(_property)?.OpenInScriptEditor()); + + // Carry the foldout caption on the toggle's BaseField label and opt into Unity's + // inspector field alignment so the label width tracks the value column exactly as + // SerializableType does (see InspectorTypeField). The expand arrow stays on the far + // left; the dropdown is then offset by the arrow width so it begins at the value column. + var toggle = _foldout.Q(); + toggle.AddClass(BaseField.alignedFieldUssClassName); + toggle.labelElement.AddClass(PropertyField.labelUssClassName); + toggle.label = label; + + var arrow = toggle.Q(className: Foldout.inputUssClassName); + toggle.Insert(0, arrow); + arrow.RegisterCallback(OnArrowGeometryChanged); + + toggle.AddChild(_dropdown) + .AddChild(_openButton); + + // Copy/Paste lives on the header only — child PropertyFields keep their own contextual menus. + toggle.AddManipulator(new ContextualMenuManipulator(BuildContextMenu)); + + this.AddChild(_foldout); + + Refresh(forceRebuild: true); + this.TrackPropertyValue(_property, _ => Refresh(forceRebuild: false)); + } + + // The arrow sits in-flow before the aligned label, so the label (and the dropdown that + // follows it) overshoot the value column by the arrow's width. Pull the dropdown back by + // that measured width so its left edge lands on the value column at any nesting depth. + private void OnArrowGeometryChanged(GeometryChangedEvent evt) + { + var inset = ((VisualElement)evt.target).resolvedStyle.width; + if (Mathf.Approximately(inset, _arrowInset)) return; + + _arrowInset = inset; + _dropdown.style.marginLeft = DropdownGap - inset; + } + + private void Refresh(bool forceRebuild) + { + // A saved-asset repair reimports the asset and invalidates this property's SerializedObject; the inspector + // is rebuilt from a fresh selection instead, so a stale property here must no-op rather than throw. + if (!IsPropertyAlive()) return; + + var currentType = SerializeReferenceHelpers.GetCurrentType(_property); + var hasValue = currentType is not null; + + _caption.SetText(GetCaption(currentType)); + _openButton.SetDisplay(hasValue ? DisplayStyle.Flex : DisplayStyle.None); + + EnableInClassList(EmptyClass, !hasValue); + _foldout.SetValueWithoutNotify(hasValue && _property.isExpanded); + + UpdateMissingBox(); + UpdateSharedBox(); + + if (forceRebuild || !_contentBuilt || currentType != _currentType) + { + _currentType = currentType; + RebuildContent(hasValue); + } + } + + // The property's SerializedObject can be torn down out from under this field (e.g. a saved-asset repair + // reimports the asset); probing the target object reports that without throwing on the dangling handle. + private bool IsPropertyAlive() + { + try { return _property.serializedObject?.targetObject != null; } + catch (Exception) { return false; } + } + + private void RebuildContent(bool hasValue) + { + _content.Clear(); + _contentBuilt = true; + if (!hasValue) return; + + var iterator = _property.Copy(); + var end = _property.GetEndProperty(); + var enterChildren = true; + + while (iterator.NextVisible(enterChildren) && !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + + var child = iterator.Copy(); + var field = new PropertyField(child); + field.BindProperty(child); + + _content.Add(field); + } + } + + private void UpdateMissingBox() + { + if (!SerializeReferenceHelpers.IsMissingType(_property)) + { + _missingNotice?.RemoveFromHierarchy(); + return; + } + + _missingNotice ??= new SerializeReferenceNotice(); + if (_missingNotice.parent is null) this.AddChild(_missingNotice); + + var typeName = SerializeReferenceHelpers.GetMissingTypeDisplayName(_property); + + // Offered for saved assets (YAML rewrite) and Prefab Mode objects (in-memory) — anything with a + // resolvable backing document. Scene objects without one fall through to the read-only hint. + var canFix = SerializeReferenceHelpers.TryGetRepairLocation(_property, out _, out _, out _); + + _missingNotice.Set( + message: "Missing type —", + actionText: canFix ? "Fix" : string.Empty, + detail: canFix + ? $"Missing type: {typeName}.\nClick Fix to re-point this reference to an existing type, keeping its data." + : $"Missing type: {typeName}.\nOpen this asset from the Project window to repair it.", + onAction: OpenFixSelector); + } + + private void UpdateSharedBox() + { + if (!SerializeReferenceHelpers.HasSharedReference(_property)) + { + _sharedNotice?.RemoveFromHierarchy(); + return; + } + + _sharedNotice ??= new SerializeReferenceNotice(); + if (_sharedNotice.parent is null) this.AddChild(_sharedNotice); + + _sharedNotice.Set( + message: "Shared reference —", + actionText: "Make unique", + detail: "This reference is shared with another field — editing one changes both.\n" + + "Click Make unique to give this field its own independent copy.", + onAction: MakeUnique); + } + + private void OpenFixSelector() + { + var window = EditorWindow.focusedWindow != null + ? EditorWindow.focusedWindow + : EditorWindow.mouseOverWindow; + if (!window) return; + + var bound = _missingNotice.worldBound; + var screenRect = new Rect( + window.position.x + bound.xMin, + window.position.y + bound.yMax, + bound.width, + bound.height); + + SerializeReferenceHelpers.ShowFixTypeSelector(_property, screenRect, () => Refresh(forceRebuild: true)); + } + + private void OnFoldoutToggled(ChangeEvent evt) + { + if (evt.target != _foldout) return; + _property.isExpanded = evt.newValue; + } + + private void OnDropdownClicked(PointerDownEvent evt) + { + if (evt.button is not 0) return; + + var window = EditorWindow.focusedWindow != null + ? EditorWindow.focusedWindow + : EditorWindow.mouseOverWindow; + + if (!window) return; + + var currentType = SerializeReferenceHelpers.GetCurrentType(_property); + var fieldType = _types.Length > 0 ? _types[0] : typeof(object); + var screenRect = GetScreenRect(); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: _types, + currentAqn: currentType?.AssemblyQualifiedName ?? string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => Apply(string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false)), + filter: SerializeReferenceHelpers.IsAssignableManagedReference, + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(fieldType), + argumentFilter: SerializeReferenceHelpers.IsValidGenericArgument); + + evt.StopPropagation(); + return; + + void Apply(Type type) + { + var previous = _property.managedReferenceValue; + _property.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, previous)); + _property.isExpanded = type is not null; + Refresh(forceRebuild: true); + } + + Rect GetScreenRect() => new( + window.position.x + _dropdown.worldBound.xMin, + window.position.y + _dropdown.worldBound.yMin, + _dropdown.worldBound.width, + _dropdown.worldBound.height); + } + + private void BuildContextMenu(ContextualMenuPopulateEvent evt) + { + evt.menu.AppendAction("Copy Serialize Reference", + _ => SerializeReferenceClipboard.Copy(_property.managedReferenceValue)); + + var fieldType = _types.Length > 0 ? _types[0] : typeof(object); + var canPaste = SerializeReferenceClipboard.CanPasteInto(fieldType); + + evt.menu.AppendAction("Paste Serialize Reference", + _ => PasteFromClipboard(), + canPaste ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled); + + if (SerializeReferenceHelpers.HasSharedReference(_property)) + evt.menu.AppendAction("Make Unique Reference", _ => MakeUnique()); + } + + private void MakeUnique() + { + SerializeReferenceHelpers.MakeReferenceUnique(_property); + Refresh(forceRebuild: true); + } + + private void PasteFromClipboard() + { + var value = SerializeReferenceClipboard.CreateInstance(); + _property.SetManagedReferenceAndApply(value); + _property.isExpanded = value is not null; + Refresh(forceRebuild: true); + } + + private string GetCaption(Type currentType) + { + if (currentType is not null) + return TypeSelectorHelpers.GetTypeSelectorTitle(currentType); + + var missingName = SerializeReferenceHelpers.IsMissingType(_property) + ? SerializeReferenceHelpers.GetMissingTypeDisplayName(_property) + : null; + + return TypeSelectorHelpers.GetTypeSelectorTitle(null, missingName); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs.meta new file mode 100644 index 00000000..5d74f4b5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceField.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4fb75ace46bd4133b8821cabec35b4c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs new file mode 100644 index 00000000..1996bfd3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs @@ -0,0 +1,63 @@ +using System; +using UnityEngine.UIElements; +using Aspid.FastTools.UIElements; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// A compact, single-row warning notice for the [SerializeReferenceSelector] drawer: a small + /// warning icon, a short yellow message and an underlined, clickable action word (e.g. Fix). + /// The full explanation is surfaced on hover through the element's , + /// so the inspector row stays terse while the detail is one hover away. Replaces the bulky + /// AspidHelpBox-plus-button pair previously used for missing-type and shared-reference states. + /// + internal sealed class SerializeReferenceNotice : VisualElement + { + private const string NoticeClass = "aspid-fasttools-serialize-reference-notice"; + private const string IconClass = NoticeClass + "__icon"; + private const string MessageClass = NoticeClass + "__message"; + private const string ActionClass = NoticeClass + "__action"; + + private readonly Label _message; + private readonly Label _action; + + private Action _onAction; + + public SerializeReferenceNotice() + { + this.AddClass(NoticeClass); + + var icon = new VisualElement() + .AddClass(IconClass) + .SetPickingMode(PickingMode.Ignore); + + _message = new Label() + .AddClass(MessageClass) + .SetPickingMode(PickingMode.Ignore); + + _action = new Label().AddClass(ActionClass); + _action.RegisterCallback(_ => _onAction?.Invoke()); + + this.AddChild(icon) + .AddChild(_message) + .AddChild(_action); + } + + /// + /// Updates the notice content. The word is the only clickable part; + /// pass an empty string to hide it (e.g. when the action is unavailable for unsaved targets). + /// + public void Set(string message, string actionText, string detail, Action onAction) + { + _message.text = message; + _onAction = onAction; + + var hasAction = !string.IsNullOrEmpty(actionText) && onAction is not null; + _action.text = actionText; + _action.SetDisplay(hasAction ? DisplayStyle.Flex : DisplayStyle.None); + + tooltip = detail; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs.meta new file mode 100644 index 00000000..37787f58 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNotice.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 466a5877b16ed4ad18778af682816b0d \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows.meta new file mode 100644 index 00000000..5d1bf13b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2526a30ff6baa4e86b11df056815cb6e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs new file mode 100644 index 00000000..916081a9 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs @@ -0,0 +1,359 @@ +using System; +using UnityEngine; +using UnityEditor; +using UnityEngine.UIElements; +using UnityEditor.UIElements; +using Aspid.FastTools.Types; +using Aspid.FastTools.UIElements; +using System.Collections.Generic; +using Aspid.FastTools.Types.Editors; +using Aspid.FastTools.UIElements.Editors.Internal; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Asset-level repair tool for missing [SerializeReference] types. Unlike the per-field Fix button, + /// this scans the whole asset file and lists every orphaned managed reference — at any nesting depth and on any + /// child object — so references the Inspector cannot navigate to (nested values, child-object components, + /// anything Unity has dropped to <None>) can still be re-pointed. Each entry is fixed by rewriting + /// its stored type directly in the YAML, so it never needs Prefab Mode. + /// + internal sealed class SerializeReferenceRepairWindow : EditorWindow + { + private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference"; + + private const string RootClass = "aspid-fasttools-repair-references"; + private const string BackgroundClass = RootClass + "__background"; + private const string ContentClass = RootClass + "__content"; + private const string CardClass = RootClass + "__card"; + private const string CardHeaderClass = RootClass + "__card-header"; + private const string FieldRowClass = RootClass + "__field-row"; + private const string AssetClass = RootClass + "__asset"; + private const string RescanClass = RootClass + "__rescan"; + private const string EmptyClass = RootClass + "__empty"; + private const string EmptyHiddenClass = EmptyClass + "--hidden"; + private const string EmptyIconClass = RootClass + "__empty-icon"; + private const string EmptyIconInfoClass = EmptyIconClass + "--info"; + private const string EmptyIconSuccessClass = EmptyIconClass + "--success"; + private const string EmptyTitleClass = RootClass + "__empty-title"; + private const string EmptyMessageClass = RootClass + "__empty-message"; + private const string ResultsClass = RootClass + "__results"; + private const string ResultsHiddenClass = ResultsClass + "--hidden"; + private const string ResultsHeaderClass = RootClass + "__results-header"; + private const string ResultsHintClass = RootClass + "__results-hint"; + private const string ScrollClass = RootClass + "__scroll"; + private const string EntryClass = RootClass + "__entry"; + private const string EntryRidClass = RootClass + "__entry-rid"; + private const string PickerClass = RootClass + "__picker"; + + private const string FixCollapsedText = "Fix ▼"; + private const string FixExpandedText = "Fix ▲"; + + private Object _target; + private ObjectField _assetField; + private VisualElement _empty; + private VisualElement _results; + private AspidLabel _resultsHeader; + private VisualElement _list; + private VisualElement _openPicker; + private AspidGradientButton _openPickerRow; + + [MenuItem("Tools/Aspid 🐍/Repair Missing References FastTools", priority = 20)] + private static void Open() => Open(Selection.activeObject as Object); + + public static void Open(Object target) + { + var window = GetWindow(); + window.titleContent = new GUIContent("Repair References"); + window.minSize = new Vector2(460f, 320f); + window.SetTarget(target); + window.Show(); + } + + private void CreateGUI() + { + var root = rootVisualElement; + root.AddStyleSheetsFromResource(AspidStyles.DefaultStyleSheet) + .AddStyleSheetsFromResource(StyleSheetPath) + .AddClass(RootClass); + + // The animated dotted canvas absolutely fills the window (its own stylesheet positions it); the content + // flows above it, mirroring the Welcome window so the dark Aspid components read against black instead of + // the muddy default inspector grey. + var background = new AspidAnimatedDotsBackground() + .AddClass(BackgroundClass) + .SetPickingMode(PickingMode.Ignore); + + // The asset picker sits in a Welcome-style card: an Aspid header with the signature green divider above + // the full-width field, Rescan trailing it on the same row. + var assetHeader = new AspidLabel("Asset", AspidLabelPreset.Default + .SetLabelTheme(ThemeStyle.Type.Lightness) + .SetLabelSize(AspidLabelSizeStyle.Type.H3) + .SetLineTheme(ThemeStyle.Type.Dark) + .SetLineStatus(StatusStyle.Type.Success)) + .AddClass(CardHeaderClass); + + _assetField = new ObjectField + { + objectType = typeof(Object), + allowSceneObjects = false, + value = _target, + }; + _assetField.AddClass(AssetClass); + _assetField.RegisterValueChangedCallback(evt => SetTarget(evt.newValue)); + + var rescan = new AspidGradientButton("Rescan", _ => Rescan()) + .AddClass(RescanClass); + + var fieldRow = new VisualElement() + .AddClass(FieldRowClass) + .AddChild(_assetField) + .AddChild(rescan); + + var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(CardClass) + .AddChild(assetHeader) + .AddChild(fieldRow); + + // The two terminal states (no asset / nothing to repair) share one hero centred in the space below the + // card; scan results swap it for a warning-accented header, a short hint and the row list. + _empty = new VisualElement().AddClass(EmptyClass); + + _resultsHeader = new AspidLabel(string.Empty, AspidLabelPreset.Default + .SetLabelStatus(StatusStyle.Type.Warning) + .SetLabelSize(AspidLabelSizeStyle.Type.H4) + .SetLineTheme(ThemeStyle.Type.Dark) + .SetLineStatus(StatusStyle.Type.Warning)) + .AddClass(ResultsHeaderClass); + + var resultsHint = new Label("Pick a replacement type for each entry — Fix rewrites the stored type directly in the asset file.") + .AddClass(ResultsHintClass); + + _list = new VisualElement(); + + var scroll = new ScrollView().AddClass(ScrollClass); + scroll.AddChild(_list); + + _results = new VisualElement() + .AddClass(ResultsClass) + .AddChild(_resultsHeader) + .AddChild(resultsHint) + .AddChild(scroll); + + var content = new VisualElement() + .AddClass(ContentClass) + .AddChild(card) + .AddChild(_empty) + .AddChild(_results); + + root.AddChild(background) + .AddChild(content); + + Rescan(); + } + + private void SetTarget(Object target) + { + _target = target; + // Open() retargets an already-open window, so the field must follow the new target — + // without notifying, or the change callback would trigger a second scan. + _assetField?.SetValueWithoutNotify(target); + if (_list is not null) Rescan(); + } + + private void Rescan() + { + if (_list is null) return; + + ClosePicker(); + _list.Clear(); + + var assetPath = _target ? AssetDatabase.GetAssetPath(_target) : null; + if (string.IsNullOrEmpty(assetPath)) + { + ShowEmptyState( + success: false, + title: "No asset selected", + message: "Select a saved asset (a prefab or ScriptableObject) to scan for missing references."); + return; + } + + var missing = SerializeReferenceYamlEditor.FindMissingReferences(assetPath, SerializeReferenceHelpers.StoredTypeResolves); + if (missing.Count == 0) + { + ShowEmptyState( + success: true, + title: "All references intact", + message: "No missing managed references in this asset."); + return; + } + + ShowResults(missing.Count); + + // The declared field type backing each missing reference constrains the replacement list, so the picker + // only offers types actually assignable to the field — re-pointing to an incompatible type would drop the + // reference to null on the next import. + var constraints = BuildConstraintMap(assetPath); + + foreach (var entry in missing) + { + constraints.TryGetValue((entry.FileId, entry.Rid), out var constraint); + _list.AddChild(BuildRow(assetPath, entry, constraint)); + } + } + + // Both terminal states reuse one hero: the package icon in the status colour, a headline and a dimmed + // explanation. Rebuilt per scan — the icon, accent and copy all differ between the two states. + private void ShowEmptyState(bool success, string title, string message) + { + _results.AddClass(ResultsHiddenClass); + _empty.RemoveClass(EmptyHiddenClass); + _empty.Clear(); + + var icon = new VisualElement() + .AddClass(EmptyIconClass) + .AddClass(success ? EmptyIconSuccessClass : EmptyIconInfoClass); + + var titlePreset = AspidLabelPreset.Default + .SetLabelTheme(success ? ThemeStyle.Type.Light : ThemeStyle.Type.Lightness) + .SetLabelSize(AspidLabelSizeStyle.Type.H3) + .SetLineSize(AspidDividingLineSizeStyle.Type.None); + + if (success) titlePreset = titlePreset.SetLabelStatus(StatusStyle.Type.Success); + + _empty.AddChild(icon) + .AddChild(new AspidLabel(title, titlePreset).AddClass(EmptyTitleClass)) + .AddChild(new Label(message).AddClass(EmptyMessageClass)); + } + + private void ShowResults(int count) + { + _empty.AddClass(EmptyHiddenClass); + _results.RemoveClass(ResultsHiddenClass); + + _resultsHeader.Text = count == 1 + ? "1 missing reference" + : $"{count} missing references"; + } + + // Each missing reference is a full-width gradient row (label = stored type, dimmed rid, trailing "Fix" cue), + // the whole row acting as the Fix button — the same affordance the Welcome window uses for its sample list. + private VisualElement BuildRow(string assetPath, MissingReferenceEntry entry, Type constraint) + { + var typeName = string.IsNullOrEmpty(entry.StoredType.Namespace) + ? entry.StoredType.Class + : $"{entry.StoredType.Namespace}.{entry.StoredType.Class}"; + + AspidGradientButton row = null; + row = new AspidGradientButton(entry.StoredType.Class, FixCollapsedText, _ => TogglePicker(assetPath, entry, constraint, row)); + row.AddClass(EntryClass); + row.tooltip = typeName; + + var rid = new Label($"rid {entry.Rid}") + .AddClass(EntryRidClass) + .SetPickingMode(PickingMode.Ignore); + + // Slot the rid between the label (index 1) and the trailing "Fix" cue (index 2) so the flex-grown label + // pushes it to the right edge alongside the action. + row.InsertChild(2, rid); + + return row; + } + + // The picker expands inline as an accordion panel right below the clicked row — the same selector view the + // dropdown window hosts, boxed in this window's dark style instead of a floating grey popup. One panel at a + // time; the row's trailing cue flips to ▲ while its panel is open and clicking the row again collapses it. + private void TogglePicker(string assetPath, MissingReferenceEntry entry, Type constraint, AspidGradientButton row) + { + var wasOpen = _openPickerRow == row; + ClosePicker(); + if (wasOpen) return; + + var baseType = constraint ?? typeof(object); + + var view = new TypeSelectorView( + types: new[] { baseType }, + currentAqn: string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => ApplyFix(assetPath, entry, assemblyQualifiedName), + filter: SerializeReferenceHelpers.IsAssignableManagedReference, + additionalTypes: constraint is null ? null : GenericTypeResolver.GetAssignableGenericDefinitions(constraint), + argumentFilter: SerializeReferenceHelpers.IsValidGenericArgument, + onDismiss: ClosePicker); + + _openPicker = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness)) + .AddClass(PickerClass) + .AddChild(view); + + _openPickerRow = row; + row.TrailingText = FixExpandedText; + + _list.InsertChild(_list.IndexOf(row) + 1, _openPicker); + view.FocusSearch(); + } + + private void ClosePicker() + { + _openPicker?.RemoveFromHierarchy(); + if (_openPickerRow is not null) _openPickerRow.TrailingText = FixCollapsedText; + + _openPicker = null; + _openPickerRow = null; + } + + private void ApplyFix(string assetPath, MissingReferenceEntry entry, string assemblyQualifiedName) + { + var type = string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false); + + if (type is null) return; + if (!SerializeReferenceYamlEditor.TryRewriteType(assetPath, entry.FileId, entry.Rid, ManagedTypeName.FromType(type))) return; + + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + Rescan(); + } + + // Maps every managed reference in the asset to the declared field type that holds it, keyed by the object + // document and the reference's RefIds id. A missing reference reads back null through the serialization API + // but its field still reports the declared element type via managedReferenceFieldTypename, and the orphaned + // rid survives in the YAML — so the two together recover the constraint for the picker. References nested + // inside a missing parent are unreachable here (the parent is null) and simply fall back to an unconstrained + // picker, as do orphaned rids no field points at. + private static Dictionary<(long fileId, long rid), Type> BuildConstraintMap(string assetPath) + { + var map = new Dictionary<(long, long), Type>(); + + foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (obj == null) continue; + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var fileId)) continue; + + using var serialized = new SerializedObject(obj); + var iterator = serialized.GetIterator(); + if (!iterator.Next(enterChildren: true)) continue; + + do + { + if (iterator.propertyType != SerializedPropertyType.ManagedReference) continue; + + var fieldType = SerializeReferenceHelpers.GetFieldType(iterator); + if (fieldType is null || fieldType == typeof(object)) continue; + + long rid; + if (iterator.managedReferenceValue is not null) + rid = iterator.managedReferenceId; + else if (!SerializeReferenceYamlEditor.TryReadReferenceId(assetPath, fileId, iterator.propertyPath, out rid)) + continue; + + map[(fileId, rid)] = fieldType; + } + while (iterator.Next(enterChildren: true)); + } + + return map; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs.meta new file mode 100644 index 00000000..cceb1f07 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceRepairWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9764830919cc642f08c39607a7078334 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeExtensions.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeExtensions.cs index ee98c977..be06018b 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeExtensions.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeExtensions.cs @@ -28,9 +28,14 @@ public static class TypeExtensions /// public static MonoScript FindMonoScript(this Type type) { - var isEnum = type.IsEnum; - var typeName = type.Name; - var typeNamespace = type.Namespace; + if (type is null) return null; + + // A closed generic (e.g. Modifier>) has no script of its own — the source file + // declares the open definition, so look that up and match by its arity-stripped name. + var lookupType = type.IsGenericType ? type.GetGenericTypeDefinition() : type; + var isEnum = lookupType.IsEnum; + var typeName = StripArity(lookupType.Name); + var typeNamespace = lookupType.Namespace; var scripts = AssetDatabase.FindAssets(filter: $"t:MonoScript {typeName}") .Select(AssetDatabase.GUIDToAssetPath) @@ -42,7 +47,7 @@ public static MonoScript FindMonoScript(this Type type) foreach (var script in scripts) { - if (script.GetClass() != type) continue; + if (script.GetClass() != lookupType) continue; return script; } @@ -92,9 +97,16 @@ public static (MonoScript script, int line) FindMonoScriptWithLine(this Type typ var script = type.FindMonoScript(); if (script is null) return (script: null, line: 0); - var line = FindTypeLineNumber(script.text, type.Name, type.IsEnum); + var lookupType = type.IsGenericType ? type.GetGenericTypeDefinition() : type; + var line = FindTypeLineNumber(script.text, StripArity(lookupType.Name), lookupType.IsEnum); return (script, line); } + + private static string StripArity(string name) + { + var tick = name.IndexOf('`'); + return tick >= 0 ? name[..tick] : name; + } private static int FindTypeLineNumber(string text, string typeName, bool isEnum) { diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeSelectorHelpers.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeSelectorHelpers.cs index af0efccc..35511dac 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeSelectorHelpers.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Extensions/TypeSelectorHelpers.cs @@ -33,11 +33,33 @@ internal static class TypeSelectorHelpers /// public static string GetTypeSelectorTitle(Type value, string assemblyQualifiedName = null) { - if (value is not null) return value.Name; + if (value is not null) return FormatName(value); return string.IsNullOrWhiteSpace(assemblyQualifiedName) ? NoneOption : $""; } + + /// + /// Short type name with angle-bracket generic arguments (Modifier<Single>) instead of + /// the raw arity form (Modifier`1). Generic arguments are formatted recursively, so a nested + /// closed generic renders fully (Modifier<Modifier<Int32>>). Non-generic types + /// are returned unchanged. + /// + private static string FormatName(Type value) + { + if (!value.IsGenericType) return value.Name; + + var name = value.Name; + var tick = name.IndexOf('`'); + var baseName = tick >= 0 ? name[..tick] : name; + + var arguments = value.GetGenericArguments(); + var argumentNames = new string[arguments.Length]; + for (var i = 0; i < arguments.Length; i++) + argumentNames[i] = FormatName(arguments[i]); + + return $"{baseName}<{string.Join(", ", argumentNames)}>"; + } } } diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs new file mode 100644 index 00000000..43cefadf --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs @@ -0,0 +1,234 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Types.Editors +{ + /// + /// General-purpose helpers for resolving an open generic type definition into a concrete closed type + /// inside the argument-selection flow: enumerating the open generic + /// definitions assignable to a field, inferring arguments from a closed-generic field, computing a + /// parameter's constraint base types and special-constraint filter, and constructing/validating the + /// closed type. Carries no dependency on any particular feature (e.g. [SerializeReference]); + /// the Unity-serializability of an argument is supplied by the caller as a separate filter. + /// + internal static class GenericTypeResolver + { + /// + /// Predicate identifying open generic type definitions that can be offered for a field once closed + /// over concrete arguments: non-abstract generic classes that are neither + /// nor delegates, and that are not compiler-generated + /// (anonymous types, closure/iterator display classes such as <>c__11<T> or + /// <>f__AnonymousType0<…>) — these are added verbatim via the selector's + /// additionalTypes path, which bypasses the name/ + /// checks applied to ordinary candidates, so they must be excluded here. + /// + public static bool IsAssignableGenericDefinition(Type type) => + type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: true } && + !typeof(UnityEngine.Object).IsAssignableFrom(type) && + !typeof(Delegate).IsAssignableFrom(type) && + !IsCompilerGenerated(type); + + /// + /// Returns for compiler-emitted types that should never surface in the + /// selector: those marked or whose name carries the + /// angle-bracket marker of an anonymous type / closure display class. + /// + private static bool IsCompilerGenerated(Type type) => + type.IsDefined(typeof(CompilerGeneratedAttribute), false) || + type.Name.Contains('<') || + type.Name.Contains('>'); + + /// + /// Enumerates the open generic type definitions whose closed form could be assigned to + /// — i.e. generic classes that implement/inherit the field's type + /// (matched by generic definition for a generic field, or directly for a non-generic field). + /// + public static IEnumerable GetAssignableGenericDefinitions(Type fieldType) + { + if (fieldType is null) yield break; + + foreach (var type in EnumerateDomainTypes()) + { + if (!IsAssignableGenericDefinition(type)) continue; + if (CanCloseToFieldType(type, fieldType)) yield return type; + } + } + + /// + /// Attempts to close using the type arguments of a closed-generic + /// (e.g. a Modifier<float> field directly determines the + /// argument of a Modifier<> candidate). Returns when the field is + /// not a closed generic or the inferred type would not be assignable. + /// + public static bool TryInferFromFieldType(Type fieldType, Type openDefinition, out Type closed) + { + closed = null; + + if (fieldType is null || !fieldType.IsGenericType || fieldType.ContainsGenericParameters) return false; + + var fieldArguments = fieldType.GetGenericArguments(); + if (fieldArguments.Length != openDefinition.GetGenericArguments().Length) return false; + + var fieldDefinition = fieldType.GetGenericTypeDefinition(); + var matchesDefinition = GenericBaseDefinitions(openDefinition) + .Any(definition => definition == fieldDefinition); + + if (!matchesDefinition) return false; + + try + { + closed = openDefinition.MakeGenericType(fieldArguments); + } + catch (Exception) + { + closed = null; + return false; + } + + return fieldType.IsAssignableFrom(closed); + } + + /// + /// Returns the explicit base-type/interface constraints of (excluding + /// other type parameters), or { typeof(object) } when it has none. Used as the base-type filter + /// for the argument's candidate list. + /// + public static Type[] GetConstraintBaseTypes(Type parameter) + { + var constraints = parameter.GetGenericParameterConstraints() + .Where(constraint => !constraint.IsGenericParameter && !constraint.ContainsGenericParameters) + .ToArray(); + + return constraints.Length > 0 ? constraints : new[] { typeof(object) }; + } + + /// + /// Returns when satisfies the special + /// (struct/class/new()) constraints declared on . + /// + public static bool SatisfiesSpecialConstraints(Type parameter, Type candidate) + { + if (candidate is null) return false; + + var special = parameter.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask; + var requireValueType = (special & GenericParameterAttributes.NotNullableValueTypeConstraint) != 0; + var requireReferenceType = (special & GenericParameterAttributes.ReferenceTypeConstraint) != 0; + var requireDefaultCtor = (special & GenericParameterAttributes.DefaultConstructorConstraint) != 0; + + if (requireValueType && !candidate.IsValueType) return false; + if (requireReferenceType && candidate.IsValueType) return false; + + return !requireDefaultCtor || candidate.IsValueType || candidate.GetConstructor(Type.EmptyTypes) is not null; + } + + /// + /// Closes over and validates the result + /// against every entry of . Returns with a + /// human-readable when construction throws (a violated parameter constraint) + /// or the closed type is not assignable to the field. + /// + public static bool TryConstruct(Type openDefinition, Type[] arguments, Type[] fieldTypes, out Type closed, out string error) + { + closed = null; + error = null; + + try + { + closed = openDefinition.MakeGenericType(arguments); + } + catch (Exception exception) + { + error = $"Cannot construct {FormatDefinitionName(openDefinition)}: {exception.Message}"; + return false; + } + + // The chosen arguments may satisfy the type parameters' own constraints yet still produce a type + // that is not assignable to the managed-reference field — guard against a value Unity would drop. + if (fieldTypes is not null) + { + foreach (var fieldType in fieldTypes) + { + if (fieldType is null || fieldType == typeof(object)) continue; + if (fieldType.IsAssignableFrom(closed)) continue; + + error = $"{closed.Name} is not assignable to {fieldType.Name}."; + closed = null; + return false; + } + } + + return true; + } + + /// + /// Short display form of an open definition with its parameter names (Modifier<T>). + /// + public static string FormatDefinitionName(Type definition) + { + var name = definition.Name; + var tick = name.IndexOf('`'); + var baseName = tick >= 0 ? name[..tick] : name; + var arguments = string.Join(", ", definition.GetGenericArguments().Select(argument => argument.Name)); + return $"{baseName}<{arguments}>"; + } + + private static bool CanCloseToFieldType(Type openDefinition, Type fieldType) + { + if (fieldType.IsGenericType) + { + var fieldDefinition = fieldType.GetGenericTypeDefinition(); + return GenericBaseDefinitions(openDefinition).Any(definition => definition == fieldDefinition); + } + + if (fieldType.IsAssignableFrom(openDefinition)) return true; + if (openDefinition.GetInterfaces().Contains(fieldType)) return true; + + for (var current = openDefinition.BaseType; current is not null; current = current.BaseType) + if (current == fieldType) return true; + + return false; + } + + /// + /// Enumerates the generic-type-definition view of itself, its base + /// class chain, and its interfaces (only the generic ones, reduced to their definitions). + /// + private static IEnumerable GenericBaseDefinitions(Type openDefinition) + { + if (openDefinition.IsGenericType) + yield return openDefinition.GetGenericTypeDefinition(); + + for (var current = openDefinition.BaseType; current is not null; current = current.BaseType) + if (current.IsGenericType) + yield return current.GetGenericTypeDefinition(); + + foreach (var contract in openDefinition.GetInterfaces()) + if (contract.IsGenericType) + yield return contract.GetGenericTypeDefinition(); + } + + private static IEnumerable EnumerateDomainTypes() + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] types; + + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(type => type is not null).ToArray(); + } + + foreach (var type in types) + yield return type; + } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs.meta new file mode 100644 index 00000000..51af064f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/GenericTypeResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f7c2a1d9b6e4c84a1d05f3e72c8b9a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/HierarchyBuilder.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/HierarchyBuilder.cs index d74335b6..2bae64ca 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/HierarchyBuilder.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/HierarchyBuilder.cs @@ -7,12 +7,20 @@ namespace Aspid.FastTools.Types.Editors { internal static class HierarchyBuilder { - public static TreeNode Build(Type[] types, TypeAllow allow) + public static TreeNode Build( + Type[] types, + TypeAllow allow, + Func filter = null, + IEnumerable additionalTypes = null, + bool includeNoneOption = true) { - var allTypes = TypeInfo.GetAllTypeInfos(types, allow); + var allTypes = TypeInfo.GetAllTypeInfos(types, allow, filter, additionalTypes); var root = new TreeNode("/"); - root.Children.Add(new TreeNode(TypeSelectorHelpers.NoneOption, null, TypeSelectorHelpers.NoneOption)); + + // Generic-argument pages must yield a concrete type, so they omit the entry. + if (includeNoneOption) + root.Children.Add(new TreeNode(TypeSelectorHelpers.NoneOption, null, TypeSelectorHelpers.NoneOption)); AddGlobalNamespaceGroup(root, allTypes); AddNamespaceHierarchy(root, allTypes); diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeInfo.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeInfo.cs index c4353e09..7b0266c5 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeInfo.cs +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeInfo.cs @@ -18,21 +18,31 @@ internal sealed class TypeInfo public TypeInfo(Type type) { - Name = type.Name; + Name = FormatName(type); FullName = type.FullName; Assembly = type.Assembly.GetName().Name; AssemblyQualifiedName = type.AssemblyQualifiedName; Namespace = string.IsNullOrEmpty(type.Namespace) ? TypeSelectorHelpers.GlobalNamespace : type.Namespace; } - - public static List GetAllTypeInfos(Type[] baseTypes, TypeAllow allow) + + /// + /// Collects the type infos shown in the selector. are appended + /// verbatim (bypassing the base-type, name and checks) so callers can inject + /// entries — such as open generic definitions — that the standard + /// scan cannot match. + /// + public static List GetAllTypeInfos( + Type[] baseTypes, + TypeAllow allow, + Func filter = null, + IEnumerable additionalTypes = null) { var result = new List(); foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { Type[] types; - + try { types = assembly.GetTypes(); @@ -49,11 +59,37 @@ public static List GetAllTypeInfos(Type[] baseTypes, TypeAllow allow) !t.Name.Contains("<") && !t.Name.Contains(">") && (allow.HasFlag(TypeAllow.Abstract) || !t.IsAbstract) && - (allow.HasFlag(TypeAllow.Interface) || !t.IsInterface)) + (allow.HasFlag(TypeAllow.Interface) || !t.IsInterface) && + (filter is null || filter(t))) + .Select(type => new TypeInfo(type))); + } + + if (additionalTypes is not null) + { + var existing = new HashSet(result.Select(info => info.AssemblyQualifiedName)); + + result.AddRange(additionalTypes + .Where(type => type is not null && existing.Add(type.AssemblyQualifiedName)) .Select(type => new TypeInfo(type))); } return result; } + + /// + /// Short display name for a type. Open generic definitions are rendered with angle-bracket + /// parameters (Modifier<T>) instead of Unity's raw arity form (Modifier`1); + /// generic arguments are formatted recursively so nested closed generics render fully. + /// + private static string FormatName(Type type) + { + if (!type.IsGenericType) return type.Name; + + var name = type.Name; + var tick = name.IndexOf('`'); + var baseName = tick >= 0 ? name[..tick] : name; + var arguments = string.Join(", ", type.GetGenericArguments().Select(FormatName)); + return $"{baseName}<{arguments}>"; + } } } diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs new file mode 100644 index 00000000..bc61d4f6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs @@ -0,0 +1,605 @@ +using System; +using System.Linq; +using UnityEngine; +using UnityEditor.UIElements; +using UnityEngine.UIElements; +using System.Collections.Generic; +using Aspid.FastTools.UIElements; +using Aspid.FastTools.UIElements.Editors.Internal; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Types.Editors +{ + /// + /// The hierarchical type selector as a host-agnostic : search, keyboard + /// navigation, namespace drill-down and the generic-argument resolution flow all live here. + /// hosts it as a dropdown; embedding hosts (e.g. the Repair References + /// window) add it inline and collapse it through the dismiss callback. + /// + /// + /// Selecting an open generic definition (injected via additionalTypes) is not a final selection; + /// instead it drills into an argument-selection flow — one hierarchical page per type parameter, + /// reusing the same search/keyboard/navigation — and emits the constructed closed type once every argument + /// is resolved. The argument flow stays dormant unless open generics are present, so the ordinary + /// type-selection contract is unchanged. + /// + internal sealed class TypeSelectorView : VisualElement + { + private const string StyleSheetPath = "UI/Types/Aspid-FastTools-TypeSelector"; + + private const string BlockClass = "aspid-fasttools-type-selector"; + private const string HeaderClass = BlockClass + "__header"; + private const string ItemTitleClass = BlockClass + "__item-title"; + private const string ItemArrowClass = BlockClass + "__item-arrow"; + + private Label _titleLabel; + private Button _backButton; + private ListView _listView; + private Label _errorLabel; + private ToolbarSearchField _searchField; + + private readonly List _pages = new(); + + private readonly Action _onDismiss; + private readonly Action _onSelected; + private readonly Func _argumentFilter; + private readonly Type[] _fieldTypes; + private readonly string _currentAqn; + + private NavigationController Nav => _pages[^1].Navigation; + private bool CanGoBack => Nav.CanNavigateBack || _pages.Count > 1; + + /// + /// Creates a type selector view. + /// + /// Base types used to filter which concrete types are shown. Only types assignable to all entries are listed. + /// Assembly-qualified name of the currently selected type, used to pre-navigate to that type's location. Pass null or empty to start at the root. + /// Which type kinds are included in the list. + /// Callback invoked with the assembly-qualified name of the selected type, or null if the user chose <None>. When an open generic is resolved, the assembly-qualified name of the constructed closed type is passed. + /// Optional predicate applied to each candidate type after the base-type and checks. Return false to hide a type. Pass null to keep every matching type. + /// Optional extra types appended to the list verbatim, bypassing the base-type and checks — used to inject entries the assignability scan cannot match, such as open generic definitions. + /// Optional predicate applied to candidate types offered for an open generic's type arguments (in addition to the parameter's own constraints). Pass null to accept any constraint-satisfying type. + /// Invoked when the selector is done — after a selection is emitted, or when the user cancels with Escape. The host closes its window or collapses the inline panel here. + public TypeSelectorView( + Type[] types = null, + string currentAqn = "", + TypeAllow allow = TypeAllow.None, + Action onSelected = null, + Func filter = null, + IEnumerable additionalTypes = null, + Func argumentFilter = null, + Action onDismiss = null) + { + types ??= new[] { typeof(object) }; + + _onDismiss = onDismiss; + _onSelected = onSelected; + _argumentFilter = argumentFilter; + _currentAqn = currentAqn ?? string.Empty; + _fieldTypes = types; + + BuildUI(); + + var hierarchy = HierarchyBuilder.Build(types, allow, filter, additionalTypes); + var navigation = new NavigationController(hierarchy); + + if (!string.IsNullOrWhiteSpace(_currentAqn)) + navigation.NavigateToAssemblyQualifiedName(_currentAqn); + + _pages.Add(new PickerPage + { + Navigation = navigation, + TitlePrefix = null, + ConstraintType = types.Length > 0 ? types[0] : typeof(object), + OnPicked = closed => Emit(closed?.AssemblyQualifiedName), + IsBase = true, + }); + + RefreshView(); + } + + /// + /// Moves keyboard focus into the search field. Call after the view is attached to a panel. + /// + public void FocusSearch() => _searchField.Focus(); + + #region Initialization + private void BuildUI() + { + _searchField = CreateSearchField(); + _listView = CreateListView(); + _errorLabel = CreateErrorLabel(); + + this.AddStyleSheetsFromResource(AspidStyles.DefaultStyleSheet) + .AddStyleSheetsFromResource(StyleSheetPath) + .AddClass(BlockClass) + .AddChild(CreateHeader()) + .AddChild(_searchField) + .AddChild(_errorLabel) + .AddChild(_listView); + + RegisterCallback(HandleKeyDown, TrickleDown.TrickleDown); + return; + + VisualElement CreateHeader() + { + _titleLabel = new Label(string.Empty); + _backButton = new Button(NavigateBack).SetText("←"); + + return new VisualElement() + .AddClass(HeaderClass) + .AddChild(_backButton) + .AddChild(_titleLabel); + } + + ToolbarSearchField CreateSearchField() + { + var field = new ToolbarSearchField(); + + field.RegisterValueChangedCallback(e => HandleSearchChanged(e.newValue ?? string.Empty)); + + return field; + } + + Label CreateErrorLabel() + { + var label = new Label(string.Empty); + + label.style.display = DisplayStyle.None; + label.style.color = new Color(0.9f, 0.35f, 0.35f); + label.style.whiteSpace = WhiteSpace.Normal; + label.style.marginLeft = 4; + label.style.marginRight = 4; + label.style.marginTop = 2; + label.style.marginBottom = 2; + + return label; + } + + ListView CreateListView() + { + var list = new ListView + { + selectionType = SelectionType.Single, + virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight, + }; + + list.SetMakeItem(CreateListItem); + list.SetBindItem(BindListItem); + list.itemsChosen += HandleItemChosen; + + return list; + } + + VisualElement CreateListItem() + { + var label = new Label() + .AddClass(ItemTitleClass); + + var arrow = new Label("›") + .AddClass(ItemArrowClass); + + return new VisualElement() + .AddChild(label) + .AddChild(arrow); + } + + void BindListItem(VisualElement element, int index) + { + var items = _pages.Count > 0 ? Nav.CurrentItems : null; + + if (items is null) return; + if (index < 0 || index >= items.Count) return; + + var node = items[index]; + element.Q