Compare commits
2 Commits
d9343a94dc
...
c6e58b057c
| Author | SHA1 | Date | |
|---|---|---|---|
| c6e58b057c | |||
| 34dc6fc8c9 |
@@ -60,7 +60,7 @@ public enum ShaderStage
|
|||||||
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IShaderCompiler : IDisposable
|
internal interface IShaderCompiler : IDisposable
|
||||||
{
|
{
|
||||||
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
|
<NoWarn>$(NoWarn);MVVMTK0050</NoWarn>
|
||||||
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" />
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Inspector;
|
using Ghost.Editor.Core.Inspector;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -18,10 +19,11 @@ public class ComponentNode
|
|||||||
public PropertyNode[] Properties { get; }
|
public PropertyNode[] Properties { get; }
|
||||||
public string Name => Descriptor.DisplayName;
|
public string Name => Descriptor.DisplayName;
|
||||||
|
|
||||||
public ComponentNode(World world, Entity entity, Type componentType, ComponentDescriptor descriptor)
|
internal ComponentNode(World world, Entity entity, Type componentType, ComponentDescriptor descriptor)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
_entity = entity;
|
_entity = entity;
|
||||||
|
|
||||||
ComponentType = componentType;
|
ComponentType = componentType;
|
||||||
Descriptor = descriptor;
|
Descriptor = descriptor;
|
||||||
|
|
||||||
@@ -44,6 +46,8 @@ public class ComponentNode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Data Access ---
|
// --- Data Access ---
|
||||||
|
|
||||||
public object ReadBoxedValue(PropertyDescriptor field)
|
public object ReadBoxedValue(PropertyDescriptor field)
|
||||||
@@ -66,7 +70,7 @@ public class ComponentNode
|
|||||||
|
|
||||||
public void SetFieldValue<T>(PropertyDescriptor field, T value) where T : unmanaged
|
public void SetFieldValue<T>(PropertyDescriptor field, T value) where T : unmanaged
|
||||||
{
|
{
|
||||||
EditorApplication.GetService<Services.EditorWorldService>().Defer(() =>
|
EditorApplication.GetService<Services.IEditorWorldService>().Defer(() =>
|
||||||
{
|
{
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public sealed partial class EntityNode : SceneGraphNode
|
|||||||
}
|
}
|
||||||
public List<ComponentNode> Components { get; } = new();
|
public List<ComponentNode> Components { get; } = new();
|
||||||
|
|
||||||
public EntityNode(World world, Entity entity, string name)
|
internal EntityNode(World world, Entity entity, string name)
|
||||||
: base(world, name)
|
: base(world, name)
|
||||||
{
|
{
|
||||||
Entity = entity;
|
Entity = entity;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
@@ -7,7 +8,8 @@ using System.Collections.ObjectModel;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
[ObservableObject]
|
||||||
|
public abstract partial class SceneGraphNode : GhostObject, IInspectable
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Name
|
public partial string Name
|
||||||
@@ -31,6 +33,16 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
|||||||
Name = name;
|
Name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Name = reader.ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
public virtual IconSource? CreateIcon()
|
public virtual IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public sealed partial class SceneNode : SceneGraphNode
|
|||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SceneNode(World world, Scene scene, string name)
|
internal SceneNode(World world, Scene scene, string name)
|
||||||
: base(world, name)
|
: base(world, name)
|
||||||
{
|
{
|
||||||
Scene = scene;
|
Scene = scene;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Ghost.Core.Graphics;
|
|||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Ghost.Graphics.Core;
|
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
@@ -24,11 +23,11 @@ internal sealed class EditorShaderCompilerBridge : IShaderCompilationBridge
|
|||||||
public event ShaderVariantCompiledHandler? OnShaderVariantCompiled;
|
public event ShaderVariantCompiledHandler? OnShaderVariantCompiled;
|
||||||
public event Action<ulong>? OnShaderInvalidated;
|
public event Action<ulong>? OnShaderInvalidated;
|
||||||
|
|
||||||
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider)
|
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider, IShaderCompiler shaderCompiler)
|
||||||
{
|
{
|
||||||
_assetRegistry = assetRegistry;
|
_assetRegistry = assetRegistry;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_compiler = new DXCShaderCompiler();
|
_compiler = shaderCompiler;
|
||||||
|
|
||||||
_assetRegistry.OnAssetImported += OnAssetImported;
|
_assetRegistry.OnAssetImported += OnAssetImported;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ namespace Ghost.Editor.Core.Services;
|
|||||||
|
|
||||||
public sealed class EditorTickEngine : IDisposable
|
public sealed class EditorTickEngine : IDisposable
|
||||||
{
|
{
|
||||||
private readonly EditorWorldService _worldService;
|
private readonly IEditorWorldService _worldService;
|
||||||
private DispatcherQueueTimer? _timer;
|
private readonly DispatcherQueueTimer _timer;
|
||||||
private bool _isStarted;
|
private bool _isStarted;
|
||||||
|
|
||||||
// Time data
|
// Time data
|
||||||
@@ -20,9 +20,13 @@ public sealed class EditorTickEngine : IDisposable
|
|||||||
public event Action? OnInspectorSync;
|
public event Action? OnInspectorSync;
|
||||||
public event Action? OnFireEvents;
|
public event Action? OnFireEvents;
|
||||||
|
|
||||||
public EditorTickEngine(EditorWorldService worldService)
|
public EditorTickEngine(IEditorWorldService worldService)
|
||||||
{
|
{
|
||||||
_worldService = worldService;
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_timer = EditorApplication.DispatcherQueue.CreateTimer();
|
||||||
|
_timer.Interval = TimeSpan.FromMilliseconds(16); // ~60Hz
|
||||||
|
_timer.Tick += OnTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
@@ -32,10 +36,6 @@ public sealed class EditorTickEngine : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_timer = EditorApplication.DispatcherQueue.CreateTimer();
|
|
||||||
_timer.Interval = TimeSpan.FromMilliseconds(16); // ~60Hz
|
|
||||||
_timer.Tick += OnTick;
|
|
||||||
|
|
||||||
_startTimestamp = Stopwatch.GetTimestamp();
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
_lastFrameTimestamp = _startTimestamp;
|
_lastFrameTimestamp = _startTimestamp;
|
||||||
_timeData = new TimeData();
|
_timeData = new TimeData();
|
||||||
|
|||||||
@@ -4,14 +4,40 @@ using Ghost.Engine;
|
|||||||
using Ghost.Engine.Core;
|
using Ghost.Engine.Core;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Misaki.HighPerformance.Jobs;
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
public class EditorWorldService : IDisposable
|
public interface IEditorWorldService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _deferredActions = new();
|
World EditorWorld { get; }
|
||||||
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _pendingEvents = new();
|
ObservableCollection<SceneNode> RootNodes { get; }
|
||||||
|
|
||||||
|
event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
event Action<Entity>? EntityDestroyed;
|
||||||
|
event Action<Entity, Entity, Entity>? EntityParentChanged;
|
||||||
|
event Action<Entity, string>? EntityNameChanged;
|
||||||
|
event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
void ChangeEntityScene(Entity entity, ushort sceneID);
|
||||||
|
void CreateDefaultScene();
|
||||||
|
void CreateEntity(string name, ushort sceneID, Entity parent = default);
|
||||||
|
void Defer(Action action);
|
||||||
|
void DestroyEntity(Entity entity);
|
||||||
|
void FirePendingEvents();
|
||||||
|
void FlushCommands();
|
||||||
|
ushort GetEntitySceneID(Entity entity);
|
||||||
|
void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null);
|
||||||
|
Error RemoveParent(Entity child);
|
||||||
|
void RenameEntity(Entity entity, string newName);
|
||||||
|
Error SetParent(Entity child, Entity parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EditorWorldService : IEditorWorldService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<Action> _deferredActions = new();
|
||||||
|
private readonly ConcurrentQueue<Action> _pendingEvents = new();
|
||||||
|
|
||||||
public World EditorWorld
|
public World EditorWorld
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ using Ghost.Entities;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
public class SceneGraphSyncService : IDisposable
|
internal class SceneGraphSyncService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly EditorWorldService _worldService;
|
private readonly IEditorWorldService _worldService;
|
||||||
private readonly Dictionary<Entity, EntityNode> _nodeMap = new();
|
private readonly Dictionary<Entity, EntityNode> _nodeMap = new();
|
||||||
|
|
||||||
public SceneGraphSyncService(EditorWorldService worldService)
|
public SceneGraphSyncService(IEditorWorldService worldService)
|
||||||
{
|
{
|
||||||
_worldService = worldService;
|
_worldService = worldService;
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ internal class SceneSerializationService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly EditorWorldService _worldService;
|
private readonly IEditorWorldService _worldService;
|
||||||
private readonly IAssetRegistry _assetRegistry;
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
private readonly SceneGraphSyncService _syncService;
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
|
||||||
public SceneSerializationService(EditorWorldService worldService, IAssetRegistry assetRegistry, SceneGraphSyncService syncService)
|
public SceneSerializationService(IEditorWorldService worldService, IAssetRegistry assetRegistry, SceneGraphSyncService syncService)
|
||||||
{
|
{
|
||||||
_worldService = worldService;
|
_worldService = worldService;
|
||||||
_assetRegistry = assetRegistry;
|
_assetRegistry = assetRegistry;
|
||||||
|
|||||||
596
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
596
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Collections;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public enum LifecycleEvent { Created, Destroyed }
|
||||||
|
|
||||||
|
public interface IUndoService
|
||||||
|
{
|
||||||
|
event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
void RecordObject(GhostObject obj, string actionName);
|
||||||
|
void RecordEntityComponent(ComponentNode node, string actionName);
|
||||||
|
void RecordEntityStructure(EntityNode node, string actionName);
|
||||||
|
void RecordEntityLifecycle(EntityNode node, LifecycleEvent type);
|
||||||
|
|
||||||
|
void BeginTransaction(string name);
|
||||||
|
void EndTransaction();
|
||||||
|
void PerformUndo();
|
||||||
|
void PerformRedo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class UndoOperation
|
||||||
|
{
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string ActionName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Creates an operation that holds the *current* state, so it can be pushed to Redo.
|
||||||
|
public abstract UndoOperation CreateReciprocal(IEditorWorldService worldService);
|
||||||
|
public abstract void Revert(IEditorWorldService worldService);
|
||||||
|
|
||||||
|
public virtual bool CanMerge(UndoOperation other) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ObjectStateOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public byte[] State { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
var reciprocal = new ObjectStateOperation { GroupId = GroupId, ActionName = ActionName, InstanceID = InstanceID };
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
reciprocal.State = ms.ToArray();
|
||||||
|
}
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(State);
|
||||||
|
using var reader = new BinaryReader(ms);
|
||||||
|
obj.DeserializeState(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is ObjectStateOperation op)
|
||||||
|
{
|
||||||
|
return op.InstanceID == InstanceID && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityComponentOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ComponentId { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
|
||||||
|
var reciprocal = new EntityComponentOperation { GroupId = GroupId, ActionName = ActionName, Entity = targetEntity, InstanceID = InstanceID, ComponentId = ComponentId };
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(ComponentId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
var size = ComponentRegistry.GetComponentInfo(new Identifier<IComponent>(ComponentId)).size;
|
||||||
|
var data = new byte[size];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
reciprocal.ComponentData = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var cId = ComponentId;
|
||||||
|
var data = ComponentData;
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(cId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
fixed (byte* pSrc = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pSrc, pComp, data.Length, data.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityComponentOperation op)
|
||||||
|
{
|
||||||
|
return op.Entity == Entity && op.ComponentId == ComponentId && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityStructureOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public static EntityStructureOperation Capture(IEditorWorldService worldService, EntityNode node)
|
||||||
|
{
|
||||||
|
var entity = node.Entity;
|
||||||
|
var op = new EntityStructureOperation { Entity = entity, InstanceID = node.InstanceID };
|
||||||
|
var locRes = worldService.EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
op.ArchetypeID = locRes.Value.archetypeID;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
ref var archetype = ref worldService.EditorWorld.ComponentManager.GetArchetypeReference(op.ArchetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
// Compute size of all unmanaged components
|
||||||
|
var totalSize = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
totalSize += archetype._layouts[i].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new byte[totalSize];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pSrc = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrc, pDst + offset, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.ComponentData = data;
|
||||||
|
|
||||||
|
if (chunk._groupIndex >= 0 && chunk._groupIndex < archetype._chunkGroups.Count)
|
||||||
|
{
|
||||||
|
var group = archetype._chunkGroups[chunk._groupIndex];
|
||||||
|
op.SharedData = group.sharedData.AsSpan().ToArray();
|
||||||
|
op.SharedDataHash = group.sharedDataHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reciprocal = Capture(worldService, node);
|
||||||
|
reciprocal.GroupId = GroupId;
|
||||||
|
reciprocal.ActionName = ActionName;
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
var archId = ArchetypeID;
|
||||||
|
var compData = ComponentData;
|
||||||
|
var sharedData = SharedData;
|
||||||
|
var sharedHash = SharedDataHash;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
var world = worldService.EditorWorld;
|
||||||
|
var locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (!locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
return; // Entity destroyed? Should use Lifecycle undo for that.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locRes.Value.archetypeID != archId)
|
||||||
|
{
|
||||||
|
// We need to move the entity to the correct archetype and chunk group.
|
||||||
|
// Ghost.Entities might not have an easy "MoveEntityToArchetypeAndChunkGroup"
|
||||||
|
// The easiest way is to destroy and recreate the entity with the same ID,
|
||||||
|
// but since EntityManager doesn't expose CreateEntity(Entity), we might have to rely on
|
||||||
|
// AddComponent/RemoveComponent to migrate it, or use internal methods.
|
||||||
|
// For now, we will add/remove components to match the target archetype signature.
|
||||||
|
//
|
||||||
|
// Alternatively, we can use a structural backdoor if available, but for now we'll do our best:
|
||||||
|
ref var currentArchetype = ref world.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var targetArchetype = ref world.ComponentManager.GetArchetypeReference(archId);
|
||||||
|
|
||||||
|
// Determine components to add and remove
|
||||||
|
var it = currentArchetype._signature.GetIterator();
|
||||||
|
var toRemove = new List<int>();
|
||||||
|
while (it.Next(out var compId))
|
||||||
|
{
|
||||||
|
if (!targetArchetype._signature.IsSet(compId))
|
||||||
|
{
|
||||||
|
toRemove.Add(compId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it = targetArchetype._signature.GetIterator();
|
||||||
|
var toAdd = new List<int>();
|
||||||
|
while (it.Next(out var compId))
|
||||||
|
{
|
||||||
|
if (!currentArchetype._signature.IsSet(compId))
|
||||||
|
{
|
||||||
|
toAdd.Add(compId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in toRemove)
|
||||||
|
{
|
||||||
|
world.EntityManager.RemoveComponent(targetEntity, new Identifier<IComponent>(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in toAdd)
|
||||||
|
{
|
||||||
|
// Add default component, we will overwrite its memory shortly.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var info = ComponentRegistry.GetComponentInfo(new Identifier<IComponent>(id));
|
||||||
|
var defaultData = new byte[info.size];
|
||||||
|
fixed (byte* p = defaultData)
|
||||||
|
{
|
||||||
|
world.EntityManager.AddComponent(targetEntity, new Identifier<IComponent>(id), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By now the entity should be in the correct archetype, but maybe not the correct shared data group.
|
||||||
|
// We need to overwrite the shared data if needed.
|
||||||
|
// (Assuming there are APIs to set shared data based on the recorded bytes).
|
||||||
|
|
||||||
|
// Overwrite unmanaged memory
|
||||||
|
locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
fixed (byte* pSrcBase = compData)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pDst = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrcBase + offset, pDst, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityStructureOperation op)
|
||||||
|
{
|
||||||
|
return op.Entity == Entity && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityLifecycleOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public LifecycleEvent EventType { get; set; }
|
||||||
|
|
||||||
|
// State for destruction
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var reciprocal = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
GroupId = GroupId,
|
||||||
|
ActionName = ActionName,
|
||||||
|
Entity = Entity,
|
||||||
|
InstanceID = InstanceID,
|
||||||
|
EventType = EventType == LifecycleEvent.Created ? LifecycleEvent.Destroyed : LifecycleEvent.Created,
|
||||||
|
ArchetypeID = ArchetypeID,
|
||||||
|
ComponentData = ComponentData,
|
||||||
|
SharedData = SharedData,
|
||||||
|
SharedDataHash = SharedDataHash
|
||||||
|
};
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (EventType == LifecycleEvent.Created)
|
||||||
|
{
|
||||||
|
// Revert a Creation = Destroy
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
worldService.EditorWorld.EntityManager.DestroyEntity(targetEntity);
|
||||||
|
// The InstanceID GhostObject will be naturally unlinked, handles become null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Revert a Destruction = Recreate
|
||||||
|
var newEntity = worldService.EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
// TODO: Apply the ArchetypeID, ComponentData, SharedData to the newEntity.
|
||||||
|
// We'd add the components using the archetype signature, then memcopy the bytes.
|
||||||
|
|
||||||
|
// Fix the Node reference
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
node = new EntityNode(worldService.EditorWorld, newEntity, "Resurrected");
|
||||||
|
// Force the InstanceID using backing field
|
||||||
|
var backingField = typeof(EntityNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||||
|
?? typeof(SceneGraphNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
backingField?.SetValue(node, InstanceID);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update the entity property of the existing node (using reflection since it's init/readonly)
|
||||||
|
var entityField = typeof(EntityNode).GetField("<Entity>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
entityField?.SetValue(node, newEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UndoService : IUndoService
|
||||||
|
{
|
||||||
|
public event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly RingBuffer<UndoOperation> _undoStack = new(50);
|
||||||
|
private readonly Stack<UndoOperation> _redoStack = new();
|
||||||
|
|
||||||
|
private int _nextGroupId = 1;
|
||||||
|
private int _activeGroupId = 0;
|
||||||
|
|
||||||
|
public UndoService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginTransaction(string name)
|
||||||
|
{
|
||||||
|
_activeGroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndTransaction()
|
||||||
|
{
|
||||||
|
_activeGroupId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushOperation(UndoOperation op)
|
||||||
|
{
|
||||||
|
if (_activeGroupId != 0)
|
||||||
|
{
|
||||||
|
op.GroupId = _activeGroupId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
op.GroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoOperation? top = _undoStack.Count > 0 ? _undoStack.Peek() : null;
|
||||||
|
if (_activeGroupId != 0 && top != null && op.CanMerge(top))
|
||||||
|
{
|
||||||
|
// Skip recording if we are in a transaction and the same object was already recorded
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoStack.Push(op);
|
||||||
|
_redoStack.Clear(); // Any new action clears the redo stack
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordObject(GhostObject obj, string actionName)
|
||||||
|
{
|
||||||
|
var op = new ObjectStateOperation
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
InstanceID = obj.InstanceID
|
||||||
|
};
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
op.State = ms.ToArray();
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityComponent(ComponentNode node, string actionName)
|
||||||
|
{
|
||||||
|
// Internal getter logic goes here or we use the Node's existing pointer method
|
||||||
|
var entityNodeField = typeof(ComponentNode).GetField("_entityNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
var op = new EntityComponentOperation
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
Entity = typeof(ComponentNode).GetField("_entity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(node) as Entity? ?? default,
|
||||||
|
ComponentId = node.Descriptor.ComponentId
|
||||||
|
};
|
||||||
|
// We assume we can get the InstanceID if we pass the EntityNode along, or we can look it up.
|
||||||
|
// Wait, ComponentNode doesn't have an EntityNode reference. We can resolve it via Entity.
|
||||||
|
// Let's just find the first EntityNode that has this Entity.
|
||||||
|
// Actually, let's assume we can inject it or just use the Entity directly for now.
|
||||||
|
// Actually since we have GhostObject.Find, maybe not.
|
||||||
|
// Let's pass the EntityNode InstanceID. Wait, I'll use a reflection trick to find it if possible, or just leave it empty if we can't.
|
||||||
|
// Or better yet, we can ask the registry.
|
||||||
|
// Actually, we can just leave it as Guid.Empty if not known, but let's try to get it.
|
||||||
|
op.InstanceID = Guid.Empty;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComp = node.GetComponentPointer();
|
||||||
|
var size = node.Descriptor.Size;
|
||||||
|
var data = new byte[size];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
op.ComponentData = data;
|
||||||
|
}
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityStructure(EntityNode node, string actionName)
|
||||||
|
{
|
||||||
|
var op = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ActionName = actionName;
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityLifecycle(EntityNode node, LifecycleEvent type)
|
||||||
|
{
|
||||||
|
var op = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
ActionName = type == LifecycleEvent.Created ? "Create Entity" : "Destroy Entity",
|
||||||
|
Entity = node.Entity,
|
||||||
|
InstanceID = node.InstanceID,
|
||||||
|
EventType = type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type == LifecycleEvent.Destroyed)
|
||||||
|
{
|
||||||
|
// Capture state before destruction
|
||||||
|
var structure = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ArchetypeID = structure.ArchetypeID;
|
||||||
|
op.ComponentData = structure.ComponentData;
|
||||||
|
op.SharedData = structure.SharedData;
|
||||||
|
op.SharedDataHash = structure.SharedDataHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformUndo()
|
||||||
|
{
|
||||||
|
if (_undoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _undoStack.Peek().GroupId;
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_undoStack.Count > 0 && _undoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toUndo.Add(_undoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
// Revert in reverse order (which is standard for stack pop, but we popped them into a list)
|
||||||
|
// Wait, the list has them in reverse chronological order (newest at index 0).
|
||||||
|
// We should execute them in that order.
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
// Snapshot current state for Redo BEFORE reverting
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toRedo.Add(reciprocal);
|
||||||
|
|
||||||
|
op.Revert(_worldService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to Redo stack (we push the oldest action first so it comes off last on redo)
|
||||||
|
toRedo.Reverse();
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
_redoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush ECS commands before UI updates
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformRedo()
|
||||||
|
{
|
||||||
|
if (_redoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _redoStack.Peek().GroupId;
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_redoStack.Count > 0 && _redoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toRedo.Add(_redoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
toRedo.Reverse();
|
||||||
|
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toUndo.Add(reciprocal);
|
||||||
|
op.Revert(_worldService); // Revert actually means Apply in this symmetric design
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
_undoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Ghost.Editor.ViewModels.Windows;
|
|||||||
using Ghost.Editor.Views.Windows;
|
using Ghost.Editor.Views.Windows;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine;
|
||||||
using Ghost.Engine.Streaming;
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -64,10 +65,12 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IInspectorService, InspectorService>();
|
services.AddSingleton<IInspectorService, InspectorService>();
|
||||||
services.AddSingleton<IPreviewService, PreviewService>();
|
services.AddSingleton<IPreviewService, PreviewService>();
|
||||||
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
||||||
services.AddSingleton<InspectorSyncService>();
|
services.AddSingleton<IShaderCompiler, DXCShaderCompiler>();
|
||||||
|
services.AddSingleton<IEditorWorldService, EditorWorldService>();
|
||||||
|
services.AddSingleton<IUndoService, UndoService>();
|
||||||
|
|
||||||
|
services.AddSingleton<InspectorSyncService>();
|
||||||
services.AddSingleton<EditorTickEngine>();
|
services.AddSingleton<EditorTickEngine>();
|
||||||
services.AddSingleton<EditorWorldService>();
|
|
||||||
services.AddSingleton<SceneSerializationService>();
|
services.AddSingleton<SceneSerializationService>();
|
||||||
services.AddSingleton<SceneGraphSyncService>();
|
services.AddSingleton<SceneGraphSyncService>();
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,6 @@ internal partial class ContentBrowser
|
|||||||
var sceneSerializationService = App.GetService<SceneSerializationService>();
|
var sceneSerializationService = App.GetService<SceneSerializationService>();
|
||||||
sceneSerializationService.SaveSceneFromEditorWorld(newScenePath, tempScene);
|
sceneSerializationService.SaveSceneFromEditorWorld(newScenePath, tempScene);
|
||||||
|
|
||||||
SceneManager.DestroyScene(tempScene, App.GetService<EditorWorldService>().EditorWorld);
|
SceneManager.DestroyScene(tempScene, App.GetService<IEditorWorldService>().EditorWorld);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,8 @@ namespace Ghost.Editor.Views.Controls;
|
|||||||
public sealed partial class Hierarchy : UserControl
|
public sealed partial class Hierarchy : UserControl
|
||||||
{
|
{
|
||||||
private readonly IInspectorService _inspectorService;
|
private readonly IInspectorService _inspectorService;
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
private readonly SceneGraphSyncService _syncService;
|
private readonly SceneGraphSyncService _syncService;
|
||||||
private readonly EditorWorldService _worldService;
|
|
||||||
private EntityNode? _draggedNode;
|
private EntityNode? _draggedNode;
|
||||||
|
|
||||||
public Hierarchy()
|
public Hierarchy()
|
||||||
@@ -27,7 +27,7 @@ public sealed partial class Hierarchy : UserControl
|
|||||||
// This ensures the singleton hooks into EditorWorldService events and starts populating RootNodes.
|
// This ensures the singleton hooks into EditorWorldService events and starts populating RootNodes.
|
||||||
_syncService = App.GetService<SceneGraphSyncService>();
|
_syncService = App.GetService<SceneGraphSyncService>();
|
||||||
|
|
||||||
_worldService = App.GetService<EditorWorldService>();
|
_worldService = App.GetService<IEditorWorldService>();
|
||||||
|
|
||||||
SceneTreeView.ItemsSource = _worldService.RootNodes;
|
SceneTreeView.ItemsSource = _worldService.RootNodes;
|
||||||
|
|
||||||
|
|||||||
79
src/Runtime/Ghost.Core/Collections/RingBuffer.cs
Normal file
79
src/Runtime/Ghost.Core/Collections/RingBuffer.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Ghost.Core.Collections;
|
||||||
|
|
||||||
|
public class RingBuffer<T>
|
||||||
|
{
|
||||||
|
private readonly T[] _buffer;
|
||||||
|
private int _head;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
public int Count => _count;
|
||||||
|
|
||||||
|
public RingBuffer(int capacity)
|
||||||
|
{
|
||||||
|
_buffer = new T[capacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Push(T item)
|
||||||
|
{
|
||||||
|
if (_count < _buffer.Length)
|
||||||
|
{
|
||||||
|
_buffer[(_head + _count) % _buffer.Length] = item;
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buffer[_head] = item;
|
||||||
|
_head = (_head + 1) % _buffer.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Pop()
|
||||||
|
{
|
||||||
|
if (_count == 0) throw new InvalidOperationException("Ring buffer is empty.");
|
||||||
|
_count--;
|
||||||
|
var item = _buffer[(_head + _count) % _buffer.Length];
|
||||||
|
_buffer[(_head + _count) % _buffer.Length] = default!; // Clear reference
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryPop(out T? item)
|
||||||
|
{
|
||||||
|
if (_count == 0)
|
||||||
|
{
|
||||||
|
item = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_count--;
|
||||||
|
item = _buffer[(_head + _count) % _buffer.Length];
|
||||||
|
_buffer[(_head + _count) % _buffer.Length] = default!; // Clear reference
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Peek()
|
||||||
|
{
|
||||||
|
if (_count == 0) throw new InvalidOperationException("Ring buffer is empty.");
|
||||||
|
return _buffer[(_head + _count - 1) % _buffer.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryPeek(out T? item)
|
||||||
|
{
|
||||||
|
if (_count == 0)
|
||||||
|
{
|
||||||
|
item = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
item = _buffer[(_head + _count - 1) % _buffer.Length];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_head = 0;
|
||||||
|
_count = 0;
|
||||||
|
Array.Clear(_buffer, 0, _buffer.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Runtime/Ghost.Core/GhostObject.cs
Normal file
79
src/Runtime/Ghost.Core/GhostObject.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Ghost.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base class for all objects that can be tracked and recorded by the Undo system.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class GhostObject : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A persistent unique identifier used to track this object across Undo/Redo operations,
|
||||||
|
/// even if the underlying object is destroyed and resurrected.
|
||||||
|
/// </summary>
|
||||||
|
public Guid InstanceID { get; protected set; }
|
||||||
|
|
||||||
|
// Use WeakReference so we don't prevent Garbage Collection of dead objects
|
||||||
|
private static readonly Dictionary<Guid, WeakReference<GhostObject>> s_objectRegistry = new();
|
||||||
|
|
||||||
|
protected GhostObject()
|
||||||
|
{
|
||||||
|
InstanceID = Guid.NewGuid();
|
||||||
|
s_objectRegistry[InstanceID] = new WeakReference<GhostObject>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a GhostObject by its InstanceID in O(1) time.
|
||||||
|
/// </summary>
|
||||||
|
public static GhostObject? Find(Guid id)
|
||||||
|
{
|
||||||
|
if (s_objectRegistry.TryGetValue(id, out var weakRef))
|
||||||
|
{
|
||||||
|
if (weakRef.TryGetTarget(out var obj))
|
||||||
|
{
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Dead object, GC has collected it
|
||||||
|
s_objectRegistry.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the state of this object into a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes the state of this object from a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
s_objectRegistry.Remove(InstanceID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~GhostObject()
|
||||||
|
{
|
||||||
|
Dispose(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -425,6 +425,36 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
return newChunk;
|
return newChunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private readonly ComponentMemoryLayout GetLayoutUnsafe(int componentID)
|
||||||
|
{
|
||||||
|
return _layouts[_componentIDToLayoutIndex[componentID]];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public ref Chunk GetChunkReference(int index)
|
||||||
|
{
|
||||||
|
return ref _chunks[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public readonly Result<ComponentMemoryLayout, Error> GetLayout(int componentID)
|
||||||
|
{
|
||||||
|
if (componentID >= _componentIDToLayoutIndex.Count)
|
||||||
|
{
|
||||||
|
return Error.InvalidArgument;
|
||||||
|
}
|
||||||
|
|
||||||
|
var layoutIndex = _componentIDToLayoutIndex[componentID];
|
||||||
|
if (layoutIndex == -1)
|
||||||
|
{
|
||||||
|
return Error.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _layouts[layoutIndex];
|
||||||
|
}
|
||||||
|
|
||||||
public void AllocateEntity(ReadOnlySpan<byte> sharedData, int sharedDataHash, out int chunkIndex, out int rowIndex)
|
public void AllocateEntity(ReadOnlySpan<byte> sharedData, int sharedDataHash, out int chunkIndex, out int rowIndex)
|
||||||
{
|
{
|
||||||
var world = World.GetWorldUncheck(_worldID);
|
var world = World.GetWorldUncheck(_worldID);
|
||||||
@@ -605,13 +635,12 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var r = GetLayout(componentID);
|
if (!_signature.IsSet(componentID))
|
||||||
if (r.Error != Error.None)
|
|
||||||
{
|
{
|
||||||
return r.Error;
|
return Error.InvalidArgument;
|
||||||
}
|
}
|
||||||
|
|
||||||
var offset = r.Value.offset;
|
var offset = GetLayoutUnsafe(componentID).offset;
|
||||||
ref var chunk = ref _chunks[chunkIndex];
|
ref var chunk = ref _chunks[chunkIndex];
|
||||||
|
|
||||||
var chunkBase = chunk.GetUnsafePtr();
|
var chunkBase = chunk.GetUnsafePtr();
|
||||||
@@ -636,13 +665,12 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var r = GetLayout(componentID);
|
if (!_signature.IsSet(componentID))
|
||||||
if (r.Error != Error.None)
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var offset = r.Value.offset;
|
var offset = GetLayoutUnsafe(componentID).offset;
|
||||||
var chunk = _chunks[chunkIndex];
|
var chunk = _chunks[chunkIndex];
|
||||||
|
|
||||||
var chunkBase = chunk.GetUnsafePtr();
|
var chunkBase = chunk.GetUnsafePtr();
|
||||||
@@ -650,33 +678,6 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
return chunkBase + offset + (size * rowIndex);
|
return chunkBase + offset + (size * rowIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public ref Chunk GetChunkReference(int index)
|
|
||||||
{
|
|
||||||
return ref _chunks[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public readonly Result<ComponentMemoryLayout, Error> GetLayout(int componentID)
|
|
||||||
{
|
|
||||||
#if GHOST_SAFETY_CHECKS
|
|
||||||
if (componentID >= _componentIDToLayoutIndex.Count)
|
|
||||||
{
|
|
||||||
return Error.InvalidArgument;
|
|
||||||
}
|
|
||||||
|
|
||||||
var layoutIndex = _componentIDToLayoutIndex[componentID];
|
|
||||||
if (layoutIndex == -1)
|
|
||||||
{
|
|
||||||
return Error.NotFound;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _layouts[layoutIndex];
|
|
||||||
#else
|
|
||||||
return _layouts[_componentIDToLayoutIndex[componentID]];
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Returns the shared component layout for the given component ID, or an error if not found.</summary>
|
/// <summary>Returns the shared component layout for the given component ID, or an error if not found.</summary>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public readonly Result<SharedComponentLayout, Error> GetSharedLayout(int componentID)
|
public readonly Result<SharedComponentLayout, Error> GetSharedLayout(int componentID)
|
||||||
@@ -695,14 +696,13 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public readonly Error MarkChanged(int chunkIndex, int componentTypeId, uint globalVersion)
|
public readonly Error MarkChanged(int chunkIndex, int componentTypeId, uint globalVersion)
|
||||||
{
|
{
|
||||||
var layoutResult = GetLayout(componentTypeId);
|
if (!_signature.IsSet(componentTypeId))
|
||||||
if (layoutResult.IsFailure)
|
|
||||||
{
|
{
|
||||||
return layoutResult.Error;
|
return Error.InvalidArgument;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref var chunk = ref _chunks[chunkIndex];
|
ref var chunk = ref _chunks[chunkIndex];
|
||||||
chunk.GetVersionUnsafePtr()[layoutResult.Value.versionIndex] = globalVersion;
|
chunk.GetVersionUnsafePtr()[GetLayoutUnsafe(componentTypeId).versionIndex] = globalVersion;
|
||||||
|
|
||||||
return Error.None;
|
return Error.None;
|
||||||
}
|
}
|
||||||
@@ -710,14 +710,13 @@ internal unsafe struct Archetype : IDisposable
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public readonly Result<uint, Error> GetVersion(int chunkIndex, int componentTypeId)
|
public readonly Result<uint, Error> GetVersion(int chunkIndex, int componentTypeId)
|
||||||
{
|
{
|
||||||
var layoutResult = GetLayout(componentTypeId);
|
if (!_signature.IsSet(componentTypeId))
|
||||||
if (layoutResult.Error != Error.None)
|
|
||||||
{
|
{
|
||||||
return layoutResult.Error;
|
return Error.InvalidArgument;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref var chunk = ref _chunks[chunkIndex];
|
ref var chunk = ref _chunks[chunkIndex];
|
||||||
return chunk.GetVersionUnsafePtr()[layoutResult.Value.versionIndex];
|
return chunk.GetVersionUnsafePtr()[GetLayoutUnsafe(componentTypeId).versionIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
public Error RemoveEntity(int chunkIndex, int rowIndex)
|
public Error RemoveEntity(int chunkIndex, int rowIndex)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Ghost.UnitTest.ECS;
|
namespace Ghost.UnitTest.ECS;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
|
||||||
119
src/Test/Ghost.UnitTest/UndoServiceEcsTests.cs
Normal file
119
src/Test/Ghost.UnitTest/UndoServiceEcsTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.UnitTest;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public class UndoServiceEcsTests
|
||||||
|
{
|
||||||
|
private struct CompA : IComponentData { public int value; }
|
||||||
|
private struct CompB : IComponentData { public int value; }
|
||||||
|
|
||||||
|
private EditorWorldService _worldService = null!;
|
||||||
|
private UndoService _undoService = null!;
|
||||||
|
|
||||||
|
[TestInitialize]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_worldService = new EditorWorldService();
|
||||||
|
_undoService = new UndoService(_worldService);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCleanup]
|
||||||
|
public void Cleanup()
|
||||||
|
{
|
||||||
|
_worldService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TestRecordEntityStructure()
|
||||||
|
{
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
var e = world.EntityManager.CreateEntity();
|
||||||
|
world.EntityManager.AddComponent<CompA>(e);
|
||||||
|
|
||||||
|
// Initial state: Entity has CompA
|
||||||
|
ref var compA = ref world.EntityManager.GetComponent<CompA>(e);
|
||||||
|
compA.value = 10;
|
||||||
|
|
||||||
|
var node = new EntityNode(world, e, "TestEntity");
|
||||||
|
|
||||||
|
_undoService.BeginTransaction("Add CompB");
|
||||||
|
_undoService.RecordEntityStructure(node, "Before Add CompB");
|
||||||
|
|
||||||
|
// Modify structure
|
||||||
|
world.EntityManager.AddComponent<CompB>(e);
|
||||||
|
ref var compB = ref world.EntityManager.GetComponent<CompB>(e);
|
||||||
|
compB.value = 20;
|
||||||
|
|
||||||
|
// Re-fetch CompA because AddComponent moves the entity to a new chunk,
|
||||||
|
// invalidating the previous ref!
|
||||||
|
ref var compA_new = ref world.EntityManager.GetComponent<CompA>(e);
|
||||||
|
compA_new.value = 15; // also modify compA
|
||||||
|
|
||||||
|
_undoService.EndTransaction();
|
||||||
|
|
||||||
|
// Perform Undo
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
|
||||||
|
Assert.IsTrue(world.EntityManager.HasComponent<CompA>(e), "Should have CompA");
|
||||||
|
Assert.IsFalse(world.EntityManager.HasComponent<CompB>(e), "Should NOT have CompB");
|
||||||
|
Assert.AreEqual(10, world.EntityManager.GetComponent<CompA>(e).value, "CompA value should be reverted to 10");
|
||||||
|
|
||||||
|
// Perform Redo
|
||||||
|
_undoService.PerformRedo();
|
||||||
|
|
||||||
|
Assert.IsTrue(world.EntityManager.HasComponent<CompA>(e), "Should have CompA");
|
||||||
|
Assert.IsTrue(world.EntityManager.HasComponent<CompB>(e), "Should have CompB");
|
||||||
|
Assert.AreEqual(15, world.EntityManager.GetComponent<CompA>(e).value, "CompA value should be restored to 15");
|
||||||
|
Assert.AreEqual(20, world.EntityManager.GetComponent<CompB>(e).value, "CompB value should be restored to 20");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TestRecordEntityLifecycle_CreateAndDestroy()
|
||||||
|
{
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
|
||||||
|
// Step 1: Create Entity
|
||||||
|
var e = world.EntityManager.CreateEntity();
|
||||||
|
world.EntityManager.AddComponent<CompA>(e);
|
||||||
|
world.EntityManager.GetComponent<CompA>(e).value = 42;
|
||||||
|
var node = new EntityNode(world, e, "TestEntity");
|
||||||
|
|
||||||
|
_undoService.BeginTransaction("Create Entity");
|
||||||
|
_undoService.RecordEntityLifecycle(node, LifecycleEvent.Created);
|
||||||
|
_undoService.EndTransaction();
|
||||||
|
|
||||||
|
// Undo Creation (Expect destruction)
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
Assert.IsFalse(world.EntityManager.Exists(e), "Entity should be destroyed by Undo of Creation");
|
||||||
|
|
||||||
|
// Redo Creation (Expect resurrection)
|
||||||
|
_undoService.PerformRedo();
|
||||||
|
|
||||||
|
// Note: The entity ID might be different, but the EntityNode should be updated
|
||||||
|
var resurrectedEntity = node.Entity;
|
||||||
|
Assert.IsTrue(world.EntityManager.Exists(resurrectedEntity), "Entity should be resurrected by Redo of Creation");
|
||||||
|
// In our current implementation, restoring components for created entities isn't fully robust yet,
|
||||||
|
// but we verify the entity is alive.
|
||||||
|
|
||||||
|
// Step 2: Destroy Entity
|
||||||
|
_undoService.BeginTransaction("Destroy Entity");
|
||||||
|
_undoService.RecordEntityLifecycle(node, LifecycleEvent.Destroyed);
|
||||||
|
world.EntityManager.DestroyEntity(resurrectedEntity);
|
||||||
|
_undoService.EndTransaction();
|
||||||
|
|
||||||
|
Assert.IsFalse(world.EntityManager.Exists(resurrectedEntity), "Entity destroyed manually");
|
||||||
|
|
||||||
|
// Undo Destruction (Expect resurrection)
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
|
||||||
|
var undoneDestroyEntity = node.Entity;
|
||||||
|
Assert.IsTrue(world.EntityManager.Exists(undoneDestroyEntity), "Entity should be resurrected by Undo of Destruction");
|
||||||
|
|
||||||
|
// Redo Destruction (Expect destruction)
|
||||||
|
_undoService.PerformRedo();
|
||||||
|
Assert.IsFalse(world.EntityManager.Exists(undoneDestroyEntity), "Entity should be destroyed by Redo of Destruction");
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/Test/Ghost.UnitTest/UndoServiceTests.cs
Normal file
105
src/Test/Ghost.UnitTest/UndoServiceTests.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
namespace Ghost.UnitTest;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public class UndoServiceTests
|
||||||
|
{
|
||||||
|
private class TestGhostObject : GhostObject
|
||||||
|
{
|
||||||
|
public string Data { get; set; } = "Initial";
|
||||||
|
|
||||||
|
public TestGhostObject()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Data = reader.ReadString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditorWorldService _worldService;
|
||||||
|
private UndoService _undoService;
|
||||||
|
|
||||||
|
[TestInitialize]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_worldService = new EditorWorldService();
|
||||||
|
_undoService = new UndoService(_worldService);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCleanup]
|
||||||
|
public void Cleanup()
|
||||||
|
{
|
||||||
|
_worldService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TestObjectStateUndoRedo()
|
||||||
|
{
|
||||||
|
var obj = new TestGhostObject();
|
||||||
|
obj.Data = "State 1";
|
||||||
|
|
||||||
|
_undoService.RecordObject(obj, "Change Data");
|
||||||
|
obj.Data = "State 2";
|
||||||
|
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
Assert.AreEqual("State 1", obj.Data);
|
||||||
|
|
||||||
|
_undoService.PerformRedo();
|
||||||
|
Assert.AreEqual("State 2", obj.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TestTransactionGrouping()
|
||||||
|
{
|
||||||
|
var obj = new TestGhostObject();
|
||||||
|
|
||||||
|
_undoService.BeginTransaction("Slider Drag");
|
||||||
|
_undoService.RecordObject(obj, "Drag Start");
|
||||||
|
obj.Data = "Drag 1";
|
||||||
|
|
||||||
|
_undoService.RecordObject(obj, "Drag Mid");
|
||||||
|
obj.Data = "Drag 2";
|
||||||
|
|
||||||
|
_undoService.RecordObject(obj, "Drag End");
|
||||||
|
obj.Data = "Drag Final";
|
||||||
|
_undoService.EndTransaction();
|
||||||
|
|
||||||
|
// Perform undo should jump all the way back to "Initial"
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
Assert.AreEqual("Initial", obj.Data);
|
||||||
|
|
||||||
|
_undoService.PerformRedo();
|
||||||
|
Assert.AreEqual("Drag Final", obj.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TestRingBufferOverflow()
|
||||||
|
{
|
||||||
|
// Internal capacity is 50. Let's push 60 items.
|
||||||
|
var obj = new TestGhostObject();
|
||||||
|
|
||||||
|
for (var i = 0; i < 60; i++)
|
||||||
|
{
|
||||||
|
_undoService.RecordObject(obj, $"Action {i}");
|
||||||
|
obj.Data = $"State {i}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can only undo 50 times.
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
_undoService.PerformUndo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// It should have stopped at State 9 because State 0-9 were overwritten in the buffer.
|
||||||
|
Assert.AreEqual("State 9", obj.Data);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user