diff --git a/.claude/hooks/rebuild-analyzers-on-change.sh b/.claude/hooks/rebuild-analyzers-on-change.sh new file mode 100755 index 00000000..8bb7c81c --- /dev/null +++ b/.claude/hooks/rebuild-analyzers-on-change.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# PostToolUse hook: rebuild the Roslyn analyzer after edits inside the analyzer +# project (a git submodule), then redeploy the DLL into the Unity package. +# The submodule has no Directory.Build.targets on purpose (it stays independent +# of this repo's layout), so the copy step lives here. +# +# Path-scoped on purpose: +# - Triggers ONLY for *.cs under Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/ +# - Skips the Tests and Sample projects. +# +# Build success -> exit 0 (silent). +# Path mismatch -> exit 0 (silent). +# Build failure -> exit 2 with stderr piped through, so the assistant sees it. + +set -uo pipefail + +file_path=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) + +case "$file_path" in + */Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/*.cs) ;; + *) exit 0 ;; +esac + +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +dotnet build \ + Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.csproj \ + -c Release --nologo -v quiet 1>&2 || exit 2 + +cp Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/bin/Release/netstandard2.0/Aspid.FastTools.Analyzers.dll \ + Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll 1>&2 || exit 2 diff --git a/.claude/settings.json b/.claude/settings.json index 548c507c..44bb7e48 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -18,6 +18,10 @@ { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/rebuild-generators-on-change.sh\"" + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/rebuild-analyzers-on-change.sh\"" } ] } diff --git a/.claude/skills/build-analyzer/SKILL.md b/.claude/skills/build-analyzer/SKILL.md new file mode 100644 index 00000000..fe2a34e9 --- /dev/null +++ b/.claude/skills/build-analyzer/SKILL.md @@ -0,0 +1,17 @@ +--- +name: build-analyzer +description: Build the Roslyn analyzer submodule and deploy the resulting DLL into the Unity package +user-invocable: true +--- + +Build the Aspid.FastTools analyzer (git submodule) and deploy to Unity: + +1. If `Aspid.FastTools.Analyzers/` is empty, run `git submodule update --init` first +2. Run `dotnet build Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.csproj -c Release` from the repository root +3. Run `dotnet test Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.sln -c Release` and stop if any test fails +4. Copy `Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/bin/Release/netstandard2.0/Aspid.FastTools.Analyzers.dll` to `Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll` +5. Report the result: build/test output, any errors, and confirm the DLL was copied successfully + +Note: diagnostic IDs use the `AFT*` prefix. After bumping the submodule commit, remember the gitlink change in the superproject (`git add Aspid.FastTools.Analyzers`). + +Arguments: $ARGUMENTS (optional: pass `Debug` to build in Debug configuration instead of Release) diff --git a/.gitignore b/.gitignore index 798138e6..42429634 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .worktrees/ .claude/worktrees/ .zed/ +.DS_Store # UPM convention: `Samples~` is hidden from Unity importer but must be tracked. # Override global `*~` ignore. diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..aaf13703 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Aspid.FastTools.Analyzers"] + path = Aspid.FastTools.Analyzers + url = https://github.com/VPDPersonal/Aspid.FastTools.Analyzers.git diff --git a/Aspid.FastTools.Analyzers b/Aspid.FastTools.Analyzers new file mode 160000 index 00000000..569c4fdc --- /dev/null +++ b/Aspid.FastTools.Analyzers @@ -0,0 +1 @@ +Subproject commit 569c4fdc53535c41ff6d1185f52d68ebb0a883e3 diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj b/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj index a09224de..40073400 100644 --- a/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj +++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj index b3d7d6c4..564f2bba 100644 --- a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj +++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj @@ -4,6 +4,7 @@ netstandard2.0 enable latest + true true Aspid.FastTools @@ -22,8 +23,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets new file mode 100644 index 00000000..8bdc86ec --- /dev/null +++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets @@ -0,0 +1,32 @@ + + + + + + $(IntermediateOutputPath)unity-merge/ + $(UnityAnalyzerMergeDir)$(TargetFileName) + + + + <_MergeDependency Include="@(ReferencePath)" + Condition="'%(Filename)' == 'Aspid.Generators.Helper' Or '%(Filename)' == 'Aspid.Generators.Helper.Unity'" /> + <_MergeLibraryDir Include="@(ReferencePath->'%(RootDir)%(Directory)'->Distinct())" /> + + + + + + + + + + diff --git a/Aspid.FastTools.Generators/CLAUDE.md b/Aspid.FastTools.Generators/CLAUDE.md index 34d8b639..9c3f14c6 100644 --- a/Aspid.FastTools.Generators/CLAUDE.md +++ b/Aspid.FastTools.Generators/CLAUDE.md @@ -12,18 +12,18 @@ dotnet build -c Release dotnet test ``` -`Directory.Build.targets` copies the compiled DLL to `../Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll` on build. +Deploy pipeline: `Aspid.FastTools.Generators/ILRepack.targets` merges `Aspid.Generators.Helper*` into a single-file copy of the DLL under `obj/` (Unity references exactly one analyzer DLL; `bin/` stays multi-assembly for tests), then `Directory.Build.targets` copies the merged DLL to `../Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll`. A repo-level PostToolUse hook (`.claude/hooks/rebuild-generators-on-change.sh`) also runs `dotnet build` automatically after any `Edit`/`Write` to `*.cs` under `Aspid.FastTools.Generators/Aspid.FastTools.Generators/`. The hook intentionally **does not** trigger for tests, the Sample project, or Unity-side edits — keep that scope if you modify it. ## Solution Structure ``` -Aspid.FastTools.Generators/ ← generator implementation +Aspid.FastTools.Generators/ ← generator implementation (+ ILRepack.targets deploy merge) Aspid.FastTools.Generators.Tests/ ← unit tests + GeneratorTestHost helper Aspid.FastTools.Generators.Sample/ ← manual smoke-test project Aspid.FastTools.Generators.sln -Directory.Build.targets +Directory.Build.targets ← copies the merged DLL into the Unity package ``` ## Target Framework @@ -37,7 +37,9 @@ Directory.Build.targets | `Microsoft.CodeAnalysis.CSharp` 4.3.0 | Roslyn semantic model and syntax | | `Aspid.Generators.Helper` | `CodeWriter` utility for emitting source | | `Aspid.Generators.Helper.Unity` | Unity-specific analysis helpers | -| `SourceGenerator.Foundations` 2.0.13 | Incremental generator infrastructure | +| `ILRepack.Lib.MSBuild.Task` | Build-only: merges the Helper DLLs into the deployed analyzer DLL | + +**Never add `SourceGenerator.Foundations` (SGF).** Its MSBuild injector plants a module initializer that writes `Console.WriteLine` per embedded assembly on every generator load. Inside Unity's long-lived VBCSCompiler server nobody drains stdout, so the pipe buffer eventually fills and the `write()` blocks forever — script compilation hangs indefinitely (observed: 15 min – 2 h). It also embeds ~17 MB of unrelated assemblies (own copies of `Microsoft.CodeAnalysis*`, `envdte`, …) into the DLL. The same rule generalizes: no `Console` output anywhere in generator/analyzer code paths. ## Generator Implementation Pattern diff --git a/Aspid.FastTools.Generators/Directory.Build.targets b/Aspid.FastTools.Generators/Directory.Build.targets index 79d227d7..e11505b0 100644 --- a/Aspid.FastTools.Generators/Directory.Build.targets +++ b/Aspid.FastTools.Generators/Directory.Build.targets @@ -1,11 +1,12 @@ - + + <_UnityDestination>$(MSBuildThisFileDirectory)../Aspid.FastTools/Packages/tech.aspid.fasttools/ - + diff --git a/Aspid.FastTools/.gitignore b/Aspid.FastTools/.gitignore index 23373c51..eccac06c 100644 --- a/Aspid.FastTools/.gitignore +++ b/Aspid.FastTools/.gitignore @@ -78,6 +78,16 @@ crashlytics-build.properties /[Aa]ssets/[Tt]utorialInfo.meta /[Aa]ssets/[Tt]utorialInfo/* +# Package samples imported into the dev project via Package Manager. +# Source of truth is the package's `Samples~/`; the imported copy is local-only. +/[Aa]ssets/[Ss]amples/ +/[Aa]ssets/[Ss]amples.meta + +# Auto-generated tool settings created by optional Editor packages +# (Project Auditor, Timeline). Default values only, dev-project-local. +/[Pp]roject[Ss]ettings/ProjectAuditorSettings.asset +/[Pp]roject[Ss]ettings/TimelineSettings.asset + # OS generated .DS_Store ._* diff --git a/Aspid.FastTools/Packages/manifest.json b/Aspid.FastTools/Packages/manifest.json index ca2e03c3..80f6a977 100755 --- a/Aspid.FastTools/Packages/manifest.json +++ b/Aspid.FastTools/Packages/manifest.json @@ -48,5 +48,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0" - } + }, + "testables": [ + "tech.aspid.fasttools" + ] } diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll new file mode 100644 index 00000000..ed01cdda Binary files /dev/null and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll differ diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta new file mode 100644 index 00000000..582e1749 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta @@ -0,0 +1,67 @@ +fileFormatVersion: 2 +guid: b8706429bd1d471380db4bce9eff80fe +labels: +- RoslynAnalyzer +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Android: + enabled: 0 + settings: + AndroidLibraryDependee: UnityLibrary + AndroidSharedLibraryType: Executable + CPU: ARMv7 + Any: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + Editor: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + Linux64: + enabled: 0 + settings: + CPU: None + OSXUniversal: + enabled: 0 + settings: + CPU: None + Win: + enabled: 0 + settings: + CPU: None + Win64: + enabled: 0 + settings: + CPU: None + WindowsStoreApps: + enabled: 0 + settings: + CPU: AnyCPU + iOS: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll index 54cbb84b..d0ecd0d6 100644 Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll differ 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 f7533b69..5975589c 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md @@ -1,6 +1,6 @@ Aspid.FastTools -**Aspid.FastTools** is a set of tools designed to minimize routine code writing in Unity. It combines Roslyn-powered source generators with a curated collection of runtime and editor utilities — including per-call-site `ProfilerMarker` registration, a serializable `System.Type`, an `EnumValues` dictionary, a stable `int ↔ string` ID registry, fluent UI Toolkit extensions and IMGUI layout scopes. +**Aspid.FastTools** is a set of tools designed to minimize routine code writing in Unity: `SerializeReference` tooling (an inspector type picker plus a reference explorer window), Roslyn-powered source generators, and a collection of runtime and editor utilities — from a serializable `System.Type` to fluent UI Toolkit extensions. ### \[[Unity Asset Store](https://assetstore.unity.com/packages/slug/365584)\] \[[Donate](#donate)\] @@ -17,18 +17,19 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) + - [VisualElement Extensions](#visualelement-extensions) - [SerializedProperty Extensions](#serializedproperty-extensions) - [IMGUI Layout Scopes](#imgui-layout-scopes) - - [VisualElement Extensions](#visualelement-extensions) - [Editor Helper Extensions](#editor-helper-extensions) --- ## Integration -Install Aspid.FastTools via UPM (Unity Package Manager) — add the package using its Git URL. The release workflow publishes two branches containing only the package contents at their root, so no `?path=` query is needed. +Install Aspid.FastTools via UPM: in the Package Manager click **+ → Install package from git URL…** and paste one of the URLs below. ### Stable @@ -75,6 +76,9 @@ Add the marketplace and install the plugin: ```sh /plugin marketplace add VPDPersonal/Aspid.Claude.Plugins +``` + +```sh /plugin install aspid-fasttools@aspid-claude-plugins ``` @@ -211,7 +215,8 @@ public sealed class TypeSelectorAttribute : PropertyAttribute public TypeSelectorAttribute(string assemblyQualifiedName) public TypeSelectorAttribute(params string[] assemblyQualifiedNames) - public TypeAllow Allow { get; set; } // default: TypeAllow.None + public TypeAllow Allow { get; set; } // default: TypeAllow.None + public bool Required { get; set; } // default: false } [Flags] @@ -227,6 +232,7 @@ public enum TypeAllow | Property | Description | |----------|-------------| | `Allow` | Which special type categories (abstract classes, interfaces) the picker includes in addition to plain concrete classes. Default: `TypeAllow.None` | +| `Required` | Flags an unset field: a `[SerializeReference]` managed reference left `null`, or a `string` field left empty, shows an inline "required" warning in the Inspector and counts as a violation for the build/CI gate. Default: `false` | ```csharp using UnityEngine; @@ -247,6 +253,27 @@ public sealed class AbilitySelector : MonoBehaviour > The complete sample — `Ability` / `AbilitySelector` / `EnemyBase` and their subclasses — ships in the `Types` sample (Package Manager → Aspid.FastTools → Samples). +Decorate a candidate type with `[TypeSelectorDisplay]` to tune how it appears in the picker — an editor-only attribute (`[Conditional("UNITY_EDITOR")]`) in `Aspid.FastTools.Types` that carries no runtime cost: + +```csharp +using Aspid.FastTools.Types; + +// Rename the type in the picker, place it under an explicit group, give it a tooltip and an icon: +[TypeSelectorDisplay( + Name = "Damage ×", + Group = "Combat/Modifiers", + Tooltip = "Scales incoming damage", + Icon = "d_ScriptableObject Icon")] +public sealed class DamageModifier { } +``` + +| Member | Description | +|--------|-------------| +| `Name` | Display name shown instead of the type's short name — in the picker rows and in the closed dropdown's caption. Search still matches the real type name too, and the hover tooltip keeps revealing the full `Namespace.Class, Assembly` identity. `null` or whitespace means no override. | +| `Group` | Explicit picker path with `/` separating levels (e.g. `"Combat/Melee"`). **Replaces** the type's namespace placement — the type appears only under this path, and path segments are shared between types. `null` or whitespace keeps the namespace placement. | +| `Tooltip` | Tooltip shown when hovering the type's row. `null` means no tooltip override. | +| `Icon` | Editor icon shown left of the label — an `EditorGUIUtility.IconContent` name, a project-relative asset path with extension (loaded via `AssetDatabase`), or a `Resources` texture path without extension. `null` means no icon. | + --- ### Type Selector Window @@ -255,9 +282,14 @@ The Inspector shows a button that opens a searchable popup window with: - Hierarchical namespace organization - Text search with filtering -- Keyboard navigation (Arrow keys, Enter, Escape) -- Navigation history (back button) +- Keyboard navigation (Arrow keys, Enter, Escape; Space toggles a favorite) +- Breadcrumb trail with back navigation (Left arrow or a click on a crumb) - Assembly disambiguation for types with identical names +- **Favorites** (★ on hover) and **Recent** (last picks) sections on the root page — stored locally per project (`EditorPrefs`, never committed), hidden while searching +- A `` option pinned at the top and a ✓ mark on the current value — its row is pre-selected on open +- Type counters on namespace/group rows and section headers +- Generic type support — picking an open generic walks through its type parameters and emits the constructed type +- Favorites/Recent tuning (on/off, Recent capacity) in the Settings tab of the SerializeReference window ![aspid_fasttools_type_selector_window.png](../Images/aspid_fasttools_type_selector_window.png) @@ -319,10 +351,117 @@ public sealed class TankEnemy : EnemyBase } ``` + ![aspid_fasttools_component_type_selector.gif](../Images/aspid_fasttools_component_type_selector.gif) --- +## SerializeReference Selector + +A drop-in type-picker dropdown for `[SerializeReference]` fields. Add `[TypeSelector]` next to `[SerializeReference]` and the Inspector replaces the default managed-reference UI with the same searchable, hierarchical picker used by `SerializableType`. You choose which concrete implementation of the field's type is instantiated, right in the Inspector; `` clears the reference. + +```csharp +using System; +using UnityEngine; +using System.Collections.Generic; +using Aspid.FastTools.Types; + +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] [TypeSelector] + private IWeapon _primary; + + [SerializeReference] [TypeSelector] + private List _sidearms; +} +``` + +The attribute is editor-only (`[Conditional("UNITY_EDITOR")]`) and carries no runtime cost. It works on single fields, arrays, and `List`, in both IMGUI and UIToolkit inspectors. + +### Capabilities + +| Capability | What it does | +|---|---| +| **Pick an implementation** | The list shows the concrete, non-`UnityEngine.Object` classes assignable to the field's type. `[TypeSelector(typeof(IMelee))]` narrows it to `IMelee` implementations. | +| **Inline inspector** | The selected instance's serialized fields are drawn under a foldout. | +| **Open generics** | `Modifier` and the like: arguments are inferred from a closed-generic field, or picked on a second page inside the picker. | +| **Data preserved** | Switching type carries over fields shared by name and serialized shape instead of resetting them to defaults. | +| **Copy / Paste** | Right-click the header to copy the value and paste it as an independent instance into any compatible field. | +| **Multi-object editing** | A mixed selection shows a mixed dropdown; picking a type or pasting applies an independent instance to each object in one Undo group. | +| **Compile-time checks** | Roslyn analyzer: `AFT0004` (error) — the type derives from `UnityEngine.Object`; `AFT0005` (warning) — the picker would be empty. | + +### Repairing broken references + +| Case | Fix | +|---|---| +| **Missing type** (renamed or deleted) | A yellow notice instead of a silent clear. The underlined **Fix** opens the picker and re-points the type while keeping its data — at any depth, in saved assets and live in Prefab Mode. | +| **Smart Fix** | Next to **Fix**, suggests the most likely replacement (`[MovedFrom]`, a different namespace/assembly, casing, a near-miss name) and applies it in one click — never automatically. | +| **Shared reference** (two fields share one instance) | Flagged with a notice; **Make unique** splits it into an independent copy. Duplicating a list element (Ctrl+D, `+`) no longer aliases the reference. | + + + +Bulk repair lives in two dedicated tabs: + +| Tab | Purpose | +|---|---| +| **Asset References** (`Tools → Aspid 🐍 → FastTools → Asset References`) | Maps an asset's whole managed-reference graph from its YAML — a per-component tree with field paths, shared and orphaned references, `MISSING` / `SHARED` badges, and an inline type dropdown on every card. Surfaces the missing references the Inspector cannot show. | +| **Project References** (`Tools → Aspid 🐍 → FastTools → Project References`) | `Scan Project` sweeps every `.prefab` / `.asset` / `.unity` under `Assets/`, groups broken references by stored type, and rewrites a whole group with a single `Fix all` (plus Smart Fix). A group whose stored type matches a declared `[MovedFrom]` rename reads as a pending migration instead of a breakage — one **Migrate all** click bakes the rename into the files, after which the attribute can be removed from code. | + +### Project settings & the build/CI gate + +**`Project Settings → Aspid FastTools → SerializeReference`** exposes: + +| Setting | Scope | What it does | +|---|---|---| +| **Breakage detection** | per-user | The proactive toast + console warning when references newly become missing after a recompile / import. | +| **Auto de-alias duplicated list elements** | committed | A duplicated list element gets its own instance instead of sharing the original's reference id. | +| **Build / CI gate** | committed | `Off` / `Warn` / `Fail`: at player-build time, log or abort on missing (and, for CI, unset-required) managed references. | +| **Excluded scan folders** | committed | Paths skipped by every project scan. | + +- Committed values live in `ProjectSettings/SerializeReferenceSharedSettings.asset` — commit it so teammates and CI behave identically; breakage detection stays per-machine (`EditorPrefs`). +- Rid colours are not a setting — a shared reference is always colour-coded by id, so matching colours reveal shared instances at a glance. + +The same options are mirrored in the window's **Settings** tab (`Tools → Aspid 🐍 → FastTools → Settings`) and at **`Preferences → Aspid FastTools`**, alongside the picker's per-user preferences: + +- **Favorites** — section on/off toggle. +- **Recent items** — capacity slider (0–20; 0 hides the section and pauses recording without wiping history). +- **Saved lists** — clears the stored Favorites / Recent. +- **Appearance** — editor-theme override `StyleSheet` with **Create template…**. +- **Welcome** — auto-show toggle. + +Every row carries a scope stripe (green — committed, blue — per-user); a pinned footer offers **Reset to defaults** per scope (saved Favorites / Recent lists survive a reset). All surfaces stay in live sync. + +For headless CI, `SerializeReferenceCiGate.RunCheck` (invoked via `-batchmode -executeMethod`) writes a report and honours the committed gate severity: + +- `Off` skips the check, `Warn` logs but exits 0, `Fail` exits non-zero when violations exist. +- `-srGateRequired` also flags unset `[TypeSelector(Required = true)]` fields across prefabs, ScriptableObjects and scenes (top-level fields, pure-YAML pass). +- `-srGateWarnOnly` / `-srGateFail` override the committed severity per run. + +> The full sample — `Loadout` / `IWeapon` / `Modifier` and the missing-reference repair scenarios — ships in the `SerializeReferences` sample (Package Manager → Aspid.FastTools → Samples). A step-by-step walkthrough lives in that sample's `TUTORIAL.md`. + +--- + ## Enum System Provides serializable enum-to-value mappings configurable from the Inspector. @@ -480,69 +619,6 @@ The registry derives from `ScriptableObject` directly and exposes a generic coun --- -## SerializedProperty Extensions - -Chainable extensions on `SerializedProperty` for synchronizing the owning `SerializedObject`, writing typed values, and reflecting on the underlying field. - -```csharp -property - .Update() - .SetVector3(Vector3.up) - .SetBool(true) - .ApplyModifiedProperties(); -``` - -The package covers: - -- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`. -- **Typed setters** — `SetValue` (generic dispatch) and `SetXxx` for `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (and `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). Each comes with a paired `SetXxxAndApply` variant. -- **Enum setters** — `SetEnumFlag` and `SetEnumIndex` (each + `AndApply`). -- **Arrays** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (each + `AndApply`). -- **References** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, and `SetBoxed` (Unity 6+). -- **Reflection helpers** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` for resolving the C# member and runtime instance behind a property. - -> Full method-by-method reference: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md) - ---- - -## IMGUI Layout Scopes - -Three `ref struct` scopes — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — wrap `EditorGUILayout.Begin*` / `End*`. Each exposes a `Rect` property and calls the matching `End*` method on `Dispose`: - -```csharp -using (VerticalScope.Begin()) -{ - EditorGUILayout.LabelField("Item 1"); - EditorGUILayout.LabelField("Item 2"); -} - -using (HorizontalScope.Begin()) -{ - EditorGUILayout.LabelField("Left"); - EditorGUILayout.LabelField("Right"); -} - -var scrollPos = Vector2.zero; -using (ScrollViewScope.Begin(ref scrollPos)) -{ - EditorGUILayout.LabelField("Scrollable content"); -} -``` - -Capture the group rect with the `out`-overload when needed: - -```csharp -using (VerticalScope.Begin(out var rect, GUI.skin.box)) -{ - EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f)); - EditorGUILayout.LabelField("Boxed content"); -} -``` - -All `Begin` overloads match the corresponding `EditorGUILayout.Begin*` signatures (optional `GUIStyle`, `GUILayoutOption[]`, scroll view options, etc.). - ---- - ## VisualElement Extensions Fluent extension methods for building UIToolkit trees in code. All methods return `T` (the element itself) for chaining. @@ -610,6 +686,69 @@ internal sealed class AbilityConfigEditor : Editor --- +## SerializedProperty Extensions + +Chainable extensions on `SerializedProperty` for synchronizing the owning `SerializedObject`, writing typed values, and reflecting on the underlying field. + +```csharp +property + .Update() + .SetVector3(Vector3.up) + .SetBool(true) + .ApplyModifiedProperties(); +``` + +The package covers: + +- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`. +- **Typed setters** — `SetValue` (generic dispatch) and `SetXxx` for `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (and `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). Each comes with a paired `SetXxxAndApply` variant. +- **Enum setters** — `SetEnumFlag` and `SetEnumIndex` (each + `AndApply`). +- **Arrays** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (each + `AndApply`). +- **References** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, and `SetBoxed` (Unity 6+). +- **Reflection helpers** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` for resolving the C# member and runtime instance behind a property. + +> Full method-by-method reference: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md) + +--- + +## IMGUI Layout Scopes + +Three `ref struct` scopes — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — wrap `EditorGUILayout.Begin*` / `End*`. Each exposes a `Rect` property and calls the matching `End*` method on `Dispose`: + +```csharp +using (VerticalScope.Begin()) +{ + EditorGUILayout.LabelField("Item 1"); + EditorGUILayout.LabelField("Item 2"); +} + +using (HorizontalScope.Begin()) +{ + EditorGUILayout.LabelField("Left"); + EditorGUILayout.LabelField("Right"); +} + +var scrollPos = Vector2.zero; +using (ScrollViewScope.Begin(ref scrollPos)) +{ + EditorGUILayout.LabelField("Scrollable content"); +} +``` + +Capture the group rect with the `out`-overload when needed: + +```csharp +using (VerticalScope.Begin(out var rect, GUI.skin.box)) +{ + EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f)); + EditorGUILayout.LabelField("Boxed content"); +} +``` + +All `Begin` overloads match the corresponding `EditorGUILayout.Begin*` signatures (optional `GUIStyle`, `GUILayoutOption[]`, scroll view options, etc.). + +--- + ## Editor Helper Extensions Utility methods for getting display names of Unity objects in custom editors. diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif index 30de9b89..e0a46576 100644 Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif differ diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png index afb53020..918ef3fb 100644 Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png differ 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 28f9c34a..2c0fc456 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md @@ -1,6 +1,6 @@ Aspid.FastTools -**Aspid.FastTools** — набор инструментов, предназначенных для минимизации рутинного написания кода в Unity. Пакет объединяет генераторы кода на базе Roslyn и подборку runtime- и editor-утилит: регистрация `ProfilerMarker` для каждого места вызова, сериализуемый `System.Type`, словарь `EnumValues`, стабильный реестр `int ↔ string` ID, fluent-расширения UI Toolkit и IMGUI-скоупы для разметки. +**Aspid.FastTools** — набор инструментов, предназначенных для минимизации рутинного написания кода в Unity: инструменты для `SerializeReference` (выбор типа в инспекторе и окно-обозреватель ссылок), генераторы кода на базе Roslyn и подборка runtime- и editor-утилит — от сериализуемого `System.Type` до fluent-расширений UI Toolkit. ### \[[Unity Asset Store](https://assetstore.unity.com/packages/slug/365584)\] \[[Donate](#donate)\] @@ -17,18 +17,19 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) + - [VisualElement Extensions](#visualelement-extensions) - [SerializedProperty Extensions](#serializedproperty-extensions) - [IMGUI Layout Scopes](#imgui-layout-scopes) - - [VisualElement Extensions](#visualelement-extensions) - [Editor Helper Extensions](#editor-helper-extensions) --- ## Integration -Установите Aspid.FastTools через UPM (Unity Package Manager) — добавьте пакет по его Git URL. Релизный workflow публикует две ветки, в корне которых лежит само содержимое пакета, поэтому параметр `?path=` указывать не нужно. +Установите Aspid.FastTools через UPM: в Package Manager нажмите **+ → Install package from git URL…** и вставьте один из URL ниже. ### Stable @@ -75,6 +76,9 @@ https://github.com/VPDPersonal/Aspid.FastTools.git#upm-preview/1.0.0-rc.2 ```sh /plugin marketplace add VPDPersonal/Aspid.Claude.Plugins +``` + +```sh /plugin install aspid-fasttools@aspid-claude-plugins ``` @@ -211,7 +215,8 @@ public sealed class TypeSelectorAttribute : PropertyAttribute public TypeSelectorAttribute(string assemblyQualifiedName) public TypeSelectorAttribute(params string[] assemblyQualifiedNames) - public TypeAllow Allow { get; set; } // по умолчанию: TypeAllow.None + public TypeAllow Allow { get; set; } // по умолчанию: TypeAllow.None + public bool Required { get; set; } // по умолчанию: false } [Flags] @@ -227,6 +232,7 @@ public enum TypeAllow | Свойство | Описание | |----------|----------| | `Allow` | Какие специальные категории типов (абстрактные классы, интерфейсы) включаются в список выбора в дополнение к обычным конкретным классам. По умолчанию: `TypeAllow.None` | +| `Required` | Помечает незаполненное поле: managed reference `[SerializeReference]`, оставшийся `null`, или пустое `string`-поле показывает предупреждение «required» в инспекторе и считается нарушением для build/CI-гейта. По умолчанию: `false` | ```csharp using UnityEngine; @@ -247,6 +253,27 @@ public sealed class AbilitySelector : MonoBehaviour > Полный сэмпл — `Ability` / `AbilitySelector` / `EnemyBase` и их наследники — поставляется в сэмпле `Types` (Package Manager → Aspid.FastTools → Samples). +Пометьте тип-кандидат атрибутом `[TypeSelectorDisplay]`, чтобы настроить, как он показывается в селекторе — это editor-only атрибут (`[Conditional("UNITY_EDITOR")]`) в `Aspid.FastTools.Types`, не несущий стоимости в рантайме: + +```csharp +using Aspid.FastTools.Types; + +// Переименовать тип в пикере, положить его в явную группу, задать tooltip и иконку: +[TypeSelectorDisplay( + Name = "Damage ×", + Group = "Combat/Modifiers", + Tooltip = "Scales incoming damage", + Icon = "d_ScriptableObject Icon")] +public sealed class DamageModifier { } +``` + +| Член | Описание | +|------|----------| +| `Name` | Отображаемое имя вместо короткого имени типа — в строках пикера и в подписи закрытого дропдауна. Поиск по-прежнему находит тип и по настоящему имени, а tooltip при наведении показывает полную идентичность `Namespace.Class, Assembly`. `null` или пробелы — без переопределения. | +| `Group` | Явный путь в пикере, уровни разделяются `/` (например `"Combat/Melee"`). **Заменяет** размещение по namespace — тип показывается только под этим путём, сегменты пути общие для разных типов. `null` или пробелы — размещение по namespace. | +| `Tooltip` | Tooltip, показываемый при наведении на строку типа. `null` — без переопределения tooltip. | +| `Icon` | Иконка редактора слева от лейбла — имя `EditorGUIUtility.IconContent`, путь к ассету в проекте с расширением (загружается через `AssetDatabase`) или путь к текстуре в `Resources` без расширения. `null` — без иконки. | + --- ### Type Selector Window @@ -255,9 +282,14 @@ public sealed class AbilitySelector : MonoBehaviour - Иерархическую организацию по пространствам имён - Текстовый поиск с фильтрацией -- Навигацию с клавиатуры (стрелки, Enter, Escape) -- Историю навигации (кнопка «назад») +- Навигацию с клавиатуры (стрелки, Enter, Escape; Space — в избранное) +- Хлебные крошки и возврат назад (стрелка ← или клик по крошке) - Разрешение неоднозначности для типов с одинаковыми именами из разных сборок +- Секции **Favorites** (★ при наведении) и **Recent** (последние выборы) на корневой странице — хранятся локально для каждого проекта (`EditorPrefs`, не попадают в репозиторий), скрыты во время поиска +- Пункт `` вверху списка и галочку ✓ у текущего значения — его строка выбирается при открытии +- Счётчики типов у групп и заголовков секций +- Поддержку generic-типов — выбор открытого generic ведёт через выбор его аргументов и возвращает сконструированный тип +- Настройку Favorites/Recent (вкл/выкл, ёмкость Recent) во вкладке Settings окна SerializeReference ![aspid_fasttools_type_selector_window.png](../Images/aspid_fasttools_type_selector_window.png) @@ -319,10 +351,117 @@ public sealed class TankEnemy : EnemyBase } ``` + ![aspid_fasttools_component_type_selector.gif](../Images/aspid_fasttools_component_type_selector.gif) --- +## SerializeReference Selector + +Готовый выпадающий список выбора типа для полей `[SerializeReference]`. Добавьте `[TypeSelector]` рядом с `[SerializeReference]` — Inspector заменит стандартный UI managed-ссылки тем же иерархическим селектором с поиском, что и `SerializableType`. Вы прямо в инспекторе выбираете, какая конкретная реализация типа поля будет создана; `` очищает ссылку. + +```csharp +using System; +using UnityEngine; +using System.Collections.Generic; +using Aspid.FastTools.Types; + +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] [TypeSelector] + private IWeapon _primary; + + [SerializeReference] [TypeSelector] + private List _sidearms; +} +``` + +Атрибут существует только в редакторе (`[Conditional("UNITY_EDITOR")]`) и не несёт стоимости в рантайме. Работает с одиночными полями, массивами и `List`, в инспекторах IMGUI и UIToolkit. + +### Возможности + +| Возможность | Что делает | +|---|---| +| **Выбор реализации** | В списке — конкретные не-`UnityEngine.Object` классы, совместимые с типом поля. `[TypeSelector(typeof(IMelee))]` сужает его до реализаций `IMelee`. | +| **Вложенный inspector** | Сериализуемые поля выбранного экземпляра рисуются под foldout. | +| **Open generics** | `Modifier` и подобные: аргументы выводятся из закрытого generic-поля либо выбираются на второй странице селектора. | +| **Сохранение данных** | При смене типа поля, совпадающие по имени и сериализуемой форме, переносятся, а не сбрасываются в значения по умолчанию. | +| **Copy / Paste** | Правый клик по заголовку копирует значение и вставляет его независимым экземпляром в любое совместимое поле. | +| **Мультивыделение** | Смешанное выделение показывает смешанное состояние dropdown; выбор или вставка применяется к каждому объекту в одной группе Undo. | +| **Проверка компилятором** | Анализатор Roslyn: `AFT0004` (ошибка) — тип наследует `UnityEngine.Object`; `AFT0005` (предупреждение) — селектор оказался бы пустым. | + +### Починка сломанных ссылок + +| Случай | Решение | +|---|---| +| **Потерянный тип** (переименован или удалён) | Жёлтое предупреждение вместо молчаливой очистки. Подчёркнутое **Fix** открывает селектор и переназначает тип с сохранением данных — на любой глубине, в сохранённых ассетах и прямо в Prefab Mode. | +| **Smart Fix** | Рядом с **Fix** предлагает наиболее вероятную замену (`[MovedFrom]`, другой namespace/сборка, регистр, близкое имя) и применяет в один клик — никогда не автоматически. | +| **Общая ссылка** (два поля делят экземпляр) | Помечается лейблом; **Make unique** расщепляет её в независимую копию. Дублирование элемента списка (Ctrl+D, `+`) больше не создаёт алиас. | + + + +Массовая починка вынесена в отдельные вкладки: + +| Вкладка | Назначение | +|---|---| +| **Asset References** (`Tools → Aspid 🐍 → FastTools → Asset References`) | Строит весь граф managed-ссылок ассета прямо из YAML — дерево по компонентам с путями полей, общими и осиротевшими ссылками, значками `MISSING` / `SHARED` и инлайн-выбором типа на каждой карточке. Достаёт потерянные ссылки, которые инспектор не показывает. | +| **Project References** (`Tools → Aspid 🐍 → FastTools → Project References`) | `Scan Project` обходит каждый `.prefab` / `.asset` / `.unity` под `Assets/`, группирует сломанные ссылки по сохранённому типу и чинит всю группу одним `Fix all` (плюс Smart Fix). Группа, чей сохранённый тип совпадает с объявленным переименованием `[MovedFrom]`, читается как ожидающая миграция, а не поломка — один клик **Migrate all** запекает переименование в файлы, после чего атрибут можно удалить из кода. | + +### Настройки проекта и build/CI gate + +**`Project Settings → Aspid FastTools → SerializeReference`** содержит: + +| Настройка | Scope | Что делает | +|---|---|---| +| **Breakage detection** | per-user | Проактивный тост + предупреждение в Console, когда ссылки заново становятся потерянными после рекомпиляции / импорта. | +| **Auto de-alias duplicated list elements** | коммитимая | Дублированный элемент списка получает собственный экземпляр вместо совместного использования id оригинала. | +| **Build / CI gate** | коммитимая | `Off` / `Warn` / `Fail`: при сборке плеера логировать или прерывать сборку на потерянных (а для CI — и на незаданных обязательных) managed-ссылках. | +| **Excluded scan folders** | коммитимая | Пути, пропускаемые при всех проектных сканах. | + +- Коммитимые значения хранятся в `ProjectSettings/SerializeReferenceSharedSettings.asset` — закоммитьте его, чтобы команда и CI вели себя одинаково; breakage detection остаётся per-machine (`EditorPrefs`). +- Rid colours — не настройка: общая ссылка всегда раскрашивается по id — совпадающий цвет и показывает, какие поля делят один экземпляр. + +Те же опции продублированы во вкладке **Settings** окна (`Tools → Aspid 🐍 → FastTools → Settings`) и на странице **`Preferences → Aspid FastTools`**, рядом с индивидуальными настройками пикера: + +- **Favorites** — переключатель секции. +- **Recent items** — слайдер ёмкости (0–20; 0 скрывает секцию и приостанавливает запись, не стирая историю). +- **Saved lists** — очищает сохранённые Favorites / Recent. +- **Appearance** — override-`StyleSheet` темы редактора с действием **Create template…**. +- **Welcome** — переключатель автопоказа. + +Каждая строка помечена полоской scope (зелёная — коммитимые, синяя — индивидуальные); закреплённый футер предлагает **Reset to defaults** отдельно для каждого scope (сохранённые списки Favorites / Recent сброс переживают). Все поверхности зеркалят друг друга живьём. + +Для headless-CI метод `SerializeReferenceCiGate.RunCheck` (через `-batchmode -executeMethod`) пишет отчёт и учитывает коммитимую строгость гейта: + +- `Off` пропускает проверку, `Warn` логирует, но завершается с кодом 0, `Fail` завершается с ненулевым кодом при нарушениях. +- `-srGateRequired` дополнительно проверяет незаданные поля `[TypeSelector(Required = true)]` в префабах, ScriptableObject и сценах (required-поля верхнего уровня, чистый YAML-проход). +- Per-run флаги `-srGateWarnOnly` / `-srGateFail` переопределяют коммитимую строгость. + +> Полный сэмпл — `Loadout` / `IWeapon` / `Modifier` и сценарии починки потерянных ссылок — поставляется в сэмпле `SerializeReferences` (Package Manager → Aspid.FastTools → Samples). Пошаговый разбор — в `TUTORIAL.md` этого сэмпла. + +--- + ## Enum System Предоставляет сериализуемые отображения enum → значение, настраиваемые через Inspector. @@ -480,69 +619,6 @@ public sealed class UniqueIdAttribute : PropertyAttribute { } --- -## SerializedProperty Extensions - -Цепочные расширения над `SerializedProperty` для синхронизации владеющего `SerializedObject`, записи типизированных значений и рефлексии над полем-источником. - -```csharp -property - .Update() - .SetVector3(Vector3.up) - .SetBool(true) - .ApplyModifiedProperties(); -``` - -Пакет покрывает: - -- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`. -- **Типизированные сеттеры** — `SetValue` (обобщённый диспетчер) и `SetXxx` для `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (и `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). К каждому идёт парный вариант `SetXxxAndApply`. -- **Enum-сеттеры** — `SetEnumFlag` и `SetEnumIndex` (каждый + `AndApply`). -- **Массивы** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (каждый + `AndApply`). -- **Ссылки** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, а также `SetBoxed` (Unity 6+). -- **Рефлексионные хелперы** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` для разрешения C#-члена и runtime-экземпляра, стоящих за property. - -> Полный справочник по методам: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md) - ---- - -## IMGUI Layout Scopes - -Три `ref struct`-области — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — оборачивают `EditorGUILayout.Begin*` / `End*`. Каждая предоставляет свойство `Rect` и вызывает соответствующий метод `End*` в `Dispose`: - -```csharp -using (VerticalScope.Begin()) -{ - EditorGUILayout.LabelField("Item 1"); - EditorGUILayout.LabelField("Item 2"); -} - -using (HorizontalScope.Begin()) -{ - EditorGUILayout.LabelField("Left"); - EditorGUILayout.LabelField("Right"); -} - -var scrollPos = Vector2.zero; -using (ScrollViewScope.Begin(ref scrollPos)) -{ - EditorGUILayout.LabelField("Scrollable content"); -} -``` - -Получить rect области через перегрузку с `out`-параметром: - -```csharp -using (VerticalScope.Begin(out var rect, GUI.skin.box)) -{ - EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f)); - EditorGUILayout.LabelField("Boxed content"); -} -``` - -Все перегрузки `Begin` соответствуют сигнатурам `EditorGUILayout.Begin*` (опциональные `GUIStyle`, `GUILayoutOption[]`, параметры scroll view и т.д.). - ---- - ## VisualElement Extensions Fluent-методы расширения для построения UIToolkit-деревьев в коде. Все методы возвращают `T` (сам элемент) для цепочки вызовов. @@ -610,6 +686,69 @@ internal sealed class AbilityConfigEditor : Editor --- +## SerializedProperty Extensions + +Цепочные расширения над `SerializedProperty` для синхронизации владеющего `SerializedObject`, записи типизированных значений и рефлексии над полем-источником. + +```csharp +property + .Update() + .SetVector3(Vector3.up) + .SetBool(true) + .ApplyModifiedProperties(); +``` + +Пакет покрывает: + +- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`. +- **Типизированные сеттеры** — `SetValue` (обобщённый диспетчер) и `SetXxx` для `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (и `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). К каждому идёт парный вариант `SetXxxAndApply`. +- **Enum-сеттеры** — `SetEnumFlag` и `SetEnumIndex` (каждый + `AndApply`). +- **Массивы** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (каждый + `AndApply`). +- **Ссылки** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, а также `SetBoxed` (Unity 6+). +- **Рефлексионные хелперы** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` для разрешения C#-члена и runtime-экземпляра, стоящих за property. + +> Полный справочник по методам: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md) + +--- + +## IMGUI Layout Scopes + +Три `ref struct`-области — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — оборачивают `EditorGUILayout.Begin*` / `End*`. Каждая предоставляет свойство `Rect` и вызывает соответствующий метод `End*` в `Dispose`: + +```csharp +using (VerticalScope.Begin()) +{ + EditorGUILayout.LabelField("Item 1"); + EditorGUILayout.LabelField("Item 2"); +} + +using (HorizontalScope.Begin()) +{ + EditorGUILayout.LabelField("Left"); + EditorGUILayout.LabelField("Right"); +} + +var scrollPos = Vector2.zero; +using (ScrollViewScope.Begin(ref scrollPos)) +{ + EditorGUILayout.LabelField("Scrollable content"); +} +``` + +Получить rect области через перегрузку с `out`-параметром: + +```csharp +using (VerticalScope.Begin(out var rect, GUI.skin.box)) +{ + EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f)); + EditorGUILayout.LabelField("Boxed content"); +} +``` + +Все перегрузки `Begin` соответствуют сигнатурам `EditorGUILayout.Begin*` (опциональные `GUIStyle`, `GUILayoutOption[]`, параметры scroll view и т.д.). + +--- + ## Editor Helper Extensions Утилитарные методы для получения отображаемых имён объектов Unity в пользовательских редакторах. 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..c7f9e68c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab @@ -0,0 +1,87 @@ +%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: + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1003 + _onHitEffect: + rid: 1004 + _modifier: + rid: -2 + _floatModifier: + rid: -2 + _modifiers: [] + 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/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..8b09b585 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab @@ -0,0 +1,87 @@ +%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: + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1003 + _onHitEffect: + rid: 1004 + _modifier: + rid: -2 + _floatModifier: + rid: -2 + _modifiers: [] + 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..574508b8 --- /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: + _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..c1e2147c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab @@ -0,0 +1,72 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &6500000000000000101 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6500000000000000102} + - component: {fileID: 6500000000000000103} + m_Layer: 0 + m_Name: LoadoutSharedRef + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6500000000000000102 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000101} + 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 &6500000000000000103 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6500000000000000101} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1002 + _onHitEffect: + rid: 1005 + 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: 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..6c2eda37 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 497032feb2ea4062b652b78771e6fe02 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab new file mode 100644 index 00000000..b2b8b856 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab @@ -0,0 +1,203 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &7100000000000000001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7100000000000000002} + - component: {fileID: 7100000000000000003} + m_Layer: 0 + m_Name: NestedLoadout + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7100000000000000002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000001} + 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: + - {fileID: 7100000000000000012} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &7100000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1003 + - rid: -2 + _onHitEffect: + rid: 1004 + references: + version: 2 + RefIds: + - rid: -2 + type: {class: , ns: , asm: } + - rid: 1001 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 1.5 + _chargeEffect: + rid: 1002 + - rid: 1002 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 + - rid: 1003 + type: {class: GhostPistol, 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 +--- !u!1 &7100000000000000011 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7100000000000000012} + - component: {fileID: 7100000000000000013} + m_Layer: 0 + m_Name: WeaponSlot + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7100000000000000012 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000011} + 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: + - {fileID: 7100000000000000022} + m_Father: {fileID: 7100000000000000002} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &7100000000000000013 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000011} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: + _primaryWeapon: + rid: 2001 + _sidearms: + - rid: 2002 + references: + version: 2 + RefIds: + - rid: 2001 + type: {class: GhostBlade, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 25 + _reach: 1.8 + - rid: 2002 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 10 + _magazineSize: 8 +--- !u!1 &7100000000000000021 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7100000000000000022} + - component: {fileID: 7100000000000000023} + m_Layer: 0 + m_Name: BackupSlot + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7100000000000000022 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000021} + 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: 7100000000000000012} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &7100000000000000023 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7100000000000000021} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3} + m_Name: + m_EditorClassIdentifier: + _primaryWeapon: + rid: 3002 + _onHitEffect: + rid: 3001 + references: + version: 2 + RefIds: + - rid: 3001 + type: {class: GhostAura, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 4 + _radius: 3 + - rid: 3002 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 6 + _spreadAngle: 30 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta new file mode 100644 index 00000000..84c1ca83 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0e1b2a3402c742c69e160b1b1476e348 +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..2d8b389e --- /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: + _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/BrokenArsenalPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset new file mode 100644 index 00000000..dce91638 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset @@ -0,0 +1,43 @@ +%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: BrokenArsenalPreset + m_EditorClassIdentifier: + _weapon: + rid: 8000 + _alternates: + - rid: 8001 + - rid: 8002 + - rid: 8003 + references: + version: 2 + RefIds: + - rid: 8000 + type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 40 + _magazineSize: 6 + - rid: 8001 + type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 18 + _magazineSize: 30 + - rid: 8002 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 12 + _magazineSize: 10 + - rid: 8003 + type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 55 + _magazineSize: 4 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta new file mode 100644 index 00000000..4d2f0dda --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c8d4e7a91b3f4e62a9d0517c3e6b2f48 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + 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/Presets/MovedWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset new file mode 100644 index 00000000..a8419d3f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.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: MovedWeaponPreset + m_EditorClassIdentifier: + _weapon: + rid: 7200 + _alternates: [] + references: + version: 2 + RefIds: + - rid: 7200 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences.Legacy, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 21 + _magazineSize: 6 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta new file mode 100644 index 00000000..0bebc674 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5eee53194e7347148e541067d421712f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset new file mode 100644 index 00000000..e79d915e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.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: RenamedWeaponPreset + m_EditorClassIdentifier: + _weapon: + rid: 7300 + _alternates: [] + references: + version: 2 + RefIds: + - rid: 7300 + type: {class: CrossbowLauncher, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 17 + _boltCount: 5 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta new file mode 100644 index 00000000..0842a705 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa278a63a2cd44d1844f31ec4281db98 +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..042611cf --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md @@ -0,0 +1,116 @@ +# SerializeReferences Sample + +A tiny loadout system that demonstrates `[TypeSelector]` — a searchable, hierarchical type dropdown for `[SerializeReference]` fields. Put both attributes on one field and the Inspector lets you pick which concrete implementation is instantiated; `` clears the reference, and the instance's serialized fields appear inline under the foldout. + +```csharp +[SerializeReference] [TypeSelector] +private IWeapon _weapon; +``` + +> **New here? Start with [TUTORIAL.md](TUTORIAL.md)** ([RU](TUTORIAL_RU.md)) — a guided, step-by-step tour (Lessons 1–8) built around `Scripts/Tutorial/TypeSelectorTutorial.cs` and `Scenes/TypeSelectorTutorial.unity`. This page is the feature reference; the tutorial is the walkthrough. + + +![The type picker window](../../Documentation/Images/aspid_fasttools_type_selector_window.png) + +*The same searchable picker window, shown here on another candidate list — your fields open it filtered to their own type hierarchy.* + +## How to run + +Ready-made demo prefabs live in `Prefabs/` — double-click one to open it in Prefab Mode, or drag it into any scene. Start with **`Loadout.prefab`**, pre-filled with `Primary Weapon = Railgun` (carrying a nested `BurnEffect` charge effect), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. Then experiment: + +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 picker offers the concrete subclasses **and** the open generic `Modifier`; picking `Modifier` opens a second page inside the same picker to choose `T`. Open `Float Modifier` — only candidates assignable to `Modifier` are offered, with `T` inferred (no extra page). The full walkthrough is [TUTORIAL.md, Lesson 6](TUTORIAL.md#lesson-6--generic-hierarchies). +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** 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. + +### The demo prefabs + +| Prefab | Shows | +|---|---| +| `Loadout.prefab` | Every field flavour: single, list, abstract base, generics, nesting | +| `SlottedLoadout.prefab` | References inside plain `[Serializable]` containers (Lesson 7) | +| `LoadoutMissingType.prefab` | The missing-type warning and its inline **Fix** | +| `NestedLoadout.prefab` | A three-level hierarchy for the **Asset References** graph | +| `LoadoutSharedRef.prefab` | Shared-reference pairs, colour coding, **Make Unique Reference** | +| `IMGUILoadout.prefab` | The same data as `Loadout.prefab`, forced through the IMGUI renderer (see *The IMGUI path* below) | + +## What's in the code + +- `Scripts/Loadout.cs` — single (`IWeapon`), `List`, abstract-base (`StatusEffect`) and generic (`IModifier` / `Modifier`) `[SerializeReference]` fields, each annotated with `[TypeSelector]`. +- `Scripts/Weapons/` — `IWeapon` interface with its `IMelee` / `IRanged` branches and implementations (`Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`). `Railgun` nests another `[TypeSelector]` field; `Crossbow` carries a `[MovedFrom]` used by the migration demo. +- `Scripts/Effects/` — abstract `StatusEffect` base with `BurnEffect` / `FreezeEffect`. The dropdown offers only the concrete subclasses. +- `Scripts/Modifiers/` — the generic hierarchy: open generic `Modifier` plus closed subclasses over `float` / `int` / `string` ([TUTORIAL.md, Lesson 6](TUTORIAL.md#lesson-6--generic-hierarchies)). +- `Scripts/WeaponSlot.cs` — the plain `[Serializable]` container used by `SlottedLoadout` and the tutorial's Lesson 7. +- `Scripts/WeaponPreset.cs` + `Presets/` — `ScriptableObject`s whose stored type identities are deliberately broken or stale, used by the repair flows below. + +## Maintenance features + +The drawer also helps recover from the 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` + +Five assets ship storing type identities that no longer resolve directly: + +- `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` references a missing `GhostWeapon`. +- `Presets/BrokenArsenalPreset.asset` — a second `ScriptableObject` that also references the missing `GhostWeapon`, three times over (`Weapon` plus two of its `Alternates`), so it shares a broken type with `BrokenWeaponPreset.asset`. +- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`. +- `Presets/MovedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` stores `Pistol` under an old `…Samples.SerializeReferences.Legacy` namespace, as if the class had been moved without `[MovedFrom]` — this one demonstrates the one-click **Smart Fix** below. +- `Presets/RenamedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` stores the old `CrossbowLauncher` class name; the class now ships as `Crossbow` carrying a declared `[MovedFrom]`, so the Inspector shows a healthy weapon and only the file is stale — this one demonstrates the **Migrate all** flow in Project References. + +Select any of the first four **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. + +When the broken identity has a plausible successor, the warning also carries a one-click **Smart Fix** suggestion — +open `MovedWeaponPreset.asset`: its notice ends with **`→ Pistol?`** (hover for the full identity and the ranking +reason). Click it to re-point the reference without opening the picker, keeping `_damage = 21`, `_magazineSize = 6`. + +- Ranking, highest first: a declared `[MovedFrom]` match, a same-named type in another namespace/assembly, a casing-only rename, a near-miss name backed by the orphaned data's field shape. +- Never applied automatically — you always click. +- A move that ships `[MovedFrom]` from the start never breaks (Unity migrates it on load); Smart Fix catches the moves that forgot it. The `GhostWeapon`/`GhostPistol` assets have no plausible successor, so they show no suggestion — that contrast is intentional. + +> 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 works for ScriptableObjects and prefab assets selected in the Project (rewritten in their YAML), for objects open in **Prefab Mode** (repaired on the live instance), and for objects in a **clean saved scene** (located via `GlobalObjectId`) — but not for an **unsaved/dirty scene** or a **prefab-instance override**, which have no committed asset document to map the reference to. +> +> When a missing reference is nested inside another value or sits on a child object the Inspector can't reach, use **`Tools → Aspid 🐍 → FastTools → Asset References`** instead: it scans the whole asset file and lists every missing reference (any depth, any child) with its own **Fix** picker. +> +> Its **Project References** tab sweeps every asset under `Assets/` and groups the broken references by their stored type — so `BrokenWeaponPreset.asset` and `BrokenArsenalPreset.asset` collapse into a single **GhostWeapon** group (`4 entries · 2 files`). One **Fix all** picks a single replacement and re-points every entry across both files at once. And `RenamedWeaponPreset.asset` surfaces there as a calm, info-tinted **pending migration** instead of a warning: its stored `CrossbowLauncher` matches the `[MovedFrom]` declared on `Crossbow`, so the card offers an authoritative **Migrate all (1) → Crossbow** that bakes the rename into the file — after which the attribute could be deleted from code. + +### Map a nested graph — `NestedLoadout.prefab` + +`Prefabs/NestedLoadout.prefab` is a three-level hierarchy — `NestedLoadout → WeaponSlot → BackupSlot` — with a `Loadout` on **every** object, so each child carries a broken reference the Inspector can't reach from outside Prefab Mode: + +- **NestedLoadout** (root) — `Primary Weapon = Railgun` (with a nested `BurnEffect` charge effect), `Sidearms = [GhostPistol (missing), (empty slot)]`, `On Hit Effect = FreezeEffect`. +- **WeaponSlot** (child) — `Primary Weapon = GhostBlade` (missing), `Sidearms[0] = Pistol`. +- **BackupSlot** (grandchild) — `On Hit Effect = GhostAura` (missing), `Primary Weapon = Shotgun`. + +Select it **in the Project window** and open the **Asset References** tab — **`Tools → Aspid 🐍 → FastTools → Asset References`**. The graph maps all three components at once (one document per object). Every reference is an inline dropdown: pick a type to assign / re-point it, or `` to clear it; the missing `GhostPistol` / `GhostBlade` / `GhostAura` cards carry the amber **Fix Missing** action. Nesting is read from the field path (`_primaryWeapon._chargeEffect`), not from indentation, so the flat card stack stays scannable. + +### Un-share aliased references & tell groups apart by colour — `LoadoutSharedRef.prefab` + +`Prefabs/LoadoutSharedRef.prefab` carries **two independent** shared-reference pairs on one object (each pair is a state you can also reach by duplicating an array element), so the rid-colour stripe/notice actually earns its keep: + +- `Sidearms[0]` and `Sidearms[1]` both back the same `Pistol` — one colour. +- `Primary Weapon → Charge Effect` and `On Hit Effect` both back the same `BurnEffect` — a different colour, even though one is nested three levels deep and the other is a top-level field. + +1. Open it — each pair shows a **shared reference** notice and editing one member changes its partner. Matching stripe/notice colour means matching instance regardless of where the field sits in the hierarchy, so the two pairs read as two distinct colours. +2. **Right-click** a member → **Make Unique Reference**. It gets its own copy of the data and the two fields become independent — its notice clears, and so does its former partner's, since nothing is shared any more. + +## The IMGUI path + +The drawer ships both a UIToolkit and an IMGUI rendering path, at full feature parity. **`Prefabs/IMGUILoadout.prefab`** carries the same data as `Loadout.prefab` but forces the IMGUI path, so you can compare the two renderers side by side — or copy the pattern into an IMGUI-only project. + +The trick is the companion editor: `IMGUILoadoutEditor` overrides `OnInspectorGUI` **without** `CreateInspectorGUI` — that alone routes every nested `[TypeSelector]` field through `SerializeReferenceIMGUIPropertyDrawer` instead of the UIToolkit `CreatePropertyGUI`. The one IMGUI-specific caveat: Unity applies the drawer per list *element*, so a `[SerializeReference]` list's **+** button is drawn with `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` to keep the picker-backed, de-aliased add. See `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs`. + +All maintenance windows (**Asset References**, **Project References**) are renderer-agnostic and work identically for both paths. 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..81c283a7 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md @@ -0,0 +1,117 @@ +# Пример SerializeReferences + +Маленькая система снаряжения, демонстрирующая `[TypeSelector]` — иерархический выпадающий список с поиском для полей `[SerializeReference]`. Поставьте оба атрибута на одно поле — и прямо в Inspector выбираете, какая конкретная реализация будет создана; `` очищает ссылку, а сериализуемые поля экземпляра появляются вложенно под foldout. + +```csharp +[SerializeReference] [TypeSelector] +private IWeapon _weapon; +``` + +> **Впервые здесь? Начните с [TUTORIAL_RU.md](TUTORIAL_RU.md)** ([EN](TUTORIAL.md)) — пошаговый разбор (уроки 1–8) вокруг `Scripts/Tutorial/TypeSelectorTutorial.cs` и `Scenes/TypeSelectorTutorial.unity`. Эта страница — справочник по фичам, а туториал — пошаговое прохождение. + + +![Окно выбора типа](../../Documentation/Images/aspid_fasttools_type_selector_window.png) + +*То же окно-селектор с поиском, здесь показано на другом списке кандидатов — ваши поля открывают его отфильтрованным под собственную иерархию типов.* + +## Как запустить + +В `Prefabs/` лежат готовые демо-префабы — дважды кликните, чтобы открыть в Prefab Mode, или перетащите любой в сцену. Начните с **`Loadout.prefab`** — он предзаполнен: `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. Дальше поэкспериментируйте: + +1. Кликните по любому дропдауну типа и выберите другую реализацию — экземпляр создастся, а его сериализуемые поля появятся вложенно под foldout. +2. Разверните `Railgun` и смените вложенный `Charge Effect` — увидите рекурсивное полиморфное редактирование. +3. Нажмите **+** на `Sidearms` и задайте каждому элементу свой тип оружия. +4. Откройте `On Hit Effect` — обратите внимание, что предлагаются только `BurnEffect` / `FreezeEffect` (абстрактный `StatusEffect` скрыт). +5. Откройте `Modifier` — селектор предлагает конкретные подтипы **и** открытый `Modifier`; при выборе `Modifier` внутри того же окна открывается вторая страница для выбора `T`. Откройте `Float Modifier` — предлагаются только присваиваемые к `Modifier` кандидаты, `T` выводится сам (без дополнительной страницы). Полный разбор — [TUTORIAL_RU.md, урок 6](TUTORIAL_RU.md#урок-6--generic-иерархии). +6. ПКМ по заголовку компонента → **Log Loadout**, чтобы вывести настроенное снаряжение в Console. + +Хотите собрать с нуля? Добавьте пустой GameObject и прикрепите компонент **Loadout**. + +Переключение поля обратно на `` очищает ссылку. Если сохранённый тип позже переименуют или удалят, в списке появится подпись `` и предупреждение, вместо тихой очистки. + +### Демо-префабы + +| Префаб | Показывает | +|---|---| +| `Loadout.prefab` | Все виды полей: одиночное, список, абстрактная база, generics, вложенность | +| `SlottedLoadout.prefab` | Ссылки внутри обычных `[Serializable]`-контейнеров (урок 7) | +| `LoadoutMissingType.prefab` | Предупреждение о потерянном типе и инлайн-**Fix** | +| `NestedLoadout.prefab` | Трёхуровневая иерархия для графа **Asset References** | +| `LoadoutSharedRef.prefab` | Пары общих ссылок, цветовая кодировка, **Make Unique Reference** | +| `IMGUILoadout.prefab` | Те же данные, что у `Loadout.prefab`, но принудительно через IMGUI-рендерер (см. *Путь IMGUI* ниже) | + +## Что в коде + +- `Scripts/Loadout.cs` — одиночное поле (`IWeapon`), `List`, поле с абстрактным базовым типом (`StatusEffect`) и generic-поля (`IModifier` / `Modifier`), каждое с `[SerializeReference]` и `[TypeSelector]`. +- `Scripts/Weapons/` — интерфейс `IWeapon` с ветками `IMelee` / `IRanged` и реализациями (`Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`). `Railgun` вкладывает ещё одно поле `[TypeSelector]`; `Crossbow` несёт `[MovedFrom]`, используемый демо миграции. +- `Scripts/Effects/` — абстрактный базовый `StatusEffect` с `BurnEffect` / `FreezeEffect`. В списке предлагаются только конкретные подтипы. +- `Scripts/Modifiers/` — generic-иерархия: открытый `Modifier` и закрытые подтипы над `float` / `int` / `string` ([TUTORIAL_RU.md, урок 6](TUTORIAL_RU.md#урок-6--generic-иерархии)). +- `Scripts/WeaponSlot.cs` — обычный `[Serializable]`-контейнер, общий для `SlottedLoadout` и урока 7 туториала. +- `Scripts/WeaponPreset.cs` + `Presets/` — `ScriptableObject`-ы с намеренно сломанными или устаревшими идентичностями типов, используемые потоками починки ниже. + +## Сервисные функции + +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`. +- `Presets/BrokenArsenalPreset.asset` — второй `ScriptableObject`, который тоже ссылается на потерянный `GhostWeapon`, причём трижды (`Weapon` плюс два элемента `Alternates`), поэтому делит сломанный тип с `BrokenWeaponPreset.asset`. +- `Prefabs/LoadoutMissingType.prefab` — префаб, `Sidearms → Element 0` ссылается на потерянный `GhostPistol`. +- `Presets/MovedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` хранит `Pistol` под старым namespace `…Samples.SerializeReferences.Legacy` — как будто класс перенесли без `[MovedFrom]`; именно он демонстрирует одно-кликовый **Smart Fix** ниже. +- `Presets/RenamedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` хранит старое имя класса `CrossbowLauncher`; сам класс теперь называется `Crossbow` и несёт объявленный `[MovedFrom]`, поэтому инспектор показывает здоровое оружие и устарел только файл — именно он демонстрирует поток **Migrate all** в Project References. + +Выделите любой из первых четырёх **в окне Project**. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix**: + +1. Нажмите **Fix** — откроется привычный селектор типов с поиском. Выберите `Pistol`. +2. Ссылка восстановится в `Pistol` с сохранёнными данными (префаб сохранит `_damage = 15`, `_magazineSize = 12`; ассет — `_damage = 25`, `_magazineSize = 8`). Выбор типа переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново — поэтому значения сохраняются. + +Когда у сломанной идентичности есть правдоподобный преемник, предупреждение дополняется одно-кликовой подсказкой +**Smart Fix** — откройте `MovedWeaponPreset.asset`: его плашка заканчивается на **`→ Pistol?`** (наведите курсор, +чтобы увидеть полную идентичность и причину ранжирования). Клик перенаправляет ссылку без открытия селектора, +сохраняя `_damage = 21`, `_magazineSize = 6`. + +- Ранжирование, от высшего балла: объявленное совпадение `[MovedFrom]`, одноимённый тип в другом namespace/сборке, переименование только по регистру, похожее имя, подкреплённое формой полей осиротевших данных. +- Никогда не применяется автоматически — клик всегда за вами. +- Перенос, сразу снабжённый `[MovedFrom]`, вообще не ломается (Unity мигрирует ссылку при загрузке); Smart Fix ловит переносы, где про атрибут забыли. У `GhostWeapon`/`GhostPistol`-ассетов преемника нет, поэтому подсказки у них не появляется — этот контраст намеренный. + +> Починка читает и переписывает файл ассета напрямую — Unity не отдаёт потерянный тип через свой serialization API (а на GameObject/префабах ещё и обнуляет его в живом объекте, UUM-129100), поэтому осиротевшие тип и данные восстанавливаются прямо из YAML. Работает для ScriptableObject и префаб-ассетов, выделенных в Project (переписывается их YAML), для объектов в **Prefab Mode** (чинится на живом экземпляре) и для объектов в **сохранённой чистой сцене** (находятся через `GlobalObjectId`) — но не для **несохранённой/грязной сцены** или **переопределения на экземпляре префаба**, у которых нет зафиксированного файла ассета для маппинга ссылки. +> +> Если потерянная ссылка вложена в другое значение или лежит на дочернем объекте, до которого не добраться в инспекторе — используйте **`Tools → Aspid 🐍 → FastTools → Asset References`**: вкладка сканирует весь файл ассета и выводит все потерянные ссылки (любой глубины, на любом дочернем объекте), каждую со своим **Fix**. +> +> Вкладка **Project References** проходит по всем ассетам в `Assets/` и группирует потерянные ссылки по сохранённому типу — поэтому `BrokenWeaponPreset.asset` и `BrokenArsenalPreset.asset` сворачиваются в одну группу **GhostWeapon** (`4 entries · 2 files`). Одна кнопка **Fix all** выбирает единственную замену и перенаправляет все вхождения сразу в обоих файлах. А `RenamedWeaponPreset.asset` всплывает там как спокойная info-подсвеченная **ожидающая миграция**, а не предупреждение: его сохранённый `CrossbowLauncher` совпадает с `[MovedFrom]`, объявленным на `Crossbow`, поэтому карточка предлагает авторитетный **Migrate all (1) → Crossbow**, запекающий переименование в файл — после чего атрибут можно удалить из кода. + +### Карта вложенного графа — `NestedLoadout.prefab` + +`Prefabs/NestedLoadout.prefab` — трёхуровневая иерархия `NestedLoadout → WeaponSlot → BackupSlot`, на **каждом** объекте свой `Loadout`, так что на каждом дочернем объекте есть потерянная ссылка, до которой не добраться в инспекторе вне Prefab Mode: + +- **NestedLoadout** (корень) — `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [GhostPistol (потерян), (пустой слот)]`, `On Hit Effect = FreezeEffect`. +- **WeaponSlot** (дочерний) — `Primary Weapon = GhostBlade` (потерян), `Sidearms[0] = Pistol`. +- **BackupSlot** (внук) — `On Hit Effect = GhostAura` (потерян), `Primary Weapon = Shotgun`. + +Выделите его **в окне Project** и откройте вкладку **Asset References** — **`Tools → Aspid 🐍 → FastTools → Asset References`**. Граф строит сразу все три компонента (по документу на объект). Каждая ссылка — инлайн-дропдаун: выберите тип, чтобы присвоить / перенаправить её, или ``, чтобы очистить; у потерянных `GhostPistol` / `GhostBlade` / `GhostAura` карточек — янтарное действие **Fix Missing**. Вложенность читается по пути поля (`_primaryWeapon._chargeEffect`), а не по отступу, поэтому плоский список карточек остаётся читаемым. + +### Расцепление общих ссылок и различение групп по цвету — `LoadoutSharedRef.prefab` + +В `Prefabs/LoadoutSharedRef.prefab` на одном объекте — **две независимые** пары общих ссылок (каждую пару можно также получить дублированием элемента массива), поэтому цветовая полоска/нотис по rid здесь действительно полезна: + +- `Sidearms[0]` и `Sidearms[1]` ссылаются на один и тот же `Pistol` — один цвет. +- `Primary Weapon → Charge Effect` и `On Hit Effect` ссылаются на один и тот же `BurnEffect` — другой цвет, хотя одно поле лежит на три уровня вложенности глубже, а другое — на верхнем уровне. + +1. Откройте его — каждая пара показывает пометку **shared reference**, и редактирование одного элемента меняет его партнёра. Совпадающий цвет полоски/нотиса означает один и тот же экземпляр, независимо от места поля в иерархии, поэтому две пары читаются как два разных цвета. +2. **ПКМ** по элементу → **Make Unique Reference**. Он получит собственную копию данных, и поля станут независимыми — его пометка исчезнет, как и у бывшего партнёра, ведь ничего больше не общее. + +## Путь IMGUI + +Drawer поддерживает и UIToolkit-, и IMGUI-рендеринг на полном паритете функций. **`Prefabs/IMGUILoadout.prefab`** несёт те же данные, что и `Loadout.prefab`, но принудительно идёт по IMGUI-пути — так можно сравнить оба рендерера бок о бок или скопировать паттерн в IMGUI-проект. + +Секрет — в companion-editor: `IMGUILoadoutEditor` переопределяет `OnInspectorGUI` **без** `CreateInspectorGUI` — этого достаточно, чтобы каждое вложенное поле `[TypeSelector]` пошло через `SerializeReferenceIMGUIPropertyDrawer`, а не через UIToolkit-путь `CreatePropertyGUI`. Единственная IMGUI-особенность: Unity применяет drawer к каждому *элементу* списка, поэтому кнопка **+** `[SerializeReference]`-списка рисуется через `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` — это сохраняет добавление через селектор без алиасинга. Образец — `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs`. + +Все сервисные окна (**Asset References**, **Project References**) не зависят от рендерера и работают одинаково для обоих путей. 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/Scenes.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes.meta new file mode 100644 index 00000000..132f1395 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 075cca7b11084f27bbf9242a1f54c774 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity new file mode 100644 index 00000000..0ec8ea98 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity @@ -0,0 +1,442 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 100003} + - component: {fileID: 100002} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &100002 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100001} + m_Enabled: 1 + serializedVersion: 13 + m_Type: 1 + m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1} + m_Intensity: 1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 10, y: 10} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShapeRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &100003 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100001} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261} + m_LocalPosition: {x: 0, y: 3, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} +--- !u!1 &200001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 200004} + - component: {fileID: 200003} + - component: {fileID: 200002} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!81 &200002 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200001} + m_Enabled: 1 +--- !u!20 &200003 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200001} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: -1 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &200004 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200001} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 1, z: -10} + 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!1 &300001 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 300002} + - component: {fileID: 300003} + m_Layer: 0 + m_Name: TypeSelector Tutorial + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &300002 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 300001} + 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 &300003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 300001} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5c5ff252d28c48738739916254d8480f, type: 3} + m_Name: + m_EditorClassIdentifier: + _step1Single: + rid: 1001 + _step2List: + - rid: 1002 + - rid: 1003 + _step3Abstract: + rid: 1004 + _step4Ranged: + rid: 1005 + _step4Melee: + rid: 1006 + _step4MeleeOrRanged: + rid: 1007 + _step5Nested: + rid: 1008 + _step6Open: + rid: -2 + _step6Closed: + rid: -2 + _step7Slot: + label: Primary + priority: 0 + _weapon: + rid: 1010 + _step7Slots: [] + _step8Required: + rid: -2 + references: + version: 2 + RefIds: + - rid: 1001 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 15 + _magazineSize: 12 + - rid: 1002 + type: {class: Sword, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 30 + _reach: 1.8 + - rid: 1003 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 8 + _spreadAngle: 25 + - rid: 1004 + type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 3 + _damagePerSecond: 5 + - rid: 1005 + type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _pellets: 6 + _spreadAngle: 30 + - rid: 1006 + type: {class: Sword, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 25 + _reach: 1.5 + - rid: 1007 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 10 + _magazineSize: 8 + - rid: 1008 + type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _chargeTime: 2 + _chargeEffect: + rid: 1009 + - rid: 1009 + type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _duration: 2.5 + _slowPercent: 40 + - rid: 1010 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 12 + _magazineSize: 10 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 200004} + - {fileID: 100003} + - {fileID: 300002} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta new file mode 100644 index 00000000..f58969d1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ed68d5dff9146498c5c12697d811d0a +DefaultImporter: + 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..f8990489 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c6859af7d0f4560a27fc9398c16cac2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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..7ac9b27c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5ae54c703d8f4ad4a074367f0614d47c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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..31ebbc9b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "Aspid.FastTools.Samples.SerializeReferences.Editor", + "rootNamespace": "", + "references": [ + "Aspid.FastTools.Samples.SerializeReferences", + "Aspid.FastTools.Unity.Editor" + ], + "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..b3627e37 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs @@ -0,0 +1,53 @@ +using UnityEditor; +using UnityEngine; +using Aspid.FastTools.SerializeReferences.Editors; + +// 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 [TypeSelector] fields + // through SerializeReferenceIMGUIPropertyDrawer.OnGUI instead of CreatePropertyGUI. + // + // The single-reference fields (_primaryWeapon, _onHitEffect, _modifier, _floatModifier) + // render straight through PropertyField. The two [SerializeReference] LISTS are drawn via + // SerializeReferenceIMGUIList instead: Unity applies a [TypeSelector] drawer per element in + // IMGUI, so it can never reach the list's "+" — the helper restores the picker-backed, + // de-aliased add the UIToolkit ListView gets automatically. + [CustomEditor(typeof(IMGUILoadout))] + internal sealed class IMGUILoadoutEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + var iterator = serializedObject.GetIterator(); + iterator.NextVisible(enterChildren: true); // skip m_Script + + while (iterator.NextVisible(enterChildren: false)) + { + switch (iterator.name) + { + case "_sidearms": + SerializeReferenceIMGUIList.Draw(iterator.Copy(), + new GUIContent(iterator.displayName), typeof(IWeapon)); + break; + + case "_modifiers": + SerializeReferenceIMGUIList.Draw(iterator.Copy(), + new GUIContent(iterator.displayName), typeof(IModifier)); + break; + + default: + EditorGUILayout.PropertyField(iterator, includeChildren: true); + break; + } + } + + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta similarity index 86% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta index e8f28c1e..0d589592 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 13105acb34d494c0dbc255827c74607b +guid: 49097153234d4b66b7eacaff0ba4d88d MonoImporter: externalObjects: {} serializedVersion: 2 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..18c6a76c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f9e11c2bbf345a0a2895fc744afba61 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta similarity index 86% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta index 97f0c06a..1c4aba5a 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 623f45f16b1a4e1ba50feb1123e63176 +guid: 7dd6bde21faf4019b74c28d087f07570 MonoImporter: externalObjects: {} serializedVersion: 2 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/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta similarity index 86% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta index e6fb6c30..f5bdb149 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 5c5edebc830a4b6e87b10075287af86d +guid: 95af46da00c94957a8642c716ab6aabb MonoImporter: externalObjects: {} serializedVersion: 2 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..7f033c7a --- /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, [TypeSelector] 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..40344a43 --- /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.Types; + +// 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 [TypeSelector] 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] [TypeSelector] + private IWeapon _primaryWeapon; + + [SerializeReference] [TypeSelector] + private List _sidearms = new(); + + [SerializeReference] [TypeSelector] + private StatusEffect _onHitEffect; + + [SerializeReference] [TypeSelector] + private IModifier _modifier; + + [SerializeReference] [TypeSelector] + private Modifier _floatModifier; + + [SerializeReference] [TypeSelector] + 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..b856506e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // The demo component behind the Prefabs/ scenarios: every flavour of [SerializeReference] + + // [TypeSelector] field (single, list, abstract base, generics) on one MonoBehaviour, rendered + // through the default UIToolkit Inspector. The guided walkthrough of each flavour lives in + // TUTORIAL.md and Scripts/Tutorial/TypeSelectorTutorial.cs. + public sealed class Loadout : MonoBehaviour + { + // Interface-typed field: lists every IWeapon implementation (Sword, Pistol, Shotgun, Railgun, Crossbow). + [SerializeReference] [TypeSelector] + private IWeapon _primaryWeapon; + + // Each list element is its own independent picker. + [SerializeReference] [TypeSelector] + private List _sidearms = new(); + + // Abstract-base field: the picker offers BurnEffect / FreezeEffect, never StatusEffect. + [SerializeReference] [TypeSelector] + private StatusEffect _onHitEffect; + + // Open-generic entry point: offers the closed subclasses AND Modifier itself (see Modifiers/). + [SerializeReference] [TypeSelector] + private IModifier _modifier; + + // Closed-generic field type: candidates are constrained by assignability to Modifier. + [SerializeReference] [TypeSelector] + private Modifier _floatModifier; + + // Polymorphic list mixing different closed-generic subclasses. + [SerializeReference] [TypeSelector] + 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..ef9c0909 --- /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 [TypeSelector] 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..c10a5e4d --- /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 [TypeSelector] generic test. + // + // Because it is a concrete open generic, [TypeSelector] 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..1fcf1835 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates [TypeSelector] on references that live INSIDE plain [Serializable] + // containers (the shared WeaponSlot.cs) — a single container field and a List of them — + // instead of directly on the component. See TUTORIAL.md, Lesson 7: everything (picker, + // inline child fields, missing-type Fix) works at this depth exactly as for a top-level field. + public sealed class SlottedLoadout : MonoBehaviour + { + // 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..8862fc1a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 251896effa22341e7981b29000d77094 +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/Tutorial.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial.meta new file mode 100644 index 00000000..4ebb835a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 09e31347a89c49b1b3fd66d884de46b6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs new file mode 100644 index 00000000..2f3bdac0 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // ───────────────────────────────────────────────────────────────────────────────────────────── + // [TypeSelector] for [SerializeReference] — a guided, step-by-step tour. + // + // Read this component top to bottom: each [Header("STEP N …")] is one self-contained lesson, + // ordered from the simplest picker to generics, narrowing and required validation. Open the + // bundled Scenes/TypeSelectorTutorial.unity (a few steps are pre-filled) and follow along, or + // drop this component on an empty GameObject and start from a clean slate. + // + // The companion TUTORIAL.md / TUTORIAL_RU.md walk through the same steps in prose and add the + // power-user gestures (copy/paste, templates, drag-drop, repair) and window-level tools that + // do not show up as plain fields. + // + // The rule for every step: put [SerializeReference] AND [TypeSelector] on the same field. The + // first makes Unity store a polymorphic instance; the second renders the searchable type picker. + // ───────────────────────────────────────────────────────────────────────────────────────────── + public sealed class TypeSelectorTutorial : MonoBehaviour + { + // STEP 1 — Your first picker. + // A single interface-typed field. Click the dropdown to pick any IWeapon implementation + // (Sword, Pistol, Shotgun, Railgun, Crossbow). Type to search; ↑↓ navigate; Space stars a favourite; + // the first row, , clears the field. The chosen instance's own fields appear inline. + [Header("STEP 1 — Single polymorphic reference")] + [SerializeReference] [TypeSelector] + [Tooltip("Click the dropdown → pick an IWeapon. Type to search, Space favourites, clears.")] + private IWeapon _step1Single; + + // STEP 2 — Lists and arrays. + // Every element gets its own independent picker. The list's "+" is replaced: instead of + // duplicating the last element it opens the picker so you choose the new element's type. + [Header("STEP 2 — List / array of references")] + [SerializeReference] [TypeSelector] + [Tooltip("Press + — the picker opens (it does not duplicate). Each element picks its own type.")] + private List _step2List = new(); + + // STEP 3 — Abstract bases and interfaces. + // StatusEffect is abstract, so the picker offers only its concrete subclasses + // (BurnEffect, FreezeEffect). You can never pick the abstract base — it cannot be instantiated. + [Header("STEP 3 — Abstract base → only concrete subclasses")] + [SerializeReference] [TypeSelector] + [Tooltip("Only BurnEffect / FreezeEffect are listed; the abstract StatusEffect is hidden.")] + private StatusEffect _step3Abstract; + + // STEP 4 — Narrowing the candidate list. + // All three fields are declared IWeapon, yet the picker shows different sets: the base type(s) + // passed to [TypeSelector(...)] act as an extra filter BELOW the declared type. You can narrow + // to one branch, or pass several base types to union them. + [Header("STEP 4 — Narrow candidates with [TypeSelector(typeof(...))]")] + [SerializeReference] [TypeSelector(typeof(IRanged))] + [Tooltip("typeof(IRanged) → only Pistol, Shotgun, Railgun, Crossbow.")] + private IWeapon _step4Ranged; + + [SerializeReference] [TypeSelector(typeof(IMelee))] + [Tooltip("typeof(IMelee) → only Sword.")] + private IWeapon _step4Melee; + + [SerializeReference] [TypeSelector(typeof(IMelee), typeof(IRanged))] + [Tooltip("Multiple base types are OR-ed → the whole IWeapon hierarchy again.")] + private IWeapon _step4MeleeOrRanged; + + // STEP 5 — Nested references (recursion). + // Pick Railgun here, then expand it: its Charge Effect is itself a [SerializeReference] + // [TypeSelector] field, so it gets its own picker. The drawer nests to any depth. + [Header("STEP 5 — Nested reference inside the payload")] + [SerializeReference] [TypeSelector] + [Tooltip("Pick Railgun, expand it — its Charge Effect is a picker too (recursive).")] + private IWeapon _step5Nested; + + // STEP 6 — Generics. + // Modifier is a concrete OPEN generic. On a non-generic IModifier field the picker offers the + // closed subclasses (DamageModifier/AmmoModifier/NameModifier) AND "Modifier" itself — picking + // the latter opens a second page to choose T before the instance is built. + [Header("STEP 6 — Generic hierarchy (open + closed)")] + [SerializeReference] [TypeSelector] + [Tooltip("Offers the 3 subclasses AND open Modifier; picking Modifier asks for T (try string, then float).")] + private IModifier _step6Open; + + // A closed-generic field type fixes T, so the picker constrains candidates by assignability and + // builds Modifier directly — no second page. AmmoModifier (int) / NameModifier (string) + // are excluded; DamageModifier (float) and Modifier remain. + [SerializeReference] [TypeSelector] + [Tooltip("T is fixed to float: only DamageModifier and Modifier are offered, no extra page.")] + private Modifier _step6Closed; + + // STEP 7 — References nested in plain [Serializable] containers. + // The picker, inline child fields and every repair gesture work at any depth — not just when the + // field sits directly on the component. Here a polymorphic weapon lives inside a container + // (the shared WeaponSlot.cs), and inside each element of a list of containers. + [Header("STEP 7 — References inside [Serializable] containers")] + [SerializeField] + [Tooltip("The weapon lives one level deep, inside this container — still a full picker.")] + private WeaponSlot _step7Slot = new(); + + [SerializeField] + [Tooltip("Each list element is a container whose weapon is its own picker.")] + private List _step7Slots = new(); + + // STEP 8 — Required references. + // TypeSelector's Required flag marks an unset reference with an inline warning (and feeds the + // build/CI gate). Leave it empty to see the warning; pick any type to clear it. + [Header("STEP 8 — Required reference validation")] + [SerializeReference] [TypeSelector(Required = true)] + [Tooltip("Empty → 'Required reference is not set' notice. Pick any IWeapon to satisfy it.")] + private IWeapon _step8Required; + + // Right-click the component header → "Log Tutorial State" to print every configured step. + [ContextMenu("Log Tutorial State")] + private void LogTutorialState() + { + Debug.Log($"STEP 1 single: {Describe(_step1Single)}"); + + for (var i = 0; i < _step2List.Count; i++) + Debug.Log($"STEP 2 list[{i}]: {Describe(_step2List[i])}"); + + Debug.Log($"STEP 3 abstract: {(_step3Abstract is null ? "none" : _step3Abstract.Describe())}"); + Debug.Log($"STEP 4 ranged: {Describe(_step4Ranged)} | melee: {Describe(_step4Melee)} | either: {Describe(_step4MeleeOrRanged)}"); + Debug.Log($"STEP 5 nested: {Describe(_step5Nested)}"); + Debug.Log($"STEP 6 open: {(_step6Open is null ? "none" : _step6Open.Describe())} | closed: {(_step6Closed is null ? "none" : _step6Closed.Describe())}"); + Debug.Log($"STEP 8 required: {Describe(_step8Required)}"); + } + + private static string Describe(IWeapon weapon) => weapon?.Describe() ?? "none"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta new file mode 100644 index 00000000..b6770353 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c5ff252d28c48738739916254d8480f +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/WeaponPreset.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs new file mode 100644 index 00000000..969c4e2a --- /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.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // ScriptableObject host for [TypeSelector], 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 "Fix" 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 a "Fix" 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] [TypeSelector] + private IWeapon _weapon; + + [SerializeReference] [TypeSelector] + 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/WeaponSlot.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs new file mode 100644 index 00000000..5c1b6f1b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs @@ -0,0 +1,21 @@ +using System; +using UnityEngine; +using Aspid.FastTools.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // 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 (TUTORIAL.md, Lesson 7). Shared by TypeSelectorTutorial and SlottedLoadout. + [Serializable] + public sealed class WeaponSlot + { + public string label = "Slot"; + + [Min(0)] public int priority; + + [SerializeReference] [TypeSelector] + private IWeapon _weapon; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta new file mode 100644 index 00000000..24606f3e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8fd4eb93eab448f6afee150ba0df31a5 +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.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta new file mode 100644 index 00000000..baff8dd5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b9b0ae5ee78a4faa9a79767c9f22771b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs new file mode 100644 index 00000000..f3cf6584 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs @@ -0,0 +1,22 @@ +using System; +using UnityEngine; +using UnityEngine.Scripting.APIUpdating; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Renamed from "CrossbowLauncher" — the declared [MovedFrom] is what turns Presets/RenamedWeaponPreset.asset + // (whose YAML still stores the old class name) into a pending migration rather than a breakage: Unity migrates + // the loaded object in memory, the Inspector shows a healthy Crossbow, and Project References offers an + // authoritative "Migrate all" that — after a confirm dialog with a YAML diff preview — bakes the rename into + // the file. Once no file stores the old name, this attribute can be deleted. + [Serializable] + [MovedFrom(false, null, null, "CrossbowLauncher")] + public sealed class Crossbow : IRanged + { + [SerializeField] [Min(0)] private int _damage = 14; + [SerializeField] [Min(0)] private int _boltCount = 8; + + public string Describe() => $"Crossbow — {_damage} dmg, {_boltCount} bolts"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta new file mode 100644 index 00000000..994d0fc6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fddd370bf45b4362b0de436ec2fc0c1b +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/IMelee.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs new file mode 100644 index 00000000..48960d23 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs @@ -0,0 +1,10 @@ +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Marker sub-interface used by the tutorial's "narrowing" step. + // + // A field declared as IWeapon normally lists every IWeapon implementation. Annotating it with + // [TypeSelector(typeof(IMelee))] narrows that same field to the melee branch only (Sword) — the + // attribute's base type is applied as an extra filter BELOW the declared field type. + public interface IMelee : IWeapon { } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta new file mode 100644 index 00000000..7502913f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ae1c814cbe44fb28cc39dd64004e7b0 +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/IRanged.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs new file mode 100644 index 00000000..7064a609 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs @@ -0,0 +1,10 @@ +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Marker sub-interface used by the tutorial's "narrowing" step. + // + // Pistol, Shotgun, Railgun and Crossbow implement it. [TypeSelector(typeof(IRanged))] on an IWeapon + // field offers only those four; [TypeSelector(typeof(IMelee), typeof(IRanged))] offers both branches + // (multiple base types are OR-ed), and a bare [TypeSelector] offers every IWeapon. + public interface IRanged : IWeapon { } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta new file mode 100644 index 00000000..45c0500f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74915b06fb274b28a1704a6d835b4766 +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/IWeapon.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs new file mode 100644 index 00000000..7322fc92 --- /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. + // + // [TypeSelector] 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..0811fcee --- /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 (ranged branch). 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 : IRanged + { + [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..b45c8928 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs @@ -0,0 +1,25 @@ +using System; +using UnityEngine; +using Aspid.FastTools.Types; + +// 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. + // Ranged branch of the IWeapon hierarchy (see IRanged). + [Serializable] + public sealed class Railgun : IRanged + { + [SerializeField] [Min(0f)] private float _chargeTime = 1.5f; + + [SerializeReference] [TypeSelector] + 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..313781f9 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs @@ -0,0 +1,16 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Ranged branch of the IWeapon hierarchy (see IRanged). + [Serializable] + public sealed class Shotgun : IRanged + { + [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/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs new file mode 100644 index 00000000..d286b84e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs @@ -0,0 +1,17 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // The melee branch of the IWeapon hierarchy, added for the tutorial's narrowing step. + // It is the only IMelee, so [TypeSelector(typeof(IMelee))] resolves to exactly this type. + [Serializable] + public sealed class Sword : IMelee + { + [SerializeField] [Min(0)] private int _damage = 30; + [SerializeField] [Range(0f, 5f)] private float _reach = 1.8f; + + public string Describe() => $"Sword — {_damage} dmg, {_reach}m reach"; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta new file mode 100644 index 00000000..5f0b51bf --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef56508b96df4d8aaabf9a53c4c15a73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md new file mode 100644 index 00000000..c5910da5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md @@ -0,0 +1,320 @@ +# TypeSelector for SerializeReference — Step-by-Step Tutorial + +A guided, hands-on tour of everything `[TypeSelector]` does for `[SerializeReference]` fields. Work through the +lessons in order: each one builds on the previous and maps to one section of the `TypeSelectorTutorial` component. + +**The one rule:** put `[SerializeReference]` **and** `[TypeSelector]` on the same field. The first tells Unity to +store a polymorphic instance; the second renders the searchable type picker. + +```csharp +[SerializeReference] [TypeSelector] +private IWeapon _weapon; +``` + +## Open the tutorial + +1. Import this sample from **Package Manager → Aspid.FastTools → Samples → SerializeReferences → Import**. +2. Open **`Scenes/TypeSelectorTutorial.unity`** and select the **TypeSelector Tutorial** GameObject. +3. **Checkpoint:** the Inspector shows eight numbered sections, **STEP 1 → STEP 8**, with STEP 1 reading + *"Single polymorphic reference"*. If you see plain foldouts without type dropdowns, the sample assembly has + not finished compiling — wait for Unity to recompile. A few steps come pre-filled so you can see working + examples immediately; the rest are empty for you to try. + +Prefer a clean slate? Add an empty GameObject and attach the **TypeSelectorTutorial** component. + +Every lesson below works in both the default **UIToolkit** inspector and the **IMGUI** inspector — the package +ships a drawer for each path and they are at feature parity. (`Prefabs/IMGUILoadout.prefab` forces the IMGUI path +if you want to compare — see *The IMGUI path* in [README.md](README.md).) + +--- + +## Lesson 1 — Your first picker + +**Field:** `IWeapon _step1Single` · `[SerializeReference] [TypeSelector]` + +Click the dropdown in the field header. A searchable, hierarchical window opens listing every concrete `IWeapon` +implementation: `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`. + + +![The type picker window](../../Documentation/Images/aspid_fasttools_type_selector_window.png) + +*The picker window (shown here on another candidate list — yours opens filtered to `IWeapon`).* + +- **Pick a type** → Unity instantiates it and its own serialized fields appear inline under the foldout. +- **``** (first row) → clears the reference back to `null`. +- **Type to search** — just start typing; or click the magnifier. `↑ ↓` navigate, `Enter` selects, `Esc` closes. +- **Favourites & Recent** — hover a row and click the star, or press `Space`, to pin a type to the **Favorites** + group. Types you pick are remembered under **Recent**. Both groups are collapsible. + +> The picker only ever lists types you can actually instantiate — concrete, non-abstract classes that are not +> `UnityEngine.Object`. That filtering is automatic; you never see an invalid choice. + +--- + +## Lesson 2 — Lists and arrays + +**Field:** `List _step2List` · `[SerializeReference] [TypeSelector]` + +A `List` (or array) of a `[SerializeReference]` type turns every element into its own independent picker. + +1. Press **+**. Instead of duplicating the last element, the **picker opens** so you choose the new element's type. +2. Add a few elements and give each a different weapon — they are fully independent instances. +3. Reorder or remove elements as usual. + +This is the first behaviour the attribute *changes* versus stock Unity: the native "+" would clone the previous +element (and alias its data); here "+" always appends a fresh, typed instance. + +> **IMGUI note.** The UIToolkit field manages the list "+" automatically. In an IMGUI inspector Unity applies the +> `[TypeSelector]` drawer to each *element*, so it cannot reach the list's "+" — draw such lists with +> `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` to get the same picker-backed "+" (see +> `Editor/IMGUILoadoutEditor.cs`). The per-element dropdowns need no special handling. + +--- + +## Lesson 3 — Abstract bases and interfaces + +**Field:** `StatusEffect _step3Abstract` · `[SerializeReference] [TypeSelector]` + +`StatusEffect` is an **abstract** class — you cannot `new` it. + +**Try it:** +1. Open the picker. +2. Note that only the concrete subclasses `BurnEffect` and `FreezeEffect` are offered. + +**Notice:** +- The abstract base itself is never listed — there would be nothing to instantiate. +- An interface-typed field behaves identically (Lesson 1's `IWeapon` is an interface). +- Declare the field as the broadest type that makes sense; the picker shows only the concrete leaves you can create. + +--- + +## Lesson 4 — Narrowing the candidate list + +**Fields:** three `IWeapon` fields with different attribute arguments. + +```csharp +[SerializeReference] [TypeSelector(typeof(IRanged))] private IWeapon _step4Ranged; +[SerializeReference] [TypeSelector(typeof(IMelee))] private IWeapon _step4Melee; +[SerializeReference] [TypeSelector(typeof(IMelee), typeof(IRanged))] private IWeapon _step4MeleeOrRanged; +``` + +All three fields are declared `IWeapon`, yet the picker shows different sets. The base type(s) you pass to +`[TypeSelector(...)]` act as an **extra filter, applied below the declared field type**: + +| Attribute | Offered types | +|---|---| +| `[TypeSelector]` (Lesson 1) | `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow` (all `IWeapon`) | +| `[TypeSelector(typeof(IRanged))]` | `Pistol`, `Shotgun`, `Railgun`, `Crossbow` | +| `[TypeSelector(typeof(IMelee))]` | `Sword` | +| `[TypeSelector(typeof(IMelee), typeof(IRanged))]` | all five (multiple base types are OR-ed) | + +Use this to keep a field's *type* broad (so your code stays generic) while constraining what designers can pick. + +> The base types narrow **below** the declared field type — they can never widen it. `[TypeSelector(typeof(object))]` +> on an `IWeapon` field still only shows `IWeapon` implementations. + +> **Note — `TypeAllow` does not apply here.** The `Allow = TypeAllow.Abstract / Interface` option on the attribute +> only affects `[TypeSelector]` on a **`string`** field (where you are naming a type, not instantiating it). For a +> `[SerializeReference]` field it is ignored — you can never pick an abstract class or interface, because there would +> be nothing to instantiate. + +--- + +## Lesson 5 — Nested references (recursion) + +**Field:** `IWeapon _step5Nested` · `[SerializeReference] [TypeSelector]` + +**Try it:** +1. Pick **`Railgun`** and expand its foldout. +2. Find its `Charge Effect` field — itself a `[SerializeReference] [TypeSelector] StatusEffect`. +3. Open *that* picker and assign a `BurnEffect` or `FreezeEffect`. + +**Notice:** +- A managed reference can contain another managed reference, to any depth. +- Every nested level gets its own picker, inline child fields, notices and context-menu actions — the drawer is fully recursive. + +--- + +## Lesson 6 — Generic hierarchies + +**Fields:** `IModifier _step6Open` and `Modifier _step6Closed`. + +`Modifier` is a **concrete open generic** (`IModifier`), with closed subclasses `DamageModifier : Modifier`, +`AmmoModifier : Modifier`, `NameModifier : Modifier`. + +**On the `IModifier` field** (`_step6Open`), the picker offers: + +- the three concrete closed subclasses, **and** +- the open generic **`Modifier`** itself. + +Pick `Modifier` and a **second page** opens to choose the argument `T`. Try `string`, then `float` — only after `T` +is resolved is the closed type (`Modifier` / `Modifier`) constructed and assigned. + +**On the `Modifier` field** (`_step6Closed`), `T` is already fixed by the field type, so: + +- candidates are constrained by assignability — only `DamageModifier` (a `Modifier`) and `Modifier` + itself are offered; `AmmoModifier` (int) and `NameModifier` (string) are excluded, and +- picking `Modifier<…>` builds `Modifier` **directly**, with no second page. + +--- + +## Lesson 7 — References inside `[Serializable]` containers + +**Fields:** `WeaponSlot _step7Slot` and `List _step7Slots`. + +`WeaponSlot` (`Scripts/WeaponSlot.cs`) is a plain `[Serializable]` class (not a managed reference itself) holding a +`label`, a `priority`, and a `[SerializeReference] [TypeSelector] IWeapon`. + +**Try it:** +1. Expand `_step7Slot` and pick a weapon for its inner field. +2. Add elements to `_step7Slots` — each element is a container whose weapon is its own picker. + +**Notice:** +- A managed reference does **not** have to sit directly on the component. +- Everything you learned still applies at this depth: the picker, inline child fields, the missing-type warning and its inline **Fix**. +- It works on the component, one level inside a container, or inside each element of a `List`. + +--- + +## Lesson 8 — Required references + +**Field:** `IWeapon _step8Required` · `[SerializeReference] [TypeSelector(Required = true)]` + +Set `Required = true` on `[TypeSelector]` to mark a reference as mandatory. + +```csharp +[SerializeReference, TypeSelector(Required = true)] +private IWeapon _weapon; +``` + +**Try it:** +1. Leave the field empty — an inline **"Required reference is not set"** warning appears. +2. Pick any `IWeapon` — the notice clears. + +**Notice:** +- The same `Required = true` works on a `[TypeSelector] string` type field — there "unset" means an empty type name. +- A present-but-missing managed-reference type is never a *required* violation — it keeps its own missing-type notice. +- Required references also feed the **build / CI gate** (see *Project settings* at the end of this page). + +--- + +## Power-user gestures (right-click any field above) + +These are not separate fields — they are gestures available on every `[TypeSelector]` field via its right-click +context menu, header, or drag-and-drop. Try them on the fields from Lessons 1–8. + +| Gesture | What it does | +|---|---| +| **Switch the type** | Keeps the fields the old and new type share. Set a `Pistol`'s damage, switch to `Shotgun` and back — the value survives (data is carried over by name). | +| **Copy / Paste Serialize Reference** | Right-click the header. Paste rebuilds an **independent** instance; it is greyed-out when the copied type does not fit the field. | +| **Make Unique Reference** | When two fields share one instance (a "shared reference" notice appears — e.g. after duplicating a list element), this gives the field its own copy so edits stop bleeding across. | +| **Link to Existing ▸ …** | The inverse: deliberately point this field at a sibling field's instance, sharing one object across both. | +| **Drag a `MonoScript` onto the field** | Drag a `.cs` script from the Project window onto the header; if its class fits (honouring the narrowing from Lesson 4) a fresh instance is assigned. | +| **Create New Script…** | Generates a `[Serializable]` class deriving from the field's base type, then auto-assigns a new instance after the recompile. | +| **Save as Template… / Paste Template ▸ …** | Save the current value as a named, project-wide template and re-apply it to any compatible field later. | +| **Find Usages of …** | Opens the Search window (`sr:`) listing every place that type is used as a managed reference. | + +--- + +## Maintenance: repairing broken references + +When a managed-reference type is renamed, moved or deleted, its stored data is orphaned. The tutorial ships sibling +assets **pre-broken** on purpose so you can practise the recovery flow: + +- `Presets/BrokenWeaponPreset.asset`, `Presets/BrokenArsenalPreset.asset` — `ScriptableObject`s referencing a missing `GhostWeapon`. +- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`. +- `Presets/MovedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` still stores `Pistol` under an old `…Samples.SerializeReferences.Legacy` namespace, as if the class had been moved without a `[MovedFrom]` attribute. The type itself exists — only the stored identity is stale. +- `Presets/RenamedWeaponPreset.asset` — still stores the old `CrossbowLauncher` class name; the class now ships as `Crossbow` with a declared `[MovedFrom]`. Not broken, just stale on disk — demonstrates the **Migrate all** flow below. + +### Inline repair (one field) + +1. Select a broken asset **in the Project window**. +2. The missing field shows a `` caption, a **Missing type** warning and a **Fix** button (often with a one-click **Smart Fix** suggestion of the likely new type). +3. Click **Fix**, pick the replacement (e.g. `Pistol`) — the reference is rewritten **keeping its data** (the picker rewrites the stored type in the asset file rather than recreating the instance). + + + +### Smart Fix (one click, no picker) + +The `GhostWeapon` assets above have no plausible successor, so their notice only offers the manual **Fix**. Open +`Presets/MovedWeaponPreset.asset` instead — its warning ends with a clickable **`→ Pistol?`** suggestion: + +1. Hover the suggestion — the tooltip shows the full suggested identity and the ranking reason (`same type name`). +2. Click it — the reference is re-pointed at the moved `Pistol` in one step, keeping `_damage = 21`, `_magazineSize = 6`. + +Ranking, highest first (the same candidate pool the picker would offer): + +1. a declared `[MovedFrom]` match +2. a same-named type in another namespace/assembly +3. a casing-only rename +4. a near-miss name backed by the orphaned data's field shape + +The suggestion is **never applied automatically** — you always click. + +> A rename/move that ships `[MovedFrom]` from the start never breaks at all — Unity migrates the reference on load. +> Smart Fix is the safety net for the moves that forgot it. The files themselves still store the old name until each +> asset is re-saved, though — the migration flow below bakes the rename in. + +### Migrate a `[MovedFrom]` rename (Project References) + +`Presets/RenamedWeaponPreset.asset` stores its weapon under the old class name `CrossbowLauncher`; the class now ships +as `Crossbow` with `[MovedFrom(false, null, null, "CrossbowLauncher")]`. The Inspector shows a healthy `Crossbow` — +Unity migrates the reference **in memory** on load — but the file on disk still stores the old name: stale for version +control, YAML scans and any asset that never gets re-saved. + +1. Open **`Tools → Aspid 🐍 → FastTools → Project References`** and **Scan Project**. +2. The `CrossbowLauncher` group renders as a calm, info-tinted **pending migration** — not a warning: an authoritative + `[MovedFrom]` match is not a guess, so in place of a Smart Fix suggestion the card carries a + **`Migrate all (1) → Crossbow`** button. +3. Click it and confirm (the dialog previews the exact YAML lines that will change). The file now stores `Crossbow`, + keeping `_damage = 17`, `_boltCount = 5`. + +Once no file in the project stores the old name, the `[MovedFrom]` attribute can be deleted from the code — migrating +is what makes that cleanup safe. + +> Repair reads and rewrites the asset YAML directly, because Unity does not expose a missing type through its +> serialization API (and on GameObjects/prefabs even drops it from the live object — UUM-129100). It therefore needs a +> **saved asset file**: it works on ScriptableObjects and prefab assets selected in the Project, and on objects in +> Prefab Mode / a clean saved scene, but not on a dirty scene or a prefab-instance override. + +### Whole-asset & project-wide repair + +Open **`Tools → Aspid 🐍 → FastTools`**: + +| Tab | What it does | +|---|---| +| **Asset References** | Maps a saved asset's entire `[SerializeReference]` graph and repairs any missing node inline — any depth, any child object the Inspector can't otherwise reach. | +| **Project References** | Sweeps every asset under `Assets/` and groups broken references **by stored type** — `BrokenWeaponPreset.asset` and `BrokenArsenalPreset.asset` collapse into one **GhostWeapon** group (`4 entries · 2 files`). **Fix all** re-points every entry across both files at once. A group whose stored type matches a declared `[MovedFrom]` shows **Migrate all** instead — see the migration section above. | + +### Guard rails (no window needed) + +- **Delete guard** — deleting a `.cs` whose class is still used as a managed reference pops a confirm dialog listing the affected assets before it lets the delete through. +- **Breakage toast** — when references newly become missing after a recompile, a dismissable toast and one console warning deep-link straight to the repair window. Switch it off via **Breakage detection** in Project settings (below) if you find it intrusive. + +--- + +## Project settings + +**`Project Settings → Aspid FastTools → SerializeReference`** exposes: + +- **Breakage detection** — the proactive toast; per-machine. +- **Auto de-alias duplicated list elements**, **Build / CI gate** (`Off` / `Warn` / `Fail`), **Excluded scan folders** — saved to a **committed** `ProjectSettings/SerializeReferenceSharedSettings.asset` so teammates and CI behave identically. +- The same options, plus the picker's per-user preferences (Favorites, Recent capacity, appearance), also live in the window's **Settings** tab and at **`Preferences → Aspid FastTools`**. + +The full reference — every setting, scope rules and the headless-CI entry point +(`SerializeReferenceCiGate.RunCheck` and its `-srGate*` flags) — lives in the package documentation: +**Documentation → SerializeReference Selector → Project settings & the build/CI gate**. + +--- + +## Where to look in code + +| File | Shows | +|---|---| +| `Scripts/Tutorial/TypeSelectorTutorial.cs` | All eight lessons as numbered fields | +| `Scripts/Weapons/` | `IWeapon` + `IMelee`/`IRanged` branches and `Sword`/`Pistol`/`Shotgun`/`Railgun`/`Crossbow` | +| `Scripts/Effects/` | abstract `StatusEffect` + `BurnEffect`/`FreezeEffect` | +| `Scripts/Modifiers/` | the `Modifier` generic hierarchy | +| `Scripts/WeaponSlot.cs` + `Scripts/SlottedLoadout.cs` | references nested in `[Serializable]` containers (Lesson 7, standalone) | +| `Scripts/WeaponPreset.cs` + `Presets/Broken*.asset` / `Presets/MovedWeaponPreset.asset` / `Presets/RenamedWeaponPreset.asset` | the missing-type repair flow — manual **Fix**, the one-click **Smart Fix** and the `[MovedFrom]` **Migrate all** | +| `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` | the same fields forced through the IMGUI path (see *The IMGUI path* in [README.md](README.md)) | diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta new file mode 100644 index 00000000..d3ede7c6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cc5aeec9b48e4ea49f6a98938aefc54d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md new file mode 100644 index 00000000..3fe629dd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md @@ -0,0 +1,310 @@ +# TypeSelector для SerializeReference — пошаговый туториал + +Практический разбор всего, что `[TypeSelector]` даёт полям `[SerializeReference]`. Проходите уроки по порядку: каждый опирается на предыдущий и соответствует одной секции компонента `TypeSelectorTutorial`. + +**Единственное правило:** ставьте `[SerializeReference]` **и** `[TypeSelector]` на одно и то же поле. Первое говорит Unity хранить полиморфный экземпляр, второе рисует выпадающий селектор типов с поиском. + +```csharp +[TypeSelector] +[SerializeReference] private IWeapon _weapon; +``` + +## Как открыть туториал + +1. Импортируйте пример: **Package Manager → Aspid.FastTools → Samples → SerializeReferences → Import**. +2. Откройте **`Scenes/TypeSelectorTutorial.unity`** и выделите объект **TypeSelector Tutorial**. +3. **Контрольная точка:** Inspector показывает восемь пронумерованных секций **STEP 1 → STEP 8**, где STEP 1 — + *«Single polymorphic reference»*. Если видны обычные foldout'ы без дропдаунов типов — сборка примера ещё + компилируется, дождитесь рекомпиляции Unity. Несколько шагов уже предзаполнены, чтобы сразу видеть рабочие + примеры; остальные пустые — для самостоятельной практики. + +Хотите с чистого листа? Добавьте пустой GameObject и прикрепите компонент **TypeSelectorTutorial**. + +Каждый урок работает и в инспекторе **UIToolkit**, и в **IMGUI** — пакет поставляет drawer для каждого пути, и они на паритете по функциям. (`Prefabs/IMGUILoadout.prefab` принудительно использует путь IMGUI, если хотите сравнить — см. *Путь IMGUI* в [README_RU.md](README_RU.md).) + +--- + +## Урок 1 — Ваш первый селектор + +**Поле:** `[TypeSelector] [SerializeReference] IWeapon _step1Single` + +Кликните по выпадающему списку в заголовке поля. Откроется иерархическое окно с поиском, в котором перечислены все конкретные реализации `IWeapon`: `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`. + + +![Окно выбора типа](../../Documentation/Images/aspid_fasttools_type_selector_window.png) + +*Окно-селектор (здесь показано на другом списке кандидатов — ваше откроется отфильтрованным под `IWeapon`).* + +- **Выберите тип** → Unity создаёт экземпляр, а его сериализуемые поля появляются вложенно под foldout. +- **``** (первая строка) → очищает ссылку обратно в `null`. +- **Поиск по вводу** — просто начните печатать; или нажмите на лупу. `↑ ↓` — навигация, `Enter` — выбор, `Esc` — + закрыть. +- **Избранное и недавние** — наведитесь на строку и нажмите на звезду либо клавишу `Space`, чтобы закрепить тип в + группе **Favorites**. Выбранные типы запоминаются в **Recent**. Обе группы сворачиваются. + +> В списке всегда только те типы, которые реально можно создать — конкретные, неабстрактные классы, не наследники `UnityEngine.Object`. Эта фильтрация автоматическая; невалидного варианта вы никогда не увидите. + +--- + +## Урок 2 — Списки и массивы + +**Поле:** `[TypeSelector] [SerializeReference] List _step2List` + +`List` (или массив) поля `[SerializeReference]` превращает каждый элемент в собственный независимый селектор. + +1. Нажмите **+**. Вместо дублирования последнего элемента **откроется селектор**, чтобы выбрать тип нового элемента. +2. Добавьте несколько элементов и задайте каждому своё оружие — это полностью независимые экземпляры. +3. Меняйте порядок и удаляйте элементы как обычно. + +Это первое поведение, которое аттрибут *меняет* относительно стандартного Unity: родная «+» клонировала бы предыдущий элемент (и делила бы его данные), здесь «+» всегда добавляет новый типизированный экземпляр. + +> **Про IMGUI.** В UIToolkit-поле кнопка «+» списка обрабатывается автоматически. В IMGUI-инспекторе Unity применяет drawer `[TypeSelector]` к каждому *элементу*, поэтому до «+» списка он «не дотягивается» — рисуйте такие списки через `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)`, чтобы получить ту же «+» с селектором (см. `Editor/IMGUILoadoutEditor.cs`). Поэлементным выпадающим селекторам ничего дополнительно не нужно. + +--- + +## Урок 3 — Абстрактные базы и интерфейсы + +**Поле:** `[TypeSelector] [SerializeReference] StatusEffect _step3Abstract` + +`StatusEffect` — **абстрактный** класс, создать его через `new` нельзя. + +**Попробуйте:** +1. Откройте селектор. +2. Убедитесь, что предлагаются только конкретные подтипы `BurnEffect` и `FreezeEffect`. + +**Обратите внимание:** +- Сам абстрактный базовый класс никогда не показывается — инстанцировать было бы нечего. +- Поле с типом-интерфейсом ведёт себя так же (`IWeapon` из урока 1 — интерфейс). +- Объявляйте поле максимально широким осмысленным типом; селектор покажет только конкретные «листья». + +--- + +## Урок 4 — Сужение списка кандидатов + +**Поля:** три поля `IWeapon` с разными аргументами аттрибута. + +```csharp +[TypeSelector(typeof(IRanged))] +[SerializeReference] private IWeapon _step4Ranged; + +[TypeSelector(typeof(IMelee))] +[SerializeReference] private IWeapon _step4Melee; + +[TypeSelector(typeof(IMelee), typeof(IRanged))] +[SerializeReference] private IWeapon _step4MeleeOrRanged; +``` + +Все три поля объявлены как `IWeapon`, но селектор показывает разные наборы. Базовые типы, переданные в `[TypeSelector(...)]`, работают как **дополнительный фильтр, применяемый ниже объявленного типа поля**: + +| Аттрибут | Предлагаемые типы | +|---|---| +| `[TypeSelector]` (урок 1) | `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow` (все `IWeapon`) | +| `[TypeSelector(typeof(IRanged))]` | `Pistol`, `Shotgun`, `Railgun`, `Crossbow` | +| `[TypeSelector(typeof(IMelee))]` | `Sword` | +| `[TypeSelector(typeof(IMelee), typeof(IRanged))]` | все пять (несколько базовых типов объединяются по ИЛИ) | + +Так можно держать *тип* поля широким (чтобы код оставался обобщённым) и при этом ограничивать выбор для дизайнеров. + +> Базовые типы сужают **ниже** объявленного типа поля и никогда не расширяют его. `[TypeSelector(typeof(object))]` на поле `IWeapon` всё равно покажет только реализации `IWeapon`. + +> **Важно — `TypeAllow` здесь не применяется.** Опция `Allow = TypeAllow.Abstract / Interface` аттрибута влияет только +> на `[TypeSelector]` для поля **`string`** (где вы именуете тип, а не создаёте его). Для поля `[SerializeReference]` она игнорируется — абстрактный класс или интерфейс выбрать нельзя, потому что нечего было бы инстанцировать. + +--- + +## Урок 5 — Вложенные ссылки (рекурсия) + +**Поле:** `[TypeSelector] [SerializeReference] IWeapon _step5Nested` + +**Попробуйте:** +1. Выберите **`Railgun`** и разверните его foldout. +2. Найдите его поле `Charge Effect` — это снова `[TypeSelector] [SerializeReference] StatusEffect`. +3. Откройте *этот* селектор и присвойте `BurnEffect` или `FreezeEffect`. + +**Обратите внимание:** +- Managed-ссылка может содержать другую managed-ссылку на любой глубине. +- У каждого вложенного уровня свой селектор, вложенные дочерние поля, пометки и пункты контекстного меню — drawer полностью рекурсивен. + +--- + +## Урок 6 — Generic-иерархии + +**Поля:** `IModifier _step6Open` и `Modifier _step6Closed`. + +`Modifier` — **конкретный открытый generic** (`IModifier`) с закрытыми подтипами `DamageModifier : Modifier`, +`AmmoModifier : Modifier`, `NameModifier : Modifier`. + +**На поле `IModifier`** (`_step6Open`) селектор предлагает: + +- три конкретных закрытых подтипа **и** +- сам открытый generic **`Modifier`**. + +Выберите `Modifier` — откроется **вторая страница** для выбора аргумента `T`. Попробуйте `string`, затем `float` — закрытый тип (`Modifier` / `Modifier`) создаётся и присваивается только после того, как `T` определён. + +**На поле `Modifier`** (`_step6Closed`) `T` уже зафиксирован типом поля, поэтому: + +- кандидаты ограничены присваиваемостью — предлагаются только `DamageModifier` (это `Modifier`) и сам `Modifier`; `AmmoModifier` (int) и `NameModifier` (string) исключены, а +- выбор `Modifier<…>` создаёт `Modifier` **сразу**, без второй страницы. + +--- + +## Урок 7 — Ссылки внутри `[Serializable]`- контейнеров + +**Поля:** `WeaponSlot _step7Slot` и `List _step7Slots`. + +`WeaponSlot` (`Scripts/WeaponSlot.cs`) — обычный `[Serializable]`-класс (сам не managed-ссылка), содержащий `label`, `priority` и `[TypeSelector] [SerializeReference] IWeapon`. + +**Попробуйте:** +1. Разверните `_step7Slot` и выберите оружие для его внутреннего поля. +2. Добавьте элементы в `_step7Slots` — каждый элемент это контейнер, и у его оружия свой селектор. + +**Обратите внимание:** +- Managed-ссылка **не обязана** лежать прямо на компоненте. +- Всё, что вы изучили, работает и на этой глубине: селектор, вложенные дочерние поля, предупреждение о потерянном типе и встроенная кнопка **Fix**. +- Работает и на компоненте, и на уровень глубже внутри контейнера, и внутри каждого элемента `List`. + +--- + +## Урок 8 — Обязательные ссылки + +**Поле:** `IWeapon _step8Required` · `[SerializeReference] [TypeSelector(Required = true)]` + +Установите `Required = true` у `[TypeSelector]`, чтобы пометить ссылку как обязательную. + +```csharp +[SerializeReference, TypeSelector(Required = true)] +private IWeapon _weapon; +``` + +**Попробуйте:** +1. Оставьте поле пустым — появится встроенное предупреждение **«Required reference is not set»**. +2. Выберите любой `IWeapon` — пометка исчезнет. + +**Обратите внимание:** +- Тот же `Required = true` работает и на `[TypeSelector] string` поле — там «не задано» означает пустое имя типа. +- Present-but-missing тип managed-ссылки никогда не считается нарушением *required* — у него собственная пометка о пропавшем типе. +- Обязательные ссылки также учитываются **build / CI gate** (см. *Настройки проекта* в конце страницы). + +--- + +## Приёмы для продвинутых (ПКМ по любому полю выше) + +Это не отдельные поля, а жесты, доступные на каждом поле `[TypeSelector]` через контекстное меню (ПКМ), заголовок или +drag-and-drop. Попробуйте их на полях из уроков 1–8. + +| Жест | Что делает | +|---|---| +| **Смена типа** | Сохраняет поля, общие у старого и нового типа. Задайте `Pistol` урон, переключите на `Shotgun` и обратно — значение сохранится (данные переносятся по имени). | +| **Copy / Paste Serialize Reference** | ПКМ по заголовку. Вставка создаёт **независимый** экземпляр; неактивна, если скопированный тип не подходит полю. | +| **Make Unique Reference** | Когда два поля делят один экземпляр (появляется пометка «shared reference» — например, после дублирования элемента списка), даёт полю собственную копию, и правки перестают «протекать». | +| **Link to Existing ▸ …** | Обратное: намеренно указать поле на экземпляр соседнего поля, разделив один объект на два. | +| **Перетащить `MonoScript` на поле** | Перетащите `.cs`-скрипт из Project на заголовок; если его класс подходит (с учётом сужения из урока 4) — присвоится новый экземпляр. | +| **Create New Script…** | Генерирует `[Serializable]`-класс, наследующий базовый тип поля, и после рекомпиляции автоматически присваивает новый экземпляр. | +| **Save as Template… / Paste Template ▸ …** | Сохранить текущее значение как именованный проектный шаблон и применить его к любому подходящему полю позже. | +| **Find Usages of …** | Открывает окно Search (`sr:`) со всеми местами, где тип используется как managed-ссылка. | + +--- + +## Обслуживание: починка сломанных ссылок + +Когда тип managed-ссылки переименован, перемещён или удалён, сохранённые данные осиротевают. Туториал специально +поставляет соседние ассеты **заранее сломанными**, чтобы потренироваться в восстановлении: + +- `Presets/BrokenWeaponPreset.asset`, `Presets/BrokenArsenalPreset.asset` — `ScriptableObject`-ы, ссылающиеся на потерянный `GhostWeapon`. +- `Prefabs/LoadoutMissingType.prefab` — префаб, у которого `Sidearms → Element 0` ссылается на потерянный `GhostPistol`. +- `Presets/MovedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` всё ещё хранит `Pistol` под старым namespace `…Samples.SerializeReferences.Legacy` — как будто класс перенесли без атрибута `[MovedFrom]`. Сам тип существует — устарела только сохранённая идентичность. +- `Presets/RenamedWeaponPreset.asset` — всё ещё хранит старое имя класса `CrossbowLauncher`; сам класс теперь называется `Crossbow` и несёт объявленный `[MovedFrom]`. Не сломан, просто устарел на диске — демонстрирует поток **Migrate all** ниже. + +### Починка одного поля в инспекторе + +1. Выделите сломанный ассет **в окне Project**. +2. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix** (часто с подсказкой **Smart Fix** в один клик — вероятным новым типом). +3. Нажмите **Fix**, выберите замену (например, `Pistol`) — ссылка перепишется **с сохранением данных** (селектор переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново). + + + +### Smart Fix (один клик, без селектора) + +У `GhostWeapon`-ассетов выше нет правдоподобного преемника, поэтому их плашка предлагает только ручной **Fix**. +Откройте вместо них `Presets/MovedWeaponPreset.asset` — его предупреждение заканчивается кликабельной подсказкой +**`→ Pistol?`**: + +1. Наведите курсор на подсказку — тултип покажет полную предлагаемую идентичность и причину ранжирования (`same type name`). +2. Кликните — ссылка в один шаг перенаправится на переехавший `Pistol` с сохранением данных (`_damage = 21`, `_magazineSize = 6`). + +Ранжирование, от высшего балла (пул кандидатов — тот же, что у селектора): + +1. объявленное совпадение `[MovedFrom]` +2. одноимённый тип в другом namespace/сборке +3. переименование только по регистру +4. похожее имя, подкреплённое формой полей осиротевших данных + +Подсказка **никогда не применяется автоматически** — клик всегда за вами. + +> Переименование/перенос, сразу снабжённый `[MovedFrom]`, вообще не ломается — Unity мигрирует ссылку при загрузке. +> Smart Fix — страховка для переносов, где про атрибут забыли. Но сами файлы хранят старое имя, пока каждый ассет не +> пересохранят — поток миграции ниже запекает переименование в файлы. + +### Миграция переименования с `[MovedFrom]` (Project References) + +`Presets/RenamedWeaponPreset.asset` хранит оружие под старым именем класса `CrossbowLauncher`; сам класс теперь +называется `Crossbow` и несёт `[MovedFrom(false, null, null, "CrossbowLauncher")]`. Инспектор показывает здоровый +`Crossbow` — Unity мигрирует ссылку **в памяти** при загрузке, — но файл на диске всё ещё хранит старое имя: +«протухшее» для контроля версий, YAML-сканов и любого ассета, который никогда не пересохраняют. + +1. Откройте **`Tools → Aspid 🐍 → FastTools → Project References`** и нажмите **Scan Project**. +2. Группа `CrossbowLauncher` рендерится как спокойная info-подсвеченная **ожидающая миграция**, а не предупреждение: + авторитетное совпадение по `[MovedFrom]` — не догадка, поэтому вместо подсказки Smart Fix карточка несёт кнопку + **`Migrate all (1) → Crossbow`**. +3. Кликните и подтвердите (диалог показывает точные YAML-строки, которые изменятся). Файл теперь хранит `Crossbow`, + данные сохранены (`_damage = 17`, `_boltCount = 5`). + +Когда ни один файл в проекте не хранит старое имя, атрибут `[MovedFrom]` можно удалить из кода — миграция и делает +эту зачистку безопасной. + +> Починка читает и переписывает YAML ассета напрямую, потому что Unity не отдаёт потерянный тип через свой serialization +> API (а на GameObject/префабах ещё и обнуляет его в живом объекте — UUM-129100). Поэтому нужен **сохранённый файл +> ассета**: работает для ScriptableObject и префаб-ассетов, выделенных в Project, и для объектов в Prefab Mode / чистой +> сохранённой сцене, но не для «грязной» сцены или override экземпляра префаба. + +### Починка всего ассета и всего проекта + +Откройте **`Tools → Aspid 🐍 → FastTools`**: + +| Вкладка | Что делает | +|---|---| +| **Asset References** | Строит граф всех `[SerializeReference]` сохранённого ассета и чинит любой потерянный узел встроенно — любой глубины, на любом дочернем объекте, до которого инспектор иначе не дотянется. | +| **Project References** | Проходит по всем ассетам в `Assets/` и группирует сломанные ссылки **по сохранённому типу** — `BrokenWeaponPreset.asset` и `BrokenArsenalPreset.asset` сворачиваются в одну группу **GhostWeapon** (`4 entries · 2 files`). **Fix all** перенаправляет все вхождения сразу в обоих файлах. Группа, чей сохранённый тип совпадает с объявленным `[MovedFrom]`, вместо этого показывает **Migrate all** — см. секцию миграции выше. | + +### Защитные механизмы (без открытия окон) + +- **Delete guard** — удаление `.cs`, чей класс ещё используется как managed-ссылка, вызывает диалог подтверждения со списком затронутых ассетов, прежде чем пропустить удаление. +- **Breakage toast** — когда ссылки заново становятся потерянными после рекомпиляции, всплывающее уведомление и одно предупреждение в Console ведут прямо в окно починки. Отключается через **Breakage detection** в настройках проекта (ниже), если мешает. + +--- + +## Настройки проекта + +**`Project Settings → Aspid FastTools → SerializeReference`** содержит: + +- **Breakage detection** — проактивный тост; per-machine. +- **Auto de-alias duplicated list elements**, **Build / CI gate** (`Off` / `Warn` / `Fail`), **Excluded scan folders** — сохраняются в **коммитимый** `ProjectSettings/SerializeReferenceSharedSettings.asset`, чтобы команда и CI вели себя одинаково. +- Те же опции, плюс индивидуальные настройки пикера (Favorites, ёмкость Recent, оформление), доступны во вкладке **Settings** окна и на странице **`Preferences → Aspid FastTools`**. + +Полный справочник — каждая настройка, правила scope и headless-CI-входная точка +(`SerializeReferenceCiGate.RunCheck` с флагами `-srGate*`) — в документации пакета: +**Documentation → SerializeReference Selector → Настройки проекта и build/CI gate**. + +--- + +## Где смотреть в коде + +| Файл | Что показывает | +|---|---| +| `Scripts/Tutorial/TypeSelectorTutorial.cs` | Все восемь уроков как пронумерованные поля | +| `Scripts/Weapons/` | `IWeapon` + ветки `IMelee`/`IRanged` и `Sword`/`Pistol`/`Shotgun`/`Railgun`/`Crossbow` | +| `Scripts/Effects/` | абстрактный `StatusEffect` + `BurnEffect`/`FreezeEffect` | +| `Scripts/Modifiers/` | generic-иерархия `Modifier` | +| `Scripts/WeaponSlot.cs` + `Scripts/SlottedLoadout.cs` | ссылки, вложенные в `[Serializable]`-контейнеры (урок 7, отдельно) | +| `Scripts/WeaponPreset.cs` + `Presets/Broken*.asset` / `Presets/MovedWeaponPreset.asset` / `Presets/RenamedWeaponPreset.asset` | сценарий починки потерянного типа — ручной **Fix**, одно-кликовый **Smart Fix** и `[MovedFrom]`-**Migrate all** | +| `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` | те же поля, принудительно через путь IMGUI (см. *Путь IMGUI* в [README_RU.md](README_RU.md)) | diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta new file mode 100644 index 00000000..1f7d8ab4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6267c23143c5450b87f253de3a180630 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta index affaf382..181ef549 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: dacaf7afcfc54fc2ad76fb2533213aa4 +guid: 63a1ce046a89e405ba8f3843a964d66e folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta index e517099d..420b4b99 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f2a422d094104109a79a6e91ff366e4b +guid: 2a7e1769cbf4c4d35a3fccad05961fab folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef index 2cf4ab89..4ebf0f84 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef @@ -4,7 +4,10 @@ "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "Aspid.FastTools.Unity.Editor.SerializeReferences" + "Aspid.FastTools", + "Aspid.FastTools.Unity", + "Aspid.FastTools.Unity.Editor", + "Aspid.FastTools.Unity.Editor.SerializeReferences.Yaml" ], "includePlatforms": [ "Editor" diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs new file mode 100644 index 00000000..9dbef0cb --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs @@ -0,0 +1,275 @@ +using System; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using UnityEditor.UIElements; +using Object = UnityEngine.Object; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Behavioural coverage for the attribute-free dropdown opt-in ("Dropdown without [TypeSelector]"): + /// + /// the decision matrix — only a bare managed reference (or a + /// bare list of them) is substituted, only under the opt-in, and an attributed field always keeps its drawer — + /// at the top level and on the nested fields of an assigned instance alike; + /// the fallback inspector contract — it is the editor Unity selects for a plain component, it hands the + /// component back to the default inspector (null) whenever it has nothing to substitute, and when it does build, + /// exactly the bare reference fields come out as dropdown fields; + /// the public facade's shape validation; + /// the setting's persistence contract — Changed fires, and the per-user reset restores the default (off). + /// + /// + [TestFixture] + internal sealed class SerializeReferenceAutoDropdownTests + { + private bool _dropdownWithoutAttribute; + private bool _breakageDetection; + + [SetUp] + public void SetUp() + { + // Snapshot every per-user setting the tests (and ResetUserToDefaults) touch, restoring on teardown. + _dropdownWithoutAttribute = SerializeReferenceSettings.DropdownWithoutAttributeEnabled; + _breakageDetection = SerializeReferenceSettings.BreakageDetectionEnabled; + } + + [TearDown] + public void TearDown() + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = _dropdownWithoutAttribute; + SerializeReferenceSettings.BreakageDetectionEnabled = _breakageDetection; + } + + // ----------------------------------------------------------------------------------------------------- + // A — the ShouldDraw decision matrix + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void ShouldDraw_BareReference_FollowsTheOptIn() + { + WithTarget((_, serialized) => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false; + Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain")), + "With the opt-in off, nothing is ever substituted."); + + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain")), + "With the opt-in on, a bare managed reference gets the dropdown."); + }); + } + + [Test] + public void ShouldDraw_AttributedReference_False_TheAttributeAlwaysWins() + { + WithTarget((_, serialized) => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("marked")), + "A [TypeSelector] field keeps its drawer (and its base-type narrowing) — never substituted."); + }); + } + + [Test] + public void ShouldDraw_BareList_True_AndRecognisedAsManagedReferenceArray() + { + WithTarget((_, serialized) => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + var list = serialized.FindProperty("list"); + + Assert.IsTrue(SerializeReferenceAutoDropdown.IsManagedReferenceArray(list), + "A [SerializeReference] List must be recognised as a managed-reference array."); + Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(list), + "A bare managed-reference list gets the dropdown list."); + }); + } + + [Test] + public void ShouldDraw_PlainValueField_False() + { + WithTarget((_, serialized) => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("number")), + "A plain value field is never substituted."); + }); + } + + [Test] + public void ShouldDraw_NestedChildren_FollowTheirOwnAttribute() + { + WithTarget((_, serialized) => + { + // The nested fields live on the CONCRETE assigned type (TestWeaponHolder), not the declared + // interface — exactly what the runtime-instance attribute walk must cross. + serialized.FindProperty("plain").managedReferenceValue = new TestWeaponHolder(); + serialized.ApplyModifiedProperties(); + serialized.Update(); + + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + + Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain.inner")), + "A bare nested reference on the assigned instance gets the dropdown."); + Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain.innerMarked")), + "An attributed nested reference keeps its drawer."); + }); + } + + // ----------------------------------------------------------------------------------------------------- + // B — element-type resolution and the factory dispatch + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void GetElementType_BareList_ReturnsTheDeclaredElementType() + { + WithTarget((_, serialized) => + Assert.AreEqual(typeof(ITestWeapon), + SerializeReferenceAutoDropdown.GetElementType(serialized.FindProperty("list")), + "The add-picker constraint must be the list's declared element type, even while the list is empty.")); + } + + [Test] + public void CreateField_DispatchesOnTheShape() + { + WithTarget((_, serialized) => + { + Assert.IsInstanceOf( + SerializeReferenceAutoDropdown.CreateField(serialized.FindProperty("plain")), + "A single managed reference builds the dropdown field."); + Assert.IsInstanceOf( + SerializeReferenceAutoDropdown.CreateField(serialized.FindProperty("list")), + "A managed-reference list builds the list field."); + }); + } + + // ----------------------------------------------------------------------------------------------------- + // C — the fallback inspector contract + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void FallbackInspector_IsTheSelectedEditor_ForAPlainScriptableObject() + { + WithTarget((target, _) => WithEditor(target, editor => + Assert.IsInstanceOf(editor, + "With no custom editor declared, Unity must select the package's fallback inspector."))); + } + + [Test] + public void FallbackInspector_ReturnsNull_WhenTheOptInIsOff() + { + WithTarget((target, _) => WithEditor(target, editor => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false; + Assert.IsNull(editor.CreateInspectorGUI(), + "With the opt-in off the fallback inspector must hand the component to Unity's default inspector."); + })); + } + + [Test] + public void FallbackInspector_ReturnsNull_WithoutEligibleFields() + { + var target = ScriptableObject.CreateInstance(); + try + { + WithEditor(target, editor => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + Assert.IsNull(editor.CreateInspectorGUI(), + "A component with no bare managed reference must be left to Unity's default inspector."); + }); + } + finally { Object.DestroyImmediate(target); } + } + + [Test] + public void FallbackInspector_SubstitutesExactlyTheBareReferences() + { + WithTarget((target, _) => WithEditor(target, editor => + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + + var root = editor.CreateInspectorGUI(); + Assert.IsNotNull(root, "An eligible component must get the substituted inspector."); + + Assert.AreEqual(1, root.Query().ToList().Count, + "Exactly the one bare reference ('plain') becomes a dropdown field — 'marked' keeps its drawer."); + Assert.AreEqual(1, root.Query().ToList().Count, + "Exactly the one bare list ('list') becomes a dropdown list."); + + // m_Script, 'marked' and 'number' all stay plain PropertyFields (the drawer resolution stays Unity's). + Assert.AreEqual(3, root.Query().ToList().Count, + "Every non-substituted property must remain a plain PropertyField."); + })); + } + + // ----------------------------------------------------------------------------------------------------- + // D — the public custom-editor facade validates the property shape + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void EditorGUIFacade_BuildsForTheRightShapes_AndThrowsForTheWrongOnes() + { + WithTarget((_, serialized) => + { + Assert.IsInstanceOf( + SerializeReferenceEditorGUI.CreateField(serialized.FindProperty("plain"))); + Assert.IsInstanceOf( + SerializeReferenceEditorGUI.CreateList(serialized.FindProperty("list"))); + + Assert.Throws( + () => SerializeReferenceEditorGUI.CreateField(serialized.FindProperty("number")), + "CreateField must reject a non-managed-reference property."); + Assert.Throws( + () => SerializeReferenceEditorGUI.CreateList(serialized.FindProperty("plain")), + "CreateList must reject a non-list property."); + }); + } + + // ----------------------------------------------------------------------------------------------------- + // E — the setting's persistence contract + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void SettingToggle_RaisesChanged() + { + var fired = 0; + void Handler() => fired++; + SerializeReferenceSettings.Changed += Handler; + try + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = + !SerializeReferenceSettings.DropdownWithoutAttributeEnabled; + Assert.GreaterOrEqual(fired, 1, "The setter must raise Changed for repaint and live-sync."); + } + finally { SerializeReferenceSettings.Changed -= Handler; } + } + + [Test] + public void ResetUserToDefaults_TurnsTheOptInOff() + { + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + SerializeReferenceSettings.ResetUserToDefaults(); + Assert.IsFalse(SerializeReferenceSettings.DropdownWithoutAttributeEnabled, + "The attribute-free dropdown is a deliberate opt-in — its default (and reset value) is off."); + } + + // ----------------------------------------------------------------------------------------------------- + + private static void WithTarget(Action assert) + { + var target = ScriptableObject.CreateInstance(); + try { assert(target, new SerializedObject(target)); } + finally { Object.DestroyImmediate(target); } + } + + private static void WithEditor(Object target, Action assert) + { + var editor = Editor.CreateEditor(target); + try { assert(editor); } + finally { Object.DestroyImmediate(editor); } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta new file mode 100644 index 00000000..757052d7 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84c86a8318cde4252a88c0be0ae2d713 +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/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs new file mode 100644 index 00000000..2c1eafcc --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using Aspid.FastTools.SerializeReferences.Editors; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Pure decision-logic coverage for the headless CI gate: that the committed gate severity decides the exit code + /// and that the CLI flags override it as documented (ASP-21). No + /// is invoked — only the extracted and + /// helpers are exercised. + /// + [TestFixture] + internal sealed class SerializeReferenceCiGateTests + { + // No violations: never fail, whatever the severity. + [TestCase(GateSeverity.Off)] + [TestCase(GateSeverity.Warn)] + [TestCase(GateSeverity.Fail)] + public void ComputeExitCode_NoViolations_IsZero(GateSeverity severity) => + Assert.AreEqual(0, SerializeReferenceCiGate.ComputeExitCode(0, severity)); + + // Violations present: only Fail turns into a non-zero exit code. + [TestCase(GateSeverity.Off, 0)] + [TestCase(GateSeverity.Warn, 0)] + [TestCase(GateSeverity.Fail, 1)] + public void ComputeExitCode_WithViolations_MatchesSeverity(GateSeverity severity, int expected) => + Assert.AreEqual(expected, SerializeReferenceCiGate.ComputeExitCode(3, severity)); + + // No flags: the committed Project Settings value passes straight through. + [TestCase(GateSeverity.Off)] + [TestCase(GateSeverity.Warn)] + [TestCase(GateSeverity.Fail)] + public void ResolveSeverity_NoFlags_UsesCommittedValue(GateSeverity committed) => + Assert.AreEqual(committed, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: false, failOverride: false)); + + // -srGateWarnOnly forces Warn regardless of the committed value. + [TestCase(GateSeverity.Off)] + [TestCase(GateSeverity.Warn)] + [TestCase(GateSeverity.Fail)] + public void ResolveSeverity_WarnOnly_ForcesWarn(GateSeverity committed) => + Assert.AreEqual(GateSeverity.Warn, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: true, failOverride: false)); + + // -srGateFail forces Fail regardless of the committed value. + [TestCase(GateSeverity.Off)] + [TestCase(GateSeverity.Warn)] + [TestCase(GateSeverity.Fail)] + public void ResolveSeverity_FailOverride_ForcesFail(GateSeverity committed) => + Assert.AreEqual(GateSeverity.Fail, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: false, failOverride: true)); + + // Both flags together: warn-only wins (the safe choice — never fail unexpectedly). + [Test] + public void ResolveSeverity_BothFlags_WarnOnlyWins() => + Assert.AreEqual(GateSeverity.Warn, SerializeReferenceCiGate.ResolveSeverity(GateSeverity.Fail, warnOnly: true, failOverride: true)); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta new file mode 100644 index 00000000..533a470e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1acbc9f7a4534e4a995a76aac814268c +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/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs new file mode 100644 index 00000000..e4258be8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs @@ -0,0 +1,119 @@ +using System; +using UnityEngine; +using NUnit.Framework; +using System.Collections.Generic; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Coverage for the two data-preservation copiers behind type switches and Make-unique: + /// + /// carries nested + /// [SerializeReference] children ACROSS a type switch by reference — JsonUtility alone drops them, + /// which silently reset every nested reference before this coverage existed; + /// deep-copies for Make-unique / + /// de-alias: children become independent, internal aliasing topology survives, cycles terminate. + /// + /// + [TestFixture] + internal sealed class SerializeReferenceCloneTests + { + private interface IPart { } + + [Serializable] + private sealed class Gem : IPart + { + public int power; + } + + [Serializable] + private sealed class Weapon : IPart + { + public int damage; + [SerializeReference] public IPart gem; + [SerializeReference] public List mods = new(); + [SerializeReference] public IPart[] attachments = Array.Empty(); + } + + [Serializable] + private sealed class OtherWeapon : IPart + { + public int damage; + [SerializeReference] public IPart gem; + } + + [Serializable] + private sealed class Link : IPart + { + [SerializeReference] public IPart next; + } + + [Test] + public void CreateInstancePreservingData_TypeSwitch_CarriesNestedReferencesByIdentity() + { + var previous = new Weapon { damage = 7, gem = new Gem { power = 3 } }; + + var switched = (OtherWeapon)SerializeReferenceHelpers.CreateInstancePreservingData( + typeof(OtherWeapon), previous); + + Assert.AreEqual(7, switched.damage, "Plain shared fields must keep riding the JSON round-trip."); + Assert.AreSame(previous.gem, switched.gem, + "A nested [SerializeReference] shared by name must carry over as the same instance — the old " + + "parent is discarded, so reuse is what preserves the child (JsonUtility alone drops it)."); + } + + [Test] + public void CloneManagedReferenceGraph_MakesNestedChildrenIndependent() + { + var source = new Weapon + { + damage = 5, + gem = new Gem { power = 9 }, + mods = new List { new Gem { power = 1 } }, + attachments = new IPart[] { new Gem { power = 2 } }, + }; + + var clone = (Weapon)SerializeReferenceHelpers.CloneManagedReferenceGraph(source); + + Assert.AreEqual(5, clone.damage); + Assert.AreNotSame(source.gem, clone.gem, "Make-unique promises an independent nested instance."); + Assert.AreEqual(9, ((Gem)clone.gem).power, "The independent copy must keep the child's data."); + + Assert.AreNotSame(source.mods, clone.mods, "A list of references must be rebuilt, never shared."); + Assert.AreNotSame(source.mods[0], clone.mods[0]); + Assert.AreEqual(1, ((Gem)clone.mods[0]).power); + + Assert.AreNotSame(source.attachments, clone.attachments, "An array of references must be rebuilt too."); + Assert.AreNotSame(source.attachments[0], clone.attachments[0]); + Assert.AreEqual(2, ((Gem)clone.attachments[0]).power); + } + + [Test] + public void CloneManagedReferenceGraph_PreservesInternalAliasing() + { + var shared = new Gem { power = 4 }; + var source = new Weapon { gem = shared, mods = new List { shared } }; + + var clone = (Weapon)SerializeReferenceHelpers.CloneManagedReferenceGraph(source); + + Assert.AreNotSame(shared, clone.gem, "The shared child itself must still be copied."); + Assert.AreSame(clone.gem, clone.mods[0], + "Two fields aliasing one nested instance must alias one copy — internal topology is data."); + } + + [Test] + public void CloneManagedReferenceGraph_TerminatesOnCycles() + { + var first = new Link(); + var second = new Link { next = first }; + first.next = second; + + var clone = (Link)SerializeReferenceHelpers.CloneManagedReferenceGraph(first); + + Assert.AreNotSame(first, clone); + Assert.AreNotSame(second, clone.next); + Assert.AreSame(clone, ((Link)clone.next).next, + "A cyclic graph must clone into its own cycle instead of recursing forever."); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta new file mode 100644 index 00000000..265da409 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02703da7cda743d1b5ae3a5ec62391df +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/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs new file mode 100644 index 00000000..a982af95 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Guards the cross-drawer notice-position contract (ASP-24): the UIToolkit + /// must render the shared-reference notice BELOW the assigned instance's child fields, exactly like the IMGUI + /// drawer, which draws that notice after the children. A shared reference is the only notice that coexists with + /// children (missing / required / mixed render no value, so no children), so it sits at the very bottom of the + /// field. The notice host is a sibling placed after the foldout content, so it never inherits the child indent. + /// + /// + /// Reuses LinkerTestObject / TestSword from : linking two + /// managed-reference fields onto one rid surfaces the shared-reference notice on a field that also has a value (so it + /// renders child fields), which is the only notice that co-exists with children — the case the bug was about. + /// + [TestFixture] + internal sealed class SerializeReferenceFieldNoticeLayoutTests + { + [Test] + public void SharedNotice_RendersBelowChildFields() + { + var obj = ScriptableObject.CreateInstance(); + try + { + var serialized = new SerializedObject(obj); + serialized.FindProperty("a").managedReferenceValue = new TestSword { damage = 7 }; + serialized.ApplyModifiedProperties(); + + // Put both fields on one shared rid so field 'a' shows the shared-reference notice while still holding a + // value (and therefore rendering the TestSword.damage child field). + Assert.IsTrue(SerializeReferenceLinker.LinkTo(serialized.FindProperty("b"), "a")); + serialized.Update(); + + var property = serialized.FindProperty("a"); + property.isExpanded = true; + + var field = new SerializeReferenceField("A", property); + + var notice = FindFirst(field); + Assert.IsNotNull(notice, "A shared reference must surface a notice in the UIToolkit field."); + + var content = FindFirstWithClass(field, Foldout.contentUssClassName); + Assert.IsNotNull(content, "The foldout content container (which hosts the child fields) must exist."); + + // The notice must NOT live inside the content container — it is a sibling that follows it, so it never + // inherits the child indent and always renders below the children. + Assert.IsFalse(IsAncestorOf(content, notice), + "The notice must not be nested inside the foldout content (the children container)."); + + Assert.Greater( + PreOrderIndex(field, notice), + PreOrderIndex(field, content), + "The notice must render below (after) the foldout content/children, matching the IMGUI drawer."); + } + finally + { + Object.DestroyImmediate(obj); + } + } + + // Pre-order (document-order) position of an element in the real visual tree, so "renders below" reduces to a + // larger index. Walks the hierarchy (not the contentContainer view) so the toggle, the notices host and the + // content container are all visited as siblings of the foldout. + private static int PreOrderIndex(VisualElement root, VisualElement target) + { + var index = 0; + foreach (var element in DescendantsAndSelf(root)) + { + if (element == target) return index; + index++; + } + + return -1; + } + + private static T FindFirst(VisualElement root) where T : VisualElement + { + foreach (var element in DescendantsAndSelf(root)) + if (element is T match) + return match; + + return null; + } + + private static VisualElement FindFirstWithClass(VisualElement root, string className) + { + foreach (var element in DescendantsAndSelf(root)) + if (element.ClassListContains(className)) + return element; + + return null; + } + + private static bool IsAncestorOf(VisualElement ancestor, VisualElement node) + { + for (var current = node.hierarchy.parent; current is not null; current = current.hierarchy.parent) + if (current == ancestor) + return true; + + return false; + } + + private static IEnumerable DescendantsAndSelf(VisualElement root) + { + yield return root; + + for (var i = 0; i < root.hierarchy.childCount; i++) + foreach (var descendant in DescendantsAndSelf(root.hierarchy[i])) + yield return descendant; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta new file mode 100644 index 00000000..f7c921d0 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81d5faee01a145f1b06783e54fda3f98 +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/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs new file mode 100644 index 00000000..5cd8f57a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs @@ -0,0 +1,36 @@ +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Coverage for — the gate's pre-filter that keeps + /// properly declared renames from ever warning or failing a build / CI run. + /// + [TestFixture] + internal sealed class SerializeReferenceGateScannerTests + { + // A [MovedFrom]-claimed stale name is a pending migration, not a violation: Unity migrates the reference in + // memory at load, so the gate must accept it — a properly declared rename can never warn or fail a build / + // CI run. A scene path exercises the trust-the-claim branch (constraints are unrecoverable for scenes). + // (RenamedRanged is the shared [MovedFrom(..., "OldRenamedRanged")] fixture.) + [Test] + public void IsPendingMigration_MovedFromClaimedName_IsNotAViolation() + { + var stored = new ManagedTypeName( + typeof(RenamedRanged).Assembly.GetName().Name, typeof(RenamedRanged).Namespace, "OldRenamedRanged"); + var entry = new MissingReferenceEntry(fileId: 1, rid: 100, stored); + + Assert.IsTrue(SerializeReferenceGateScanner.IsPendingMigration("Assets/Fake.unity", entry)); + } + + [Test] + public void IsPendingMigration_UnknownName_StaysAViolation() + { + var stored = new ManagedTypeName( + typeof(RenamedRanged).Assembly.GetName().Name, typeof(RenamedRanged).Namespace, "GhostNeverExisted"); + var entry = new MissingReferenceEntry(fileId: 1, rid: 100, stored); + + Assert.IsFalse(SerializeReferenceGateScanner.IsPendingMigration("Assets/Fake.unity", entry)); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta new file mode 100644 index 00000000..5454e327 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf9e611e982b4f678530cf7f7b3af003 +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/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs new file mode 100644 index 00000000..15173724 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs @@ -0,0 +1,153 @@ +using System.Linq; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Structural coverage for : the field-pointer roots, the nested + /// parent → child edges (and the relative field path each carries), and the unassigned (null-sentinel) slots the + /// scanner now surfaces as empty roots / edges. Drives the public Build against temp YAML files, so it + /// exercises the parser in isolation — no asset import, no SerializedObject. Assertions stay off the per-node + /// Resolves flag (which depends on whether the Samples assembly's types load in the test domain) and only + /// check graph structure, which is purely a function of the YAML. + /// + [TestFixture] + internal sealed class SerializeReferenceGraphScannerTests + { + private string _missingPath; + private string _emptyPath; + private string _allUnassignedPath; + + // Every test only reads the fixtures, so the temp files are written once per fixture, not per test. + [OneTimeSetUp] + public void OneTimeSetUp() + { + _missingPath = YamlFixtures.WriteTemp(YamlFixtures.MissingTypePrefab); + _emptyPath = YamlFixtures.WriteTemp(YamlFixtures.EmptyFieldsPrefab); + _allUnassignedPath = YamlFixtures.WriteTemp(YamlFixtures.AllUnassignedAsset); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + YamlFixtures.Delete(_missingPath); + YamlFixtures.Delete(_emptyPath); + YamlFixtures.Delete(_allUnassignedPath); + } + + // The MonoBehaviour document is the one carrying the RefIds graph; the GameObject document has none and is + // dropped by the scanner, so Build returns exactly the MonoBehaviour graph. + private static ReferenceGraphDocument SingleDocument(string path) + { + var documents = SerializeReferenceGraphScanner.Build(path); + Assert.AreEqual(1, documents.Count, "Only the MonoBehaviour document carries managed references."); + return documents[0]; + } + + // --- Issue 1: nested references carry their full field path ------------------------------------------------- + + [Test] + public void Build_NestedReference_EdgeCarriesRelativeFieldPath() + { + var document = SingleDocument(_missingPath); + + // Railgun (_primaryWeapon) owns BurnEffect through its _chargeEffect field. The edge records the path + // relative to Railgun's data block — "_chargeEffect" — which the view joins onto "_primaryWeapon" to show + // the full "_primaryWeapon._chargeEffect" path on the nested card. + var children = document.ChildrenOf(YamlFixtures.RailgunRid); + Assert.AreEqual(1, children.Count); + Assert.AreEqual(YamlFixtures.BurnEffectRid, children[0].Rid); + Assert.AreEqual("_chargeEffect", children[0].Label); + Assert.IsFalse(children[0].IsEmpty); + } + + [Test] + public void Build_Roots_CarryIndexedFieldPaths() + { + var document = SingleDocument(_missingPath); + + Assert.AreEqual("_primaryWeapon", LabelOfRoot(document, YamlFixtures.RailgunRid)); + Assert.AreEqual("_sidearms[0]", LabelOfRoot(document, YamlFixtures.GhostPistolRid)); + Assert.AreEqual("_sidearms[1]", LabelOfRoot(document, YamlFixtures.ShotgunRid)); + Assert.AreEqual("_onHitEffect", LabelOfRoot(document, YamlFixtures.FreezeEffectRid)); + } + + [Test] + public void Build_AllAssigned_HasNoEmptySlots() + { + var document = SingleDocument(_missingPath); + + Assert.IsFalse(document.Roots.Any(root => root.IsEmpty), "Every field in the fixture is assigned."); + Assert.IsFalse(document.Edges.Values.SelectMany(edges => edges).Any(edge => edge.IsEmpty)); + } + + // --- Issue 2: unassigned (null-sentinel) slots surface ------------------------------------------------------ + + [Test] + public void Build_ClearedTopLevelField_SurfacesAsEmptyRoot() + { + var document = SingleDocument(_emptyPath); + + var emptyRoots = document.Roots.Where(root => root.IsEmpty).Select(root => root.Label).ToList(); + + // The cleared single field and the null list element both surface, each at its own field path. + CollectionAssert.AreEquivalent(new[] { "_onHitEffect", "_sidearms[1]" }, emptyRoots); + Assert.IsTrue(document.Roots.All(root => !root.IsEmpty || root.Rid < 0), + "An empty root must carry a null sentinel rid."); + } + + [Test] + public void Build_ClearedNestedField_SurfacesAsEmptyEdge() + { + var document = SingleDocument(_emptyPath); + + // Railgun's _chargeEffect was cleared: the edge is kept (so the empty slot renders nested) but points at the + // null sentinel, so the view draws an "" leaf and never recurses. + var children = document.ChildrenOf(YamlFixtures.EmptyRailgunRid); + Assert.AreEqual(1, children.Count); + Assert.IsTrue(children[0].IsEmpty); + Assert.AreEqual("_chargeEffect", children[0].Label); + } + + [Test] + public void Build_NullSentinels_ExcludedFromSharedAndOrphans() + { + var document = SingleDocument(_emptyPath); + + // Two empty roots and one empty edge all point at the same -2 sentinel — it must never be flagged shared, + // and the assigned references are all reachable, so there are no orphans either. + Assert.IsEmpty(document.Shared, "The shared -2 null sentinel must not count as an aliased reference."); + Assert.IsEmpty(document.Orphans, "Every assigned reference is reachable from a root."); + } + + // --- An asset whose every managed-ref field is unassigned must still surface (regression) ------------------ + + [Test] + public void Build_AllFieldsUnassigned_KeepsDocumentWithEmptyRoot() + { + // The whole RefIds block is just Unity's null sentinel, so the document carries zero real nodes — yet its + // single unassigned field is a slot worth showing, so the scanner must keep the document (with one empty + // root) rather than dropping it as "no managed references". Regression: a Nodes.Count == 0 early-return + // used to drop these assets entirely so the Inspect Asset view showed its empty state. + var documents = SerializeReferenceGraphScanner.Build(_allUnassignedPath); + Assert.AreEqual(1, documents.Count, "An all-unassigned asset still has a slot to surface."); + + var document = documents[0]; + Assert.IsEmpty(document.Nodes, "The RefIds block holds only the null sentinel — no real nodes."); + + var emptyRoots = document.Roots.Where(root => root.IsEmpty).Select(root => root.Label).ToList(); + CollectionAssert.AreEquivalent(new[] { "_weapon" }, emptyRoots); + + Assert.IsEmpty(document.Shared, "The shared -2 sentinel must not count as an aliased reference."); + Assert.IsEmpty(document.Orphans, "There are no real nodes, so nothing can be orphaned."); + } + + private static string LabelOfRoot(ReferenceGraphDocument document, long rid) + { + foreach (var root in document.Roots) + if (root.Rid == rid) return root.Label; + + return null; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta new file mode 100644 index 00000000..47b094ca --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25370c1a526a4cc1bd184146b0e2be20 +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/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs new file mode 100644 index 00000000..b95535fb --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// EditMode integration coverage that needs a live : the rid-sharing contract of + /// (a single managedReferenceId across two fields) and the + /// violation logic for both the managed-reference and string shapes. + /// + [TestFixture] + internal sealed class SerializeReferenceInspectorTests + { + [Test] + public void LinkTo_SharesTheSameRid() + { + var obj = ScriptableObject.CreateInstance(); + try + { + var serialized = new SerializedObject(obj); + serialized.FindProperty("a").managedReferenceValue = new TestSword { damage = 7 }; + serialized.ApplyModifiedProperties(); + + Assert.IsTrue(SerializeReferenceLinker.LinkTo(serialized.FindProperty("b"), "a")); + serialized.Update(); + + var ridA = serialized.FindProperty("a").managedReferenceId; + var ridB = serialized.FindProperty("b").managedReferenceId; + + Assert.AreNotEqual(-2L, ridA, "Field 'a' should hold a real managed reference."); + Assert.AreEqual(ridA, ridB, "Link to Existing must put both fields on a single shared managedReferenceId."); + } + finally { UnityEngine.Object.DestroyImmediate(obj); } + } + + [Test] + public void IsViolation_RequiredManagedRef_TrueWhenEmpty_FalseWhenSet() + { + var obj = ScriptableObject.CreateInstance(); + try + { + var serialized = new SerializedObject(obj); + Assert.IsTrue(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredRef")), + "An empty required managed reference is a violation."); + + var prop = serialized.FindProperty("requiredRef"); + prop.managedReferenceValue = new TestSword(); + serialized.ApplyModifiedProperties(); + serialized.Update(); + + Assert.IsFalse(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredRef")), + "A set required managed reference is not a violation."); + } + finally { UnityEngine.Object.DestroyImmediate(obj); } + } + + [Test] + public void IsViolation_RequiredString_TrueWhenEmpty_FalseWhenSet() + { + var obj = ScriptableObject.CreateInstance(); + try + { + var serialized = new SerializedObject(obj); + Assert.IsTrue(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredString")), + "An empty required string type field is a violation."); + + serialized.FindProperty("requiredString").stringValue = "Some.Namespace.SomeType, Some.Assembly"; + serialized.ApplyModifiedProperties(); + serialized.Update(); + + Assert.IsFalse(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredString")), + "A populated required string type field is not a violation."); + } + finally { UnityEngine.Object.DestroyImmediate(obj); } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta new file mode 100644 index 00000000..b3e88f2b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 242d62dc34f04451b43546fe0cc949ec +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/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs new file mode 100644 index 00000000..0e9f6dfa --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + // Top-level (namespace-scoped) types so the nested-identity test sees exactly "NestingOuter/NestingInner" rather than + // the test-fixture class prepended to the chain. + internal sealed class NestingOuter + { + internal sealed class NestingInner { } + } + + // Top-level generic types so the open/closed identity test keys on "Modifier`1"/"Pair`2" rather than a nested name. + internal sealed class GenericModifier { } + + internal sealed class GenericPair { } + + /// + /// Coverage for — the YAML type-identity builder used by every repair write. Pins the + /// closed-generic Name`N[[arg, asm]] shape and the single-quote escaping Unity requires for class identities + /// containing reserved characters, the write-side half of the generic round-trip (risk register #1). + /// + [TestFixture] + internal sealed class SerializeReferenceManagedTypeNameTests + { + [Test] + public void FromType_ClosedGeneric_BuildsBacktickName_AndQuotesYaml() + { + var name = ManagedTypeName.FromType(typeof(List)); + + StringAssert.Contains("List`1[[", name.Class, "A closed generic must use Unity's Name`N[[arg]] class shape."); + StringAssert.Contains("System.Single", name.Class); + StringAssert.Contains("{class: '", name.ToYamlType(), + "A class identity with reserved chars ([ ] , { }) must be single-quoted in the inline mapping."); + } + + [Test] + public void ToYamlType_NonGeneric_IsNotQuoted() + { + var name = new ManagedTypeName("Asm", "Ns", "Pistol"); + Assert.AreEqual("{class: Pistol, ns: Ns, asm: Asm}", name.ToYamlType()); + } + + [Test] + public void FromType_Null_IsEmpty() + { + Assert.IsTrue(ManagedTypeName.FromType(null).IsEmpty); + } + + [Test] + public void FromType_NestedType_JoinsDeclaringChainWithSlash() + { + // Unity stores nested managed-reference types as "Outer/Inner"; reflection's Type.Name is only the leaf, so + // FromType must rebuild the declaring-type prefix or a repair to a nested type writes an unresolvable class. + var name = ManagedTypeName.FromType(typeof(NestingOuter.NestingInner)); + Assert.AreEqual("NestingOuter/NestingInner", name.Class); + } + + [Test] + public void OpenTypeKey_OpenAndClosedGeneric_CollapseToSameKey() + { + // The delete guard reads a generic script's OPEN definition (Modifier`1[[T]]), while YAML stores each CLOSED + // instantiation (Modifier`1[[System.Single, …]]) — they must reduce to the same open-generic key, or deleting + // a generic [SerializeReference] type's script never warns (the closed StoredTypeKey would never match). + var open = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier<>))); + var closedFloat = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier))); + var closedInt = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier))); + + Assert.AreEqual(open, closedFloat, "The open definition and a closed instantiation must share one open-generic key."); + Assert.AreEqual(open, closedInt, "Every closed instantiation of the same definition must share one open-generic key."); + StringAssert.Contains("GenericModifier`1", open, "The open key keeps the backtick arity and drops the [[…]] argument expansion."); + StringAssert.DoesNotContain("[", open, "The open key must drop the bracketed closed-argument expansion."); + } + + [Test] + public void OpenTypeKey_DifferentArity_DoNotCollapse() + { + // The backtick arity is retained, so a one-arg and a two-arg definition never share a key. + var arityOne = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier<>))); + var arityTwo = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericPair<,>))); + + Assert.AreNotEqual(arityOne, arityTwo); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta new file mode 100644 index 00000000..9a9949aa --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 206158cc3d944bd68cbc912cf8c89ec1 +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/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs new file mode 100644 index 00000000..5edad75a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs @@ -0,0 +1,98 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Scripting.APIUpdating; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + // A namespace move recorded by [MovedFrom]: the stored identity keeps the class name, only the namespace differs. + [Serializable] + [MovedFrom(true, sourceNamespace: "Aspid.FastTools.SerializeReferences.Editors.Tests.Legacy")] + internal sealed class MovedNamespacePistol + { + [SerializeField] private int _damage; + } + + // Two types claiming the same recorded old class name: neither claim is authoritative. + [Serializable] + [MovedFrom(false, null, null, "AmbiguousOldSword")] + internal sealed class AmbiguousNewSwordA { } + + [Serializable] + [MovedFrom(false, null, null, "AmbiguousOldSword")] + internal sealed class AmbiguousNewSwordB { } + + /// + /// Coverage for — the authoritative-rename resolver behind the + /// migration classification (breakage report entries and the Project References bulk Migrate all): + /// a recorded namespace move and a recorded class rename each resolve to their single declaring type, while an + /// ambiguous claim, an unknown identity or a mismatched assembly resolve to nothing. + /// + [TestFixture] + internal sealed class SerializeReferenceMovedFromResolverTests + { + private static string Assembly => typeof(MovedNamespacePistol).Assembly.GetName().Name; + + private static string Namespace => typeof(MovedNamespacePistol).Namespace; + + [Test] + public void TryResolve_RecordedNamespaceMove_FindsTheSingleTarget() + { + var stored = new ManagedTypeName(Assembly, Namespace + ".Legacy", nameof(MovedNamespacePistol)); + + Assert.IsTrue(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target), + "A stored identity matching a recorded [MovedFrom] namespace move must resolve."); + Assert.AreEqual(typeof(MovedNamespacePistol), target); + } + + [Test] + public void TryResolve_RecordedClassRename_FindsTheSingleTarget() + { + // Reuses the RenamedRanged fixture declared for the ranking tests: [MovedFrom(..., "OldRenamedRanged")]. + var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged"); + + Assert.IsTrue(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target), + "A stored identity matching a recorded [MovedFrom] class rename must resolve."); + Assert.AreEqual(typeof(RenamedRanged), target); + } + + [Test] + public void TryResolve_AmbiguousClaims_RefusesToPick() + { + var stored = new ManagedTypeName(Assembly, Namespace, "AmbiguousOldSword"); + + Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target), + "Two types claiming the same old identity make the rename non-authoritative."); + Assert.IsNull(target); + } + + [Test] + public void TryResolve_UnknownIdentity_ReturnsFalse() + { + var stored = new ManagedTypeName(Assembly, Namespace, "NoSuchOldTypeAnywhere"); + + Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _)); + } + + [Test] + public void TryResolve_ClosedGenericIdentity_NeverMigrates() + { + // TypeCache yields definitions and the eligibility filter excludes generic parameters, so the only + // possible claimant for a stored closed-generic identity is an arity-stripped name collision — a guess + // the resolver must leave to the scored Smart Fix path. + var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged`1[[System.Single, mscorlib]]"); + + Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _)); + } + + [Test] + public void TryResolve_AssemblyMismatch_ReturnsFalse() + { + // The fixture's [MovedFrom] records no assembly move, so the old assembly is the declaring one — a stored + // identity from a different assembly must not match. + var stored = new ManagedTypeName("Some.Other.Assembly", Namespace, "OldRenamedRanged"); + + Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _)); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta new file mode 100644 index 00000000..31743f3e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e68b248b3d4742bb8dcad1f5b2b3e120 +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/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs new file mode 100644 index 00000000..fcdc5fa4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Entry = Aspid.FastTools.SerializeReferences.Editors.SerializeReferencePendingAssignment.Entry; +using Store = Aspid.FastTools.SerializeReferences.Editors.SerializeReferencePendingAssignment; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Coverage for the persistence contract of — the deferred + /// "Create new script" assignment that must survive every domain reload until the new type compiles. The bug being + /// pinned here is the old up-front erase that dropped any entry unresolvable in the first post-reload pass; these + /// tests fix the wire format (so re-persisted entries round-trip), the legacy back-compat decode, malformed-line + /// rejection, the supersede-on-re-pick merge, and the cross-reload give-up boundary. + /// + [TestFixture] + internal sealed class SerializeReferencePendingAssignmentTests + { + private const string GlobalId = "GlobalObjectId_V1-2-0000000000000000f000000000000000-12345678901234567-0"; + private const string Path = "_weapons.Array.data[3]._primary"; + private const string TypeName = "Game.Weapons.Pistol"; + + [Test] + public void Entry_RoundTrips_AllFieldsThroughEncodeDecode() + { + var entry = new Entry(GlobalId, Path, TypeName, attempts: 7); + + Assert.IsTrue(Entry.TryDecode(entry.Encode(), out var decoded)); + Assert.AreEqual(entry, decoded, "Encode/TryDecode must be a lossless round-trip including the attempt count."); + } + + [Test] + public void Entry_GenericAqnTypeName_SurvivesRoundTrip() + { + // An assembly-qualified closed-generic name carries '[', ']', ',' and '.' — but never '|' or '\n', so the + // pipe/newline split must not corrupt it. + const string generic = "Game.Inventory.Slot`1[[Game.Weapons.Pistol, Game.Runtime]]"; + var entry = new Entry(GlobalId, Path, generic, attempts: 0); + + Assert.IsTrue(Entry.TryDecode(entry.Encode(), out var decoded)); + Assert.AreEqual(generic, decoded.FullTypeName); + } + + [Test] + public void TryDecode_LegacyThreeFieldLine_DecodesWithZeroAttempts() + { + // Entries written before retry tracking have no attempt field; they must decode (attempts = 0), not be dropped. + var legacy = $"{GlobalId}|{Path}|{TypeName}"; + + Assert.IsTrue(Entry.TryDecode(legacy, out var decoded)); + Assert.AreEqual(0, decoded.Attempts); + Assert.AreEqual(TypeName, decoded.FullTypeName); + } + + [Test] + public void TryDecode_NonPositiveOrGarbageAttempts_ClampToZero() + { + Assert.IsTrue(Entry.TryDecode($"{GlobalId}|{Path}|{TypeName}|-5", out var negative)); + Assert.AreEqual(0, negative.Attempts); + + Assert.IsTrue(Entry.TryDecode($"{GlobalId}|{Path}|{TypeName}|oops", out var garbage)); + Assert.AreEqual(0, garbage.Attempts); + } + + [Test] + public void TryDecode_MalformedLines_AreRejected() + { + Assert.IsFalse(Entry.TryDecode(null, out _), "null line"); + Assert.IsFalse(Entry.TryDecode("", out _), "empty line"); + Assert.IsFalse(Entry.TryDecode($"{GlobalId}|{Path}", out _), "fewer than three fields"); + Assert.IsFalse(Entry.TryDecode($"|{Path}|{TypeName}", out _), "empty global id"); + Assert.IsFalse(Entry.TryDecode($"{GlobalId}||{TypeName}", out _), "empty property path"); + Assert.IsFalse(Entry.TryDecode($"{GlobalId}|{Path}|", out _), "empty type name"); + } + + [Test] + public void Decode_SkipsMalformedLines_KeepsValidOnes() + { + var raw = string.Join("\n", + $"{GlobalId}|{Path}|{TypeName}|1", + "garbage-without-separators", + $"{GlobalId}|{Path}|Game.Weapons.Rifle|0"); + + var entries = Store.Decode(raw); + + Assert.AreEqual(2, entries.Count, "The malformed middle line must be skipped, not abort the whole decode."); + Assert.AreEqual(TypeName, entries[0].FullTypeName); + Assert.AreEqual("Game.Weapons.Rifle", entries[1].FullTypeName); + } + + [Test] + public void Decode_EmptyOrNull_ReturnsEmptyList() + { + Assert.IsEmpty(Store.Decode(null)); + Assert.IsEmpty(Store.Decode(string.Empty)); + } + + [Test] + public void EncodeDecode_MultipleEntries_PreserveOrderAndContent() + { + var original = new List + { + new(GlobalId, "_a", "Game.A", 0), + new(GlobalId, "_b", "Game.B", 4), + new(GlobalId, "_c", "Game.C", 31), + }; + + var roundTripped = Store.Decode(Store.Encode(original)); + + CollectionAssert.AreEqual(original, roundTripped, "A multi-entry queue must survive encode/decode in order."); + } + + [Test] + public void WithIncrementedAttempt_BumpsCount_PreservesIdentity() + { + var entry = new Entry(GlobalId, Path, TypeName, attempts: 2); + var next = entry.WithIncrementedAttempt(); + + Assert.AreEqual(3, next.Attempts); + Assert.IsTrue(entry.SameTarget(next), "Incrementing the attempt must not change the entry's target."); + Assert.AreEqual(TypeName, next.FullTypeName); + } + + [Test] + public void Merge_RePickSameField_SupersedesPreviousEntry() + { + var queue = new List { new(GlobalId, Path, "Game.OldPick", 5) }; + + Store.Merge(queue, new Entry(GlobalId, Path, "Game.NewPick", 0)); + + Assert.AreEqual(1, queue.Count, "Re-picking the same field must replace, not append, the pending assignment."); + Assert.AreEqual("Game.NewPick", queue[0].FullTypeName); + Assert.AreEqual(0, queue[0].Attempts, "The superseding pick starts its own attempt budget."); + } + + [Test] + public void Merge_DifferentField_AppendsAndKeepsExisting() + { + var queue = new List { new(GlobalId, "_a", "Game.A", 1) }; + + Store.Merge(queue, new Entry(GlobalId, "_b", "Game.B", 0)); + + Assert.AreEqual(2, queue.Count); + Assert.AreEqual("Game.A", queue[0].FullTypeName, "An unrelated pending field must be untouched."); + Assert.AreEqual("Game.B", queue[1].FullTypeName); + } + + [Test] + public void GiveUpBoundary_LastIncrementBeforeCapSurvives_NextOneIsAbandoned() + { + var oneShort = new Entry(GlobalId, Path, TypeName, Store.MaxResolveAttempts - 2).WithIncrementedAttempt(); + Assert.Less(oneShort.Attempts, Store.MaxResolveAttempts, + "An entry one short of the cap must remain pending after the next reload."); + + Assert.GreaterOrEqual(oneShort.WithIncrementedAttempt().Attempts, Store.MaxResolveAttempts, + "Reaching the attempt cap is the give-up boundary the resolver drops (with a warning) on."); + } + } + + // A persistable target for the resolve-pass tests: saved as an asset so its GlobalObjectId round-trips back to the + // live object (an in-memory ScriptableObject's id does not). No fields are needed — an unresolved type short-circuits + // before the property is ever read. + internal sealed class PendingAssignmentProbeObject : ScriptableObject { } + + /// + /// Integration coverage that drives against a real + /// . This is the assertion that actually pins ASP-29: an entry that cannot be applied this + /// pass must be re-persisted, not erased. It also pins the budget split — a not-yet-loaded target must not + /// spend the cross-reload give-up budget, while a loaded target whose type is unresolved spends exactly one attempt. + /// + [TestFixture] + internal sealed class SerializeReferencePendingAssignmentResolveTests + { + // A well-formed GlobalObjectId that parses but resolves to no live object (its owner is "not loaded"). + private const string UnloadedGlobalId = + "GlobalObjectId_V1-2-0000000000000000f000000000000000-12345678901234567-0"; + private const string UnresolvableType = "Aspid.FastTools.Tests.NoSuchPendingType"; + private const string ProbeAssetPath = "Assets/__AspidPendingAssignmentProbe__.asset"; + + [SetUp] + public void SetUp() => SessionState.EraseString(Store.Key); + + [TearDown] + public void TearDown() => SessionState.EraseString(Store.Key); + + [Test] + public void ResolvePass_UnapplicableEntry_IsRePersisted_NotErased() + { + // The ASP-29 regression guard: the old code erased the key up front and dropped any entry it could not + // resolve in the first pass. A re-introduction of that bug must fail HERE, not slip past the wire-model tests. + Seed(new Entry(UnloadedGlobalId, "_field", UnresolvableType, attempts: 0)); + + var stillPending = Store.ResolvePass(countAttempt: true); + + Assert.IsTrue(stillPending, "An entry that cannot be applied yet must keep the queue pending."); + var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty)); + Assert.AreEqual(1, survivors.Count, "The unapplied entry must be re-persisted, not erased (the ASP-29 bug)."); + Assert.AreEqual(UnresolvableType, survivors[0].FullTypeName); + } + + [Test] + public void ResolvePass_TargetNotLoaded_DoesNotSpendTheGiveUpBudget() + { + // A closed scene/asset can never be fixed by a domain reload, so its attempt counter must stay put — otherwise + // unrelated reloads burn the budget and silently drop a still-valid assignment. + Seed(new Entry(UnloadedGlobalId, "_field", UnresolvableType, attempts: 5)); + + Store.ResolvePass(countAttempt: true); + + var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty)); + Assert.AreEqual(1, survivors.Count); + Assert.AreEqual(5, survivors[0].Attempts, "A not-yet-loaded target must not increment the give-up budget."); + } + + [Test] + public void ResolvePass_LoadedTargetUnresolvedType_SpendsExactlyOneAttempt() + { + var probe = ScriptableObject.CreateInstance(); + try + { + AssetDatabase.CreateAsset(probe, ProbeAssetPath); + var globalId = GlobalObjectId.GetGlobalObjectIdSlow(probe).ToString(); + Seed(new Entry(globalId, "_field", UnresolvableType, attempts: 0)); + + Store.ResolvePass(countAttempt: true); + + var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty)); + Assert.AreEqual(1, survivors.Count, "A resolvable target with an unresolved type stays pending."); + Assert.AreEqual(1, survivors[0].Attempts, + "A loaded target whose type has not compiled yet spends exactly one attempt per reload pass."); + } + finally + { + AssetDatabase.DeleteAsset(ProbeAssetPath); + } + } + + private static void Seed(Entry entry) => + SessionState.SetString(Store.Key, Store.Encode(new List { entry })); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta new file mode 100644 index 00000000..e595155d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 918145ce11349475e8e26598f39fd9c2 +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/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs new file mode 100644 index 00000000..3219efae --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs @@ -0,0 +1,62 @@ +using System; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Coverage for — the ranking engine behind the + /// missing-type Smart Fix suggestion. Pins the three outcomes the bundled sample assets rely on: a + /// same-named type in another namespace surfaces with the full field-shape bonus (MovedWeaponPreset.asset), a + /// declared [MovedFrom] match takes the authoritative top score, and a Ghost-style identity with no + /// plausible successor surfaces nothing (BrokenWeaponPreset.asset intentionally shows no suggestion). + /// + [TestFixture] + internal sealed class SerializeReferenceRepairSuggestionTests + { + private static string Assembly => typeof(RelocatedRanged).Assembly.GetName().Name; + + private static string Namespace => typeof(RelocatedRanged).Namespace; + + [Test] + public void Rank_SameNameInOtherNamespace_SurfacesWithFieldShapeBonus() + { + var stored = new ManagedTypeName(Assembly, Namespace + ".Legacy", nameof(RelocatedRanged)); + + var ranked = SerializeReferenceRepairSuggestions.Rank( + stored, new[] { "_damage", "_magazineSize" }, typeof(IRepairRankTarget)); + + Assert.IsNotEmpty(ranked, "A same-named type in another namespace must surface as a Smart Fix candidate."); + Assert.AreEqual(typeof(RelocatedRanged), ranked[0].Type); + Assert.AreEqual("same type name", ranked[0].Reason); + Assert.AreEqual(1f, ranked[0].Score, 1e-4f, + "Full field-shape overlap must add the whole bonus on top of the same-name base score."); + } + + [Test] + public void Rank_DeclaredMovedFrom_TakesTopScore() + { + var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged"); + + var ranked = SerializeReferenceRepairSuggestions.Rank( + stored, Array.Empty(), typeof(IRepairRankTarget)); + + Assert.IsNotEmpty(ranked, "A candidate whose [MovedFrom] records the stored identity must surface."); + Assert.AreEqual(typeof(RenamedRanged), ranked[0].Type); + Assert.AreEqual("declared [MovedFrom]", ranked[0].Reason); + Assert.GreaterOrEqual(ranked[0].Score, 1f, "A declared rename is authoritative — the top base score."); + } + + [Test] + public void Rank_NoPlausibleSuccessor_SurfacesNothing() + { + // Levenshtein distance from every candidate is far above the near-miss bound, and no [MovedFrom] records + // the identity — matching field names alone must never conjure a suggestion (bonus without base score). + var stored = new ManagedTypeName(Assembly, Namespace, "GhostRanged"); + + var ranked = SerializeReferenceRepairSuggestions.Rank( + stored, new[] { "_damage", "_magazineSize" }, typeof(IRepairRankTarget)); + + Assert.IsEmpty(ranked); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta new file mode 100644 index 00000000..0e9589c9 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11397fe40c884b7f92675099756e3674 +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/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs new file mode 100644 index 00000000..14d6fca7 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Linq; +using NUnit.Framework; +using UnityEngine; +using Aspid.FastTools.Types; +using System.Collections.Generic; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + // A type mixing required, optional and plain fields, to prove GetRequiredFields returns only the required ones and + // classifies each by kind (string vs [SerializeReference] managed reference). + internal sealed class MixedRequiredObject : ScriptableObject + { + [SerializeReference, TypeSelector(Required = true)] public ITestWeapon requiredRef; + [TypeSelector(Required = true)] public string requiredString; + [SerializeReference, TypeSelector] public ITestWeapon optionalRef; + [TypeSelector] public string optionalString; + public int plain; + } + + /// + /// Coverage for the scene-safe required-field gate: + /// (reflection over a type's required fields) and + /// (the pure-YAML scan that reads scene MonoBehaviours straight from the file). The YAML tests drive the public + /// method against temp .unity-shaped fixtures with an injected script→fields resolver, so the parser and the + /// violation logic are exercised in isolation — no asset import, no , no AssetDatabase. + /// + [TestFixture] + internal sealed class SerializeReferenceRequiredYamlTests + { + // Maps the fixtures' first script guid to RequiredTestObject's required fields; every other guid is unknown. + private static IReadOnlyList Resolve(string guid) => + guid == YamlFixtures.RequiredSceneScriptGuid + ? SerializeReferenceRequiredGate.GetRequiredFields(typeof(RequiredTestObject)) + : Array.Empty(); + + private string _path; + + [TearDown] + public void TearDown() => YamlFixtures.Delete(_path); + + [Test] + public void GetRequiredFields_ReturnsOnlyRequiredFields_ClassifiedByKind() + { + var byName = SerializeReferenceRequiredGate.GetRequiredFields(typeof(MixedRequiredObject)) + .ToDictionary(field => field.FieldName, field => field.IsString); + + Assert.AreEqual(2, byName.Count, "Only the two Required fields should be returned."); + Assert.IsTrue(byName.ContainsKey("requiredRef"), "The required managed reference should be included."); + Assert.IsTrue(byName.ContainsKey("requiredString"), "The required string field should be included."); + Assert.IsFalse(byName["requiredRef"], "A [SerializeReference] field is a managed reference, not a string."); + Assert.IsTrue(byName["requiredString"], "A string type field is classified as a string."); + } + + [Test] + public void GetRequiredFields_NoRequiredFields_ReturnsEmpty() + { + // LinkerTestObject has [SerializeReference] fields but none mark Required. + Assert.AreEqual(0, SerializeReferenceRequiredGate.GetRequiredFields(typeof(LinkerTestObject)).Count); + } + + [Test] + public void GetRequiredFields_NullType_ReturnsEmpty() + { + Assert.AreEqual(0, SerializeReferenceRequiredGate.GetRequiredFields(null).Count); + } + + [Test] + public void FindUnsetRequiredFields_BothUnset_ReportsBoth() + { + _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneUnset); + + var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve); + + Assert.AreEqual(2, violations.Count, "An unset required reference and string should both be reported."); + Assert.IsTrue(violations.All(v => v.FileId == YamlFixtures.RequiredSceneMonoFileId), + "Both violations belong to the single MonoBehaviour document."); + + var managed = violations.Single(v => v.FieldName == "requiredRef"); + Assert.AreEqual(-2L, managed.Rid, "An unset managed reference reads the null id (-2)."); + Assert.IsTrue(violations.Any(v => v.FieldName == "requiredString"), "The empty string field is a violation."); + } + + [Test] + public void FindUnsetRequiredFields_BothSet_ReportsNone() + { + _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneSet); + + var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve); + + Assert.AreEqual(0, violations.Count, "A set managed reference and a populated string are not violations."); + } + + [Test] + public void FindUnsetRequiredFields_AbsentKeys_ReportsNone() + { + // Both required keys are missing from the document (object saved before the fields were added / stripped doc). + // An absent key needs a reserialize, not a build failure, so it must not be reported as a violation. + _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneAbsentKeys); + + var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve); + + Assert.AreEqual(0, violations.Count, "An absent required key is not a violation — it needs a reserialize."); + } + + [Test] + public void FindUnsetRequiredFields_MixedAndUnknownScript_ReportsOnlyKnownUnset() + { + _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneMixedUnknownScript); + + var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve); + + Assert.AreEqual(1, violations.Count, + "Only the empty string on the known script is reported; the unknown-script document is skipped."); + Assert.AreEqual("requiredString", violations[0].FieldName); + Assert.AreEqual(YamlFixtures.RequiredSceneMonoFileId, violations[0].FileId, + "The violation must be attributed to the first MonoBehaviour, not the unknown-script one."); + } + + [Test] + public void FindUnsetRequiredFields_NullResolver_ReturnsEmpty() + { + _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneUnset); + Assert.AreEqual(0, SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, null).Count); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta new file mode 100644 index 00000000..d44722d6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2371606da73a46b29b80172e1ed694f5 +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/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs new file mode 100644 index 00000000..2d806b34 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Linq; +using NUnit.Framework; +using UnityEngine.UIElements; +using Aspid.FastTools.Editors; +using Aspid.FastTools.UIElements.Editors.Internal; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Behavioural coverage for the SerializeReference settings propagation contract (ASP-22): + /// + /// the excluded-folder set raises the dedicated + /// signal only when it genuinely changes — so the usage index drops its warm copy on a real change but an unrelated + /// setting never triggers a costly index rebuild; + /// the shared controls built by mirror the store live, so the + /// in-window Settings tab and the Project Settings page stay in sync when either edits a value. + /// + /// + [TestFixture] + internal sealed class SerializeReferenceSettingsTests + { + private bool _autoDeAlias; + private bool _breakageDetection; + private bool _dropdownWithoutAttribute; + private string[] _excludedFolders; + private GateSeverity _buildSeverity; + + [SetUp] + public void SetUp() + { + // Snapshot the project's real settings so the assertions below can mutate them freely and restore on teardown. + _autoDeAlias = SerializeReferenceSettings.AutoDeAliasEnabled; + _breakageDetection = SerializeReferenceSettings.BreakageDetectionEnabled; + _dropdownWithoutAttribute = SerializeReferenceSettings.DropdownWithoutAttributeEnabled; + _excludedFolders = SerializeReferenceSettings.ExcludedFolders; + _buildSeverity = SerializeReferenceSettings.BuildSeverity; + } + + [TearDown] + public void TearDown() + { + SerializeReferenceSettings.AutoDeAliasEnabled = _autoDeAlias; + SerializeReferenceSettings.BreakageDetectionEnabled = _breakageDetection; + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = _dropdownWithoutAttribute; + SerializeReferenceSettings.ExcludedFolders = _excludedFolders; + SerializeReferenceSettings.BuildSeverity = _buildSeverity; + } + + // ----------------------------------------------------------------------------------------------------- + // A — excluded folders drive the dedicated index-invalidation signal (and nothing else does) + // ----------------------------------------------------------------------------------------------------- + + // Counts how many times ExcludedFoldersChanged fires while `mutate` runs, leaving the static event clean. + private static int ExcludedFoldersChangedCount(Action mutate) + { + var fired = 0; + void Handler() => fired++; + SerializeReferenceSettings.ExcludedFoldersChanged += Handler; + try { mutate(); } + finally { SerializeReferenceSettings.ExcludedFoldersChanged -= Handler; } + return fired; + } + + [Test] + public void ExcludedFolders_NewValue_RaisesExcludedFoldersChanged() + { + SerializeReferenceSettings.ExcludedFolders = Array.Empty(); + + var fired = ExcludedFoldersChangedCount(() => + SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Third Party/" }); + + Assert.AreEqual(1, fired, "A genuinely new excluded-folder set must raise ExcludedFoldersChanged exactly once."); + } + + [Test] + public void ExcludedFolders_SameValue_DoesNotRaiseExcludedFoldersChanged() + { + SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/" }; + + // Same paths, fresh array instance: the set did not move, so the warm index must not be dropped. + var fired = ExcludedFoldersChangedCount(() => + SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/" }); + + Assert.AreEqual(0, fired, "Re-assigning an identical set must not raise ExcludedFoldersChanged (no needless index rebuild)."); + } + + [Test] + public void UnrelatedSetting_DoesNotRaiseExcludedFoldersChanged() + { + var fired = ExcludedFoldersChangedCount(() => + { + SerializeReferenceSettings.BreakageDetectionEnabled = !SerializeReferenceSettings.BreakageDetectionEnabled; + SerializeReferenceSettings.AutoDeAliasEnabled = !SerializeReferenceSettings.AutoDeAliasEnabled; + SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail; + }); + + Assert.AreEqual(0, fired, "Toggling an unrelated setting must never raise ExcludedFoldersChanged (the index stays warm)."); + } + + [Test] + public void AnySetting_RaisesGeneralChanged() + { + var fired = 0; + void Handler() => fired++; + SerializeReferenceSettings.Changed += Handler; + try + { + SerializeReferenceSettings.BreakageDetectionEnabled = !SerializeReferenceSettings.BreakageDetectionEnabled; + Assert.GreaterOrEqual(fired, 1, "Every setter must still raise the general Changed for repaint and live-sync."); + } + finally { SerializeReferenceSettings.Changed -= Handler; } + } + + // ----------------------------------------------------------------------------------------------------- + // B — the per-scope resets restore exactly their own defaults + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void ResetSharedToDefaults_RestoresCommittedDefaults_AndLeavesUserSettingsAlone() + { + SerializeReferenceSettings.AutoDeAliasEnabled = false; + SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail; + SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Third Party/" }; + SerializeReferenceSettings.BreakageDetectionEnabled = false; + + SerializeReferenceSettings.ResetSharedToDefaults(); + + Assert.IsTrue(SerializeReferenceSettings.AutoDeAliasEnabled, "The shared reset must restore auto de-alias to on."); + Assert.AreEqual(GateSeverity.Warn, SerializeReferenceSettings.BuildSeverity, "The shared reset must restore the gate to Warn."); + Assert.IsEmpty(SerializeReferenceSettings.ExcludedFolders, "The shared reset must drop every excluded folder."); + Assert.IsFalse(SerializeReferenceSettings.BreakageDetectionEnabled, + "The shared reset must not touch the per-user breakage-detection setting."); + } + + [Test] + public void ResetUserToDefaults_RestoresBreakageDetection_AndLeavesSharedSettingsAlone() + { + SerializeReferenceSettings.BreakageDetectionEnabled = false; + SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail; + + SerializeReferenceSettings.ResetUserToDefaults(); + + Assert.IsTrue(SerializeReferenceSettings.BreakageDetectionEnabled, "The per-user reset must restore breakage detection to on."); + Assert.AreEqual(GateSeverity.Fail, SerializeReferenceSettings.BuildSeverity, + "The per-user reset must not touch the shared gate severity."); + } + + // ----------------------------------------------------------------------------------------------------- + // C — the shared controls mirror the store live (the two settings surfaces stay in sync) + // ----------------------------------------------------------------------------------------------------- + + [Test] + public void BuildControls_LiveSyncsControlsFromSettings() + { + SerializeReferenceSettings.AutoDeAliasEnabled = true; + SerializeReferenceSettings.BreakageDetectionEnabled = true; + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false; + SerializeReferenceSettings.BuildSeverity = GateSeverity.Warn; + SerializeReferenceSettings.ExcludedFolders = Array.Empty(); + + var container = new VisualElement(); + SerializeReferenceSettingsUI.BuildControls(container); + + // The three boolean settings render as iOS-style AspidSwitch fields (BaseField), not plain Toggles. + // Looked up by label, so reordering the rows never silently swaps the assertions. + var switches = container.Query().ToList(); + Assert.AreEqual(3, switches.Count, + "BuildControls must emit the breakage-detection, attribute-free-dropdown and auto-de-alias switches."); + var breakageDetection = switches.Single(s => s.label == "Breakage detection"); + var dropdownWithoutAttribute = switches.Single(s => s.label.StartsWith("Dropdown without")); + var autoDeAlias = switches.Single(s => s.label.StartsWith("Auto de-alias")); + var severity = container.Q(); + var folders = container.Q(); + Assert.IsNotNull(severity, "BuildControls must emit the build-gate EnumField."); + Assert.IsNotNull(folders, "BuildControls must emit the excluded-folders field."); + + // Mutating the shared store (as the other surface would) must reach these controls without a manual refresh: + // the switches and the gate re-read their value off SerializeReferenceSettings.Changed, and the folders list + // rebuilds off the dedicated ExcludedFoldersChanged signal. + SerializeReferenceSettings.AutoDeAliasEnabled = false; + SerializeReferenceSettings.BreakageDetectionEnabled = false; + SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true; + SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail; + SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/", "Assets/Generated/" }; + + Assert.IsFalse(autoDeAlias.value, "The auto-de-alias switch must mirror Settings live."); + Assert.IsFalse(breakageDetection.value, "The breakage-detection switch must mirror Settings live."); + Assert.IsTrue(dropdownWithoutAttribute.value, "The attribute-free-dropdown switch must mirror Settings live."); + Assert.AreEqual(GateSeverity.Fail, (GateSeverity)severity.value, "The build-gate field must mirror Settings live."); + + // The list-based folders field renders one path Label per excluded folder; both new paths must appear live. + var listedPaths = folders.Query