diff --git a/Ghost.Data/AssemblyInfo.cs b/Ghost.Data/AssemblyInfo.cs new file mode 100644 index 0000000..9092fb4 --- /dev/null +++ b/Ghost.Data/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Ghost.Editor")] diff --git a/Ghost.Data/Assets/ProjectTemplates/Empty.zip b/Ghost.Data/Assets/ProjectTemplates/Empty.zip new file mode 100644 index 0000000..07d03d4 Binary files /dev/null and b/Ghost.Data/Assets/ProjectTemplates/Empty.zip differ diff --git a/Ghost.Data/DataContext/ProjectRepository.cs b/Ghost.Data/DataContext/ProjectRepository.cs deleted file mode 100644 index 9d4d1da..0000000 --- a/Ghost.Data/DataContext/ProjectRepository.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Ghost.Data.Models; -using Ghost.Data.Resources; -using System.Data.SQLite; - -namespace Ghost.Data.DataContext; - -internal static class ProjectRepository -{ - private static class Command - { - public const string CONNECTION_STRING = "Data Source={0}\\projects.db;Version=3;"; - public const string CREATE_PROJECT_TABLE_STRING = "CREATE TABLE IF NOT EXISTS Projects (ID INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT, Path TEXT, EngineVersion TEXT, LastOpened DATETIME);"; - public const string SELECT_PROJECT_STRING = "SELECT * FROM Projects"; - public const string INSERT_PROJECT_STRING = "INSERT INTO Projects (Name, Path, EngineVersion, LastOpened) VALUES (@Name, @Path, @EngineVersion, @LastOpened);"; - public const string REMOVE_PROJECT_STRING = "DELETE FROM Projects WHERE ID = @ID;"; - public const string UPDATE_PROJECT_STRING = "UPDATE Projects SET Name = @Name, Path = @Path, EngineVersion = @EngineVersion, LastOpened = @LastOpened WHERE ID = @ID;"; - } - - private static string GetConnectionString() => string.Format(Command.CONNECTION_STRING, DataPath.APPLICATION_DATA_FOLDER); - - private static async Task EnsureTableCreatedAsync(SQLiteConnection connection) - { - using var createCommand = connection.CreateCommand(); - createCommand.CommandText = Command.CREATE_PROJECT_TABLE_STRING; - await createCommand.ExecuteNonQueryAsync(); - } - - public static async IAsyncEnumerable LoadProjectsAsync() - { - using var connection = new SQLiteConnection(GetConnectionString()); - await connection.OpenAsync(); - - await EnsureTableCreatedAsync(connection); - - using var command = connection.CreateCommand(); - command.CommandText = Command.SELECT_PROJECT_STRING; - - using var reader = command.ExecuteReader(); - while (await reader.ReadAsync()) - { - var project = new ProjectInfo - { - ID = reader.GetInt32(0), - Name = reader.GetString(1), - Path = reader.GetString(2), - EngineVersion = new Version(reader.GetString(3)), - LastOpened = reader.GetDateTime(4) - }; - - yield return project; - } - } - - public static async Task AddProjectAsync(ProjectInfo project) - { - using var connection = new SQLiteConnection(GetConnectionString()); - await connection.OpenAsync(); - - await EnsureTableCreatedAsync(connection); - - using var command = connection.CreateCommand(); - command.CommandText = Command.INSERT_PROJECT_STRING; - - command.Parameters.AddWithValue("@Name", project.Name); - command.Parameters.AddWithValue("@Path", project.Path); - command.Parameters.AddWithValue("@EngineVersion", project.EngineVersion.ToString()); - command.Parameters.AddWithValue("@LastOpened", project.LastOpened); - - await command.ExecuteNonQueryAsync(); - } - - public static async Task RemoveProjectAsync(ProjectInfo project) - { - using var connection = new SQLiteConnection(GetConnectionString()); - await connection.OpenAsync(); - - using var command = connection.CreateCommand(); - command.CommandText = Command.REMOVE_PROJECT_STRING; - - command.Parameters.AddWithValue("@ID", project.ID); - - await command.ExecuteNonQueryAsync(); - } - - public static async Task UpdateProjectAsync(ProjectInfo project) - { - using var connection = new SQLiteConnection(GetConnectionString()); - await connection.OpenAsync(); - - using var command = connection.CreateCommand(); - command.CommandText = Command.UPDATE_PROJECT_STRING; - - command.Parameters.AddWithValue("@Name", project.Name); - command.Parameters.AddWithValue("@Path", project.Path); - command.Parameters.AddWithValue("@EngineVersion", project.EngineVersion.ToString()); - command.Parameters.AddWithValue("@LastOpened", project.LastOpened); - command.Parameters.AddWithValue("@ID", project.ID); // Ensure the ID parameter is added - - await command.ExecuteNonQueryAsync(); - } -} \ No newline at end of file diff --git a/Ghost.Data/Ghost.Data.csproj b/Ghost.Data/Ghost.Data.csproj index a1e8b61..d2c294e 100644 --- a/Ghost.Data/Ghost.Data.csproj +++ b/Ghost.Data/Ghost.Data.csproj @@ -20,4 +20,10 @@ + + + PreserveNewest + + + diff --git a/Ghost.Data/JsonContext.cs b/Ghost.Data/JsonContext.cs index 330ecc2..ec6f6b1 100644 --- a/Ghost.Data/JsonContext.cs +++ b/Ghost.Data/JsonContext.cs @@ -5,6 +5,7 @@ namespace Ghost.Data; [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(TemplateInfo))] +[JsonSerializable(typeof(ProjectMetadata))] internal partial class JsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Ghost.Data/Models/ProjectInfo.cs b/Ghost.Data/Models/ProjectInfo.cs index b44ca12..ba10f9a 100644 --- a/Ghost.Data/Models/ProjectInfo.cs +++ b/Ghost.Data/Models/ProjectInfo.cs @@ -2,7 +2,7 @@ namespace Ghost.Data.Models; -public class ProjectInfo +internal class ProjectInfo { [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int ID @@ -15,17 +15,7 @@ public class ProjectInfo get; set; } - public required string Path - { - get; set; - } - - public required Version EngineVersion - { - get; set; - } - - public required DateTime LastOpened + public required string MetadataPath { get; set; } diff --git a/Ghost.Data/Models/ProjectMetadata.cs b/Ghost.Data/Models/ProjectMetadata.cs new file mode 100644 index 0000000..15b6aee --- /dev/null +++ b/Ghost.Data/Models/ProjectMetadata.cs @@ -0,0 +1,51 @@ +namespace Ghost.Data.Models; + +public class ProjectMetadata +{ + public const string PROJECT_EXTENSION = "ghostproj"; + + public Guid ID + { + get; set; + } + + public string Name + { + get; set; + } + + public Version EngineVersion + { + get; set; + } + + public DateTime CreatedAt + { + get; set; + } + + public DateTime LastOpened + { + get; set; + } + + public ProjectMetadata(string name, Version engineVersion) + { + ID = Guid.NewGuid(); + Name = name; + EngineVersion = engineVersion; + CreatedAt = DateTime.UtcNow; + LastOpened = DateTime.UtcNow; + } + + // Parameterless constructor for deserialization + public ProjectMetadata() + { + } +} + +public readonly struct ProjectMetadataInfo(string path, ProjectMetadata metadata) +{ + public readonly string Path => path; + public readonly ProjectMetadata Metadata => metadata; +} \ No newline at end of file diff --git a/Ghost.Data/Models/Result.cs b/Ghost.Data/Models/Result.cs new file mode 100644 index 0000000..134cca9 --- /dev/null +++ b/Ghost.Data/Models/Result.cs @@ -0,0 +1,53 @@ +namespace Ghost.Data.Models; + +public readonly struct Result +{ + public readonly bool success; + + public readonly string? message; + + public Result(bool success, string? message = null) + { + this.success = success; + this.message = message; + } + + public static Result OK() + { + return new Result(true); + } + + public static Result Error(string? message) + { + return new Result(false, message); + } + + public override string ToString() => success ? "OK" : $"Error: {message}"; +} + +public readonly struct Result +{ + public readonly bool success; + public readonly T? data; + + public readonly string? message; + + public Result(bool success, T? data, string? message = null) + { + this.success = success; + this.data = data; + this.message = message; + } + + public static Result OK(T data) + { + return new Result(true, data); + } + + public static Result Error(string? message) + { + return new Result(false, default, message); + } + + public override string ToString() => success ? $"OK: {data}" : $"Error: {message}"; +} diff --git a/Ghost.Data/Models/TemplateInfo.cs b/Ghost.Data/Models/TemplateInfo.cs index 3585b50..4b8ee71 100644 --- a/Ghost.Data/Models/TemplateInfo.cs +++ b/Ghost.Data/Models/TemplateInfo.cs @@ -23,21 +23,21 @@ public class TemplateInfo } } -public class TemplateData(string templatePath, TemplateInfo info) +public struct TemplateData(string templatePath, TemplateInfo info) { private const string _ICON_NAME = "icon.png"; private const string _PREVIEW_NAME = "preview.png"; public string directory = Path.GetDirectoryName(templatePath)!; - public TemplateInfo Info => info; + public readonly TemplateInfo Info => info; - public Uri GetIconURI() + public readonly Uri GetIconURI() { return new Uri(Path.Combine(directory, _ICON_NAME)); } - public Uri GetPreviewURI() + public readonly Uri GetPreviewURI() { return new Uri(Path.Combine(directory, _PREVIEW_NAME)); } diff --git a/Ghost.Data/Repository/ProjectRepository.cs b/Ghost.Data/Repository/ProjectRepository.cs new file mode 100644 index 0000000..a45755e --- /dev/null +++ b/Ghost.Data/Repository/ProjectRepository.cs @@ -0,0 +1,99 @@ +using Ghost.Data.Models; +using System.Data; +using System.Data.SQLite; + +namespace Ghost.Data.Repository; + +internal class ProjectRepository : IDisposable +{ + private readonly SQLiteConnection _connection; + + public ProjectRepository(string sourceDirectory) + { + _connection = new SQLiteConnection(string.Format(Command.CONNECTION_STRING, sourceDirectory)); + _connection.Open(); + } + + private static class Command + { + public const string CONNECTION_STRING = "Data Source={0}\\projects.db;Version=3;"; + public const string CREATE_PROJECT_TABLE_STRING = "CREATE TABLE IF NOT EXISTS Projects (ID INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT, MetadataPath TEXT);"; + public const string SELECT_PROJECT_STRING = "SELECT * FROM Projects"; + public const string INSERT_PROJECT_STRING = "INSERT INTO Projects (Name, MetadataPath) VALUES (@Name, @MetadataPath);"; + public const string REMOVE_PROJECT_STRING = "DELETE FROM Projects WHERE ID = @ID;"; + public const string UPDATE_PROJECT_STRING = "UPDATE Projects SET Name = @Name, MetadataPath = @MetadataPath WHERE ID = @ID;"; + } + + private async Task EnsureTableCreatedAsync() + { + using var createCommand = _connection.CreateCommand(); + createCommand.CommandText = Command.CREATE_PROJECT_TABLE_STRING; + await createCommand.ExecuteNonQueryAsync(); + } + + public async IAsyncEnumerable LoadProjectsAsync() + { + await EnsureTableCreatedAsync(); + + using var command = _connection.CreateCommand(); + command.CommandText = Command.SELECT_PROJECT_STRING; + + using var reader = command.ExecuteReader(); + while (await reader.ReadAsync()) + { + var project = new ProjectInfo + { + ID = reader.GetInt32(0), + Name = reader.GetString(1), + MetadataPath = reader.GetString(2), + }; + + yield return project; + } + } + + public async Task AddProjectAsync(ProjectInfo project) + { + await EnsureTableCreatedAsync(); + + using var command = _connection.CreateCommand(); + command.CommandText = Command.INSERT_PROJECT_STRING; + + command.Parameters.AddWithValue("@Name", project.Name); + command.Parameters.AddWithValue("@MetadataPath", project.MetadataPath); + + await command.ExecuteNonQueryAsync(); + } + + public async Task RemoveProjectAsync(ProjectInfo project) + { + using var command = _connection.CreateCommand(); + command.CommandText = Command.REMOVE_PROJECT_STRING; + + command.Parameters.AddWithValue("@ID", project.ID); + + await command.ExecuteNonQueryAsync(); + } + + public async Task UpdateProjectAsync(ProjectInfo project) + { + using var command = _connection.CreateCommand(); + command.CommandText = Command.UPDATE_PROJECT_STRING; + + command.Parameters.AddWithValue("@Name", project.Name); + command.Parameters.AddWithValue("@MetadataPath", project.MetadataPath); + command.Parameters.AddWithValue("@ID", project.ID); + + await command.ExecuteNonQueryAsync(); + } + + public void Dispose() + { + if (_connection.State == ConnectionState.Open) + { + _connection.Close(); + } + + _connection.Dispose(); + } +} \ No newline at end of file diff --git a/Ghost.Data/Resources/AssetsPath.cs b/Ghost.Data/Resources/AssetsPath.cs index e800f20..2c3f7d2 100644 --- a/Ghost.Data/Resources/AssetsPath.cs +++ b/Ghost.Data/Resources/AssetsPath.cs @@ -4,5 +4,5 @@ public static class AssetsPath { public const string ASSETS_FOLDER = "Assets"; - public readonly static string AppIconPath = Path.Combine(AppContext.BaseDirectory, $"{ASSETS_FOLDER}/Icon-256.ico"); + public readonly static string s_appIconPath = Path.Combine(AppContext.BaseDirectory, $"{ASSETS_FOLDER}/Icon-256.ico"); } \ No newline at end of file diff --git a/Ghost.Data/Resources/DataPath.cs b/Ghost.Data/Resources/DataPath.cs index 486a2bd..a8d9b51 100644 --- a/Ghost.Data/Resources/DataPath.cs +++ b/Ghost.Data/Resources/DataPath.cs @@ -4,6 +4,6 @@ public class DataPath { public const string ENGINE_DATA_FOLDER_NAME = "GhostEngine"; - public readonly static string APPLICATION_DATA_FOLDER = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ENGINE_DATA_FOLDER_NAME); - public readonly static string PROJECT_TEMPLATES_FOLDER = Path.Combine(APPLICATION_DATA_FOLDER, "ProjectTemplates"); + public readonly static string s_applicationDataFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ENGINE_DATA_FOLDER_NAME); + public readonly static string s_projectTemplateFolder = Path.Combine(s_applicationDataFolder, "ProjectTemplates"); } \ No newline at end of file diff --git a/Ghost.Data/Services/ProjectService.cs b/Ghost.Data/Services/ProjectService.cs index 42b1633..1a07763 100644 --- a/Ghost.Data/Services/ProjectService.cs +++ b/Ghost.Data/Services/ProjectService.cs @@ -1,25 +1,38 @@ -using Ghost.Data.DataContext; -using Ghost.Data.Models; +using Ghost.Data.Models; +using Ghost.Data.Repository; using Ghost.Data.Resources; using System.IO.Compression; using System.Text.Json; namespace Ghost.Data.Services; -public class ProjectService +internal partial class ProjectService { private const string _ASSETS_FOLDER = "Assets"; + private const string _CONFIG_FOLDER = "ProjectConfig"; private const string _TEMPLATE_CONTENT_FILE = "content.zip"; - public async IAsyncEnumerable<(string path, TemplateInfo info)> GetProjectTemplatesAsync() + public static void EnsureDefaultTemplate() { - var templatesFolder = DataPath.PROJECT_TEMPLATES_FOLDER; + var templates = Directory.GetFiles(DataPath.s_projectTemplateFolder, "template.json", SearchOption.AllDirectories); + if (templates.Length > 0) + { + return; // Default template already exists + } + + var defaultTemplatePath = Path.Combine(AppContext.BaseDirectory, "Assets/ProjectTemplates/Empty.zip"); + ZipFile.ExtractToDirectory(defaultTemplatePath, DataPath.s_projectTemplateFolder, true); + } + + public static async IAsyncEnumerable<(string path, TemplateInfo info)> GetProjectTemplatesAsync() + { + var templatesFolder = DataPath.s_projectTemplateFolder; if (!Directory.Exists(templatesFolder)) { yield break; } - var templates = Directory.GetFiles(DataPath.PROJECT_TEMPLATES_FOLDER, "template.json", SearchOption.AllDirectories); + var templates = Directory.GetFiles(DataPath.s_projectTemplateFolder, "template.json", SearchOption.AllDirectories); foreach (var templatePath in templates) { var fileStream = File.OpenRead(templatePath); @@ -33,68 +46,152 @@ public class ProjectService } } - private Task SetupAssetsFolder(string projectPath, string templatePath) + public static async Task CreateMetadataFileAsync(string path, ProjectMetadata metadata) { - return Task.Run(() => - { - var templateContentPath = Path.Combine(templatePath, _TEMPLATE_CONTENT_FILE); - var projectAssetsPath = Path.Combine(projectPath, _ASSETS_FOLDER); - - Directory.CreateDirectory(projectAssetsPath); - - if (!File.Exists(templateContentPath)) - { - return; - } - - ZipFile.ExtractToDirectory(templateContentPath, projectAssetsPath); - }); + await using var fileStream = File.Create(path); + await JsonSerializer.SerializeAsync(fileStream, metadata, JsonContext.Default.ProjectMetadata); } - public IAsyncEnumerable LoadAllProjectAsync() + public static async Task LoadMetadataAsync(string ghostprojPath) { - return ProjectRepository.LoadProjectsAsync(); - } - - public async Task CreateProjectAsync(string projectName, string projectDirectory, string templatePath) - { - var projectPath = Path.Combine(projectDirectory, projectName); - if (!Directory.Exists(projectPath)) + if (!File.Exists(ghostprojPath)) { - Directory.CreateDirectory(projectPath); + throw new FileNotFoundException("Project metadata file not found.", ghostprojPath); } - await SetupAssetsFolder(projectPath, templatePath); - - return projectPath; + await using var fileStream = File.OpenRead(ghostprojPath); + return await JsonSerializer.DeserializeAsync(fileStream, JsonContext.Default.ProjectMetadata); } + public static async Task> ValidateProjectDirectoryAsync(string? projectDirectory) + { + if (string.IsNullOrWhiteSpace(projectDirectory) || !Directory.Exists(projectDirectory)) + { + return Result.Error("Project directory is invalid or does not exist."); + } + + var projectAssetsPath = Path.Combine(projectDirectory, _ASSETS_FOLDER); + var projectConfigPath = Path.Combine(projectDirectory, _CONFIG_FOLDER); + if (!Directory.Exists(projectAssetsPath) || !Directory.Exists(projectConfigPath)) + { + return Result.Error("Project folder structure is invalid."); + } + + var metadataPath = Directory.GetFiles(projectDirectory, $"*.{ProjectMetadata.PROJECT_EXTENSION}", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(metadataPath) || !File.Exists(metadataPath)) + { + return Result.Error("Project metadata file not found."); + } + + var metadata = await LoadMetadataAsync(metadataPath); + if (metadata == null) + { + return Result.Error("Project metadata file is corrupted or invalid."); + } + + return Result.OK(new(metadataPath, metadata)); + } + + private static async ValueTask SetupRequestFolderAsync(string projectDirectory, string templateDirectory) + { + var projectAssetsPath = Path.Combine(projectDirectory, _ASSETS_FOLDER); + var projectConfigPath = Path.Combine(projectDirectory, _CONFIG_FOLDER); + var templateContentPath = Path.Combine(templateDirectory, _TEMPLATE_CONTENT_FILE); + + Directory.CreateDirectory(projectAssetsPath); + if (File.Exists(templateContentPath)) + { + await Task.Run(() => + { + ZipFile.ExtractToDirectory(templateContentPath, projectAssetsPath); + }); + } + + Directory.CreateDirectory(projectConfigPath); + } +} + +internal partial class ProjectService : IDisposable +{ + private readonly ProjectRepository _repository = new(DataPath.s_applicationDataFolder); + public Task AddProjectAsync(ProjectInfo project) { - return ProjectRepository.AddProjectAsync(project); + return _repository.AddProjectAsync(project); } - public async Task AddProjectAsync(string name, string path, Version version) + public async Task AddProjectAsync(string name, string path) { var project = new ProjectInfo { Name = name, - Path = path, - EngineVersion = version, - LastOpened = DateTime.Now + MetadataPath = path, }; - await ProjectRepository.AddProjectAsync(project); + await _repository.AddProjectAsync(project); return project; } public Task RemoveProjectAsync(ProjectInfo project) { - return ProjectRepository.RemoveProjectAsync(project); + return _repository.RemoveProjectAsync(project); } public Task UpdateProjectAsync(ProjectInfo project) { - return ProjectRepository.UpdateProjectAsync(project); + return _repository.UpdateProjectAsync(project); + } + + public IAsyncEnumerable LoadAllProjectAsync() + { + return _repository.LoadProjectsAsync(); + } + + public async Task> CreateProjectAsync(string projectName, string projectDirectory, Version engineVersion, string templatePath) + { + try + { + var projectPath = Path.Combine(projectDirectory, projectName); + if (!Directory.Exists(projectPath)) + { + Directory.CreateDirectory(projectPath); + } + else + { + // Check if folder is empty + if (Directory.EnumerateFiles(projectPath, "*", SearchOption.AllDirectories).Any()) + { + return new(false, null, "Directory is not empty"); + } + } + + var metadata = new ProjectMetadata(projectName, engineVersion); + var metadataPath = Path.Combine(projectPath, $"{projectName}.{ProjectMetadata.PROJECT_EXTENSION}"); + await CreateMetadataFileAsync(metadataPath, metadata); + await SetupRequestFolderAsync(projectPath, templatePath); + + var info = await AddProjectAsync(projectName, metadataPath); + return new(true, info); + } + catch (Exception e) + { + return Result.Error($"Failed to create project: {e.Message}"); + } + } + + public async Task> AddProjectFromDirectoryAsync(string projectDirectory) + { + var result = await ValidateProjectDirectoryAsync(projectDirectory); + if (result.success) + { + await AddProjectAsync(result.data.Metadata.Name, result.data.Path); + } + + return result; + } + + public void Dispose() + { + _repository.Dispose(); } } \ No newline at end of file diff --git a/Ghost.Editor/ActivationHandler.cs b/Ghost.Editor/ActivationHandler.cs index b920373..03e16e5 100644 --- a/Ghost.Editor/ActivationHandler.cs +++ b/Ghost.Editor/ActivationHandler.cs @@ -1,4 +1,5 @@ using Ghost.Data.Resources; +using Ghost.Data.Services; using Microsoft.UI.Xaml; using System.IO; @@ -8,19 +9,20 @@ internal static class ActivationHandler { private static void FolderInitialization() { - if (!Directory.Exists(DataPath.APPLICATION_DATA_FOLDER)) + if (!Directory.Exists(DataPath.s_applicationDataFolder)) { - Directory.CreateDirectory(DataPath.APPLICATION_DATA_FOLDER); + Directory.CreateDirectory(DataPath.s_applicationDataFolder); } - if (!Directory.Exists(DataPath.PROJECT_TEMPLATES_FOLDER)) + if (!Directory.Exists(DataPath.s_projectTemplateFolder)) { - Directory.CreateDirectory(DataPath.PROJECT_TEMPLATES_FOLDER); + Directory.CreateDirectory(DataPath.s_projectTemplateFolder); } } public static void Handle(LaunchActivatedEventArgs args) { FolderInitialization(); + ProjectService.EnsureDefaultTemplate(); } } \ No newline at end of file diff --git a/Ghost.Editor/App.xaml b/Ghost.Editor/App.xaml index 379f5c5..b414503 100644 --- a/Ghost.Editor/App.xaml +++ b/Ghost.Editor/App.xaml @@ -9,6 +9,8 @@ + + diff --git a/Ghost.Editor/App.xaml.cs b/Ghost.Editor/App.xaml.cs index 6c28d67..434e791 100644 --- a/Ghost.Editor/App.xaml.cs +++ b/Ghost.Editor/App.xaml.cs @@ -1,6 +1,6 @@ -using Ghost.Data.Services; +using Ghost.Editor.AppStates; using Ghost.Editor.Helpers; -using Ghost.Editor.View.Windows; +using Ghost.Editor.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.UI.Xaml; @@ -18,6 +18,18 @@ namespace Ghost.Editor { private Window? _window; + internal static Window? Window + { + get => (Current as App)?._window; + set + { + if (Current is App app) + { + app._window = value; + } + } + } + internal IHost Host { get; @@ -36,27 +48,29 @@ namespace Ghost.Editor UseContentRoot(AppContext.BaseDirectory). ConfigureServices((context, services) => { - services.AddSingleton(); + services.AddSingleton(sp => + { + return new AppStateService( + new LandingState(), + new EditorState()); + }); - HostHelper.SetupPageService(context, services); + HostHelper.AddLandingScope(context, services); + HostHelper.AddEngineScope(context, services); + + services.AddSingleton(); }) .Build(); + + UnhandledException += App_UnhandledException; } - internal static Window? GetWindow() + internal static IServiceScope CreateScope() { - return (Current as App)?._window; + return (Current as App)!.Host.Services.CreateScope(); } - internal static void SetWindow(Window window) - { - if (Current is App app) - { - app._window = window; - } - } - - internal static T GetService() where T : class + public static T GetService() where T : class { if ((Current as App)!.Host.Services.GetService(typeof(T)) is not T service) { @@ -70,7 +84,7 @@ namespace Ghost.Editor /// Invoked when the application is launched. /// /// Details about the launch request and process. - protected override void OnLaunched(LaunchActivatedEventArgs args) + protected override async void OnLaunched(LaunchActivatedEventArgs args) { base.OnLaunched(args); @@ -78,8 +92,13 @@ namespace Ghost.Editor Host.Start(); - _window = GetService(); - _window.Activate(); + await GetService().TransitionToAsync(StateKey.Landing); + } + + private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) + { + // TODO: Log and handle exceptions as appropriate. + // https://docs.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.application.unhandledexception. } } } \ No newline at end of file diff --git a/Ghost.Editor/AppStates/EditorState.cs b/Ghost.Editor/AppStates/EditorState.cs new file mode 100644 index 0000000..46e3055 --- /dev/null +++ b/Ghost.Editor/AppStates/EditorState.cs @@ -0,0 +1,54 @@ +using Ghost.Data.Models; +using Ghost.Editor.Contracts; +using Ghost.Editor.View.Windows; +using System.Threading.Tasks; + +namespace Ghost.Editor.AppStates; + +internal class EditorState : IAppState +{ + private EngineEditorWindow? _window; + + public StateKey StateKy => StateKey.EngineEditor; + + public Task OnExitingAsync() + { + if (App.Window == _window) + { + App.Window = null; + } + return Task.CompletedTask; + } + + public Task OnEnteringAsync(object? parameter) + { + if (parameter is not ProjectMetadata metadata) + { + throw new System.ArgumentException("Parameter must be of type ProjectMetadata.", nameof(parameter)); + } + + _window = App.GetService(); + _window.ViewModel.CurrentProject = metadata; + _window.Activate(); + + App.Window = _window; + return Task.CompletedTask; + } + + public Task OnExitedAsync() + { + if (App.Window == _window) + { + App.Window = null; + } + + _window?.Close(); + _window = null; + return Task.CompletedTask; + } + + public Task OnEnteredAsync(object? parameter) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Ghost.Editor/AppStates/LandingState.cs b/Ghost.Editor/AppStates/LandingState.cs new file mode 100644 index 0000000..fd0de78 --- /dev/null +++ b/Ghost.Editor/AppStates/LandingState.cs @@ -0,0 +1,47 @@ +using Ghost.Editor.Contracts; +using Ghost.Editor.View.Windows; +using System.Threading.Tasks; + +namespace Ghost.Editor.AppStates; + +internal class LandingState : IAppState +{ + private LandingWindow? _window; + + public StateKey StateKy => StateKey.Landing; + + public Task OnExitingAsync() + { + if (App.Window == _window) + { + App.Window = null; + } + return Task.CompletedTask; + } + + public Task OnEnteringAsync(object? parameter) + { + _window = App.GetService(); + App.Window = _window; + + _window.Activate(); + return Task.CompletedTask; + } + + public Task OnExitedAsync() + { + if (App.Window == _window) + { + App.Window = null; + } + + _window?.Close(); + _window = null; + return Task.CompletedTask; + } + + public Task OnEnteredAsync(object? parameter) + { + return Task.CompletedTask; + } +} diff --git a/Ghost.Editor/AppStates/StateKey.cs b/Ghost.Editor/AppStates/StateKey.cs new file mode 100644 index 0000000..c4eb4b2 --- /dev/null +++ b/Ghost.Editor/AppStates/StateKey.cs @@ -0,0 +1,8 @@ +namespace Ghost.Editor.AppStates; + +internal enum StateKey +{ + None, + Landing, + EngineEditor, +} \ No newline at end of file diff --git a/Ghost.Editor/Contracts/IAppState.cs b/Ghost.Editor/Contracts/IAppState.cs new file mode 100644 index 0000000..511ec2f --- /dev/null +++ b/Ghost.Editor/Contracts/IAppState.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; + +namespace Ghost.Editor.Contracts; + +internal interface IAppState +{ + public Key StateKy + { + get; + } + + /// + /// Called when exiting the state. + /// + public Task OnExitingAsync(); + + /// + /// Called when entering the state, right after OnEnteringAsync. + /// can be used to pass data into the state, such as a project to load. + /// + public Task OnEnteringAsync(object? parameter); + + /// + /// Called when exiting the state, specifically for pose transitions. + /// + public Task OnExitedAsync(); + + /// + /// Called when entered the state, specifically after the state has been fully initialized and is ready for interaction. + /// + /// can be used to pass data into the state, such as a project to load. + public Task OnEnteredAsync(object? parameter); +} \ No newline at end of file diff --git a/Ghost.Editor/Contracts/INotificationService.cs b/Ghost.Editor/Contracts/INotificationService.cs new file mode 100644 index 0000000..8669e48 --- /dev/null +++ b/Ghost.Editor/Contracts/INotificationService.cs @@ -0,0 +1,14 @@ +using Microsoft.UI.Xaml.Controls; + +namespace Ghost.Editor.Contracts; + +internal interface INotificationService +{ + public void ShowNotification(string? message, InfoBarSeverity severity, int duration = 5, string? title = null); +} + +internal interface INotificationService : INotificationService +{ + public void Initialize(T notificationQueue); + public void ClearQueueReference(); +} \ No newline at end of file diff --git a/Ghost.Editor/Controls/EditorControls.xaml b/Ghost.Editor/Controls/EditorControls.xaml index f3768a7..ac96cd7 100644 --- a/Ghost.Editor/Controls/EditorControls.xaml +++ b/Ghost.Editor/Controls/EditorControls.xaml @@ -2,5 +2,6 @@ + diff --git a/Ghost.Editor/Controls/Internal/InspectorView.cs b/Ghost.Editor/Controls/Internal/InspectorView.cs new file mode 100644 index 0000000..e87b7d6 --- /dev/null +++ b/Ghost.Editor/Controls/Internal/InspectorView.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Ghost.Editor.Controls.Internal; + +internal sealed partial class InspectorView : ContentControl +{ + public UIElement? Header + { + get => (UIElement)GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public static readonly DependencyProperty HeaderProperty = DependencyProperty.Register( + nameof(Header), + typeof(UIElement), + typeof(InspectorView), + new PropertyMetadata(null)); + + public InspectorView() + { + DefaultStyleKey = typeof(InspectorView); + } +} diff --git a/Ghost.Editor/Controls/Internal/InspectorView.xaml b/Ghost.Editor/Controls/Internal/InspectorView.xaml new file mode 100644 index 0000000..9dccfeb --- /dev/null +++ b/Ghost.Editor/Controls/Internal/InspectorView.xaml @@ -0,0 +1,41 @@ + + + + diff --git a/Ghost.Editor/Controls/Internal/InternalControls.xaml b/Ghost.Editor/Controls/Internal/InternalControls.xaml new file mode 100644 index 0000000..795c1fb --- /dev/null +++ b/Ghost.Editor/Controls/Internal/InternalControls.xaml @@ -0,0 +1,4 @@ + + + + diff --git a/Ghost.Editor/Ghost.Editor.csproj b/Ghost.Editor/Ghost.Editor.csproj index b669583..4f27c93 100644 --- a/Ghost.Editor/Ghost.Editor.csproj +++ b/Ghost.Editor/Ghost.Editor.csproj @@ -42,6 +42,12 @@ + + + + + + @@ -69,12 +75,13 @@ - - - + + + + - - + + @@ -98,10 +105,43 @@ - - + - + + + + ..\..\Class\Misaki.HighPerformance\Misaki.HighPerformance.Unsafe\bin\Release\net9.0\Misaki.HighPerformance.Unsafe.dll + + + + + MSBuild:Compile + + + + + MSBuild:Compile + + + + + MSBuild:Compile + + + + + MSBuild:Compile + + + + + MSBuild:Compile + + + + + MSBuild:Compile + diff --git a/Ghost.Editor/Helpers/ComponentTypeCache.cs b/Ghost.Editor/Helpers/ComponentTypeCache.cs new file mode 100644 index 0000000..8722e94 --- /dev/null +++ b/Ghost.Editor/Helpers/ComponentTypeCache.cs @@ -0,0 +1,30 @@ +using Ghost.Entities; +using System; +using System.Linq; + +namespace Ghost.Editor.Helpers; + +public static class ComponentTypeCache +{ + private static readonly Type?[][] _componentTypes; + + static ComponentTypeCache() + { + _componentTypes = new Type[World.WorldCount][]; + for (var i = 0; i < World.WorldCount; i++) + { + var world = World.GetWorld(i); + var typeHandles = world.ComponentStorage.ComponentPools.Keys; + _componentTypes[i] = typeHandles.Select(handle => Type.GetTypeFromHandle(RuntimeTypeHandle.FromIntPtr(handle))).ToArray(); + } + } + + public static Type?[] GetComponentTypes(int worldIndex) + { + if (worldIndex < 0 || worldIndex >= _componentTypes.Length) + { + throw new ArgumentOutOfRangeException(nameof(worldIndex), "Invalid world index."); + } + return _componentTypes[worldIndex]; + } +} \ No newline at end of file diff --git a/Ghost.Editor/Helpers/Converters/GetDirectoryNameConverter .cs b/Ghost.Editor/Helpers/Converters/GetDirectoryNameConverter .cs new file mode 100644 index 0000000..94eb597 --- /dev/null +++ b/Ghost.Editor/Helpers/Converters/GetDirectoryNameConverter .cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml.Data; +using System; + +namespace Ghost.Editor.Helpers.Converters; + +public partial class GetDirectoryNameConverter : IValueConverter +{ + public object? Convert(object value, Type targetType, object parameter, string language) + { + return value is string path ? System.IO.Path.GetDirectoryName(path) : null; + } + + public object? ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Ghost.Editor/Helpers/HostHelpers.Page.cs b/Ghost.Editor/Helpers/HostHelpers.Page.cs index 423d0c2..0f3e9ab 100644 --- a/Ghost.Editor/Helpers/HostHelpers.Page.cs +++ b/Ghost.Editor/Helpers/HostHelpers.Page.cs @@ -1,4 +1,5 @@ -using Ghost.Editor.View.Pages.Landing; +using Ghost.Data.Services; +using Ghost.Editor.View.Pages.Landing; using Ghost.Editor.View.Windows; using Ghost.Editor.ViewModel.Pages.Landing; using Ghost.Editor.ViewModel.Windows; @@ -9,7 +10,7 @@ namespace Ghost.Editor.Helpers; internal static partial class HostHelper { - public static void SetupPageService(HostBuilderContext context, IServiceCollection services) + public static void AddLandingScope(HostBuilderContext context, IServiceCollection services) { services.AddSingleton(); @@ -18,6 +19,11 @@ internal static partial class HostHelper services.AddTransient(); + services.AddTransient(); + } + + public static void AddEngineScope(HostBuilderContext context, IServiceCollection services) + { services.AddSingleton(); services.AddSingleton(); } diff --git a/Ghost.Editor/Helpers/SystemUtilities.cs b/Ghost.Editor/Helpers/SystemUtilities.cs index 88b838b..b56d413 100644 --- a/Ghost.Editor/Helpers/SystemUtilities.cs +++ b/Ghost.Editor/Helpers/SystemUtilities.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Windows.Storage; using Windows.Storage.Pickers; @@ -11,7 +12,7 @@ public static class SystemUtilities public static async Task OpenFolderPickerAsync(PickerLocationId startLocation = PickerLocationId.DocumentsLibrary, string settingsIdentifier = "") { var openPicker = new FolderPicker(); - var hWnd = WindowNative.GetWindowHandle(App.GetWindow()); + var hWnd = WindowNative.GetWindowHandle(App.Window); InitializeWithWindow.Initialize(openPicker, hWnd); openPicker.SuggestedStartLocation = startLocation; @@ -21,4 +22,21 @@ public static class SystemUtilities var folder = await openPicker.PickSingleFolderAsync(); return folder; } + + public static async Task OpenFilePickerAsync(PickerLocationId startLocation = PickerLocationId.DocumentsLibrary, string settingsIdentifier = "", params IEnumerable filter) + { + var openPicker = new FileOpenPicker(); + var hWnd = WindowNative.GetWindowHandle(App.Window); + InitializeWithWindow.Initialize(openPicker, hWnd); + + openPicker.SuggestedStartLocation = startLocation; + openPicker.SettingsIdentifier = settingsIdentifier; + foreach (var fileType in filter) + { + openPicker.FileTypeFilter.Add(fileType); + } + + var file = await openPicker.PickSingleFileAsync(); + return file; + } } \ No newline at end of file diff --git a/Ghost.Editor/Models/GameObject.cs b/Ghost.Editor/Models/GameObject.cs new file mode 100644 index 0000000..56d077a --- /dev/null +++ b/Ghost.Editor/Models/GameObject.cs @@ -0,0 +1,269 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Ghost.Entities; +using Ghost.Entities.Helpers; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Ghost.Editor.Models; + +public partial class GameObject : ObservableObject +{ + [ObservableProperty] + public partial bool IsActive + { + get; + set; + } + + [ObservableProperty] + public partial bool IsActiveHierarchy + { + get; + set; + } + + public Entity Entity + { + get; + } + + public Scene Scene + { + get; + internal set; + } + + public GameObject? Parent + { + get; + internal set; + } + + public string Name + { + get; + set; + } + + [ObservableProperty] + public partial ObservableCollection? Components + { + get; + private set; + } + + [ObservableProperty] + public partial IEnumerable? ScriptComponents + { + get; + private set; + } + + [ObservableProperty] + public partial ObservableCollection? Children + { + get; + private set; + } + + public GameObject(Scene scene, string name) + { + Entity = scene.World.EntityManager.CreateEntity(); + Scene = scene; + Name = name; + IsActive = true; + } + + partial void OnIsActiveChanged(bool value) + { + IsActiveHierarchy = value && (Parent?.IsActiveHierarchy ?? true); + HandleActiveStateChanged(); + + if (Children != null) + { + foreach (var child in Children) + { + child.IsActiveHierarchy = value && IsActiveHierarchy; + } + } + } + + partial void OnIsActiveHierarchyChanged(bool value) + { + HandleActiveStateChanged(); + } + + private void HandleActiveStateChanged() + { + if (IsActive && IsActiveHierarchy) + { + OnEnable(); + } + else + { + OnDisable(); + } + } + + internal void OnEnable() + { + if (ScriptComponents != null) + { + foreach (var script in ScriptComponents) + { + if (!script.Enable) + { + continue; + } + script.OnEnable(); + } + } + } + + internal void OnDisable() + { + if (ScriptComponents != null) + { + foreach (var script in ScriptComponents) + { + if (!script.Enable) + { + continue; + } + script.OnDisable(); + } + } + } + + public void AddChild(GameObject child) + { + if (child.Scene != Scene) + { + throw new InvalidOperationException("Child GameObject must belong to the same Scene."); + } + + Children ??= new(); + Children.Add(child); + child.Parent = this; + } + + public bool RemoveChild(GameObject child) + { + if (Children is null) + { + return false; + } + + if (!Children.Remove(child)) + { + return false; + } + + child.Parent = null; + return true; + } + + public void Destroy() + { + if (ScriptComponents != null) + { + foreach (var component in ScriptComponents) + { + if (!component.Enable) + { + continue; + } + component.OnDestroy(); + } + } + + if (Children != null) + { + foreach (var child in Children) + { + child.Destroy(); + } + + Children.Clear(); + } + + Parent?.Children?.Remove(this); + Entity.Destroy(); + } +} + +public partial class GameObject +{ + // TODO: Implement a more efficient synchronization mechanism for components + internal void SyncComponents() + { + foreach (var (typeHandle, mask) in Scene.World.ComponentStorage.ComponentEntityMasks) + { + if (!mask.IsSet(Entity.ID)) + { + continue; + } + + var pool = Scene.World.ComponentStorage.ComponentPools[typeHandle]; + } + } + + internal void SyncScripts() + { + var scriptsPool = Scene.World.ComponentStorage.ScriptComponentPool.ScriptComponents; + if (scriptsPool == null) + { + return; + } + + scriptsPool.TryGetValue(Entity, out var scripts); + ScriptComponents = scripts; + } + + public void AddComponent(T component) + where T : struct, IComponentData + { + Entity.AddComponent(component); + SyncComponents(); + } + + public bool RemoveComponent() + where T : struct, IComponentData + { + var result = Entity.RemoveComponent(); + SyncComponents(); + + return result; + } + + public void AddScript() + where T : ScriptComponent, new() + { + Entity.AddScript(); + SyncScripts(); + } + + public void AddScript(Type type) + { + Entity.AddScript(type); + SyncScripts(); + } + + public bool RemoveScript() + where T : ScriptComponent + { + var result = Scene.World.EntityManager.RemoveScript(Entity); + SyncScripts(); + + return result; + } + + public bool RemoveScriptAt(int index) + { + var result = Scene.World.EntityManager.RemoveScriptAt(Entity, index); + SyncScripts(); + + return result; + } +} \ No newline at end of file diff --git a/Ghost.Engine/Models/Scene.cs b/Ghost.Editor/Models/Scene.cs similarity index 65% rename from Ghost.Engine/Models/Scene.cs rename to Ghost.Editor/Models/Scene.cs index 0a372bd..3e38716 100644 --- a/Ghost.Engine/Models/Scene.cs +++ b/Ghost.Editor/Models/Scene.cs @@ -1,10 +1,15 @@ -namespace Ghost.Engine.Models; +using Ghost.Entities; +using System.Collections.Generic; + +namespace Ghost.Editor.Models; public class Scene { private readonly HashSet _rootObjects = new(); + private readonly World _world = World.Create(); public IEnumerable RootObjects => _rootObjects; + public World World => _world; internal Scene() { @@ -14,7 +19,7 @@ public class Scene { foreach (var gameObject in _rootObjects) { - gameObject.Start(); + gameObject.OnEnable(); } } @@ -22,6 +27,7 @@ public class Scene { foreach (var gameObject in _rootObjects) { + gameObject.OnDisable(); gameObject.Destroy(); } diff --git a/Ghost.Editor/Services/AppStateService.cs b/Ghost.Editor/Services/AppStateService.cs new file mode 100644 index 0000000..6ac3760 --- /dev/null +++ b/Ghost.Editor/Services/AppStateService.cs @@ -0,0 +1,35 @@ +using Ghost.Editor.AppStates; +using Ghost.Editor.Contracts; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ghost.Editor.Services; + +internal class AppStateService(params IEnumerable> states) +{ + private readonly Dictionary> _states = states.ToDictionary(s => s.StateKy, s => s); + private IAppState? _current; + + public async Task TransitionToAsync(StateKey stateKey, object? parameter = null) + { + var previous = _current; + var next = _states[stateKey]; + + if (previous != null) + { + await previous.OnExitingAsync(); + } + + await next.OnEnteringAsync(parameter); + + if (previous != null) + { + await previous.OnExitedAsync(); + } + + await next.OnEnteredAsync(parameter); + + _current = next; + } +} \ No newline at end of file diff --git a/Ghost.Editor/Services/StackedNotificationService.cs b/Ghost.Editor/Services/StackedNotificationService.cs new file mode 100644 index 0000000..e3e1532 --- /dev/null +++ b/Ghost.Editor/Services/StackedNotificationService.cs @@ -0,0 +1,50 @@ +using CommunityToolkit.WinUI.Behaviors; +using Microsoft.UI.Xaml.Controls; +using System; + +namespace Ghost.Editor.Services; + +public class StackedNotificationService +{ + private InfoBar? _infoBar; + private StackedNotificationsBehavior? _notificationQueue; + + internal void SetReference(InfoBar infoBar, StackedNotificationsBehavior notificationQueue) + { + _infoBar = infoBar; + _notificationQueue = notificationQueue; + } + + internal void ClearReference() + { + if (_infoBar != null) + { + _infoBar.IsOpen = false; + } + _infoBar = null; + _notificationQueue = null; + } + + public void ShowNotification(string? message, InfoBarSeverity severity, int duration = 5, string? title = null) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + var notification = new Notification + { + Message = message, + Severity = severity, + Duration = TimeSpan.FromSeconds(duration), + Title = title + }; + + ShowNotification(notification); + } + + public void ShowNotification(Notification notification) + { + _notificationQueue?.Show(notification); + } +} \ No newline at end of file diff --git a/Ghost.Editor/Themes/Dark.xaml b/Ghost.Editor/Themes/Dark.xaml new file mode 100644 index 0000000..8f22c98 --- /dev/null +++ b/Ghost.Editor/Themes/Dark.xaml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Ghost.Editor/Themes/Light.xaml b/Ghost.Editor/Themes/Light.xaml new file mode 100644 index 0000000..8527c05 --- /dev/null +++ b/Ghost.Editor/Themes/Light.xaml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml b/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml new file mode 100644 index 0000000..06dde5f --- /dev/null +++ b/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml.cs b/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml.cs new file mode 100644 index 0000000..c690558 --- /dev/null +++ b/Ghost.Editor/View/Pages/EngineEditor/ConsolePage.xaml.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml.Controls; + +namespace Ghost.Editor.View.Pages.EngineEditor; + +public sealed partial class ConsolePage : Page +{ + public ConsolePage() + { + InitializeComponent(); + } +} diff --git a/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml b/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml new file mode 100644 index 0000000..daa13cf --- /dev/null +++ b/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml.cs b/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml.cs new file mode 100644 index 0000000..cd80545 --- /dev/null +++ b/Ghost.Editor/View/Pages/EngineEditor/ProjectPage.xaml.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace Ghost.Editor.View.Pages.EngineEditor; + +/// +/// An empty page that can be used on its own or navigated to within a Frame. +/// +public sealed partial class ProjectPage : Page +{ + public ProjectPage() + { + InitializeComponent(); + } +} diff --git a/Ghost.Editor/View/Pages/Landing/CreateProjectPage.xaml b/Ghost.Editor/View/Pages/Landing/CreateProjectPage.xaml index cac6740..c448a62 100644 --- a/Ghost.Editor/View/Pages/Landing/CreateProjectPage.xaml +++ b/Ghost.Editor/View/Pages/Landing/CreateProjectPage.xaml @@ -8,6 +8,7 @@ xmlns:editor="using:Ghost.Editor.Controls" xmlns:local="using:Ghost.Editor.View.Pages.Landing" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + NavigationCacheMode="Enabled" mc:Ignorable="d"> @@ -46,7 +47,7 @@ Width="24" Height="24"> - + - + + MaxHeight="100" + VerticalAlignment="Bottom" + Background="{ThemeResource ControlOnImageFillColorDefaultBrush}"> + Foreground="{ThemeResource TextFillColorTertiaryBrush}" + Text="{x:Bind ViewModel.SelectedTemplate.Value.Info.Description, Mode=OneWay}" /> @@ -95,7 +96,7 @@ + Text="{x:Bind ViewModel.SelectedTemplate.Value.Info.Name, Mode=OneWay}" /> - + + + + + + - + + + + + + + - + + Style="{StaticResource CaptionTextBlockStyle}" + Text="NAME" /> + HorizontalAlignment="Right" + Style="{StaticResource CaptionTextBlockStyle}" + Text="LAST OPEN" /> + HorizontalAlignment="Right" + Style="{StaticResource CaptionTextBlockStyle}" + Text="ENGINE VERSION" /> - + - - - - - - - - - + AllowDrop="True" + DragEnter="ProjectContainer_DragEnter" + DragLeave="ProjectContainer_DragLeave" + DragOver="ProjectContainer_DragOver" + Drop="ProjectContainer_Drop"> + + + + + + + + + + - - - - - + + + + + + + + + + Grid.Column="1" + Margin="16,4" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Text="{x:Bind Metadata.LastOpened}" /> + Grid.Column="2" + Margin="16,4" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Text="{x:Bind Metadata.EngineVersion}" /> + + + + - - - - - - - + + + + + - + + + + \ No newline at end of file diff --git a/Ghost.Editor/View/Pages/Landing/OpenProjectPage.xaml.cs b/Ghost.Editor/View/Pages/Landing/OpenProjectPage.xaml.cs index 69664fc..89f0b62 100644 --- a/Ghost.Editor/View/Pages/Landing/OpenProjectPage.xaml.cs +++ b/Ghost.Editor/View/Pages/Landing/OpenProjectPage.xaml.cs @@ -1,56 +1,133 @@ using Ghost.Data.Models; using Ghost.Data.Services; -using Ghost.Editor.View.Windows; +using Ghost.Editor.AppStates; +using Ghost.Editor.Services; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; +using System; using System.Collections.ObjectModel; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. +using System.Linq; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; namespace Ghost.Editor.View.Pages.Landing; internal sealed partial class OpenProjectPage : Page { private readonly ProjectService _projectService; + private readonly StackedNotificationService _notificationService; + private readonly AppStateService _stateService; - public readonly ObservableCollection projects = new(); + public readonly ObservableCollection projects = new(); public OpenProjectPage() { + _notificationService = App.GetService(); _projectService = App.GetService(); - + _stateService = App.GetService(); InitializeComponent(); } + private void UpdateEmptyPlaceHolderVisibility() + { + EmptyPlaceHolder.Visibility = projects.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + protected override async void OnNavigatedTo(NavigationEventArgs e) { - await foreach (var project in _projectService.LoadAllProjectAsync()) + base.OnNavigatedTo(e); + + projects.Clear(); + await foreach (var projectInfo in _projectService.LoadAllProjectAsync()) { - projects.Add(project); + var metadata = await ProjectService.LoadMetadataAsync(projectInfo.MetadataPath); + if (metadata == null) + { + continue; + } + + projects.Add(new(projectInfo.MetadataPath, metadata)); } - if (projects.Count == 0) + UpdateEmptyPlaceHolderVisibility(); + } + + private void ProjectContainer_DragEnter(object sender, DragEventArgs e) + { + DragVisual.Visibility = Visibility.Visible; + EmptyPlaceHolder.Visibility = Visibility.Collapsed; + } + + private void ProjectContainer_DragLeave(object sender, DragEventArgs e) + { + DragVisual.Visibility = Visibility.Collapsed; + UpdateEmptyPlaceHolderVisibility(); + } + + private void ProjectContainer_DragOver(object sender, DragEventArgs e) + { + if (e.DataView.Contains(StandardDataFormats.StorageItems)) { - PlaceHolderText.Visibility = Visibility.Visible; - ProjectListView.Visibility = Visibility.Collapsed; + e.AcceptedOperation = DataPackageOperation.Link; } + else + { + e.AcceptedOperation = DataPackageOperation.None; + } + } + + private async void ProjectContainer_Drop(object sender, DragEventArgs e) + { + var errorMessage = string.Empty; + if (e.DataView.Contains(StandardDataFormats.StorageItems)) + { + var items = await e.DataView.GetStorageItemsAsync(); + var rootFolder = items.OfType().FirstOrDefault(); + if (rootFolder != null) + { + var result = await _projectService.AddProjectFromDirectoryAsync(rootFolder.Path); + if (result.success) + { + projects.Add(result.data); + DragVisual.Visibility = Visibility.Collapsed; + goto CloseDropPanel; + } + else + { + errorMessage = result.message; + } + } + } + else + { + errorMessage = "Unsupported data format. Please drop a folder containing a project."; + } + + _notificationService.ShowNotification(errorMessage, InfoBarSeverity.Error); + + CloseDropPanel: + DragVisual.Visibility = Visibility.Collapsed; + UpdateEmptyPlaceHolderVisibility(); } private async void ListView_ItemClick(object sender, ItemClickEventArgs e) { - if (e.ClickedItem is not ProjectInfo project) + if (e.ClickedItem is not ProjectMetadataInfo project) { return; } - if (EngineEditorWindow.TryLoadProject(project)) + try { - App.GetService().Close(); + project.Metadata.LastOpened = DateTime.Now; + await ProjectService.CreateMetadataFileAsync(project.Path, project.Metadata); - project.LastOpened = System.DateTime.Now; - await _projectService.UpdateProjectAsync(project); + await _stateService.TransitionToAsync(StateKey.EngineEditor, project.Metadata); + } + catch (Exception exp) + { + _notificationService.ShowNotification($"Failed to load project: {exp.Message}", InfoBarSeverity.Error); } } } \ No newline at end of file diff --git a/Ghost.Editor/View/Windows/EngineEditorWindow.xaml b/Ghost.Editor/View/Windows/EngineEditorWindow.xaml index 86e6ab6..0928334 100644 --- a/Ghost.Editor/View/Windows/EngineEditorWindow.xaml +++ b/Ghost.Editor/View/Windows/EngineEditorWindow.xaml @@ -5,16 +5,18 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:CommunityToolkit.WinUI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ee="using:Ghost.Editor.View.Pages.EngineEditor" xmlns:local="using:Ghost.Editor.View.Windows" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:winex="using:WinUIEx" + Activated="WindowEx_Activated" mc:Ignorable="d"> - + @@ -41,7 +43,7 @@ Margin="8,0,0,0" VerticalAlignment="Center" Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind ViewModel.CurrentProject.Name}" /> + Text="{x:Bind ViewModel.CurrentProject.Name, Mode=OneWay}" /> @@ -85,32 +87,41 @@ Grid.Column="0" Width="350" Background="Aquamarine" /> - + + + - - - - - - - - - + + + + + + + + + + + + + + + + + Background="{ThemeResource SolidBackgroundFillColorBaseAltBrush}"> diff --git a/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs b/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs index 90f60f3..e03e83f 100644 --- a/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs +++ b/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs @@ -1,5 +1,4 @@ -using Ghost.Data.Models; -using Ghost.Data.Resources; +using Ghost.Data.Resources; using Ghost.Editor.ViewModel.Windows; using Ghost.Engine.Resources; using WinUIEx; @@ -22,7 +21,7 @@ internal sealed partial class EngineEditorWindow : WindowEx { ViewModel = App.GetService(); - AppWindow.SetIcon(AssetsPath.AppIconPath); + AppWindow.SetIcon(AssetsPath.s_appIconPath); Title = EngineData.ENGINE_NAME; ExtendsContentIntoTitleBar = true; @@ -31,23 +30,8 @@ internal sealed partial class EngineEditorWindow : WindowEx this.CenterOnScreen(); } - public static bool TryLoadProject(ProjectInfo project) + private void WindowEx_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) { - try - { - var window = App.GetService(); - window.ViewModel.CurrentProject = project; - - window.Activate(); - window.Bindings.Update(); - - App.SetWindow(window); - - return true; - } - catch (System.Exception) - { - return false; - } + Bindings.Update(); } } \ No newline at end of file diff --git a/Ghost.Editor/View/Windows/LandingWindow.xaml b/Ghost.Editor/View/Windows/LandingWindow.xaml index 46ee4f6..d05367c 100644 --- a/Ghost.Editor/View/Windows/LandingWindow.xaml +++ b/Ghost.Editor/View/Windows/LandingWindow.xaml @@ -3,10 +3,14 @@ x:Class="Ghost.Editor.View.Windows.LandingWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:behaviors="using:CommunityToolkit.WinUI.Behaviors" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:interactivity="using:Microsoft.Xaml.Interactivity" xmlns:local="using:Ghost.Editor.View.Windows" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:winex="using:WinUIEx" + Activated="WindowEx_Activated" + Closed="WindowEx_Closed" IsResizable="False" mc:Ignorable="d"> @@ -55,8 +59,19 @@ Grid.Row="1" Padding="8" CacheMode="BitmapCache" - CacheSize="10" - IsNavigationStackEnabled="False" /> + CacheSize="10" /> + + + + + + + + + diff --git a/Ghost.Editor/View/Windows/LandingWindow.xaml.cs b/Ghost.Editor/View/Windows/LandingWindow.xaml.cs index 66e4446..87262a0 100644 --- a/Ghost.Editor/View/Windows/LandingWindow.xaml.cs +++ b/Ghost.Editor/View/Windows/LandingWindow.xaml.cs @@ -1,6 +1,8 @@ using Ghost.Data.Resources; +using Ghost.Editor.Services; using Ghost.Editor.View.Pages.Landing; using Ghost.Engine.Resources; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media.Animation; using WinUIEx; @@ -9,11 +11,13 @@ namespace Ghost.Editor.View.Windows; internal sealed partial class LandingWindow : WindowEx { + private IServiceScope? _landingScope; + private int _previousSelectedIndex; public LandingWindow() { - AppWindow.SetIcon(AssetsPath.AppIconPath); + AppWindow.SetIcon(AssetsPath.s_appIconPath); Title = EngineData.ENGINE_NAME; InitializeComponent(); @@ -24,6 +28,19 @@ internal sealed partial class LandingWindow : WindowEx ExtendsContentIntoTitleBar = true; } + private void WindowEx_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) + { + _landingScope?.Dispose(); + _landingScope = App.CreateScope(); + App.GetService().SetReference(InfoBar, NotificationQueue); + } + + private void WindowEx_Closed(object sender, Microsoft.UI.Xaml.WindowEventArgs args) + { + _landingScope?.Dispose(); + App.GetService().ClearReference(); + } + private void SelectorBar_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs e) { var selectedItem = sender.SelectedItem; @@ -37,7 +54,7 @@ internal sealed partial class LandingWindow : WindowEx var slideNavigationTransitionEffect = currentSelectedIndex - _previousSelectedIndex > 0 ? SlideNavigationTransitionEffect.FromRight : SlideNavigationTransitionEffect.FromLeft; - ContentFrame.Navigate(pageType, null, new SlideNavigationTransitionInfo() { Effect = slideNavigationTransitionEffect }); + ContentFrame.Navigate(pageType, _landingScope, new SlideNavigationTransitionInfo() { Effect = slideNavigationTransitionEffect }); _previousSelectedIndex = currentSelectedIndex; } diff --git a/Ghost.Editor/ViewModel/Pages/EngineEditor/ProjectViewModel.cs b/Ghost.Editor/ViewModel/Pages/EngineEditor/ProjectViewModel.cs new file mode 100644 index 0000000..33d74c3 --- /dev/null +++ b/Ghost.Editor/ViewModel/Pages/EngineEditor/ProjectViewModel.cs @@ -0,0 +1,5 @@ +namespace Ghost.Editor.ViewModel.Pages.EngineEditor; + +internal class ProjectViewModel +{ +} diff --git a/Ghost.Editor/ViewModel/Pages/Landing/CreateProjectViewModel.cs b/Ghost.Editor/ViewModel/Pages/Landing/CreateProjectViewModel.cs index b690bf1..9a0cce6 100644 --- a/Ghost.Editor/ViewModel/Pages/Landing/CreateProjectViewModel.cs +++ b/Ghost.Editor/ViewModel/Pages/Landing/CreateProjectViewModel.cs @@ -2,18 +2,20 @@ using CommunityToolkit.Mvvm.Input; using Ghost.Data.Models; using Ghost.Data.Services; +using Ghost.Editor.AppStates; using Ghost.Editor.Contracts; using Ghost.Editor.Helpers; -using Ghost.Editor.View.Windows; +using Ghost.Editor.Services; +using Ghost.Engine.Resources; +using Microsoft.UI.Xaml.Controls; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading.Tasks; -using Windows.ApplicationModel; namespace Ghost.Editor.ViewModel.Pages.Landing; -internal partial class CreateProjectViewModel(ProjectService projectService) : ObservableRecipient, INavigationAware +internal partial class CreateProjectViewModel(StackedNotificationService notificationService, ProjectService projectService, AppStateService stateService) : ObservableRecipient, INavigationAware { public ObservableCollection templates = new(); @@ -25,14 +27,14 @@ internal partial class CreateProjectViewModel(ProjectService projectService) : O } [ObservableProperty] - public partial string ProjectName + public partial string? ProjectName { get; set; } [ObservableProperty] - public partial string ProjectLocation + public partial string? ProjectLocation { get; set; @@ -40,7 +42,8 @@ internal partial class CreateProjectViewModel(ProjectService projectService) : O public async void OnNavigatedTo(object? parameter) { - await foreach (var (path, info) in projectService.GetProjectTemplatesAsync()) + templates.Clear(); + await foreach (var (path, info) in ProjectService.GetProjectTemplatesAsync()) { templates.Add(new(path, info)); } @@ -55,25 +58,39 @@ internal partial class CreateProjectViewModel(ProjectService projectService) : O [RelayCommand] private async Task SelectionProjectLocation() { - ProjectLocation = (await SystemUtilities.OpenFolderPickerAsync())?.Path ?? string.Empty; + var folder = await SystemUtilities.OpenFolderPickerAsync(); + if (folder != null) + { + ProjectLocation = folder.Path; + } } [RelayCommand] private async Task CreateProject() { - if (string.IsNullOrWhiteSpace(ProjectName) || !Directory.Exists(ProjectLocation) || SelectedTemplate == null) + if (string.IsNullOrWhiteSpace(ProjectName) + || !Directory.Exists(ProjectLocation) + || !SelectedTemplate.HasValue) { + notificationService.ShowNotification("Incorrect project info", InfoBarSeverity.Error); return; } - var projectPath = await projectService.CreateProjectAsync(ProjectName, ProjectLocation, SelectedTemplate.directory); - - var packageVersion = Package.Current.Id.Version; - var newProject = await projectService.AddProjectAsync(ProjectName, projectPath, new System.Version(packageVersion.Major, packageVersion.Minor, packageVersion.Build)); - - if (EngineEditorWindow.TryLoadProject(newProject)) + var result = await projectService.CreateProjectAsync(ProjectName, ProjectLocation, EngineData.s_engineVersion, SelectedTemplate.Value.directory); + if (!result.success || result.data == null) { - App.GetService().Close(); + notificationService.ShowNotification(result.message, InfoBarSeverity.Error); + return; + } + + var metadata = await ProjectService.LoadMetadataAsync(result.data.MetadataPath); // Metadata should not be null here if create project succeeded + try + { + await stateService.TransitionToAsync(StateKey.EngineEditor, metadata); + } + catch (System.Exception e) + { + notificationService.ShowNotification($"Failed to load project: {e.Message}", InfoBarSeverity.Error); } } } \ No newline at end of file diff --git a/Ghost.Editor/ViewModel/Windows/EngineEditorViewModel.cs b/Ghost.Editor/ViewModel/Windows/EngineEditorViewModel.cs index 6d278c7..91b50eb 100644 --- a/Ghost.Editor/ViewModel/Windows/EngineEditorViewModel.cs +++ b/Ghost.Editor/ViewModel/Windows/EngineEditorViewModel.cs @@ -6,10 +6,10 @@ namespace Ghost.Editor.ViewModel.Windows; internal partial class EngineEditorViewModel : ObservableRecipient { - public string engineVersionDescriptor = $"{EngineData.ENGINE_NAME} - {EngineData.ENGINE_VERSION}"; + public string engineVersionDescriptor = $"{EngineData.ENGINE_NAME} - {EngineData.s_engineVersion}"; [ObservableProperty] - public partial ProjectInfo CurrentProject + public partial ProjectMetadata CurrentProject { get; set; diff --git a/Ghost.Engine/GameObject.cs b/Ghost.Engine/GameObject.cs deleted file mode 100644 index 214a8c4..0000000 --- a/Ghost.Engine/GameObject.cs +++ /dev/null @@ -1,190 +0,0 @@ -using Ghost.Engine.Models; -using Ghost.Entities; -using System.ComponentModel; - -namespace Ghost.Engine; - -public unsafe class GameObject : INotifyPropertyChanged -{ - private readonly Dictionary _components = new(); - private readonly List _children = new(); - - public event PropertyChangedEventHandler? PropertyChanged; - - public Entity Entity - { - get; - } - - public Scene Scene - { - get; - internal set; - } - - public GameObject? Parent - { - get; - internal set; - } - - public string Name - { - get; - set; - } - - public bool IsActive - { - get; - set; - } - - public IEnumerable Components => _components.Values; - public IEnumerable Children => _children; - - public GameObject(Scene scene, string name) - { - // TODO: Initialize Entity properly - //Entity = - Scene = scene; - Name = name; - IsActive = true; - } - - public void AddComponent(T component) - where T : ScriptComponent - { - _components.Add(typeof(T), component); - component.Owner = Entity; - - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Components))); - } - - public void RemoveComponent() - where T : ScriptComponent - { - var key = typeof(T); - if (_components.Remove(key, out var component)) - { - component.OnDestroy(); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Components))); - } - } - - public T? GetComponent() - where T : ScriptComponent - { - if (_components.TryGetValue(typeof(T), out var component)) - { - return (T)component; - } - - return null; - } - - public void AddChild(GameObject child) - { - if (child.Scene != Scene) - { - throw new InvalidOperationException("Child GameObject must belong to the same Scene."); - } - - _children.Add(child); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Children))); - } - - public void RemoveChild(GameObject child) - { - if (_children.Remove(child)) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Children))); - } - } - - internal void OnEnable() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.OnEnable(); - } - - foreach (var child in _children) - { - child.OnEnable(); - } - } - - internal void Start() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.Start(); - } - } - - internal void Update() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.Update(); - } - } - - internal void LateUpdate() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.LateUpdate(); - } - } - - internal void FixedUpdate() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.FixedUpdate(); - } - } - - public void Destroy() - { - foreach (var component in Components) - { - if (!component.Enable) - { - continue; - } - component.OnDestroy(); - } - - foreach (var child in _children) - { - child.Destroy(); - } - - _children.Clear(); - _components.Clear(); - Parent?._children.Remove(this); - } -} \ No newline at end of file diff --git a/Ghost.Engine/Resources/EngineData.cs b/Ghost.Engine/Resources/EngineData.cs index 2f1bece..7f13c42 100644 --- a/Ghost.Engine/Resources/EngineData.cs +++ b/Ghost.Engine/Resources/EngineData.cs @@ -4,5 +4,5 @@ internal class EngineData { public const string ENGINE_NAME = "Ghost Engine"; - public readonly static Version ENGINE_VERSION = new(0, 1, 0); + public readonly static Version s_engineVersion = new(0, 1, 0); } \ No newline at end of file diff --git a/Ghost.Engine/Services/PlayerLoopService.cs b/Ghost.Engine/Services/PlayerLoopService.cs index adb76b1..3b7052b 100644 --- a/Ghost.Engine/Services/PlayerLoopService.cs +++ b/Ghost.Engine/Services/PlayerLoopService.cs @@ -10,43 +10,43 @@ internal static class PlayerLoopService public static void Start() { - if (_isRunning) - { - return; - } + //if (_isRunning) + //{ + // return; + //} - foreach (var gameObject in SceneManager.QueryRootGameObjects()) - { - gameObject.Start(); - } + //foreach (var gameObject in SceneManager.QueryRootGameObjects()) + //{ + // gameObject.Start(); + //} - _timer ??= new Timer(FixedUpdate, null, 0, (int)(fixedDeltaTime * 1000)); + //_timer ??= new Timer(FixedUpdate, null, 0, (int)(fixedDeltaTime * 1000)); - while (_isRunning) - { - Update(); - } + //while (_isRunning) + //{ + // Update(); + //} } private static void Update() { - foreach (var gameObject in SceneManager.QueryRootGameObjects()) - { - gameObject.Update(); - } + //foreach (var gameObject in SceneManager.QueryRootGameObjects()) + //{ + // gameObject.Update(); + //} - foreach (var gameObject in SceneManager.QueryRootGameObjects()) - { - gameObject.LateUpdate(); - } + //foreach (var gameObject in SceneManager.QueryRootGameObjects()) + //{ + // gameObject.LateUpdate(); + //} } private static void FixedUpdate(object? state) { - foreach (var gameObject in SceneManager.QueryRootGameObjects()) - { - gameObject.FixedUpdate(); - } + //foreach (var gameObject in SceneManager.QueryRootGameObjects()) + //{ + // gameObject.FixedUpdate(); + //} } public static void Stop() diff --git a/Ghost.Engine/Services/SceneManager.cs b/Ghost.Engine/Services/SceneManager.cs index 8087048..0f84469 100644 --- a/Ghost.Engine/Services/SceneManager.cs +++ b/Ghost.Engine/Services/SceneManager.cs @@ -1,6 +1,4 @@ -using Ghost.Engine.Models; - -namespace Ghost.Engine.Services; +namespace Ghost.Engine.Services; public enum SceneLoadMode { @@ -10,41 +8,41 @@ public enum SceneLoadMode public static class SceneManager { - private readonly static HashSet _activeScenes = new(); + //private readonly static HashSet _activeScenes = new(); - internal static IEnumerable QueryRootGameObjects() - { - foreach (var scene in _activeScenes) - { - foreach (var gameObject in scene.RootObjects) - { - if (!gameObject.IsActive) - { - continue; - } + //internal static IEnumerable QueryRootGameObjects() + //{ + // foreach (var scene in _activeScenes) + // { + // foreach (var gameObject in scene.RootObjects) + // { + // if (!gameObject.IsActive) + // { + // continue; + // } - yield return gameObject; - } - } - } + // yield return gameObject; + // } + // } + //} - public static void LoadScene(Scene scene, SceneLoadMode loadMode) - { - if (loadMode == SceneLoadMode.Single) - { - foreach (var activeScene in _activeScenes) - { - activeScene.Unload(); - } - _activeScenes.Clear(); - } + //public static void LoadScene(Scene scene, SceneLoadMode loadMode) + //{ + // if (loadMode == SceneLoadMode.Single) + // { + // foreach (var activeScene in _activeScenes) + // { + // activeScene.Unload(); + // } + // _activeScenes.Clear(); + // } - _activeScenes.Add(scene); - scene.Load(); - } + // _activeScenes.Add(scene); + // scene.Load(); + //} - public static Task LoadSceneAsync(Scene scene, SceneLoadMode loadMode) - { - return Task.Run(() => LoadScene(scene, loadMode)); - } + //public static Task LoadSceneAsync(Scene scene, SceneLoadMode loadMode) + //{ + // return Task.Run(() => LoadScene(scene, loadMode)); + //} } \ No newline at end of file diff --git a/Ghost.Entities/AssemblyInfo.cs b/Ghost.Entities/AssemblyInfo.cs index b9f7d98..f4854ec 100644 --- a/Ghost.Entities/AssemblyInfo.cs +++ b/Ghost.Entities/AssemblyInfo.cs @@ -5,4 +5,5 @@ global using WorldID = System.UInt16; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Ghost.Engine")] +[assembly: InternalsVisibleTo("Ghost.Editor")] [assembly: InternalsVisibleTo("Ghost.Test")] \ No newline at end of file diff --git a/Ghost.Entities/Component.cs b/Ghost.Entities/Component.cs index 47eff32..d0a2c74 100644 --- a/Ghost.Entities/Component.cs +++ b/Ghost.Entities/Component.cs @@ -23,11 +23,12 @@ internal interface IComponentPool : IDisposable get; } - public void Remove(Entity entity); + public bool Remove(Entity entity); public bool Has(Entity entity); } internal interface IComponentPool : IComponentPool + where T : IComponentData { public void Add(Entity entity, T Component); } @@ -115,8 +116,10 @@ internal class ComponentPool : IComponentPool _nextId++; } - public void Remove(Entity entity) + public bool Remove(Entity entity) { + // We do not remove anything here, the generation of the entity will be used to determine if the component is valid. + return true; } public ref T GetRef(Entity entity) @@ -213,13 +216,62 @@ internal class ScriptComponentPool : IComponentPool component.Owner = entity; } - public void Remove(Entity entity) + public bool Remove(Entity entity) + where T : ScriptComponent { if (!Has(entity) || !_scriptComponents!.TryGetValue(entity, out var scriptList) || scriptList == null) { - return; + return false; + } + + var scriptToRemove = scriptList.FirstOrDefault(script => script is T); + if (scriptToRemove == null) + { + return false; + } + + scriptToRemove.OnDestroy(); + scriptList.Remove(scriptToRemove); + if (scriptList.Count == 0) + { + _scriptComponents.Remove(entity); + } + + return true; + } + + public bool RemoveAt(Entity entity, int index) + { + if (!Has(entity) + || !_scriptComponents!.TryGetValue(entity, out var scriptList) + || scriptList == null) + { + return false; + } + + if (index < 0 || index > scriptList.Count) + { + return false; + } + + scriptList.RemoveAt(index); + if (scriptList.Count == 0) + { + _scriptComponents.Remove(entity); + } + + return true; + } + + public bool Remove(Entity entity) + { + if (!Has(entity) + || !_scriptComponents!.TryGetValue(entity, out var scriptList) + || scriptList == null) + { + return false; } foreach (var script in scriptList) @@ -228,6 +280,7 @@ internal class ScriptComponentPool : IComponentPool } _scriptComponents.Remove(entity); + return true; } public bool Has(Entity entity) @@ -287,7 +340,15 @@ internal class ComponentStorage : IDisposable private readonly Dictionary _componentEntityMasks = new(); private readonly ScriptComponentPool _scriptComponentPool = new(); + private readonly World _world; + + internal ComponentStorage(World world) + { + _world = world; + } + internal Dictionary ComponentPools => _componentPools; + internal Dictionary ComponentEntityMasks => _componentEntityMasks; internal ScriptComponentPool ScriptComponentPool => _scriptComponentPool; [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Ghost.Entities/Entity.cs b/Ghost.Entities/Entity.cs index 08a8ac9..539ada3 100644 --- a/Ghost.Entities/Entity.cs +++ b/Ghost.Entities/Entity.cs @@ -112,6 +112,11 @@ public class EntityManager : IDisposable public int EntityCount => _entities.Count; public ReadOnlySpan Entities => CollectionsMarshal.AsSpan(_entities); + public event Action? OnComponentAdded; + public event Action? OnComponentRemoved; + public event Action? OnEntityCreated; + public event Action? OnEntityRemoved; + internal EntityManager(World world, int initialCapacity) { _entities = new(initialCapacity); @@ -125,17 +130,20 @@ public class EntityManager : IDisposable /// The created . public Entity CreateEntity() { + Entity entity; if (_freeEntitySlots.TryDequeue(out var id)) { - return _entities[id]; + entity = _entities[id]; } else { id = _entities.Count; - var entity = new Entity(id, 0, _world.ID); + entity = new Entity(id, 0, _world.ID); _entities.Add(entity); - return entity; } + + OnEntityCreated?.Invoke(entity); + return entity; } /// @@ -149,13 +157,14 @@ public class EntityManager : IDisposable return; } - _world._componentStorage.Remove(entity); + _world.ComponentStorage.Remove(entity); var slot = _entities[entity.ID]; slot.IncrementGeneration(); _entities[entity.ID] = slot; _freeEntitySlots.Enqueue(entity.ID); + OnEntityRemoved?.Invoke(entity.ID); entity = Entity.Invalid; } @@ -187,8 +196,9 @@ public class EntityManager : IDisposable public void AddComponent(Entity entity, T component) where T : struct, IComponentData { - _world._componentStorage.GetOrCreateComponentPool().Add(entity, component); - _world._componentStorage.GetOrCreateMask(TypeHandle.Value).SetBit(entity.ID); + _world.ComponentStorage.GetOrCreateComponentPool().Add(entity, component); + _world.ComponentStorage.GetOrCreateMask(TypeHandle.Value).SetBit(entity.ID); + OnComponentAdded?.Invoke(entity, typeof(T)); } /// @@ -197,14 +207,23 @@ public class EntityManager : IDisposable /// The type of the component to remove. /// The entity for which the component is to be remove. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void RemoveComponent(Entity entity) + public bool RemoveComponent(Entity entity) where T : struct, IComponentData { - if (_world._componentStorage.TryGetPool(out var pool) && pool.Has(entity)) + if (!_world.ComponentStorage.TryGetPool(out var pool) || !pool.Has(entity)) { - pool.Remove(entity); - _world._componentStorage.GetOrCreateMask(TypeHandle.Value).ClearBit(entity.ID); + return false; } + + if (!pool.Remove(entity)) + { + return false; + } + + _world.ComponentStorage.GetOrCreateMask(TypeHandle.Value).ClearBit(entity.ID); + OnComponentRemoved?.Invoke(entity, typeof(T)); + + return true; } /// @@ -217,7 +236,7 @@ public class EntityManager : IDisposable public void SetComponent(Entity entity, T component) where T : struct, IComponentData { - _world._componentStorage.GetOrCreateComponentPool().Set(entity, component); + _world.ComponentStorage.GetOrCreateComponentPool().Set(entity, component); } /// @@ -229,7 +248,7 @@ public class EntityManager : IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HasComponent(Entity entity, nint typeHandle) { - return _world._componentStorage.TryGetMask(typeHandle, out var bitSet) && bitSet.IsSet(entity.ID); + return _world.ComponentStorage.TryGetMask(typeHandle, out var bitSet) && bitSet.IsSet(entity.ID); } /// @@ -242,7 +261,7 @@ public class EntityManager : IDisposable public Ref GetComponent(Entity entity) where T : struct, IComponentData { - if (_world._componentStorage.TryGetPool(out var pool) && pool.Has(entity)) + if (_world.ComponentStorage.TryGetPool(out var pool) && pool.Has(entity)) { return new Ref(ref pool.GetRef(entity)); } @@ -261,7 +280,8 @@ public class EntityManager : IDisposable public void AddScript(Entity entity) where T : ScriptComponent, new() { - _world._componentStorage.ScriptComponentPool.Add(entity, new T()); + _world.ComponentStorage.ScriptComponentPool.Add(entity, new T()); + OnComponentAdded?.Invoke(entity, typeof(ScriptComponent)); } /// @@ -280,7 +300,31 @@ public class EntityManager : IDisposable } var instance = (ScriptComponent?)Activator.CreateInstance(type) ?? throw new InvalidOperationException($"Failed to create instance of {type}."); - _world._componentStorage.ScriptComponentPool.Add(entity, instance); + _world.ComponentStorage.ScriptComponentPool.Add(entity, instance); + OnComponentAdded?.Invoke(entity, typeof(ScriptComponent)); + } + + public bool RemoveScript(Entity entity) + where T : ScriptComponent + { + if (!_world.ComponentStorage.ScriptComponentPool.Remove(entity)) + { + return false; + } + + OnComponentRemoved?.Invoke(entity, typeof(ScriptComponent)); + return true; + } + + public bool RemoveScriptAt(Entity entity, int index) + { + if (!_world.ComponentStorage.ScriptComponentPool.RemoveAt(entity, index)) + { + return false; + } + + OnComponentRemoved?.Invoke(entity, typeof(ScriptComponent)); + return true; } /// @@ -292,7 +336,7 @@ public class EntityManager : IDisposable public T? GetScript(Entity entity) where T : ScriptComponent { - return (T?)_world._componentStorage.ScriptComponentPool.Get(entity)? + return (T?)_world.ComponentStorage.ScriptComponentPool.Get(entity)? .FirstOrDefault(script => script is T tScript); } @@ -305,7 +349,7 @@ public class EntityManager : IDisposable public IEnumerable GetScripts(Entity entity) where T : ScriptComponent { - return (IEnumerable?)_world._componentStorage.ScriptComponentPool.Get(entity)?.Where(script => script is T tScript) ?? Enumerable.Empty(); + return (IEnumerable?)_world.ComponentStorage.ScriptComponentPool.Get(entity)?.Where(script => script is T tScript) ?? Enumerable.Empty(); } public void Dispose() diff --git a/Ghost.Entities/Helpers/EntityHelpers.cs b/Ghost.Entities/Helpers/EntityHelpers.cs index cd20742..01f348a 100644 --- a/Ghost.Entities/Helpers/EntityHelpers.cs +++ b/Ghost.Entities/Helpers/EntityHelpers.cs @@ -33,11 +33,11 @@ public static class EntityHelpers /// The type of the component to remove. /// The entity for which the component is to be remove. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void RemoveComponent(this Entity entity) + public static bool RemoveComponent(this Entity entity) where T : struct, IComponentData { var world = entity.GetWorld(); - world.EntityManager.RemoveComponent(entity); + return world.EntityManager.RemoveComponent(entity); } /// diff --git a/Ghost.Entities/Query/QueryFilter.cs b/Ghost.Entities/Query/QueryFilter.cs index 9078d98..7d05598 100644 --- a/Ghost.Entities/Query/QueryFilter.cs +++ b/Ghost.Entities/Query/QueryFilter.cs @@ -37,7 +37,7 @@ internal struct QueryFilter() // Compute All mask (intersection) foreach (var typeHandle in _all) { - var mask = world._componentStorage.GetOrCreateMask(typeHandle); + var mask = world.ComponentStorage.GetOrCreateMask(typeHandle); if (!hasAll) { @@ -52,7 +52,7 @@ internal struct QueryFilter() // Compute Any mask (union) foreach (var typeHandle in _any) { - var mask = world._componentStorage.GetOrCreateMask(typeHandle); + var mask = world.ComponentStorage.GetOrCreateMask(typeHandle); if (!hasAny) { @@ -66,7 +66,7 @@ internal struct QueryFilter() // Compute Absent mask (union for exclusion) foreach (var typeHandle in _absent) { - var mask = world._componentStorage.GetOrCreateMask(typeHandle); + var mask = world.ComponentStorage.GetOrCreateMask(typeHandle); if (!hasAbsent) { diff --git a/Ghost.Entities/ScriptComponent.cs b/Ghost.Entities/ScriptComponent.cs index 5339f05..3aae5b0 100644 --- a/Ghost.Entities/ScriptComponent.cs +++ b/Ghost.Entities/ScriptComponent.cs @@ -2,14 +2,32 @@ public abstract class ScriptComponent : IComponentData { + private bool _enable = true; + /// /// Gets or sets a value indicating whether this script component is enabled. /// public bool Enable { - get; - set; - } = true; + get => _enable; + set + { + if (_enable == value) + { + return; + } + + _enable = value; + if (_enable) + { + OnEnable(); + } + else + { + OnDisable(); + } + } + } /// /// Gets the entity that owns this script component. diff --git a/Ghost.Entities/System.cs b/Ghost.Entities/System.cs index 4ebfe09..2261dba 100644 --- a/Ghost.Entities/System.cs +++ b/Ghost.Entities/System.cs @@ -1,19 +1,30 @@ -namespace Ghost.Entities; +using System.Runtime.CompilerServices; + +namespace Ghost.Entities; public abstract class SystemBase { + /// + /// Gets the execution order of the current operation or component. + /// public virtual int ExecutionOrder => 0; + /// + /// Gets or sets a value indicating whether the feature is enabled. + /// public virtual bool Enable { get; set; } = true; + /// + /// The world that this system belongs to. + /// public World World { get; - init; + internal set; } = null!; public virtual void OnCreate() @@ -29,38 +40,73 @@ public abstract class SystemBase } } -internal class SystemStorage : IDisposable +public class SystemStorage : IDisposable { private readonly List _systems = new(); private readonly List _executionList = new(); + private readonly World _world; + + public event Action? SystemAdded; + public event Action? SystemRemoved; + + internal SystemStorage(World world) + { + _world = world; + } + public void AddSystem(T system) where T : SystemBase { _systems.Add(system); + system.World = _world; if (system.Enable) { system.OnCreate(); } + + SystemAdded?.Invoke(system); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddSystem() + where T : SystemBase, new() + { + AddSystem(new T()); } public void RemoveSystem(T system) where T : SystemBase { + system.World = null!; _systems.Remove(system); if (system.Enable) { system.OnDestroy(); } + + SystemRemoved?.Invoke(system); } - public void RebuildExecutionList() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RemoveSystem() + where T : SystemBase, new() + { + var system = _systems.FirstOrDefault(s => s is T); + if (system != null) + { + RemoveSystem(system); + } + } + + internal void RebuildExecutionList() { _executionList.Clear(); _executionList.AddRange(_systems.OrderBy(s => s.ExecutionOrder)); } - public void UpdateSystems() + internal void UpdateSystems() { foreach (var system in _systems) { diff --git a/Ghost.Entities/Utilities/Box.cs b/Ghost.Entities/Utilities/Box.cs deleted file mode 100644 index 0e45025..0000000 --- a/Ghost.Entities/Utilities/Box.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ghost.Entities.Utilities; - -internal class Box - where T : struct -{ - public T Value - { - get; - set; - } - - public Box(T value) - { - Value = value; - } - - public static implicit operator T(Box box) => box.Value; - public static implicit operator Box(T value) => new(value); -} \ No newline at end of file diff --git a/Ghost.Entities/Utilities/ComponentMask.cs b/Ghost.Entities/Utilities/ComponentMask.cs deleted file mode 100644 index 90c1d78..0000000 --- a/Ghost.Entities/Utilities/ComponentMask.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Numerics; - -namespace Ghost.Entities.Utilities; - -internal readonly struct ComponentMask -{ - private readonly ulong[] _words; - - public ComponentMask(int entityCapacity) - { - _words = new ulong[(entityCapacity + 63) / 64]; - } - - public void Set(int entityIndex) - => _words[entityIndex >> 6] |= 1UL << (entityIndex & 63); - - public void Clear(int entityIndex) - => _words[entityIndex >> 6] &= ~(1UL << (entityIndex & 63)); - - public bool Get(int entityIndex) - => ((_words[entityIndex >> 6] >> (entityIndex & 63)) & 1) != 0; - - // Bitwise AND - public ComponentMask And(in ComponentMask other) - { - var result = new ComponentMask(_words.Length * 64); - for (var i = 0; i < _words.Length; i++) - result._words[i] = _words[i] & other._words[i]; - return result; - } - - // Bitwise OR - public ComponentMask Or(in ComponentMask other) - { - var result = new ComponentMask(_words.Length * 64); - for (var i = 0; i < _words.Length; i++) - result._words[i] = _words[i] | other._words[i]; - return result; - } - - // Bitwise NOT - public ComponentMask Not() - { - var result = new ComponentMask(_words.Length * 64); - for (var i = 0; i < _words.Length; i++) - result._words[i] = ~_words[i]; - return result; - } - - // Iterate set bits (fast scan) - public IEnumerable GetEntityIndices() - { - for (var word = 0; word < _words.Length; word++) - { - var bits = _words[word]; - while (bits != 0) - { - var lowBit = BitOperations.TrailingZeroCount(bits); - yield return (word << 6) + lowBit; - bits &= bits - 1; // clear lowest set bit - } - } - } -} diff --git a/Ghost.Entities/World.cs b/Ghost.Entities/World.cs index 4a354cf..e634975 100644 --- a/Ghost.Entities/World.cs +++ b/Ghost.Entities/World.cs @@ -12,6 +12,8 @@ public partial class World private static int s_maxWorldCount = (int)MathF.Pow(2, Entity.WORLD_INDEX_BITS); + public static int WorldCount => s_worlds.Count - s_freeWorldSlots.Count; + public static World Create(int entityCapacity = 16) { lock (s_worlds) @@ -46,31 +48,21 @@ public partial class World : IDisposable { private readonly WorldID _id; private readonly EntityManager _entityManager; + private readonly ComponentStorage _componentStorage; + private readonly SystemStorage _systemStorage; - internal readonly ComponentStorage _componentStorage; - internal readonly SystemStorage _systemStorage; + internal ComponentStorage ComponentStorage => _componentStorage; public WorldID ID => _id; public EntityManager EntityManager => _entityManager; + public SystemStorage SystemStorage => _systemStorage; private World(WorldID id, int entityCapacity) { _id = id; _entityManager = new EntityManager(this, entityCapacity); - _componentStorage = new ComponentStorage(); - _systemStorage = new SystemStorage(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void AddSystem() - where T : SystemBase, new() - { - var instance = new T - { - World = this - }; - - _systemStorage.AddSystem(instance); + _componentStorage = new ComponentStorage(this); + _systemStorage = new SystemStorage(this); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Ghost.Test/Ghost.Test.csproj b/Ghost.Test/Ghost.Test.csproj index 85cad08..5a965f8 100644 --- a/Ghost.Test/Ghost.Test.csproj +++ b/Ghost.Test/Ghost.Test.csproj @@ -8,9 +8,9 @@ - + diff --git a/Ghost.Test/Program.cs b/Ghost.Test/Program.cs index f66dfab..c470c53 100644 --- a/Ghost.Test/Program.cs +++ b/Ghost.Test/Program.cs @@ -44,8 +44,8 @@ public partial class Test entity4.AddComponent(new Mesh { index = 44 }); entity4.AddScript(); - world.AddSystem(); - world._systemStorage.UpdateSystems(); + world.SystemStorage.AddSystem(); + world.SystemStorage.UpdateSystems(); //world.SystemStorage.RebuildExecutionList(); //world.ComponentStorage.RebuildExecutionList();