Refactor project from Ghost.App to Ghost.Editor

Changed the project structure to reflect a shift from `Ghost.App` to `Ghost.Editor`, updating namespaces and class names throughout.

Changed the application class in `App.xaml` and `App.xaml.cs` from `GhostApplication` to `EditorApplication`.

Changed several service interfaces to reside under `Ghost.Editor.Services.Contracts`, including `IInspectorService`, `INotificationService`, and `IProgressService`.

Added `InspectorView` and `InspectorViewModel` classes to manage inspector functionality.

Added `NavigationTabView` and `NavigationTabPage` classes to facilitate navigation within the editor.

Enhanced `WorldNode` and `EntityNode` classes to support scene graph functionality, including serialization and entity management.

Updated the project file `Ghost.Editor.csproj` to reflect the new structure and removed old references.

Modified the solution file `GhostEngine.sln` to remove references to `Ghost.App` and include `Ghost.Editor`.

Updated unit tests to align with the new namespaces and project structure.
This commit is contained in:
2025-06-17 19:37:30 +09:00
parent ff14c0f49a
commit fc44c73ca8
80 changed files with 1244 additions and 309 deletions

View File

@@ -0,0 +1,34 @@
namespace Ghost.Editor.Core.AppState;
internal class AppStateMachine
{
private Dictionary<StateKey, Lazy<IAppState>> s_states = new();
private IAppState? s_current;
public void RegisterState(StateKey key, Func<IAppState> stateFactory)
{
s_states[key] = new(stateFactory);
}
public async Task TransitionToAsync(StateKey stateKey, object? parameter = null)
{
var previous = s_current;
var next = s_states[stateKey].Value;
if (previous != null)
{
await previous.OnExitingAsync();
}
await next.OnEnteringAsync(parameter);
if (previous != null)
{
await previous.OnExitedAsync();
}
await next.OnEnteredAsync(parameter);
s_current = next;
}
}

View File

