Refactor folder structure

This commit is contained in:
2026-02-18 00:50:46 +09:00
parent 426786397c
commit db8ca971a8
413 changed files with 2885 additions and 3634 deletions

View File

@@ -0,0 +1,7 @@
using Ghost.Core.Attributes;
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
[assembly: InternalsVisibleTo("Ghost.Editor")]
[assembly: EngineAssembly]

View File

@@ -0,0 +1,185 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
namespace Ghost.Editor.Core.AssetHandler;
public abstract class Asset
{
public Guid ID
{
get;
}
public abstract Guid TypeID
{
get;
}
public Guid[] Dependencies
{
get;
}
public IAssetSettings? Settings
{
get;
}
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
{
ID = id;
Dependencies = dependencies;
Settings = settings;
}
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
{
return ValueTask.CompletedTask;
}
}
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
internal struct AssetMetadata
{
public const int CURRENT_FORMAT_VERSION = 1;
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
public AssetMetadata(Guid id, Guid typeID)
{
FormatVersion = CURRENT_FORMAT_VERSION;
ID = id;
TypeID = typeID;
}
public int FormatVersion
{
get;
}
public Guid ID
{
get;
}
public Guid TypeID
{
get;
}
public int HandlerVersion
{
get; set;
}
public int DependencyCount
{
get; set;
}
public long DependenciesOffset
{
get; set;
}
public long SettingsOffset
{
get; set;
}
public long SettingsSize
{
get; set;
}
public long ContentOffset
{
get; set;
}
public long ContentSize
{
get; set;
}
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
{
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
stream.Write(buffer);
}
public static AssetMetadata ReadFromStream(Stream stream)
{
Span<byte> buffer = stackalloc byte[SIZE];
stream.ReadExactly(buffer);
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
}
}
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
public readonly struct DependencyInfo
{
public const int SIZE = 16;
public Guid ID
{
get; init;
}
public readonly ReadOnlySpan<byte> AsBytes()
{
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
}
}
public readonly struct AssetReference : IEquatable<AssetReference>
{
private readonly int _value;
/// <summary>
/// The index of the asset in the dependency list.
/// </summary>
public int Index
{
get => Math.Abs(_value) - 1;
}
public static AssetReference Null => default;
public readonly bool IsInternal => _value >= 0;
public readonly bool IsExternal => _value < 0;
public bool Equals(AssetReference other)
{
return _value == other._value;
}
public override int GetHashCode()
{
return _value.GetHashCode();
}
public override bool Equals(object? obj)
{
return obj is AssetReference reference && Equals(reference);
}
public static bool operator ==(AssetReference left, AssetReference right)
{
return left.Equals(right);
}
public static bool operator !=(AssetReference left, AssetReference right)
{
return !(left == right);
}
}
public interface IAssetSettings
{
ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default);
ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default);
}

View File

@@ -0,0 +1,66 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandler;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetHandlerAttribute : Attribute
{
public required string ID
{
get; init;
}
public bool AllowCaching
{
get; init;
} = true;
public required string[] SupportedExtensions
{
get; init;
}
}
public enum DependencyUpdateType
{
Add,
Remove
}
public interface IAssetExportOptions;
public interface IAssetHandler
{
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default);
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default);
}
public interface IImportableAssetHandler : IAssetHandler
{
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
}
public static class AssetHandlerExtensions
{
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default)
{
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
return await handler.ImportAsync(sourceStream, targetStream, id, token);
}
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
{
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
return await handler.ExportAsync(assetStream, targetStream, options, token);
}
public static async ValueTask<Result<Asset>> ReadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
{
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await handler.LoadAsync(sourceStream, assetDatabase, token);
}
}

View File

