|
| 1 | +# Writing a Custom Asset Editor |
| 2 | + |
| 3 | +## Role |
| 4 | + |
| 5 | +A custom editor is a ViewModel + View pair registered against an `AssetViewModel` type. When the user double-clicks the asset in GameStudio, the framework instantiates the registered view, binds it to the registered ViewModel, and calls `InitializeEditor`. The ViewModel drives all logic; the View is pure WPF XAML bound to it. |
| 6 | + |
| 7 | +## Choosing a Base Class |
| 8 | + |
| 9 | +| Base class | Use when | What it adds | |
| 10 | +|---|---|---| |
| 11 | +| `AssetEditorViewModel` | Simple editor with no game viewport and no hierarchical parts (e.g. sprite sheet, graphics compositor, script) | Asset ownership, `Initialize`/`Destroy` lifecycle, `IUndoRedoService`, `SessionViewModel` | |
| 12 | +| `GameEditorViewModel` | Editor that needs a live game instance for rendering (rarely subclassed directly — prefer the composite variant below) | `IEditorGameController` integration, game startup/shutdown, error recovery | |
| 13 | +| `AssetCompositeHierarchyEditorViewModel<TAssetPartDesign, TAssetPart, TItemViewModel>` | Asset that contains a tree of selectable parts (scenes, prefabs, UI pages) | Selection tracking, copy/cut/paste/delete/duplicate for hierarchy parts, part ViewModel factory | |
| 14 | + |
| 15 | +## Registration |
| 16 | + |
| 17 | +Two attributes are required. Both are discovered automatically by `AssetsEditorPlugin` via reflection at startup — no manual registration needed. |
| 18 | + |
| 19 | +```csharp |
| 20 | +// On the editor ViewModel class — maps AssetViewModel subtype → editor ViewModel type. |
| 21 | +[AssetEditorViewModel<%%AssetName%%ViewModel>] |
| 22 | +public sealed class %%AssetName%%EditorViewModel : AssetEditorViewModel |
| 23 | +{ |
| 24 | + public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) |
| 25 | + : base(asset) { } |
| 26 | +} |
| 27 | + |
| 28 | +// On the view code-behind — maps editor ViewModel type → view type. |
| 29 | +[AssetEditorView<%%AssetName%%EditorViewModel>] |
| 30 | +public partial class %%AssetName%%EditorView : UserControl, IEditorView { ... } |
| 31 | +``` |
| 32 | + |
| 33 | +Both classes must live in `Stride.Assets.Presentation` (or an assembly loaded as a plugin via `AssetsEditorPlugin`). |
| 34 | + |
| 35 | +## Lifecycle |
| 36 | + |
| 37 | +**1. Construction** — synchronous; `base(asset)` is the only required call; do not perform async work here. |
| 38 | + |
| 39 | +**2. `Initialize()`** |
| 40 | + |
| 41 | +```csharp |
| 42 | +public override async Task<bool> Initialize() |
| 43 | +{ |
| 44 | + // Load resources, set up bindings, register selection scope. |
| 45 | + // Return false to abort — the editor will not open and Destroy() will be called. |
| 46 | + return true; |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +**3. Active editing** — user interacts; ViewModel handles commands; all mutations go through `UndoRedoService.CreateTransaction()` (see [undo-redo.md](undo-redo.md)). |
| 51 | + |
| 52 | +**4. `PreviewClose(bool? save)`** |
| 53 | + |
| 54 | +```csharp |
| 55 | +public override bool PreviewClose(bool? save) |
| 56 | +{ |
| 57 | + if (save == null) |
| 58 | + { |
| 59 | + // Ask user — show a dialog via ServiceProvider.Get<IEditorDialogService>(). |
| 60 | + // Return false to cancel close. |
| 61 | + } |
| 62 | + // save == true → force-save; save == false → discard. |
| 63 | + return true; |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +**5. `Destroy()`** — inherited from the MVVM base infrastructure (`DispatcherViewModel`/`ViewModelBase`), not declared on `AssetEditorViewModel` itself; synchronous; unhook all events, stop game instance if any, release resources; must not throw; always call `base.Destroy()`. |
| 68 | + |
| 69 | +## The View |
| 70 | + |
| 71 | +Implement `IEditorView` in the code-behind. The XAML file contains only layout and data bindings — no business logic. |
| 72 | + |
| 73 | +```csharp |
| 74 | +[AssetEditorView<%%AssetName%%EditorViewModel>] |
| 75 | +public partial class %%AssetName%%EditorView : UserControl, IEditorView |
| 76 | +{ |
| 77 | + private readonly TaskCompletionSource editorInitializationTcs = new(); |
| 78 | + |
| 79 | + public object DataContext |
| 80 | + { |
| 81 | + get => base.DataContext; |
| 82 | + set => base.DataContext = value; |
| 83 | + } |
| 84 | + |
| 85 | + public Task EditorInitialization => editorInitializationTcs.Task; |
| 86 | + |
| 87 | + public async Task<bool> InitializeEditor(IAssetEditorViewModel editor) |
| 88 | + { |
| 89 | + if (!await editor.Initialize()) |
| 90 | + { |
| 91 | + editor.Destroy(); |
| 92 | + return false; |
| 93 | + } |
| 94 | + // Wire up anything that requires the initialized ViewModel here |
| 95 | + // (e.g. inject the game viewport: somePanel.Content = myEditor.Controller.EditorHost). |
| 96 | + editorInitializationTcs.SetResult(); |
| 97 | + return true; |
| 98 | + } |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +## Services |
| 103 | + |
| 104 | +Access services via `ServiceProvider` (available on `AssetEditorViewModel`): |
| 105 | + |
| 106 | +| Service | Access | Purpose | |
| 107 | +|---|---|---| |
| 108 | +| `IUndoRedoService` | `ServiceProvider.Get<IUndoRedoService>()` | Wrap mutations in transactions — see [undo-redo.md](undo-redo.md) | |
| 109 | +| `IDispatcherService` | `ServiceProvider.Get<IDispatcherService>()` | Invoke code on the UI thread from a background thread | |
| 110 | +| `IEditorDialogService` | `ServiceProvider.Get<IEditorDialogService>()` | Show dialogs, message boxes, and file pickers | |
| 111 | +| `SelectionService` | `ServiceProvider.Get<SelectionService>()` | Register selection scope for back/forward navigation — see [navigation.md](navigation.md) | |
| 112 | +| `IAssetEditorsManager` | `ServiceProvider.TryGet<IAssetEditorsManager>()` | Open or close other asset editors programmatically | |
| 113 | + |
| 114 | +Use `TryGet<T>()` for optional services; `Get<T>()` throws if the service is not registered. |
| 115 | + |
| 116 | +`UndoRedoService` is also available as a shorthand property on `AssetEditorViewModel` (equivalent to `ServiceProvider.Get<IUndoRedoService>()`). |
| 117 | + |
| 118 | +## MVVM Patterns |
| 119 | + |
| 120 | +### Binding a property with automatic undo/redo |
| 121 | + |
| 122 | +`MemberGraphNodeBinding<T>` wraps a Quantum `IMemberNode`; get/set route through the binding and undo/redo is handled automatically. Obtain the root `IObjectNode` via `Session.AssetNodeContainer` (see [quantum/asset-graph.md](../quantum/asset-graph.md)): |
| 123 | + |
| 124 | +```csharp |
| 125 | +private readonly MemberGraphNodeBinding<Color> colorBinding; |
| 126 | + |
| 127 | +public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) |
| 128 | + : base(asset) |
| 129 | +{ |
| 130 | + // rootNode is an IObjectNode obtained via Session.AssetNodeContainer. |
| 131 | + // See docs/quantum/asset-graph.md for how to retrieve it. |
| 132 | + colorBinding = new MemberGraphNodeBinding<Color>( |
| 133 | + rootNode[nameof(%%AssetName%%.Color)], // IMemberNode |
| 134 | + nameof(%%AssetName%%EditorViewModel.Color), // ViewModel property name |
| 135 | + OnPropertyChanging, |
| 136 | + OnPropertyChanged, |
| 137 | + UndoRedoService); |
| 138 | +} |
| 139 | + |
| 140 | +public Color Color { get => colorBinding.Value; set => colorBinding.Value = value; } |
| 141 | +``` |
| 142 | + |
| 143 | +### Manual transaction wrapping |
| 144 | + |
| 145 | +For mutations that bypass the node graph (direct collection changes, renaming, structural operations): |
| 146 | + |
| 147 | +```csharp |
| 148 | +using (var transaction = UndoRedoService.CreateTransaction()) |
| 149 | +{ |
| 150 | + // perform mutations here |
| 151 | + UndoRedoService.SetName(transaction, "Descriptive operation name"); |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +See [undo-redo.md](undo-redo.md#wrapping-a-mutation) for the full pattern including `AnonymousDirtyingOperation`. |
| 156 | + |
| 157 | +### Commands |
| 158 | + |
| 159 | +```csharp |
| 160 | +public ICommandBase DoSomethingCommand { get; } |
| 161 | + |
| 162 | +public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) |
| 163 | + : base(asset) |
| 164 | +{ |
| 165 | + DoSomethingCommand = new AnonymousTaskCommand(ServiceProvider, DoSomethingAsync); |
| 166 | +} |
| 167 | + |
| 168 | +private async Task DoSomethingAsync() |
| 169 | +{ |
| 170 | + using var transaction = UndoRedoService.CreateTransaction(); |
| 171 | + // ... |
| 172 | + UndoRedoService.SetName(transaction, "Do something"); |
| 173 | +} |
| 174 | +``` |
| 175 | + |
| 176 | +## Assembly Placement |
| 177 | + |
| 178 | +| File | Path | |
| 179 | +|---|---| |
| 180 | +| `%%AssetName%%EditorViewModel.cs` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/ViewModels/` | |
| 181 | +| `%%AssetName%%EditorView.xaml` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/Views/` | |
| 182 | +| `%%AssetName%%EditorView.xaml.cs` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/Views/` | |
0 commit comments