Refactor folder structure
This commit is contained in:
7
src/Editor/Ghost.Editor.Core/AssemblyInfo.cs
Normal file
7
src/Editor/Ghost.Editor.Core/AssemblyInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Ghost.Core.Attributes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
|
||||
[assembly: InternalsVisibleTo("Ghost.Editor")]
|
||||
|
||||
[assembly: EngineAssembly]
|
||||
185
src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs
Normal file
185
src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs
Normal 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);
|
||||
}
|
||||
66
src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs
Normal file
66
src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
378
src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs
Normal file
378
src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
102
src/Editor/Ghost.Editor.Core/Attributes.cs
Normal file
102
src/Editor/Ghost.Editor.Core/Attributes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs
Normal file
49
src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs
Normal 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);
|
||||
}
|
||||
13
src/Editor/Ghost.Editor.Core/Contracts/IInspectable.cs
Normal file
13
src/Editor/Ghost.Editor.Core/Contracts/IInspectable.cs
Normal 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();
|
||||
}
|
||||
32
src/Editor/Ghost.Editor.Core/Contracts/IInspectorService.cs
Normal file
32
src/Editor/Ghost.Editor.Core/Contracts/IInspectorService.cs
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Ghost.Editor.Core.Contracts;
|
||||
|
||||
public interface INavigationAware
|
||||
{
|
||||
public void OnNavigatedTo(object? parameter);
|
||||
public void OnNavigatedFrom();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
12
src/Editor/Ghost.Editor.Core/Contracts/IPreviewService.cs
Normal file
12
src/Editor/Ghost.Editor.Core/Contracts/IPreviewService.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
13
src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.cs
Normal file
13
src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
157
src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.cs
Normal file
157
src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
215
src/Editor/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs
Normal file
215
src/Editor/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/Editor/Ghost.Editor.Core/Controls/ValueControl.cs
Normal file
70
src/Editor/Ghost.Editor.Core/Controls/ValueControl.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/Editor/Ghost.Editor.Core/EditorApplication.cs
Normal file
65
src/Editor/Ghost.Editor.Core/EditorApplication.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
39
src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj
Normal file
39
src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj
Normal 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>
|
||||
40
src/Editor/Ghost.Editor.Core/Inspector/ComponentEditor.cs
Normal file
40
src/Editor/Ghost.Editor.Core/Inspector/ComponentEditor.cs
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
27
src/Editor/Ghost.Editor.Core/Inspector/ComponentObject.cs
Normal file
27
src/Editor/Ghost.Editor.Core/Inspector/ComponentObject.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Ghost.Editor.Core.Notifications;
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Informational,
|
||||
Success,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
18
src/Editor/Ghost.Editor.Core/Resources/EditorIconSource.cs
Normal file
18
src/Editor/Ghost.Editor.Core/Resources/EditorIconSource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
8
src/Editor/Ghost.Editor.Core/Resources/StaticResource.cs
Normal file
8
src/Editor/Ghost.Editor.Core/Resources/StaticResource.cs
Normal 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;
|
||||
}
|
||||
45
src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs
Normal file
45
src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs
Normal 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="""" />
|
||||
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
|
||||
</StackPanel>
|
||||
</TreeViewItem>
|
||||
</DataTemplate>";
|
||||
|
||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||
}
|
||||
}
|
||||
87
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md
Normal file
87
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md
Normal 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
|
||||
27
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs
Normal file
27
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs
Normal 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();
|
||||
}
|
||||
45
src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs
Normal file
45
src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs
Normal 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=""""/>
|
||||
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
|
||||
</StackPanel>
|
||||
</TreeViewItem>
|
||||
</DataTemplate>";
|
||||
|
||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TestProject.AssetDB;
|
||||
|
||||
internal partial class AssetRegistry
|
||||
{
|
||||
// TODO: Sqlite backend implementation
|
||||
}
|
||||
510
src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs
Normal file
510
src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
21
src/Editor/Ghost.Editor.Core/Services/InspectorService.cs
Normal file
21
src/Editor/Ghost.Editor.Core/Services/InspectorService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/Editor/Ghost.Editor.Core/Services/NotificationService.cs
Normal file
51
src/Editor/Ghost.Editor.Core/Services/NotificationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/Editor/Ghost.Editor.Core/Services/PreviewService.cs
Normal file
35
src/Editor/Ghost.Editor.Core/Services/PreviewService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/Editor/Ghost.Editor.Core/Services/ProgressService.cs
Normal file
75
src/Editor/Ghost.Editor.Core/Services/ProgressService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Editor/Ghost.Editor.Core/Utilities/FileExtensions.cs
Normal file
13
src/Editor/Ghost.Editor.Core/Utilities/FileExtensions.cs
Normal 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";
|
||||
}
|
||||
93
src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs
Normal file
93
src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user