@@ -0,0 +1,378 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Image;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.AssetHandler;
public enum TextureType : uint
{
Default,
Normal,
Lightmap,
SingleChannel
}
public enum TextureShape : uint
{
Texture2D,
Texture3D,
TextureCube
}
public enum TextureSize : uint
{
Size256 = 256,
Size512 = 512,
Size1024 = 1024,
Size2048 = 2048,
Size4096 = 4096,
Size8192 = 8192
}
public enum TextureCompressionLevel : uint
{
Low,
Normal,
High
}
public enum TextureCompressionEffort : uint
{
Fastest,
Normal,
Production
}
public enum MipmapFilter : uint
{
Box,
Triangle,
Kaiser,
MitchellNetravali
}
public class TextureAsset : Asset
{
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
public override Guid TypeID => s_typeGuid;
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings)
: base(id, dependencies, settings)
{
}
}
public class TextureAssetSettings : IAssetSettings
{
public struct BasicSettings()
{
public TextureType TextureType
{
get; set;
} = TextureType.Default;
public TextureShape TextureShape
{
get; set;
} = TextureShape.Texture2D;
public int Columns
{
get; set;
} = 1;
public int Rows
{
get; set;
} = 1;
public bool IsSRGB
{
get; set;
} = true;
}
public struct AdvancedSettings()
{
public bool StretchToPowerOfTwo
{
get; set;
} = true;
public bool VirtualTexture
{
get; set;
} = false;
public bool GenerateMipmaps
{
get; set;
} = true;
public uint MipmapLevelCount
{
get; set;
} = 0; // 0 means generate full mipmap levels.
public bool GammaCorrection
{
get; set;
} = true;
public bool PremultiplyAlpha
{
get; set;
} = false;
public MipmapFilter MipmapFilter
{
get; set;
} = MipmapFilter.Kaiser;
public TextureCompressionLevel CompressionLevel
{
get; set;
} = TextureCompressionLevel.Normal;
public TextureCompressionEffort CompressionEffort
{
get; set;
} = TextureCompressionEffort.Normal;
public bool UseBorderColor
{
get; set;
} = false;
public Color32 BorderColor
{
get; set;
} = new Color32(0, 0, 0, 0);
public bool ZeroAlphaBorder
{
get; set;
} = false;
public bool CutoutAlpha
{
get; set;
} = false;
public byte CutoutAlphaThreshold
{
get; set;
} = 127;
public bool ScaleAlphaForMipCoverage
{
get; set;
} = false;
public byte ScaleAlphaForMipCoverageThreshold
{
get; set;
} = 127;
public bool MipmapStreaming
{
get; set;
} = false;
}
public struct SamplerSettings()
{
public TextureSize MaxSize
{
get; set;
} = TextureSize.Size2048;
public TextureFilterMode FilterMode
{
get; set;
} = TextureFilterMode.Anisotropic;
public TextureAddressMode WrapMode
{
get; set;
} = TextureAddressMode.Repeat;
}
public BasicSettings Basic
{
get; set;
} = new BasicSettings();
public AdvancedSettings Advanced
{
get; set;
} = new AdvancedSettings();
public SamplerSettings Sampler
{
get; set;
} = new SamplerSettings();
public async ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
ref byte address = ref MemoryMarshal.GetReference(tempArray);
Unsafe.WriteUnaligned(ref address, Basic);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), Advanced);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), Sampler);
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
return Result.Success<long>(size);
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
public async ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
ref byte address = ref MemoryMarshal.GetReference(tempArray);
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref address);
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()));
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()));
var settings = new TextureAssetSettings
{
Basic = basic,
Advanced = advanced,
Sampler = sampler
};
return Result.Success<IAssetSettings>(settings);
}
catch (Exception ex)
{
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
}
internal class TextureAssetHandler : IImportableAssetHandler
{
private const int _CURRENT_VERSION = 1;
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
{
throw new NotImplementedException();
}
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
{
var info = ImageInfo.FromStream(sourceStream);
if (info.BitsPerChannel <= 0)
{
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
}
ref byte pData = ref Unsafe.NullRef<byte>();
var imageSize = 0ul;
var isFloat = info.BitsPerChannel > 8;
if (isFloat)
{
using var image = ImageResultFloat.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
}
else
{
using var image = ImageResult.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
}
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = _CURRENT_VERSION,
SettingsOffset = AssetMetadata.SIZE,
};
targetStream.Seek(0, SeekOrigin.Begin);
AssetMetadata.WriteToStream(targetStream, ref header);
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
var settings = new TextureAssetSettings();
var sizeResult = await settings.WriteToStreamAsync(targetStream, token).ConfigureAwait(false);
if (sizeResult.IsFailure)
{
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
}
header.SettingsSize = sizeResult.Value;
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
header.ContentSize = (long)imageSize;
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
var offset = 0;
var tempArray = ArrayPool<byte>.Shared.Rent((int)Math.Min(imageSize, 40960ul));
var remaining = imageSize;
try
{
while (remaining > 0)
{
var chunkSize = (int)Math.Min(remaining, (ulong)tempArray.Length);
Unsafe.CopyBlockUnaligned(ref tempArray[0], ref Unsafe.Add(ref pData, offset), (uint)chunkSize);
await targetStream.WriteAsync(tempArray.AsMemory(0, chunkSize), token).ConfigureAwait(false);
offset += chunkSize;
remaining -= (ulong)chunkSize;
}
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset content to stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,102 @@
namespace Ghost.Editor.Core;
/// <summary>
/// The base class for all attributes that can be discovered via <see cref="Utilities.TypeCache"/>.
/// </summary>
public abstract class DiscoverableAttributeBase : Attribute;
[AttributeUsage(AttributeTargets.Method)]
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
{
public string[] Extensions
{
get;
}
public AssetOpenHandlerAttribute(params string[] extensions)
{
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
}
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
internal class AssetImporterAttribute : DiscoverableAttributeBase
{
public string[] SupportedExtensions
{
get;
}
public AssetImporterAttribute(params string[] supportedExtensions)
{
SupportedExtensions = supportedExtensions;
}
}
[AttributeUsage(AttributeTargets.Class)]
public class CustomEditorAttribute : DiscoverableAttributeBase
{
internal Type TargetType
{
get;
}
public CustomEditorAttribute(Type targetType)
{
TargetType = targetType;
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)]
public class EditorInjectionAttribute : DiscoverableAttributeBase
{
public enum ServiceLifetime
{
Singleton,
Transient,
Scoped
}
public ServiceLifetime Lifetime
{
get;
}
public Type? ImplementationType
{
get;
}
public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null)
{
Lifetime = lifetime;
ImplementationType = implementationType;
}
}
[AttributeUsage(AttributeTargets.Method)]
public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
{
public string Tag
{
get;
}
public string Name
{
get;
}
public int Group
{
get;
}
public ContextMenuItemAttribute(string tag, string name, int group = 0)
{
Tag = tag;
Name = name;
Group = group;
}
}

View File

@@ -0,0 +1,49 @@
using Ghost.Core;
using Ghost.Editor.Core.AssetHandler;
namespace Ghost.Editor.Core.Contracts;
public enum AssetChangeType
{
None = 0,
Created,
Deleted,
Modified,
Renamed,
}
public sealed class AssetChangedEventArgs : EventArgs
{
public string AssetPath
{
get;
}
public string? OldAssetPath
{
get;
}
public AssetChangeType ChangeType
{
get;
}
internal AssetChangedEventArgs(string assetPath, string? oldAssetPath, AssetChangeType changeType)
{
AssetPath = assetPath;
OldAssetPath = oldAssetPath;
ChangeType = changeType;
}
}
public interface IAssetRegistry : IDisposable
{
string? GetAssetPath(Guid id);
Guid GetAssetGuid(string assetPath);
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
}

View File

@@ -0,0 +1,13 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Contracts;
public interface IInspectable
{
public IconSource? CreateIcon();
public UIElement? CreateHeader();
public UIElement? CreateInspector();
}

View File

@@ -0,0 +1,32 @@
namespace Ghost.Editor.Core.Contracts;
public class InspectorSelectionChangedEventArgs : EventArgs
{
public object? Source
{
get;
}
public IInspectable? Selected
{
get;
}
public InspectorSelectionChangedEventArgs(object? source, IInspectable? selected)
{
Source = source;
Selected = selected;
}
}
public interface IInspectorService
{
IInspectable? Selected
{
get;
}
event EventHandler<InspectorSelectionChangedEventArgs> OnSelectionChanged;
void SetSelected(IInspectable? inspectable, object? source);
}

View File

@@ -0,0 +1,7 @@
namespace Ghost.Editor.Core.Contracts;
public interface INavigationAware
{
public void OnNavigatedTo(object? parameter);
public void OnNavigatedFrom();
}

View File

@@ -0,0 +1,10 @@
using CommunityToolkit.WinUI.Behaviors;
using Ghost.Editor.Core.Notifications;
namespace Ghost.Editor.Core.Contracts;
public interface INotificationService
{
public void ShowNotification(string? message, MessageType type, int duration = 5, string? title = null);
public void ShowNotification(Notification notification);
}

View File

@@ -0,0 +1,12 @@
namespace Ghost.Editor.Core.Contracts;
public enum IconSize
{
Small,
Large
}
public interface IPreviewService
{
string GetIconPath(string path, bool isDirectory, IconSize size);
}

View File

@@ -0,0 +1,9 @@
namespace Ghost.Editor.Core.Contracts;
public interface IProgressService
{
public void ShowProgress(string message, double progress = 0.0);
public void ShowIndeterminateProgress(string message);
public void SetProgress(double progress);
public void HideProgress();
}

View File

@@ -0,0 +1,69 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Misaki.HighPerformance.Mathematics;
namespace Ghost.Editor.Core.Controls;
[TemplatePart(Name = "XComponent", Type = typeof(NumberBox))]
[TemplatePart(Name = "YComponent", Type = typeof(NumberBox))]
[TemplatePart(Name = "ZComponent", Type = typeof(NumberBox))]
public sealed partial class Float3Field : ValueControl<float3>
{
private NumberBox? _xComponent;
private NumberBox? _yComponent;
private NumberBox? _zComponent;
public Float3Field()
{
DefaultStyleKey = typeof(Float3Field);
}
protected override void ValueChanged(float3 oldValue, float3 newValue)
{
SyncFromValue();
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_xComponent?.ValueChanged -= OnComponentChanged;
_yComponent?.ValueChanged -= OnComponentChanged;
_zComponent?.ValueChanged -= OnComponentChanged;
_xComponent = GetTemplateChild("XComponent") as NumberBox;
_yComponent = GetTemplateChild("YComponent") as NumberBox;
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
SyncFromValue();
_xComponent?.ValueChanged += OnComponentChanged;
_yComponent?.ValueChanged += OnComponentChanged;
_zComponent?.ValueChanged += OnComponentChanged;
}
private void SyncFromValue()
{
SuppressChangedEvent = true;
_xComponent?.Value = Value.x;
_yComponent?.Value = Value.y;
_zComponent?.Value = Value.z;
SuppressChangedEvent = false;
}
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
{
if (SuppressChangedEvent)
{
return;
}
var newValue = new float3(
(float)(_xComponent?.Value ?? 0),
(float)(_yComponent?.Value ?? 0),
(float)(_zComponent?.Value ?? 0));
RiseChangedEvent(Value, newValue);
Value = newValue;
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.Core.Controls">
<Style TargetType="local:Float3Field">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:Float3Field">
<Grid ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="X" />
<NumberBox x:Name="XComponent" Grid.Column="1" />
<TextBlock
Grid.Column="2"
VerticalAlignment="Center"
Text="Y" />
<NumberBox x:Name="YComponent" Grid.Column="3" />
<TextBlock
Grid.Column="4"
VerticalAlignment="Center"
Text="Z" />
<NumberBox x:Name="ZComponent" Grid.Column="5" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,143 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using System.Reflection;
using Windows.Globalization.NumberFormatting;
namespace Ghost.Editor.Core.Controls;
public sealed partial class PropertyField : ContentControl
{
private static readonly Dictionary<Type, DependencyProperty> _valueProperties = new()
{
{ typeof(TextBox), TextBox.TextProperty },
{ typeof(NumberBox), NumberBox.ValueProperty },
{ typeof(ToggleButton), ToggleButton.IsCheckedProperty },
{ typeof(ToggleSwitch), ToggleSwitch.IsOnProperty },
{ typeof(ComboBox), Selector.SelectedValueProperty },
{ typeof(RangeBase), RangeBase.ValueProperty },
};
private object? _sourceObject;
internal FieldInfo? _propertyInfo;
internal Type? _fieldType;
private object? _lastValue;
public event Action<PropertyField>? OnValueChanged;
public string Label
{
get => (string)GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public static readonly DependencyProperty LabelProperty = DependencyProperty.Register(
nameof(Label),
typeof(string),
typeof(PropertyField),
new PropertyMetadata(default(string)));
public PropertyField()
{
DefaultStyleKey = typeof(PropertyField);
}
private static DependencyProperty? GetValueProperty(Type? fieldType)
{
while (fieldType != null)
{
if (_valueProperties.TryGetValue(fieldType, out var dp))
{
return dp;
}
fieldType = fieldType.BaseType;
}
return null;
}
private static TField ConfigureField<TField>(PropertyField propertyField, FieldInfo fieldInfo, object sourceObject, Func<TField> factory)
where TField : FrameworkElement
{
propertyField._sourceObject = sourceObject;
propertyField._propertyInfo = fieldInfo;
propertyField._fieldType = typeof(TField);
var field = factory();
var dp = GetValueProperty(typeof(TField));
field.SetBinding(dp, new Binding
{
Source = sourceObject,
Path = new PropertyPath(fieldInfo.Name),
Mode = BindingMode.TwoWay,
});
field.RegisterPropertyChangedCallback(dp, (s, e) =>
{
propertyField.OnValueChanged?.Invoke(propertyField);
});
return field;
}
public static PropertyField Create(string label, FieldInfo fieldInfo, object sourceObject)
{
var propertyField = new PropertyField
{
Label = label
};
FrameworkElement content = fieldInfo.FieldType switch
{
Type t when t == typeof(string) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new TextBox()),
Type t when t == typeof(int) || t == typeof(float) || t == typeof(double) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new NumberBox
{
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Hidden,
AcceptsExpression = true,
NumberFormatter = new DecimalFormatter
{
FractionDigits = t == typeof(int) ? 0 : 9,
}
}),
Type t when t == typeof(bool) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new ToggleSwitch()),
Type t when t == typeof(Enum) => ConfigureField(propertyField, fieldInfo, sourceObject, () => new ComboBox
{
ItemsSource = Enum.GetValues(t),
SelectedValuePath = "Value",
}),
_ => new TextBlock
{
Text = $"Unsupported type: {fieldInfo.FieldType.Name}",
VerticalAlignment = VerticalAlignment.Center,
Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red)
},
};
propertyField.Content = content;
return propertyField;
}
public void UpdateValue()
{
if (_sourceObject == null || _propertyInfo == null || _fieldType == null)
{
return;
}
var currentValue = _propertyInfo.GetValue(_sourceObject);
if (Equals(currentValue, _lastValue))
{
return;
}
var dp = GetValueProperty(_fieldType);
if (dp != null)
{
SetValue(dp, _propertyInfo.GetValue(_sourceObject));
_lastValue = currentValue;
}
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.Core.Controls">
<Style TargetType="local:PropertyField">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:PropertyField">
<Grid Height="32" Margin="2,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="125" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Margin="0,0,0,4"
VerticalAlignment="Center"
Style="{StaticResource BodyTextBlockStyle}"
Text="{TemplateBinding Label}"
TextTrimming="CharacterEllipsis" />
<ContentPresenter
Grid.Column="1"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,13 @@
using Microsoft.UI.Xaml;
namespace Ghost.Editor.Core.Controls;
public partial class ControlsDictionary : ResourceDictionary
{
private const string _DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
public ControlsDictionary()
{
Source = new Uri(_DICTIONARY_PATH, UriKind.Absolute);
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/ComponentView.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,157 @@
using Ghost.Editor.Core.Inspector;
using Ghost.Editor.Core.Resources;
using Ghost.Editor.Core.Utilities;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Controls;
internal sealed unsafe partial class ComponentView : Control
{
private delegate void EditorUpdate();
private StackPanel? _contentContainer;
private readonly World? _world;
private readonly Entity _entity = Entity.Invalid;
private readonly Type? _componentType;
private readonly ComponentInfo _componentInfo;
private object? _managedInstance;
private void* _pComponentData;
private ComponentEditor? _customEditor;
private PropertyField[]? _propertyFields;
private EditorUpdate? _editorUpdate;
public string HeaderText
{
get => (string)GetValue(HeaderTextProperty);
set => SetValue(HeaderTextProperty, value);
}
public static readonly DependencyProperty HeaderTextProperty =
DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(ComponentView), new PropertyMetadata(string.Empty));
internal ComponentView()
{
DefaultStyleKey = typeof(ComponentView);
Unloaded += (s, e) =>
{
_customEditor?.Destroy();
_contentContainer = null;
_customEditor = null;
_propertyFields = null;
};
}
public ComponentView(string header, World world, Entity entity, Type componentType) : this()
{
HeaderText = header;
_world = world;
_entity = entity;
_componentType = componentType;
_componentInfo = ComponentRegistry.GetComponentInfo(componentType);
}
protected override void OnApplyTemplate()
{
_contentContainer = (StackPanel)GetTemplateChild("ContentContainer");
base.OnApplyTemplate();
ReBuild();
}
private void ReflectionUpdate()
{
if (_propertyFields == null)
{
return;
}
foreach (var propertyField in _propertyFields)
{
propertyField.UpdateValue();
}
}
private void CustomEditorUpdate()
{
_customEditor?.Update();
}
public void ReBuild()
{
if (_contentContainer == null)
{
return;
}
_contentContainer.Children.Clear();
if (_world == null || _componentType == null || _entity == Entity.Invalid)
{
return;
}
if (_propertyFields != null)
{
foreach (var propertyField in _propertyFields)
{
propertyField.OnValueChanged -= OnPropertyValueChanged;
}
}
var componentObject = new ComponentObject(_world, _entity);
var editorType = TypeCache.GetTypes().FirstOrDefault(t =>
typeof(ComponentEditor).IsAssignableFrom(t) &&
t.GetCustomAttribute<CustomEditorAttribute>()?.TargetType.IsAssignableFrom(_componentType) == true);
if (editorType != null)
{
_customEditor = (ComponentEditor)Activator.CreateInstance(editorType)!;
_customEditor.Initialize(componentObject);
_customEditor.Create(_contentContainer);
}
else
{
var fields = _componentType.GetFields(StaticResource.ComponentPropertyBindingFlags);
_propertyFields = new PropertyField[fields.Length];
_pComponentData = _world.EntityManager.GetComponent(_entity, _componentInfo.id);
_managedInstance = Marshal.PtrToStructure((nint)_pComponentData, _componentType);
if (_managedInstance == null)
{
return;
}
for (var i = 0; i < fields.Length; i++)
{
var field = fields[i];
var propertyField = PropertyField.Create(field.Name, field, _managedInstance);
propertyField.OnValueChanged += OnPropertyValueChanged;
_propertyFields[i] = propertyField;
_contentContainer.Children.Add(propertyField);
}
}
_editorUpdate = _customEditor == null ? ReflectionUpdate : CustomEditorUpdate;
_editorUpdate();
}
private void OnPropertyValueChanged(PropertyField field)
{
if (_managedInstance == null || _pComponentData == null)
{
return;
}
Marshal.StructureToPtr(_managedInstance, (nint)_pComponentData, false);
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ghost.Editor.Core.Controls">
<Style TargetType="local:ComponentView">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:ComponentView">
<StackPanel Margin="0,0,0,16">
<Border
Padding="8"
HorizontalAlignment="Stretch"
Background="{ThemeResource SolidBackgroundFillColorSecondaryBrush}">
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{TemplateBinding HeaderText}" />
</Border>
<StackPanel
x:Name="ContentContainer"
Margin="8,2,2,0"
Spacing="2" />
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,42 @@
using Ghost.Editor.Core.Contracts;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Controls;
public partial class NavigationTabPage : TabViewItem, INavigationAware
{
public virtual void OnNavigatedTo(object? parameter)
{
}
public virtual void OnNavigatedFrom()
{
}
}
public sealed partial class NavigationTabView : TabView
{
public NavigationTabView()
{
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
SelectionChanged += NavigationTabView_SelectionChanged;
}
private void NavigationTabView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
foreach (var oldItem in e.RemovedItems)
{
if (oldItem is NavigationTabPage oldPage)
{
oldPage.OnNavigatedFrom();
}
}
if (SelectedItem is NavigationTabPage newPage)
{
newPage.OnNavigatedTo(null);
}
}
}

View File

@@ -0,0 +1,215 @@
using Ghost.Editor.Core.Utilities;
using Microsoft.UI.Xaml.Controls;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Controls;
public sealed partial class ContextFlyout : MenuFlyout
{
private class MenuNode
{
public required string Name
{
get; init;
}
public MethodInfo? Method
{
get; set;
}
public List<MenuNode> Children
{
get;
} = new();
public int RawGroup
{
get; set;
} = int.MaxValue;
// The calculated group used for sorting (min of children for folders)
public int EffectiveGroup
{
get; set;
}
}
private bool _isPopulated;
public string Tag
{
get; set;
} = string.Empty;
public ContextFlyout()
{
Opening += ContextFlyout_Opening;
}
// Recursively sorts nodes and calculates folder groups
private static void PrepareNodes(List<MenuNode> nodes)
{
if (nodes.Count == 0)
{
return;
}
foreach (var node in nodes)
{
if (node.Children.Count > 0)
{
// Go deep first
PrepareNodes(node.Children);
// A folder's group is determined by its highest priority child (lowest group number).
// This ensures a "File" folder (containing Group 0 items) sits at the top
// alongside other Group 0 leaf items.
node.EffectiveGroup = node.Children.Min(c => c.EffectiveGroup);
}
else
{
node.EffectiveGroup = node.RawGroup;
}
}
// Sort by Group, then by Name
nodes.Sort((a, b) =>
{
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
return groupCompare != 0
? groupCompare
: string.CompareOrdinal(a.Name, b.Name);
});
}
// Recursively builds the UI elements
private static void BuildNodes(List<MenuNode> nodes, IList<MenuFlyoutItemBase> targetCollection)
{
if (nodes.Count == 0)
{
return;
}
int currentGroup = nodes[0].EffectiveGroup;
foreach (var node in nodes)
{
if (node.EffectiveGroup != currentGroup)
{
targetCollection.Add(new MenuFlyoutSeparator());
currentGroup = node.EffectiveGroup;
}
if (node.Children.Count > 0)
{
var subItem = new MenuFlyoutSubItem
{
Text = node.Name
};
// Recursively render children into the subitem
BuildNodes(node.Children, subItem.Items);
targetCollection.Add(subItem);
}
else
{
var menuItem = new MenuFlyoutItem
{
Text = node.Name
};
var methodToInvoke = node.Method;
menuItem.Click += (_, _) =>
{
methodToInvoke?.Invoke(null, null);
};
targetCollection.Add(menuItem);
}
}
}
private void PopulateContextMenu()
{
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
if (methods == null)
{
return;
}
// 1. Build the Tree
var rootNodes = new List<MenuNode>();
foreach (var method in methods)
{
var attr = method.GetCustomAttribute<ContextMenuItemAttribute>();
if (attr == null)
{
continue;
}
// Filter tags
if (!string.Equals(attr.Tag, Tag, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var nameSpan = attr.Name.AsSpan();
var pathParts = nameSpan.Split('/');
var currentLevel = rootNodes;
MenuNode? currentNode = null;
foreach (var range in pathParts)
{
var part = nameSpan[range.Start..range.End];
MenuNode? foundNode = null;
// Try to find existing node in the current level
foreach (var node in currentLevel)
{
if (part.Equals(node.Name.AsSpan(), StringComparison.Ordinal))
{
foundNode = node;
break;
}
}
if (foundNode == null)
{
foundNode = new MenuNode { Name = part.ToString() };
currentLevel.Add(foundNode);
}
currentNode = foundNode;
// If this is the last part, it's the executable item
if (range.End.Value == nameSpan.Length)
{
currentNode.Method = method;
currentNode.RawGroup = attr.Group;
}
currentLevel = currentNode.Children;
}
}
PrepareNodes(rootNodes);
BuildNodes(rootNodes, Items);
}
private async void ContextFlyout_Opening(object? sender, object e)
{
if (_isPopulated)
{
return;
}
PopulateContextMenu();
_isPopulated = true;
}
}

View File

@@ -0,0 +1,70 @@
using Ghost.Editor.Core.Event;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Controls;
public partial class ValueControl<T> : Control
{
private bool _suppressChangedEvent;
protected bool SuppressChangedEvent
{
get => _suppressChangedEvent;
set => _suppressChangedEvent = value;
}
public T Value
{
get => (T)GetValue(ValueProperty);
set
{
if (EqualityComparer<T>.Default.Equals(Value, value))
{
return;
}
SetValue(ValueProperty, value);
}
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(T), typeof(ValueControl<T>), new PropertyMetadata(default(T), ChangedCallback));
public event ValueChangedEventHandler<T>? OnValueChanged;
private static void ChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ValueControl<T> valueControl)
{
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
if (!valueControl._suppressChangedEvent)
{
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
}
}
}
protected virtual void ValueChanged(T oldValue, T newValue)
{
}
protected void RiseChangedEvent(T oldValue, T newValue)
{
OnValueChanged?.Invoke(this, new(oldValue, newValue));
}
/// <summary>
/// Sets the _value without notifying the change event.
/// </summary>
/// <param name="value">The new _value to set.</param>
/// <remarks>This method only suppresses the change event notification, not the <see cref="ValueChanged(T, T)"/> method.
/// Useful when you need to change the _value programmatically without triggering the change event.</remarks>
public void SetValueWithoutNotifying(T value)
{
_suppressChangedEvent = true;
SetValue(ValueProperty, value);
_suppressChangedEvent = false;
}
}

View File

@@ -0,0 +1,65 @@
using Ghost.Editor.Core.Contracts;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
namespace Ghost.Editor.Core;
public static class EditorApplication
{
public const string ASSETS_FOLDER_NAME = "Assets";
public const string CACHES_FOLDER_NAME = "Caches";
private static IServiceProvider? s_serviceProvider;
private static string s_currentProjectPath = string.Empty;
private static string s_currentProjectName = string.Empty;
private static DispatcherQueue? s_dispatcherQueue;
internal static Application CurrentApplication => Application.Current;
internal static string CurrentProjectPath => s_currentProjectPath;
internal static string CurrentProjectName => s_currentProjectName;
public static DispatcherQueue DispatcherQueue
{
get
{
if (s_dispatcherQueue is null)
{
throw new InvalidOperationException("DispatcherQueue is not initialized.");
}
return s_dispatcherQueue;
}
}
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
{
s_serviceProvider = serviceProvider;
s_currentProjectPath = projectPath;
s_currentProjectName = projectName;
}
internal static void SetDispatcherQueue(DispatcherQueue dispatcherQueue)
{
s_dispatcherQueue = dispatcherQueue;
}
public static T GetService<T>()
where T : class
{
if (s_serviceProvider?.GetService(typeof(T)) is not T service)
{
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices.");
}
return service;
}
internal static void Shutdown()
{
if (s_serviceProvider?.GetService(typeof(IAssetService)) is AssetHandle.AssetService assetService)
{
assetService.Shutdown();
}
}
}

View File

@@ -0,0 +1,22 @@
namespace Ghost.Editor.Core.Event;
public delegate void ValueChangedEventHandler<T>(object? sender, ValueChangedEventArgs<T> args);
public class ValueChangedEventArgs<T> : EventArgs
{
public T OldValue
{
get;
}
public T NewValue
{
get;
}
public ValueChangedEventArgs(T oldValue, T newValue)
{
OldValue = oldValue;
NewValue = newValue;
}
}

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>Ghost.Editor.Core</RootNamespace>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<ImplicitUsings>enable</ImplicitUsings>
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
<langversion>preview</langversion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7463" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
<ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
</ItemGroup>
<ItemGroup>
<Page Update="Controls\BasicInput\PropertyField.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Controls\BasicInput\Vector3Field.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Controls\Internal\ComponentView.xaml">
<SubType>Designer</SubType>
</Page>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Inspector;
public abstract class ComponentEditor
{
private ComponentObject _componentObject;
/// <summary>
/// Represents the underlying component object used by this class to manage its functionality.
/// </summary>
protected ComponentObject ComponentObject => _componentObject;
internal void Initialize(ComponentObject componentObject)
{
_componentObject = componentObject;
}
/// <summary>
/// Called when the component editor is created.
/// </summary>
/// <param name="container">The container to add the editor controls to.</param>
public virtual void Create(StackPanel container)
{
}
/// <summary>
/// Called when the component editor needs to update its UI based on the current state of the component data.
/// </summary>
public virtual void Update()
{
}
/// <summary>
/// Called when the component editor is destroyed.
/// </summary>
public virtual void Destroy()
{
}
}

View File

@@ -0,0 +1,27 @@
using Ghost.Entities;
namespace Ghost.Editor.Core.Inspector;
public readonly struct ComponentObject
{
private readonly World _world;
private readonly Entity _entity;
internal ComponentObject(World world, Entity entity)
{
_world = world;
_entity = entity;
}
public ref T GetData<T>()
where T : unmanaged, IComponent
{
return ref _world.EntityManager.GetComponent<T>(_entity);
}
public void SetData<T>(in T data)
where T : unmanaged, IComponent
{
_world.EntityManager.SetComponent(_entity, data);
}
}

View File

@@ -0,0 +1,9 @@
namespace Ghost.Editor.Core.Notifications;
public enum MessageType
{
Informational,
Success,
Warning,
Error
}

View File

@@ -0,0 +1,18 @@
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Resources;
public static class EditorIconSource
{
public static readonly IconSource scene_24 = new FontIconSource
{
Glyph = "\uF159",
FontSize = 24
};
public static readonly IconSource entity_24 = new FontIconSource
{
Glyph = "\uF158",
FontSize = 24
};
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Ghost.Editor.Core.Resources;
internal static class StaticResource
{
public static readonly BindingFlags ComponentPropertyBindingFlags = BindingFlags.Public | BindingFlags.Instance;
}

View File

@@ -0,0 +1,45 @@
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.SceneGraph;
public sealed partial class EntityNode : SceneGraphNode
{
private readonly Entity _entity;
public Entity Entity => _entity;
public override IconSource? CreateIcon()
{
return new FontIconSource
{
Glyph = "\uF158"
};
}
public override UIElement? CreateHeader()
{
return null;
}
public override UIElement? CreateInspector()
{
throw new NotImplementedException();
}
public override DataTemplate GetSceneHierarchyTemplate()
{
var template = @"
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
<FontIcon FontSize=""14"" Glyph=""&#xF158;"" />
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
}
}

View File

@@ -0,0 +1,87 @@
# Architecture Plan: Scene Graph and Scene Representation
The Scene Graph is a hierarchical structure that represents all the objects and entities within a 3D scene in the Ghost Editor.
## Scene Graph (Editor representation of runtime data)
There should be three main types of nodes in the Scene Graph for now:
1. **Scene Graph Node**: The base class for all nodes in the Scene Graph.
2. **Entity Node**: Represents an individual entity within a scene. Name stored here, not runtime component.
3. **Scene Node**: Represents a Scene object, which can contain multiple entities. Name stored here not runtime data.
### Editor World
Editor contains a different world compares to the runtime world. When user click the Play button, we will create a runtime world and load the scene data from the editor world to the runtime world.
This allows us to
1. Unload the runtime only systems like physics, rendering, etc when user stop playing.
2. Load editor only systems like gizmos, debug, etc when user stop playing.
3. Allow editor only entities like editor camera, editor lights, etc to exist in the editor world without affecting the runtime world.
### Editor Hierarchy
The Scene Graph should be represented as a tree structure in the editor (TreeView in WinUI 3), where:
- The top level nodes represents the loaded Scenes in the editor world.
- Levels below the Scene nodes represents the Entity nodes that belong to that scene.
- Each Entity node can have child Entity nodes representing parent-child relationships between entities.
An example hierarchy could look like this:
```
- Scene 1
- Entity A
- Entity B
- Entity C
- Scene 2
- Entity D
```
## Scene (The runtime representation)
A Scene is a collection of entities with SceneID component from a world that are grouped together. There can be multiple scenes in a world.
### Save a Scene
When save a scene, all entities with the SceneID component matching the scene's ID should be included in the saved data.
When an Entity references another Entity in the same scene, we should store the file local id instead of the global entity id.
For example, if Entity A (id: 10, 5th in scene) references Entity B (id: 20, 50th in scene) in the same scene, in the saved data for Entity A,
we should store 50 (the file local id) as the reference to Entity B instead of 20 (the global entity id).
> We does not allow cross-scene references for now because ideally it's not a good practice to have cross-scene references.
> We can use query or singleton pattern to access entities from other scenes if needed because they are in the same world.
### Load a Scene
When loading a scene, we need to reconstruct the entities and their relationships based on the saved data.
1. We allocate the entities in the world and assign them new global entity IDs.
2. We remap the file local IDs to the new global entity IDs and change the references accordingly.
For example if Entity A (file local id: 5) references Entity B (file local id: 50) in the saved data,
we need to find the new global entity IDs for both entities after loading and update the reference in Entity A to point to the new global entity ID of Entity B.
### Data format
The scene data should be stored in a structured format (JSON and binary) that includes:
- List of entities with their components and properties (Entities must in the order that file local id directly maps to the index in the list)
- References between entities using file local IDs
> The name of the saved scene file should match the name of the scene node in the editor.
JSON should only be used in the editor and JSON serialization/deserialization logic should also only exist in the editor codebase (Ghost.Editor.Core). Reflection is allowed here.
Binary format should be used in the runtime for better performance. The runtime codebase (Ghost.Engine) must be aot compatible.
Currently we strict the IComponent to must be unmanaged and blittable types.
However, we also support ManagedEntity and ManagedEntityRef with ScriptComponent to allow OOP like logic for common gameplay logic that DOD pattern is not suitable for.
Serializing/deserializing with those components will be tricky. We can use MemoryPack (already installed) for binary serialization/deserialization because it supports both unmanaged and managed types.
## What need to implement
- [ ] Scene type for the runtime representation if needed
- [ ] Scene Graph data structures (SceneNode, EntityNode)
- [ ] Editor World management (loading/unloading scenes, managing entities)
- [ ] Scene saving/loading logic with file local ID remapping
- [ ] Serialization/deserialization logic for scene data (JSON for editor, binary for runtime)
- [ ] UI integration for displaying and managing the Scene Graph in the editor with WinUI 3 TreeView

View File

@@ -0,0 +1,27 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Core.Contracts;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Collections.ObjectModel;
namespace Ghost.Editor.Core.SceneGraph;
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
{
[ObservableProperty]
public partial string Name
{
get; set;
}
public ObservableCollection<SceneGraphNode> Children
{
get;
} = new();
public abstract IconSource? CreateIcon();
public abstract UIElement? CreateHeader();
public abstract UIElement? CreateInspector();
public abstract DataTemplate GetSceneHierarchyTemplate();
}

View File

@@ -0,0 +1,45 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.SceneGraph;
public sealed partial class SceneNode : SceneGraphNode
{
public override IconSource? CreateIcon()
{
return new FontIconSource
{
Glyph = "\uF156"
};
}
// TODO: Implement custom header and inspector UI for the SceneNode
public override UIElement? CreateHeader()
{
return null;
}
public override UIElement? CreateInspector()
{
return null;
}
public override DataTemplate GetSceneHierarchyTemplate()
{
var template = @"
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:DataType=""sg:SceneGraphNode"">
<TreeViewItem
AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}""
Background=""{ThemeResource ControlSolidFillColorDefaultBrush}""
IsExpanded=""True""
ItemsSource=""{ x:Bind Children, Mode=OneWay}"" >
<StackPanel Orientation=""Horizontal"" >
<FontIcon FontSize=""14"" Glyph=""&#xF156;""/>
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
}
}

View File

@@ -0,0 +1,6 @@
namespace TestProject.AssetDB;
internal partial class AssetRegistry
{
// TODO: Sqlite backend implementation
}

View File

@@ -0,0 +1,510 @@
using Ghost.Core;
using Ghost.Editor.Core.AssetHandler;
using Ghost.Editor.Core.Contracts;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace TestProject.AssetDB;
internal class PathComparer : IEqualityComparer<string>
{
private static string ToCanonicalPath(string? path)
{
return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty;
}
public bool Equals(string? x, string? y)
{
return string.Equals(
ToCanonicalPath(x),
ToCanonicalPath(y),
StringComparison.Ordinal);
}
public int GetHashCode(string str)
{
return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal);
}
}
// TODO: Path based locking for multi-threaded access?
// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)?
internal partial class AssetRegistry : IAssetRegistry
{
public const string ASSET_EXTENSION = ".gasset";
public const string TEMP_EXTENSION = ".gtemp";
private readonly string _rootDirectory;
private readonly FileSystemWatcher _watcher;
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
private readonly SemaphoreSlim _cacheSlim;
private readonly Lock _pathLock;
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
public AssetRegistry(string rootDirectory)
{
if (!Directory.Exists(rootDirectory))
{
throw new DirectoryNotFoundException("The specified root directory does not exist.");
}
if (!Path.IsPathFullyQualified(rootDirectory))
{
throw new InvalidOperationException("The specified root directory must be an absolute path.");
}
_rootDirectory = rootDirectory;
_watcher = new FileSystemWatcher(rootDirectory)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true,
};
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
_cacheSlim = new SemaphoreSlim(1, 1);
_pathLock = new Lock();
LoadExistingAssets();
_watcher.Created += OnFileSystemOp;
_watcher.Deleted += OnFileSystemOp;
_watcher.Changed += OnFileSystemOp;
_watcher.Renamed += OnFileSystemRenameOp;
}
// TODO: DB Cache
private unsafe void LoadExistingAssets()
{
Span<byte> guidBuffer = stackalloc byte[sizeof(Guid)];
foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(_rootDirectory, filePath);
try
{
var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
try
{
fs.Seek(4, SeekOrigin.Begin); // Skip format version
fs.ReadExactly(guidBuffer);
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(guidBuffer));
UpdatePathMapping(relativePath, guid);
}
finally
{
fs.Dispose();
}
}
catch (Exception
#if DEBUG
ex
#endif
)
{
#if DEBUG
System.Diagnostics.Debugger.BreakForUserUnhandledException(ex);
#endif
continue;
}
}
}
private void UpdateGraph(Guid assetId, IEnumerable<Guid> newDependencies)
{
// 1. Clean up old references (reverse)
if (_dependencyCache.TryGetValue(assetId, out var oldDeps))
{
foreach (var dep in oldDeps)
{
if (_referencerGraph.TryGetValue(dep, out var refs))
{
refs.Remove(assetId);
}
}
}
// 2. Set new forward dependencies
var newDepSet = new HashSet<Guid>(newDependencies);
_dependencyCache[assetId] = newDepSet;
// 3. Add new references (reverse)
foreach (var dep in newDepSet)
{
ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists);
if (!exists || referencers is null)
{
referencers = new HashSet<Guid>();
}
referencers.Add(assetId);
}
}
private void UpdatePathMapping(string relativePath, Guid guid)
{
lock (_pathLock)
{
_pathToGuid[relativePath] = guid;
_guidToPath[guid] = relativePath;
}
}
private bool RemovePathMappingByPath(string relativePath)
{
lock (_pathLock)
{
if (_pathToGuid.Remove(relativePath, out var guid))
{
return _guidToPath.TryRemove(guid, out _);
}
}
return false;
}
private async void OnFileSystemOp(object sender, FileSystemEventArgs e)
{
if (_ignoreFileChanges.TryRemove(e.FullPath, out _))
{
return;
}
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
var ext = Path.GetExtension(relativePath);
var changeType = AssetChangeType.None;
var fireEvent = false;
var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal);
var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal);
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
changeType = AssetChangeType.Created;
if (!isAsset && !isTemp)
{
var handler = GetAssetHandlerForExtension(ext);
if (handler is IImportableAssetHandler importableHandler)
{
var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) =>
{
source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan);
ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length));
});
var newGuid = Guid.NewGuid();
await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read);
await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write);
await importableHandler.ImportAsync(sourceStream, targetStream, newGuid);
File.Delete(assetPath);
UpdatePathMapping(relativePath, newGuid);
fireEvent = true;
}
}
break;
case WatcherChangeTypes.Deleted:
changeType = AssetChangeType.Deleted;
if (isAsset)
{
fireEvent = RemovePathMappingByPath(relativePath);
}
break;
case WatcherChangeTypes.Changed:
changeType = AssetChangeType.Modified;
fireEvent = isAsset;
break;
case WatcherChangeTypes.All:
// Can this even happen?
break;
default:
break;
}
if (fireEvent)
{
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
}
}
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
{
var ext = Path.GetExtension(e.FullPath);
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
{
return;
}
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
if (_pathToGuid.Remove(oldRelativePath, out var guid))
{
UpdatePathMapping(newRelativePath, guid);
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
}
}
public string? GetAssetPath(Guid id)
{
lock (_pathLock)
{
if (_guidToPath.TryGetValue(id, out var path))
{
return path;
}
}
return null;
}
public Guid GetAssetGuid(string path)
{
lock (_pathLock)
{
if (_pathToGuid.TryGetValue(path, out var guid))
{
return guid;
}
}
return Guid.Empty;
}
private IAssetHandler GetAssetHandler(Type type)
{
var typeHandle = type.TypeHandle.Value;
if (_cachedHander.TryGetValue(typeHandle, out var handler))
{
return handler;
}
var obj = Activator.CreateInstance(type);
if (obj is not IAssetHandler newHandler)
{
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
}
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is null || attr.AllowCaching)
{
_cachedHander[typeHandle] = newHandler;
}
return newHandler;
}
private IAssetHandler? GetAssetHandlerForExtension(string extension)
{
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
{
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
return GetAssetHandler(handlerType);
}
}
return null;
}
private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId)
{
foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract))
{
var attr = handlerType.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is not null && new Guid(attr.ID) == typeId)
{
return GetAssetHandler(handlerType);
}
}
return null;
}
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
{
if (!File.Exists(sourceFilePath))
{
return Result.Failure("Source file not found.");
}
var ext = Path.GetExtension(sourceFilePath);
var handler = GetAssetHandlerForExtension(ext);
if (handler is not IImportableAssetHandler importableHandler)
{
return Result.Failure("No importable asset handler found for the given file extension.");
}
var guid = Guid.NewGuid();
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
{
return Result.Failure("Asset import failed.");
}
UpdatePathMapping(targetAssetPath, guid);
return guid;
}
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
{
var assetPath = GetAssetPath(assetId);
if (string.IsNullOrEmpty(assetPath))
{
return Result.Failure("Asset not found in DB");
}
var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory);
// 2. Identify the Handler
// (You might want to store SourcePath in metadata later so you don't need to pass it here)
var ext = Path.GetExtension(sourceFilePath);
var handler = GetAssetHandlerForExtension(ext);
if (handler is not IImportableAssetHandler importableHandler)
{
return Result.Failure("No importable asset handler found for the given file extension.");
}
_ignoreFileChanges[fullAssetPath] = true;
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);
await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write);
await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token);
if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset))
{
await liveAsset.RefreshAsync(this, token);
}
return Result.Success();
}
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default)
{
// TODO: weakRef based locking instead of global lock for better concurrency.
// We should use GetOrAdd here.
if (_loadedAssets.TryGetValue(id, out var weakRef)
&& weakRef.TryGetTarget(out var existingAsset))
{
return existingAsset;
}
await _cacheSlim.WaitAsync(token);
// Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting.
if (_loadedAssets.TryGetValue(id, out weakRef)
&& weakRef.TryGetTarget(out existingAsset))
{
return existingAsset;
}
try
{
var path = GetAssetPath(id);
if (string.IsNullOrEmpty(path))
{
return null;
}
var assetPath = Path.GetFullPath(path, _rootDirectory);
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
int sizeofGuid;
unsafe
{
sizeofGuid = sizeof(Guid);
}
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
fs.ReadExactly(typeIdBuffer);
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
var handler = GetAssetHandlerForTypeId(guid);
if (handler == null)
{
return null;
}
var assetResult = await handler.LoadAsync(fs, this, token);
if (assetResult.IsFailure)
{
return assetResult;
}
var asset = assetResult.Value;
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(asset), (key, oldRef) =>
{
// If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected.
// If the assetResult haven't been loaded before, we are in the addValue path, not here.
// If the previous reference has been collected, we can just replace it with the new one.
// Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time.
oldRef.SetTarget(asset);
return oldRef;
});
return assetResult;
}
finally
{
_cacheSlim.Release();
}
}
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
{
var path = GetAssetPath(asset.ID);
if (path == null)
{
return Result.Failure("Asset not found.");
}
var handler = GetAssetHandlerForTypeId(asset.TypeID);
if (handler == null)
{
return Result.Failure("No asset handler found for the given asset type.");
}
var fullPath = Path.GetFullPath(path, _rootDirectory);
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
return await handler.SaveAsync(asset, fs, this, token);
}
public void Dispose()
{
_cacheSlim.Dispose();
_watcher.Dispose();
}
}