@@ -0,0 +1,62 @@
using Ghost.Data.Models;
using Ghost.Data.Services;
using Ghost.Editor.Core.AssetHandle;
using Ghost.Editor.View.Windows;
using Ghost.Engine;
namespace Ghost.Editor.Core.AppState;
internal class EditorState : IAppState
{
private EngineEditorWindow? _window;
private EngineCore? _engineCore;
public Task OnExitingAsync()
{
if (EditorApplication.Window == _window)
{
EditorApplication.Window = null;
}
return Task.CompletedTask;
}
public async Task OnEnteringAsync(object? parameter)
{
if (parameter is not ProjectMetadataInfo metadataInfo)
{
throw new ArgumentException("Parameter must be of type ProjectMetadata.", nameof(parameter));
}
ProjectService.CurrentProject = metadataInfo;
_engineCore = EditorApplication.GetService<EngineCore>();
await _engineCore.StartAsync(new Engine.Models.LaunchArgument());
_window = EditorApplication.GetService<EngineEditorWindow>();
_window.Activate();
EditorApplication.Window = _window;
}
public async Task OnExitedAsync()
{
if (_engineCore != null)
{
await _engineCore.ShutDownAsync();
}
if (EditorApplication.Window == _window)
{
EditorApplication.Window = null;
}
_window?.Close();
_window = null;
}
public Task OnEnteredAsync(object? parameter)
{
AssetDatabase.Initialize();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,28 @@
using System.Threading.Tasks;
namespace Ghost.Editor.Core.AppState;
internal interface IAppState
{
/// <summary>
/// Called when exiting the state.
/// </summary>
public Task OnExitingAsync();
/// <summary>
/// Called when entering the state, right after OnEnteringAsync.
/// <paramref name="parameter">can be used to pass data into the state, such as a project to load.</summary>
/// </summary>
public Task OnEnteringAsync(object? parameter);
/// <summary>
/// Called when exiting the state, specifically for pose transitions.
/// </summary>
public Task OnExitedAsync();
/// <summary>
/// Called when entered the state, specifically after the state has been fully initialized and is ready for interaction.
/// </summary>
/// <param name="parameter">can be used to pass data into the state, such as a project to load.</param>
public Task OnEnteredAsync(object? parameter);
}

View File

@@ -0,0 +1,45 @@
using Ghost.Editor;
using Ghost.Editor.View.Windows;
using System.Threading.Tasks;
namespace Ghost.Editor.Core.AppState;
internal class LandingState : IAppState
{
private LandingWindow? _window;
public Task OnExitingAsync()
{
if (EditorApplication.Window == _window)
{
EditorApplication.Window = null;
}
return Task.CompletedTask;
}
public Task OnEnteringAsync(object? parameter)
{
_window = EditorApplication.GetService<LandingWindow>();
EditorApplication.Window = _window;
_window.Activate();
return Task.CompletedTask;
}
public Task OnExitedAsync()
{
if (EditorApplication.Window == _window)
{
EditorApplication.Window = null;
}
_window?.Close();
_window = null;
return Task.CompletedTask;
}
public Task OnEnteredAsync(object? parameter)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,8 @@
namespace Ghost.Editor.Core.AppState;
internal enum StateKey
{
None,
Landing,
EngineEditor,
}

View File

@@ -0,0 +1,24 @@
namespace Ghost.Editor.Core.AssetHandle;
public abstract class Asset
{
/// <summary>
/// Get the Guid of the asset.
/// </summary>
public Guid GUID
{
get;
} = Guid.NewGuid();
/// <summary>
/// True if the asset is a folder, false if it is a file.
/// </summary>
public bool IsFolder
{
get;
}
internal void GenerateMetadata()
{
}
}

View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using System.Reflection;
namespace Ghost.Editor.Core.AssetHandle;
public static class AssetDatabase
{
private static readonly Dictionary<string, Action<string>> _assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
static AssetDatabase()
{
Initialize();
}
internal static void Initialize()
{
RegisterAssetHandles();
}
private static void RegisterAssetHandles()
{
var methods = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.SelectMany(t => t.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
.Where(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>() != null &&
m.GetParameters().Length == 1 &&
m.GetParameters()[0].ParameterType == typeof(string));
foreach (var method in methods)
{
var attr = method.GetCustomAttribute<AssetOpenHandlerAttribute>()!;
var del = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), method);
foreach (var ext in attr.Extensions)
{
if (_assetOpenHandlers.ContainsKey(ext))
{
throw new InvalidOperationException($"Duplicate handler for extension '{ext}'");
}
_assetOpenHandlers[ext] = del;
}
}
}
public static void OpenAsset(string path)
{
var extension = Path.GetExtension(path);
if (_assetOpenHandlers.TryGetValue(extension, out var handler))
{
handler(path);
}
else
{
Process.Start(new ProcessStartInfo(path)
{
UseShellExecute = true
});
}
}
}

View File

@@ -0,0 +1,15 @@
namespace Ghost.Editor.Core.AssetHandle;
[AttributeUsage(AttributeTargets.Method)]
public class AssetOpenHandlerAttribute : Attribute
{
public string[] Extensions
{
get;
}
public AssetOpenHandlerAttribute(params string[] extensions)
{
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
}
}

View File

@@ -0,0 +1,53 @@
using Ghost.Editor.Resources;
using Ghost.Editor.Services.Contracts;
using Ghost.Engine.Resources;
using System.Text.Json;
namespace Ghost.Editor.Core.SceneGraph;
public enum OpenWorldMode
{
Single,
Additive,
AdditiveWithoutLoading
}
public static class EditorWorldManager
{
// TODO: Use guid keys instead of string paths for better performance and uniqueness
private static readonly Dictionary<string, WorldNode> _loadedWorlds = new();
public static IEnumerable<WorldNode> LoadedWorlds => _loadedWorlds.Values;
public static event Action<WorldNode>? OnWorldLoaded;
public static event Action<WorldNode>? OnWorldUnloaded;
public static async Task LoadWorld(string worldPath)
{
if (_loadedWorlds.ContainsKey(worldPath)
|| !File.Exists(worldPath)
|| Path.GetExtension(worldPath) != FileExtensions.SCENE_FILE_EXTENSION)
{
return;
}
var progressService = EditorApplication.GetService<IProgressService>();
progressService.ShowIndeterminateProgress("Loading world...");
foreach (var world in _loadedWorlds)
{
world.Value.Unload();
OnWorldUnloaded?.Invoke(world.Value);
}
await using var readStream = new FileStream(worldPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var deserializedScene = await JsonSerializer.DeserializeAsync<WorldNode>(readStream, StaticResource.defaultSerializerOptions) ?? throw new Exception("Deserialization failed.");
_loadedWorlds.Clear();
_loadedWorlds[worldPath] = deserializedScene;
await deserializedScene.LoadAsync();
progressService.HideProgress();
OnWorldLoaded?.Invoke(deserializedScene);
}
}

View File

@@ -0,0 +1,70 @@
using Ghost.Editor.Contracts;
using Ghost.Editor.Resources;
using Ghost.Entities;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
namespace Ghost.Editor.Core.SceneGraph;
public partial class EntityNode : SceneGraphNode
{
private readonly Entity _entity;
public Entity Entity => _entity;
public override SceneGraphNodeType NodeType => SceneGraphNodeType.Entity;
public EntityNode(Entity entity, string name)
{
_entity = entity;
Name = name;
}
internal EntityNode()
{
}
}
public partial class EntityNode : IInspectable
{
public IconSource? Icon => EditorIconSource.entity_24;
public UIElement? HeaderContent()
{
var root = new StackPanel()
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center
};
var nameText = new TextBox
{
Text = Name,
FontWeight = FontWeights.Bold,
};
var idText = new TextBlock
{
Text = $"ID: {_entity.ID}",
Margin = new Thickness(0, 5, 0, 0),
};
nameText.SetBinding(TextBox.TextProperty, new Binding
{
Source = this,
Path = new PropertyPath(nameof(Name)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
});
root.Children.Add(nameText);
root.Children.Add(idText);
return root;
}
public UIElement? InspectorContent()
{
return null;
}
}

View File

@@ -0,0 +1,112 @@
using Ghost.Engine.Components;
using Ghost.Entities;
namespace Ghost.Editor.Core.SceneGraph;
public class SceneGraphHelpers
{
/// <summary>
/// Creates a new <see cref="EntityNode"/> entity with default components.
/// </summary>
/// <param name="world">The world context where the entity will be created.</param>
/// <param name="entity">The entity to be wrapped in the <see cref="EntityNode"/>.</param>
public static EntityNode CreateEntityNode(World world, Entity entity, string name)
{
world.EntityManager.AddComponent(entity, LocalToWorld.Identity);
world.EntityManager.AddComponent(entity, Hierarchy.Root);
return new EntityNode(entity, name);
}
/// <summary>
/// Creates a new <see cref="Entity"/> and <see cref="EntityNode"/> entity with default components.
/// </summary>
/// <param name="world">The world context where the entity will be created.</param>
public static EntityNode CreateEntityNode(World world, string name)
{
var entity = world.EntityManager.CreateEntity();
return CreateEntityNode(world, entity, name);
}
/// <summary>
/// Attaches childEntity to parentEntity in the scene graph.
/// </summary>
/// <param name="world">The world context where the entities exist.</param>
/// <param name="parentNode">The parent entity to which the child will be attached.</param>
/// <param name="childNode">The child entity to be attached.</param>
public static void AttachChild(WorldNode scene, EntityNode parentNode, EntityNode childNode)
{
// 1) If the child already has a parent, detach it first
var childHierarchy = scene.World.EntityManager.GetComponent<Hierarchy>(childNode.Entity);
if (childHierarchy.ValueRO.parent != Entity.Invalid)
{
DetachFromParent(scene, childNode);
}
// 2) Link child to new parent
childHierarchy.ValueRW.parent = parentNode.Entity;
// 3) Insert child at the head of parent's child list
var parentHierarchy = scene.World.EntityManager.GetComponent<Hierarchy>(parentNode.Entity);
childHierarchy.ValueRW.nextSibling = parentHierarchy.ValueRO.firstChild;
parentHierarchy.ValueRW.firstChild = childNode.Entity;
// 4) Write back
scene.World.EntityManager.SetComponent(parentNode.Entity, in parentHierarchy.ValueRO);
scene.World.EntityManager.SetComponent(childNode.Entity, in childHierarchy.ValueRO);
// 5) Update children list in parent node
parentNode.AddChild(childNode);
}
/// <summary>
/// Detaches the specified entity from its parent in the scene graph.
/// </summary>
/// <param name="world">The world context where the entities exist.</param>
/// <param name="node">The entity to detach from its parent.</param>
public static void DetachFromParent(WorldNode scene, EntityNode node)
{
var hierarchy = scene.World.EntityManager.GetComponent<Hierarchy>(node.Entity);
var parent = hierarchy.ValueRO.parent;
if (parent == Entity.Invalid)
{
return; // already root
}
var parentHierarchy = scene.World.EntityManager.GetComponent<Hierarchy>(parent);
// If entity is the first child, simply move head
if (parentHierarchy.ValueRO.firstChild == node.Entity)
{
parentHierarchy.ValueRW.firstChild = hierarchy.ValueRO.nextSibling;
}
else
{
// Otherwise, find the previous sibling in the linked list
var prevSibling = parentHierarchy.ValueRO.firstChild;
while (prevSibling != Entity.Invalid)
{
var prevHierarchy = scene.World.EntityManager.GetComponent<Hierarchy>(prevSibling);
if (prevHierarchy.ValueRW.nextSibling == node.Entity)
{
prevHierarchy.ValueRW.nextSibling = hierarchy.ValueRO.nextSibling;
scene.World.EntityManager.SetComponent(prevSibling, in prevHierarchy.ValueRO);
break;
}
prevSibling = prevHierarchy.ValueRO.nextSibling;
}
}
// Clear child's references
hierarchy.ValueRW.parent = Entity.Invalid;
hierarchy.ValueRW.nextSibling = Entity.Invalid;
// Write back
scene.World.EntityManager.SetComponent(parent, in parentHierarchy.ValueRO);
scene.World.EntityManager.SetComponent(node.Entity, in hierarchy.ValueRO);
// Remove from parent's children list
scene.EntityNodeLookup[parent].RemoveChild(node);
}
}

View File

@@ -0,0 +1,54 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace Ghost.Editor.Core.SceneGraph;
public enum SceneGraphNodeType
{
Scene,
Entity,
}
public abstract partial class SceneGraphNode : ObservableObject
{
public ObservableCollection<SceneGraphNode>? Children
{
get;
private set;
}
[ObservableProperty]
public partial string Name
{
get;
set;
}
public abstract SceneGraphNodeType NodeType
{
get;
}
public int ChildCount => Children?.Count ?? 0;
public virtual void AddChild(SceneGraphNode child)
{
Children ??= new();
Children.Add(child);
}
public virtual bool RemoveChild(SceneGraphNode child)
{
return Children?.Remove(child) ?? false;
}
public SceneGraphNode GetChild(int index)
{
if (Children == null || index < 0 || index >= Children.Count)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index is out of range.");
}
return Children[index];
}
}

View File

@@ -0,0 +1,195 @@
using Ghost.Editor.Contracts;
using Ghost.Editor.Core.AssetHandle;
using Ghost.Editor.Core.Serializer;
using Ghost.Editor.Resources;
using Ghost.Engine.Components;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.SceneGraph;
[JsonConverter(typeof(WorldNodeSerializer))]
public partial class WorldNode : SceneGraphNode, IEquatable<WorldNode>
{
private World _world;
private Dictionary<Entity, EntityNode> _entityNodeLookup = new();
public World World => _world;
public Dictionary<Entity, EntityNode> EntityNodeLookup => _entityNodeLookup;
public override SceneGraphNodeType NodeType => SceneGraphNodeType.Scene;
public WorldNode(World world, string name)
{
_world = world;
Name = name;
}
internal WorldNode()
{
_world = World.Create();
}
private void UpdateLookup(Entity key, EntityNode value)
{
_entityNodeLookup[key] = value;
if (value.Children == null)
{
return;
}
foreach (var child in value.Children)
{
if (child is EntityNode entityChild)
{
UpdateLookup(entityChild.Entity, entityChild);
}
}
}
public override void AddChild(SceneGraphNode child)
{
if (child is not EntityNode entityNode)
{
throw new ArgumentException("Child must be of type EntityNode.", nameof(child));
}
base.AddChild(entityNode);
UpdateLookup(entityNode.Entity, entityNode);
}
public override bool RemoveChild(SceneGraphNode child)
{
if (child is not EntityNode entityNode)
{
throw new ArgumentException("Child must be of type EntityNode.", nameof(child));
}
var result = base.RemoveChild(child);
if (result)
{
_entityNodeLookup.Remove(entityNode.Entity);
}
return result;
}
private EntityNode BuildNodeRecursive(Entity entity, World world)
{
if (!_entityNodeLookup.TryGetValue(entity, out var node))
{
node = new EntityNode(entity, "New Entity");
_entityNodeLookup[entity] = node;
}
var hc = world.EntityManager.GetComponent<Hierarchy>(entity);
var child = hc.ValueRO.firstChild;
while (child != Entity.Invalid)
{
node.AddChild(BuildNodeRecursive(child, world));
var childHC = world.EntityManager.GetComponent<Hierarchy>(child);
child = childHC.ValueRO.nextSibling;
}
return node;
}
private void BuildGraph()
{
foreach (var (entity, hierarchy) in _world.Query<Hierarchy>())
{
if (hierarchy.ValueRO.parent == Entity.Invalid)
{
var node = BuildNodeRecursive(entity, _world);
AddChild(node);
}
}
}
public Task LoadAsync()
{
return Task.Run(BuildGraph);
}
public void Unload()
{
_world.Dispose();
_world = null!;
Children?.Clear();
_entityNodeLookup.Clear();
}
public override string ToString()
{
return $"WorldNode: {Name} (World ID: {_world.ID})";
}
public override int GetHashCode()
{
return HashCode.Combine(_world, Name);
}
public override bool Equals(object? obj)
{
return obj is WorldNode other && Equals(other);
}
public bool Equals(WorldNode? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return _world.Equals(other._world) && Name == other.Name;
}
public static bool operator ==(WorldNode? left, WorldNode? right)
{
if (left is null)
{
return right is null;
}
return left.Equals(right);
}
public static bool operator !=(WorldNode? left, WorldNode? right)
{
return !(left == right);
}
}
public partial class WorldNode : IInspectable
{
public IconSource? Icon => EditorIconSource.scene_24;
[AssetOpenHandler(FileExtensions.SCENE_FILE_EXTENSION)]
public static async void Open(string path)
{
await EditorWorldManager.LoadWorld(path);
}
public UIElement? HeaderContent()
{
return new TextBlock
{
Text = Name,
Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style,
VerticalAlignment = VerticalAlignment.Center
};
}
public UIElement? InspectorContent()
{
return null;
}
}

View File

@@ -0,0 +1,131 @@
using Ghost.Editor.Core.SceneGraph;
using Ghost.Engine.Utilities;
using Ghost.Entities;
using Ghost.Entities.Components;
using Ghost.Entities.Utilities;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.Serializer;
internal class WorldNodeSerializer : JsonConverter<WorldNode>
{
private static class Property
{
public const string NAME = "Name";
public const string ENTITIES = "Entities";
public const string ID = "ID";
public const string ENTITY_ID = "EntityID";
public const string COMPONENTS = "Components";
public const string DATA = "Data";
public const string SYSTEMS = "Systems";
}
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert == typeof(WorldNode) || typeToConvert.IsSubclassOf(typeof(WorldNode));
}
public override WorldNode? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonDocument.ParseValue(ref reader).RootElement;
var name = element.GetProperty(Property.NAME).GetString() ?? "New World";
var world = World.Create();
var result = new WorldNode(world, name);
foreach (var entityElement in element.GetProperty(Property.ENTITIES).EnumerateArray())
{
var entityName = entityElement.GetProperty(Property.NAME).GetString() ?? "New Entity";
var entityID = entityElement.GetProperty(Property.ID).GetInt32();
var entity = new Entity(entityID, 0);
var node = new EntityNode(entity, entityName);
world.EntityManager.AddEntityInternal(entity);
result.EntityNodeLookup[entity] = node;
}
foreach (var componentElement in element.GetProperty(Property.COMPONENTS).EnumerateObject())
{
var typeName = componentElement.Name;
var type = Type.GetType(typeName) ?? throw new Exception($"Type {typeName} not found.");
foreach (var dataElement in componentElement.Value.EnumerateArray())
{
var entityID = dataElement.GetProperty(Property.ENTITY_ID).GetInt32();
var entity = new Entity(entityID, 0);
var dataProperty = dataElement.GetProperty(Property.DATA);
var component = JsonSerializer.Deserialize(dataProperty.GetRawText(), type, options);
if (component is IComponentData data)
{
world.EntityManager.AddComponent(entity, data, type);
}
}
}
foreach (var systemElement in element.GetProperty(Property.SYSTEMS).EnumerateArray())
{
var typeString = systemElement.GetString();
if (string.IsNullOrEmpty(typeString))
{
continue;
}
var systemType = Type.GetType(typeString);
if (systemType == null)
{
continue;
}
world.SystemStorage.AddSystem(systemType);
}
return result;
}
public override void Write(Utf8JsonWriter writer, WorldNode value, JsonSerializerOptions options)
{
writer.WriteObject(() =>
{
writer.WriteString(Property.NAME, value.Name);
writer.WriteArray(Property.ENTITIES, value.World.EntityManager.Entities, entity =>
{
if (!entity.IsValid)
{
return;
}
writer.WriteObject(() =>
{
writer.WriteString(Property.NAME, value.EntityNodeLookup[entity].Name);
writer.WriteNumber(Property.ID, entity.ID);
});
});
writer.WriteObject(Property.COMPONENTS, () =>
{
foreach (var kvp in value.World.ComponentStorage.ComponentPools)
{
var type = TypeHandle.ToType(kvp.Key) ?? throw new Exception($"Type {kvp.Key} not found.");
var typeName = type.AssemblyQualifiedName ?? type.Name;
writer.WriteArray(typeName, kvp.Value.Enumerate(), data =>
{
writer.WriteObject(() =>
{
writer.WriteNumber(Property.ENTITY_ID, data.entity.ID);
writer.WritePropertyName(Property.DATA);
JsonSerializer.Serialize(writer, data.component, type, options);
});
});
}
});
writer.WriteArray(Property.SYSTEMS, value.World.SystemStorage.Systems, systemType =>
{
writer.WriteStringValue(systemType.AssemblyQualifiedName ?? systemType.Name);
});
});
}
}