View File

@@ -0,0 +1,21 @@
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.Services;
public class InspectorService : IInspectorService
{
private IInspectable? _selected;
public IInspectable? Selected => _selected;
public event EventHandler<InspectorSelectionChangedEventArgs>? OnSelectionChanged;
public void SetSelected(IInspectable? inspectable, object? source)
{
if (_selected != inspectable)
{
_selected = inspectable;
OnSelectionChanged?.Invoke(this, new InspectorSelectionChangedEventArgs(source, inspectable));
}
}
}

View File

@@ -0,0 +1,51 @@
using CommunityToolkit.WinUI.Behaviors;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Notifications;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Services;
public class NotificationService : INotificationService
{
private InfoBar? _infoBar;
private StackedNotificationsBehavior? _notificationQueue;
internal void SetReference(InfoBar infoBar, StackedNotificationsBehavior notificationQueue)
{
_infoBar = infoBar;
_notificationQueue = notificationQueue;
}
public void ShowNotification(string? message, MessageType type, int duration = 5, string? title = null)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
var notification = new Notification
{
Message = message,
Severity = (InfoBarSeverity)type,
Duration = TimeSpan.FromSeconds(duration),
Title = title
};
ShowNotification(notification);
}
public void ShowNotification(Notification notification)
{
_notificationQueue?.Show(notification);
}
internal void ClearReference()
{
if (_infoBar != null)
{
_infoBar.IsOpen = false;
}
_infoBar = null;
_notificationQueue = null;
}
}

View File

@@ -0,0 +1,35 @@
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.Services;
internal class PreviewService : IPreviewService
{
public string GetIconPath(string path, bool isDirectory, IconSize size)
{
string iconPath;
if (isDirectory)
{
iconPath = "ms-appx:///Assets/EditorIcons/folder-{0}.png";
}
else
{
// TODO: Generate preview icons dynamically for known file types like images, meshes, materials, etc.
var ext = Path.GetExtension(path);
iconPath = ext switch
{
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".tiff" or ".svg" => "ms-appx:///Assets/EditorIcons/image-{0}.png",
_ => "ms-appx:///Assets/EditorIcons/document-{0}.png",
};
}
var sizeIndex = size switch
{
IconSize.Small => "0",
IconSize.Large => "1",
_ => "0"
};
iconPath = string.Format(iconPath, sizeIndex);
return iconPath;
}
}

View File

@@ -0,0 +1,75 @@
using CommunityToolkit.WinUI;
using Ghost.Editor.Core.Contracts;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Runtime.CompilerServices;
namespace Ghost.Editor.Core.Services;
public class ProgressService : IProgressService
{
private Grid? _progressBarContainer;
private TextBlock? _progressMessage;
private ProgressBar? _progressBar;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsInitialized()
{
return _progressBarContainer != null && _progressMessage != null && _progressBar != null;
}
internal void SetReference(Grid progressBarContainer)
{
_progressBarContainer = progressBarContainer;
_progressMessage = _progressBarContainer.FindChild<TextBlock>();
_progressBar = _progressBarContainer.FindChild<ProgressBar>();
}
public void ShowProgress(string message, double progress = 0.0)
{
if (!IsInitialized())
{
return;
}
_progressBarContainer!.Visibility = Visibility.Visible;
_progressMessage!.Text = message;
_progressBar!.Value = progress;
}
public void ShowIndeterminateProgress(string message)
{
if (!IsInitialized())
{
return;
}
_progressBarContainer!.Visibility = Visibility.Visible;
_progressMessage!.Text = message;
_progressBar!.IsIndeterminate = true;
}
public void SetProgress(double progress)
{
_progressBar!.Value = progress;
}
public void HideProgress()
{
if (!IsInitialized())
{
return;
}
_progressBarContainer!.Visibility = Visibility.Collapsed;
_progressMessage!.Text = string.Empty;
_progressBar!.Value = 0.0;
}
internal void ClearReference()
{
_progressBarContainer = null;
_progressMessage = null;
_progressBar = null;
}
}

View File

@@ -0,0 +1,53 @@
using Ghost.Editor.Core.AssetHandler;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Utilities;
public static class AssetHandlerUtility
{
public static async ValueTask SerializeAssetAsync<TSetting>(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory<Guid> dependencies, IAssetSettings? settings, ReadOnlyMemory<byte> contents, CancellationToken token = default)
where TSetting : IAssetSettings
{
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = handlerVersion,
DependenciesOffset = AssetMetadata.SIZE,
DependencyCount = dependencies.Length,
};
var tempArray = ArrayPool<byte>.Shared.Rent(4096);
if (dependencies.Length > 0)
{
stream.Seek(header.DependenciesOffset, SeekOrigin.Begin);
for (var i = 0; i < dependencies.Length; i++)
{
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(tempArray.AsSpan(0, 16)), dependencies.Span[i]);
await stream.WriteAsync(tempArray.AsMemory(0, 16), token);
}
}
header.SettingsOffset = stream.Position;
// TODO: We can use source generator to generate optimized serializer for settings.
// For now, we just use reflection for simplicity.
if (settings is not null)
{
var properties = typeof(TSetting).GetProperties();
if (properties.Length > 0)
{
using var bw = new BinaryWriter(stream);
for (var i = 0; (i < properties.Length); i++)
{
var property = properties[i];
var value = property.GetValue(settings);
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Ghost.Editor.Core.Utilities;
internal static class FileExtensions
{
public const string META_FILE_EXTENSION = ".gmeta";
public const string PROJECT_FILE_EXTENSION = ".gproj";
public const string TEMPLATE_FILE_EXTENSION = ".gtmpl";
public const string SCENE_FILE_EXTENSION = ".gscene";
public const string ASSET_FILE_EXTENSION = ".gasset";
public const string SHADER_FILE_EXTENSION = ".gshdr";
public const string MATERIAL_FILE_EXTENSION = ".gmat";
}

View File

@@ -0,0 +1,93 @@
using Ghost.Core.Attributes;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Utilities;
public static class TypeCache
{
private static TypeInfo[] s_types;
private static Dictionary<nint, List<MethodInfo>> s_attributeMethodCache;
static TypeCache()
{
s_types = LoadTypes();
s_attributeMethodCache = FindMethodWithAttribute();
}
private static TypeInfo[] LoadTypes()
{
var loadableTypes = new List<Type>(512);
var assembliesToScan = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetCustomAttribute<EngineAssemblyAttribute>() != null);
foreach (var assembly in assembliesToScan)
{
try
{
loadableTypes.AddRange(assembly.GetTypes());
}
catch (ReflectionTypeLoadException ex)
{
var types = ex.Types.Where(t => t != null);
loadableTypes.AddRange(types!);
}
}
return loadableTypes.Select(t => t.GetTypeInfo()).ToArray();
}
private static Dictionary<nint, List<MethodInfo>> FindMethodWithAttribute()
{
var dict = new Dictionary<nint, List<MethodInfo>>();
foreach (var type in s_types)
{
foreach (var method in type.DeclaredMethods)
{
var attrs = method.GetCustomAttributes<DiscoverableAttributeBase>(false);
foreach (var attr in attrs)
{
var key = attr.GetType().TypeHandle.Value;
ref var methodList = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var exist);
if (!exist)
{
methodList = new List<MethodInfo>();
}
methodList!.Add(method);
}
}
}
return dict;
}
internal static void Init()
{
// Intentionally left blank.
// This method exists to force the static constructor to run.
}
internal static void Reload()
{
s_types = LoadTypes();
s_attributeMethodCache = FindMethodWithAttribute();
}
public static IReadOnlyCollection<TypeInfo> GetTypes()
{
return s_types;
}
public static IReadOnlyCollection<MethodInfo>? GetMethodsWithAttribute<T>()
where T : DiscoverableAttributeBase
{
var key = typeof(T).TypeHandle.Value;
if (s_attributeMethodCache.TryGetValue(key, out var methods))
{
return methods;
}
return null;
}
}