diff --git a/DoomDeathmatch.sln b/DoomDeathmatch.sln index 8d9962d..bd717de 100644 --- a/DoomDeathmatch.sln +++ b/DoomDeathmatch.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DoomDeathmatchConsole", "Do EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DoomDeathmatchWPF", "DoomDeathmatchWPF\DoomDeathmatchWPF.csproj", "{B712A719-5EB3-4869-AA4A-3BFFA3B9C918}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EngineTests", "EngineTests\EngineTests.csproj", "{CC28C26C-0998-4C13-8855-978658B8B0D6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,9 @@ Global {B712A719-5EB3-4869-AA4A-3BFFA3B9C918}.Debug|Any CPU.Build.0 = Debug|Any CPU {B712A719-5EB3-4869-AA4A-3BFFA3B9C918}.Release|Any CPU.ActiveCfg = Release|Any CPU {B712A719-5EB3-4869-AA4A-3BFFA3B9C918}.Release|Any CPU.Build.0 = Release|Any CPU + {CC28C26C-0998-4C13-8855-978658B8B0D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC28C26C-0998-4C13-8855-978658B8B0D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC28C26C-0998-4C13-8855-978658B8B0D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC28C26C-0998-4C13-8855-978658B8B0D6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Engine/Engine.csproj b/Engine/Engine.csproj index 31d943d..67df8d7 100644 --- a/Engine/Engine.csproj +++ b/Engine/Engine.csproj @@ -7,14 +7,16 @@ - true + true - - - - + + + + + + diff --git a/Engine/Scene/Component/Component.cs b/Engine/Scene/Component/Component.cs deleted file mode 100644 index 37066e4..0000000 --- a/Engine/Scene/Component/Component.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Engine.Scene.Component; - -public abstract class Component(GameObject gameObject) -{ - public Guid Id { get; } = Guid.NewGuid(); - - public GameObject GameObject { get; } = gameObject; -} \ No newline at end of file diff --git a/Engine/Scene/GameObject.cs b/Engine/Scene/GameObject.cs deleted file mode 100644 index 61e9c58..0000000 --- a/Engine/Scene/GameObject.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Engine.Scene.Component; - -namespace Engine.Scene; - -public sealed class GameObject -{ - public Guid Id { get; } = Guid.NewGuid(); - public Transform Transform { get; } - - private readonly Queue _componentActions = new(); - - private readonly List _components = new(); - private readonly ISet _addedComponentTypes = new HashSet(); - - public GameObject() - { - AddComponent(); - UpdateComponents(); - - Transform = GetComponent()!; - } - - public T? GetComponent() where T : Component.Component - { - if (!_addedComponentTypes.Contains(typeof(T))) - return null; - - return _components.OfType().FirstOrDefault(); - } - - public void AddComponent(params object?[] args) where T : Component.Component - { - if (_addedComponentTypes.Contains(typeof(T))) - return; - - var newArgs = new object?[args.Length + 1]; - newArgs[0] = this; - for (var i = 0; i < args.Length; i++) - newArgs[i + 1] = args[i]; - - var component = (T?)Activator.CreateInstance(typeof(T), newArgs); - if (component == null) - throw new InvalidOperationException($"Failed to create component of type {typeof(T)}"); - - _componentActions.Enqueue(() => - { - _components.Add(component); - _addedComponentTypes.Add(typeof(T)); - }); - } - - public void RemoveComponent() where T : Component.Component - { - if (!_addedComponentTypes.Contains(typeof(T)) || typeof(T) == typeof(Transform)) - return; - - var component = GetComponent(); - if (component == null) - return; - - _componentActions.Enqueue(() => - { - _components.Remove(component); - _addedComponentTypes.Remove(typeof(T)); - }); - } - - private void UpdateComponents() - { - while (_componentActions.TryDequeue(out var action)) - action(); - } -} \ No newline at end of file diff --git a/Engine/Asset/Image.cs b/Engine/src/Asset/Image.cs similarity index 100% rename from Engine/Asset/Image.cs rename to Engine/src/Asset/Image.cs diff --git a/Engine/Asset/Mesh/Loader/IMeshLoader.cs b/Engine/src/Asset/Mesh/Loader/IMeshLoader.cs similarity index 100% rename from Engine/Asset/Mesh/Loader/IMeshLoader.cs rename to Engine/src/Asset/Mesh/Loader/IMeshLoader.cs diff --git a/Engine/Asset/Mesh/Loader/MeshLoaderParameters.cs b/Engine/src/Asset/Mesh/Loader/MeshLoaderParameters.cs similarity index 100% rename from Engine/Asset/Mesh/Loader/MeshLoaderParameters.cs rename to Engine/src/Asset/Mesh/Loader/MeshLoaderParameters.cs diff --git a/Engine/Asset/Mesh/Loader/ObjMeshLoader.cs b/Engine/src/Asset/Mesh/Loader/ObjMeshLoader.cs similarity index 100% rename from Engine/Asset/Mesh/Loader/ObjMeshLoader.cs rename to Engine/src/Asset/Mesh/Loader/ObjMeshLoader.cs diff --git a/Engine/Asset/Mesh/Loader/StlMeshLoader.cs b/Engine/src/Asset/Mesh/Loader/StlMeshLoader.cs similarity index 100% rename from Engine/Asset/Mesh/Loader/StlMeshLoader.cs rename to Engine/src/Asset/Mesh/Loader/StlMeshLoader.cs diff --git a/Engine/Asset/Mesh/Mesh.cs b/Engine/src/Asset/Mesh/Mesh.cs similarity index 100% rename from Engine/Asset/Mesh/Mesh.cs rename to Engine/src/Asset/Mesh/Mesh.cs diff --git a/Engine/Engine.cs b/Engine/src/Engine.cs similarity index 89% rename from Engine/Engine.cs rename to Engine/src/Engine.cs index 9787f8e..3d6329a 100644 --- a/Engine/Engine.cs +++ b/Engine/src/Engine.cs @@ -1,14 +1,8 @@ -using System.Runtime.InteropServices; -using System.Text; -using Engine.Renderer.Buffer; -using Engine.Renderer.Buffer.Vertex; -using Engine.Renderer.Shader; -using OpenTK.Graphics.OpenGL; +using OpenTK.Graphics.OpenGL; using OpenTK.Mathematics; using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; using Serilog; -using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; namespace Engine; @@ -59,7 +53,7 @@ public sealed class Engine while (!_window.IsExiting) { - + _window.Update(); } } } \ No newline at end of file diff --git a/Engine/Input/IInputHandler.cs b/Engine/src/Input/IInputHandler.cs similarity index 100% rename from Engine/Input/IInputHandler.cs rename to Engine/src/Input/IInputHandler.cs diff --git a/Engine/Input/Key.cs b/Engine/src/Input/Key.cs similarity index 100% rename from Engine/Input/Key.cs rename to Engine/src/Input/Key.cs diff --git a/Engine/Input/MouseButton.cs b/Engine/src/Input/MouseButton.cs similarity index 100% rename from Engine/Input/MouseButton.cs rename to Engine/src/Input/MouseButton.cs diff --git a/Engine/Renderer/Buffer/IndexBuffer.cs b/Engine/src/Renderer/Buffer/IndexBuffer.cs similarity index 99% rename from Engine/Renderer/Buffer/IndexBuffer.cs rename to Engine/src/Renderer/Buffer/IndexBuffer.cs index 497d731..82576ee 100644 --- a/Engine/Renderer/Buffer/IndexBuffer.cs +++ b/Engine/src/Renderer/Buffer/IndexBuffer.cs @@ -15,7 +15,7 @@ public class IndexBuffer : OpenGlObject Handle = handle; GL.NamedBufferStorage(Handle, Count * sizeof(uint), IntPtr.Zero, flags); - + Log.Debug("Index buffer {Handle} created with {Count} elements", Handle, Count); } diff --git a/Engine/Renderer/Buffer/Vertex/IVertex.cs b/Engine/src/Renderer/Buffer/Vertex/IVertex.cs similarity index 100% rename from Engine/Renderer/Buffer/Vertex/IVertex.cs rename to Engine/src/Renderer/Buffer/Vertex/IVertex.cs diff --git a/Engine/Renderer/Buffer/Vertex/VertexAttribute.cs b/Engine/src/Renderer/Buffer/Vertex/VertexAttribute.cs similarity index 100% rename from Engine/Renderer/Buffer/Vertex/VertexAttribute.cs rename to Engine/src/Renderer/Buffer/Vertex/VertexAttribute.cs diff --git a/Engine/Renderer/Buffer/VertexArray.cs b/Engine/src/Renderer/Buffer/VertexArray.cs similarity index 99% rename from Engine/Renderer/Buffer/VertexArray.cs rename to Engine/src/Renderer/Buffer/VertexArray.cs index 3f7f7c2..10c1454 100644 --- a/Engine/Renderer/Buffer/VertexArray.cs +++ b/Engine/src/Renderer/Buffer/VertexArray.cs @@ -10,7 +10,7 @@ public class VertexArray : OpenGlObject { // private IndexBuffer? _boundIndexBuffer; // private readonly Dictionary> _boundVertexBuffers = new(); - + public VertexArray() { GL.CreateVertexArrays(1, out int handle); @@ -20,7 +20,7 @@ public class VertexArray : OpenGlObject public void BindIndexBuffer(IndexBuffer buffer) { GL.VertexArrayElementBuffer(Handle, buffer.Handle); - + Log.Debug("Vertex array {Handle} bound to index buffer {Buffer}", Handle, buffer.Handle); } diff --git a/Engine/Renderer/Buffer/VertexBuffer.cs b/Engine/src/Renderer/Buffer/VertexBuffer.cs similarity index 99% rename from Engine/Renderer/Buffer/VertexBuffer.cs rename to Engine/src/Renderer/Buffer/VertexBuffer.cs index aea1fd1..8fe1693 100644 --- a/Engine/Renderer/Buffer/VertexBuffer.cs +++ b/Engine/src/Renderer/Buffer/VertexBuffer.cs @@ -26,7 +26,7 @@ public class VertexBuffer : OpenGlObject Handle = handle; GL.NamedBufferStorage(Handle, Count * _stride, IntPtr.Zero, flags); - + Log.Debug("Vertex buffer {Handle} created with {Count} elements of type {Type}", Handle, Count, typeof(T).Name); } diff --git a/Engine/Renderer/Camera/ICamera.cs b/Engine/src/Renderer/Camera/ICamera.cs similarity index 75% rename from Engine/Renderer/Camera/ICamera.cs rename to Engine/src/Renderer/Camera/ICamera.cs index af339af..9c385bc 100644 --- a/Engine/Renderer/Camera/ICamera.cs +++ b/Engine/src/Renderer/Camera/ICamera.cs @@ -6,4 +6,5 @@ public interface ICamera { public Matrix4 View { get; } public Matrix4 Projection { get; } + public Vector2i ScreenSize { get; internal set; } } \ No newline at end of file diff --git a/Engine/Renderer/Camera/ScreenspaceCamera.cs b/Engine/src/Renderer/Camera/ScreenspaceCamera.cs similarity index 82% rename from Engine/Renderer/Camera/ScreenspaceCamera.cs rename to Engine/src/Renderer/Camera/ScreenspaceCamera.cs index 0e5db51..41d2dee 100644 --- a/Engine/Renderer/Camera/ScreenspaceCamera.cs +++ b/Engine/src/Renderer/Camera/ScreenspaceCamera.cs @@ -6,4 +6,5 @@ public class ScreenspaceCamera : ICamera { public Matrix4 View => Matrix4.Identity; public Matrix4 Projection => Matrix4.Identity; + public Vector2i ScreenSize { get; set; } } \ No newline at end of file diff --git a/Engine/Renderer/Renderer.cs b/Engine/src/Renderer/Debug.cs similarity index 66% rename from Engine/Renderer/Renderer.cs rename to Engine/src/Renderer/Debug.cs index b1ed6d9..1eb2e65 100644 --- a/Engine/Renderer/Renderer.cs +++ b/Engine/src/Renderer/Debug.cs @@ -1,38 +1,18 @@ using System.Runtime.InteropServices; -using Engine.Renderer.Pixel; using OpenTK.Graphics.OpenGL; using Serilog; using Serilog.Events; namespace Engine.Renderer; -public class Renderer +internal static class Debug { - internal Texture.Texture TextureInternal => _framebuffer.TextureInternal; - - private readonly Framebuffer.Framebuffer _framebuffer; - private readonly Queue> _renderActions = new(); - - public Renderer(int width, int height) - { - InitializeOpenGl(); - - _framebuffer = new Framebuffer.Framebuffer(width, height); - } - - private void InitializeOpenGl() + public static void Setup() { GL.Enable(EnableCap.DebugOutput); GL.DebugMessageCallback(DebugCallback, IntPtr.Zero); - - GL.Enable(EnableCap.DepthTest); - - GL.Enable(EnableCap.FramebufferSrgb); - - GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - GL.Enable(EnableCap.Blend); } - + private static void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, IntPtr message, IntPtr userParam) { @@ -53,7 +33,7 @@ public class Renderer DebugType.DebugTypeOther => "Info", _ => "Unknown" }; - + var sourcePrefix = source switch { DebugSource.DebugSourceApi => "API", @@ -65,8 +45,7 @@ public class Renderer _ => "Unknown" }; - logger.Write( - GetLogLevel(severity), + logger.Write(GetLogLevel(severity), "[OpenGL {TypePrefix}] [{Source}] {Message} (ID: 0x{Id:X8})", typePrefix, sourcePrefix, @@ -86,22 +65,4 @@ public class Renderer _ => LogEventLevel.Debug }; } - - internal void Commit(Action renderAction) - { - _renderActions.Enqueue(renderAction); - } - - internal void Render() - { - _framebuffer.Bind(); - while (_renderActions.TryDequeue(out var renderAction)) - renderAction(this); - _framebuffer.Unbind(); - } - - internal void Resize(int width, int height) - { - _framebuffer.Resize(width, height); - } } \ No newline at end of file diff --git a/Engine/Renderer/Framebuffer/Framebuffer.cs b/Engine/src/Renderer/Framebuffer/Framebuffer.cs similarity index 99% rename from Engine/Renderer/Framebuffer/Framebuffer.cs rename to Engine/src/Renderer/Framebuffer/Framebuffer.cs index fe32ec4..8c49660 100644 --- a/Engine/Renderer/Framebuffer/Framebuffer.cs +++ b/Engine/src/Renderer/Framebuffer/Framebuffer.cs @@ -29,7 +29,7 @@ public class Framebuffer : OpenGlObject _height = value; } } - + public IConstTexture Texture => _texture; internal Texture.Texture TextureInternal => _texture; diff --git a/Engine/Renderer/Framebuffer/Renderbuffer.cs b/Engine/src/Renderer/Framebuffer/Renderbuffer.cs similarity index 100% rename from Engine/Renderer/Framebuffer/Renderbuffer.cs rename to Engine/src/Renderer/Framebuffer/Renderbuffer.cs diff --git a/Engine/Renderer/IPresenter.cs b/Engine/src/Renderer/IPresenter.cs similarity index 100% rename from Engine/Renderer/IPresenter.cs rename to Engine/src/Renderer/IPresenter.cs diff --git a/Engine/Renderer/OpenGLObject.cs b/Engine/src/Renderer/OpenGLObject.cs similarity index 97% rename from Engine/Renderer/OpenGLObject.cs rename to Engine/src/Renderer/OpenGLObject.cs index 5069989..57a1e65 100644 --- a/Engine/Renderer/OpenGLObject.cs +++ b/Engine/src/Renderer/OpenGLObject.cs @@ -15,7 +15,7 @@ public abstract class OpenGlObject { Destroy(); Handle = -1; - + Log.Debug("OpenGL object {Handle} destroyed", Handle); } } \ No newline at end of file diff --git a/Engine/Renderer/Pixel/IPixel.cs b/Engine/src/Renderer/Pixel/IPixel.cs similarity index 100% rename from Engine/Renderer/Pixel/IPixel.cs rename to Engine/src/Renderer/Pixel/IPixel.cs diff --git a/Engine/Renderer/Pixel/Rgb8.cs b/Engine/src/Renderer/Pixel/Rgb8.cs similarity index 100% rename from Engine/Renderer/Pixel/Rgb8.cs rename to Engine/src/Renderer/Pixel/Rgb8.cs diff --git a/Engine/src/Renderer/Renderer.cs b/Engine/src/Renderer/Renderer.cs new file mode 100644 index 0000000..f691cbc --- /dev/null +++ b/Engine/src/Renderer/Renderer.cs @@ -0,0 +1,55 @@ +using System.Runtime.InteropServices; +using Engine.Renderer.Pixel; +using Engine.Renderer.Shader; +using OpenTK.Graphics.OpenGL; +using Serilog; +using Serilog.Events; + +namespace Engine.Renderer; + +public class Renderer +{ + internal Texture.Texture TextureInternal => _framebuffer.TextureInternal; + + private readonly Framebuffer.Framebuffer _framebuffer; + private readonly Queue> _renderActions = new(); + + public Renderer(int width, int height) + { + InitializeOpenGl(); + + _framebuffer = new Framebuffer.Framebuffer(width, height); + } + + private void InitializeOpenGl() + { +#if DEBUG + Debug.Setup(); +#endif + + GL.Enable(EnableCap.DepthTest); + + GL.Enable(EnableCap.FramebufferSrgb); + + GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + GL.Enable(EnableCap.Blend); + } + + internal void Commit(Action renderAction) + { + _renderActions.Enqueue(renderAction); + } + + internal void Render() + { + _framebuffer.Bind(); + while (_renderActions.TryDequeue(out var renderAction)) + renderAction(this); + _framebuffer.Unbind(); + } + + internal void Resize(int width, int height) + { + _framebuffer.Resize(width, height); + } +} \ No newline at end of file diff --git a/Engine/Renderer/Shader/ShaderProgram.cs b/Engine/src/Renderer/Shader/ShaderProgram.cs similarity index 100% rename from Engine/Renderer/Shader/ShaderProgram.cs rename to Engine/src/Renderer/Shader/ShaderProgram.cs diff --git a/Engine/Renderer/Texture/DynamicTexture.cs b/Engine/src/Renderer/Texture/DynamicTexture.cs similarity index 100% rename from Engine/Renderer/Texture/DynamicTexture.cs rename to Engine/src/Renderer/Texture/DynamicTexture.cs diff --git a/Engine/Renderer/Texture/IConstTexture.cs b/Engine/src/Renderer/Texture/IConstTexture.cs similarity index 100% rename from Engine/Renderer/Texture/IConstTexture.cs rename to Engine/src/Renderer/Texture/IConstTexture.cs diff --git a/Engine/Renderer/Texture/ITexture.cs b/Engine/src/Renderer/Texture/ITexture.cs similarity index 100% rename from Engine/Renderer/Texture/ITexture.cs rename to Engine/src/Renderer/Texture/ITexture.cs diff --git a/Engine/Renderer/Texture/StaticTexture.cs b/Engine/src/Renderer/Texture/StaticTexture.cs similarity index 100% rename from Engine/Renderer/Texture/StaticTexture.cs rename to Engine/src/Renderer/Texture/StaticTexture.cs diff --git a/Engine/Renderer/Texture/Texture.cs b/Engine/src/Renderer/Texture/Texture.cs similarity index 99% rename from Engine/Renderer/Texture/Texture.cs rename to Engine/src/Renderer/Texture/Texture.cs index f5a6e97..ec435f4 100644 --- a/Engine/Renderer/Texture/Texture.cs +++ b/Engine/src/Renderer/Texture/Texture.cs @@ -42,7 +42,7 @@ public abstract class Texture : OpenGlObject, ITexture where T : struct, I GL.CreateTextures(TextureTarget.Texture2D, 1, out int handle); Handle = handle; } - + public void UploadPixels(int x, int y, int width, int height, T[,] pixels) { if (x < 0 || y < 0) diff --git a/Engine/src/Scene/Component/BuiltIn/Camera.cs b/Engine/src/Scene/Component/BuiltIn/Camera.cs new file mode 100644 index 0000000..583193f --- /dev/null +++ b/Engine/src/Scene/Component/BuiltIn/Camera.cs @@ -0,0 +1,29 @@ +using Engine.Renderer.Camera; +using OpenTK.Mathematics; + +namespace Engine.Scene.Component.BuiltIn; + +public abstract class Camera( + float nearPlane, + float farPlane +) : Component, ICamera +{ + public float AspectRatio { get; private set; } = 1; + public float NearPlane { get; set; } = nearPlane; + public float FarPlane { get; set; } = farPlane; + + private Vector2i _screenSize = new(1, 1); + + public abstract Matrix4 View { get; } + public abstract Matrix4 Projection { get; } + + public Vector2i ScreenSize + { + get => _screenSize; + set + { + _screenSize = value; + AspectRatio = (float)value.X / value.Y; + } + } +} \ No newline at end of file diff --git a/Engine/src/Scene/Component/BuiltIn/OrthographicCamera.cs b/Engine/src/Scene/Component/BuiltIn/OrthographicCamera.cs new file mode 100644 index 0000000..6f0f3c7 --- /dev/null +++ b/Engine/src/Scene/Component/BuiltIn/OrthographicCamera.cs @@ -0,0 +1,27 @@ +using OpenTK.Mathematics; + +namespace Engine.Scene.Component.BuiltIn; + +public class OrthographicCamera( + float nearPlane = 0.1f, + float farPlane = 1000f, + float size = 10f, + OrthographicCamera.Axis axis = OrthographicCamera.Axis.Y +) + : Camera(nearPlane, farPlane) +{ + public Axis FixedAxis { get; set; } = axis; + public float Size { get; set; } = size; + + public override Matrix4 View => GameObject.Transform.TransformMatrix.Inverted(); + + public override Matrix4 Projection => FixedAxis == Axis.X + ? Matrix4.CreateOrthographic(Size, Size / AspectRatio, NearPlane, FarPlane) + : Matrix4.CreateOrthographic(Size * AspectRatio, Size, NearPlane, FarPlane); + + public enum Axis + { + X, + Y, + } +} \ No newline at end of file diff --git a/Engine/Scene/Component/PerspectiveCamera.cs b/Engine/src/Scene/Component/BuiltIn/PerspectiveCamera.cs similarity index 50% rename from Engine/Scene/Component/PerspectiveCamera.cs rename to Engine/src/Scene/Component/BuiltIn/PerspectiveCamera.cs index 7b58b2e..44ff400 100644 --- a/Engine/Scene/Component/PerspectiveCamera.cs +++ b/Engine/src/Scene/Component/BuiltIn/PerspectiveCamera.cs @@ -1,23 +1,18 @@ -using Engine.Renderer.Camera; -using Engine.Util; +using Engine.Util; using OpenTK.Mathematics; -namespace Engine.Scene.Component; +namespace Engine.Scene.Component.BuiltIn; public class PerspectiveCamera( - GameObject gameObject, - float aspectRatio, - float fieldOfView, - float nearPlane, - float farPlane) - : Component(gameObject), ICamera + float fieldOfView = 90.0f, + float nearPlane = 0.1f, + float farPlane = 1000f +) + : Camera(nearPlane, farPlane) { - public float AspectRatio { get; set; } = aspectRatio; public float FieldOfView { get; set; } = fieldOfView; - public float NearPlane { get; set; } = nearPlane; - public float FarPlane { get; set; } = farPlane; - public Matrix4 View + public override Matrix4 View { get { @@ -30,5 +25,6 @@ public class PerspectiveCamera( } } - public Matrix4 Projection => Matrix4.CreatePerspectiveFieldOfView(FieldOfView, AspectRatio, NearPlane, FarPlane); + public override Matrix4 Projection => + Matrix4.CreatePerspectiveFieldOfView(FieldOfView, AspectRatio, NearPlane, FarPlane); } \ No newline at end of file diff --git a/Engine/Scene/Component/Transform.cs b/Engine/src/Scene/Component/BuiltIn/Transform.cs similarity index 50% rename from Engine/Scene/Component/Transform.cs rename to Engine/src/Scene/Component/BuiltIn/Transform.cs index cfa80a9..2e48368 100644 --- a/Engine/Scene/Component/Transform.cs +++ b/Engine/src/Scene/Component/BuiltIn/Transform.cs @@ -1,8 +1,8 @@ using OpenTK.Mathematics; -namespace Engine.Scene.Component; +namespace Engine.Scene.Component.BuiltIn; -public class Transform(GameObject gameObject) : Component(gameObject) +public class Transform : Component { public Vector3 Position { get; set; } = Vector3.Zero; public Quaternion Rotation { get; set; } = Quaternion.Identity; @@ -15,5 +15,25 @@ public class Transform(GameObject gameObject) : Component(gameObject) public Matrix4 TransformMatrix => LocalTransformMatrix * ParentTransformMatrix; - private Matrix4 ParentTransformMatrix => Matrix4.Identity; + private Matrix4 ParentTransformMatrix + { + get + { + var parent = GameObject.Scene?.Hierarchy.GetParent(GameObject); + return parent == null ? Matrix4.Identity : parent.Transform.TransformMatrix; + } + } + + public Transform Clone() + { + var clone = new Transform + { + Position = Position, + Rotation = Rotation, + Scale = Scale, + LocalScale = LocalScale + }; + + return clone; + } } \ No newline at end of file diff --git a/Engine/src/Scene/Component/Component.cs b/Engine/src/Scene/Component/Component.cs new file mode 100644 index 0000000..c6d3e32 --- /dev/null +++ b/Engine/src/Scene/Component/Component.cs @@ -0,0 +1,45 @@ +namespace Engine.Scene.Component; + +public abstract class Component +{ + public Guid Id { get; } = Guid.NewGuid(); + + public GameObject GameObject { get; internal set; } + + internal virtual void Awake() + { + } + + internal virtual void Start() + { + } + + internal virtual void Update() + { + } + + internal virtual void Render() + { + } + + internal virtual void Destroy() + { + } +} + +public static class ComponentTypeExtensions +{ + internal static Type GetComponentBaseType(this Type type) + { + var baseType = type.BaseType; + if (baseType == null || baseType == typeof(Component)) + return type; + + while (baseType.BaseType != null && baseType.BaseType != typeof(Component)) + { + baseType = baseType.BaseType; + } + + return baseType; + } +} \ No newline at end of file diff --git a/Engine/src/Scene/GameObject.cs b/Engine/src/Scene/GameObject.cs new file mode 100644 index 0000000..2c3540c --- /dev/null +++ b/Engine/src/Scene/GameObject.cs @@ -0,0 +1,136 @@ +using System.Reflection; +using Engine.Scene.Component; +using Engine.Scene.Component.BuiltIn; + +namespace Engine.Scene; + +public sealed class GameObject +{ + public Guid Id { get; } = Guid.NewGuid(); + public Transform Transform { get; } + + internal Scene? Scene { get; set; } + + private readonly Queue _componentActions = new(); + + private readonly IList _components = new List(); + private readonly ISet _addedComponentTypes = new HashSet(); + + public GameObject() + { + AddComponent(); + ProcessChanges(); + + Transform = GetComponent()!; + } + + public GameObject(Transform transform) + { + AddComponent(transform.Clone()); + ProcessChanges(); + + Transform = GetComponent()!; + } + + public void Awake() + { + foreach (var component in _components) + component.Awake(); + } + + public void Start() + { + foreach (var component in _components) + component.Start(); + } + + public void Update() + { + foreach (var component in _components) + component.Update(); + } + + public void Render() + { + foreach (var component in _components) + component.Render(); + } + + public void Destroy() + { + foreach (var component in _components) + component.Destroy(); + } + + public T? GetComponent() where T : Component.Component + { + return !HasComponent() ? null : _components.OfType().First(); + } + + public void AddComponent() where T : Component.Component, new() + { + var component = new T(); + AddComponent(component); + } + + public void AddComponent(params object?[] args) where T : Component.Component + { + var component = (T?)Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.CreateInstance | + BindingFlags.OptionalParamBinding, + null, + args, + null + ); + + if (component == null) + throw new InvalidOperationException($"Failed to create component of type {typeof(T)}"); + + AddComponent(component); + } + + public void AddComponent(T component) where T : Component.Component + { + _componentActions.Enqueue(() => + { + if (HasComponent()) + throw new ArgumentException($"GameObject already has component of type {typeof(T)}"); + + component.GameObject = this; + _components.Add(component); + _addedComponentTypes.Add(typeof(T).GetComponentBaseType()); + }); + } + + public void RemoveComponent() where T : Component.Component + { + if (typeof(T) == typeof(Transform)) + throw new ArgumentException("GameObject cannot remove Transform component"); + + _componentActions.Enqueue(() => + { + if (!HasComponent()) + throw new ArgumentException($"GameObject does not have component of type {typeof(T)}"); + + var component = GetComponent(); + if (component == null) + return; + + _components.Remove(component); + _addedComponentTypes.Remove(typeof(T)); + }); + } + + public bool HasComponent() where T : Component.Component + { + var baseType = typeof(T).GetComponentBaseType(); + return _addedComponentTypes.Contains(baseType); + } + + internal void ProcessChanges() + { + while (_componentActions.TryDequeue(out var action)) + action(); + } +} \ No newline at end of file diff --git a/Engine/src/Scene/Hierarchy.cs b/Engine/src/Scene/Hierarchy.cs new file mode 100644 index 0000000..643babb --- /dev/null +++ b/Engine/src/Scene/Hierarchy.cs @@ -0,0 +1,140 @@ +using System.Collections; +using System.Collections.Concurrent; +using Engine.Util; + +namespace Engine.Scene; + +public class Hierarchy : IEnumerable + where T : class +{ + private readonly Dictionary, IList> _childrenLookup = new(); + private readonly Dictionary _parentLookup = new(); + + private readonly ConcurrentQueue _hierarchyActions = new(); + + public Hierarchy() + { + _childrenLookup.Add(new NullableObject(), new List()); + } + + internal void ProcessChanges() + { + while (_hierarchyActions.TryDequeue(out var action)) + action(); + } + + public void Add(T obj) + { + _hierarchyActions.Enqueue(() => + { + if (_parentLookup.ContainsKey(obj)) + throw new ArgumentException("Object is already added to hierarchy"); + + _childrenLookup.Add(obj, new List()); + _parentLookup.Add(obj, null); + _childrenLookup[null].Add(obj); + }); + } + + public void Remove(T obj) + { + foreach (var child in GetChildren(obj)) + Remove(child); + + _hierarchyActions.Enqueue(() => + { + var parent = GetParent(obj); + _childrenLookup[parent].Remove(obj); + + _parentLookup.Remove(obj); + _childrenLookup.Remove(obj); + }); + } + + public void AddChild(T parent, T child) + { + SetParent(child, parent); + } + + private void SetParent(T child, T? parent) + { + if (child.Equals(parent)) + throw new InvalidOperationException("Child cannot be parent"); + + _hierarchyActions.Enqueue(() => + { + if (IsInHierarchy(child, parent)) + throw new InvalidOperationException("Parent is a child of child"); + + var oldParent = GetParent(child); + _childrenLookup[oldParent].Remove(child); + + _childrenLookup[parent].Add(child); + _parentLookup[child] = parent; + }); + } + + public bool Contains(T obj) + { + return _parentLookup.ContainsKey(obj) && _childrenLookup.ContainsKey(obj); + } + + public T? GetParent(T child) + { + return _parentLookup.TryGetValue(child, out var parent) + ? parent + : throw new InvalidOperationException($"Child {child} is not in hierarchy"); + } + + public IEnumerable GetChildren(T? obj = null) + { + return _childrenLookup.TryGetValue(obj, out var children) ? children : Enumerable.Empty(); + } + + public bool IsInHierarchy(T? ancestor, T? child) + { + if (child == null) // if child is null (root), then it is not in hierarchy, as root can not have a parent + return false; + + if (ancestor == null) // if ancestor is null (root), then child is not in hierarchy, as root is not a parent + return false; + + if (ancestor.Equals(child)) + return true; + + var parent = GetParent(child); + + if (parent == null) + return false; + + if (ancestor.Equals(parent)) + return true; + + return IsInHierarchy(ancestor, parent); + } + + public IEnumerable GetAllChildren(T? obj = null) + { + var children = GetChildren(obj); + + foreach (var child in children) + { + yield return child; + + foreach (var descendant in GetAllChildren(child)) + { + yield return descendant; + } + } + } + + public IEnumerator GetEnumerator() + { + return _parentLookup.Keys.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} \ No newline at end of file diff --git a/Engine/src/Scene/Scene.cs b/Engine/src/Scene/Scene.cs new file mode 100644 index 0000000..001e78b --- /dev/null +++ b/Engine/src/Scene/Scene.cs @@ -0,0 +1,108 @@ +using Engine.Renderer.Camera; +using Engine.Scene.Component.BuiltIn; + +namespace Engine.Scene; + +public class Scene +{ + public bool IsPlaying { get; private set; } + public ICamera? Camera { get; private set; } + + internal Hierarchy Hierarchy { get; } = []; + + private readonly Queue _sceneActions = []; + + internal void Enter() + { + if (IsPlaying) + throw new InvalidOperationException("Scene is already playing"); + + ProcessChanges(); + + Camera = FindFirstComponent(); + + IsPlaying = true; + } + + public T? FindFirstComponent() where T : Component.Component + { + return Hierarchy.Select(gameObject => gameObject.GetComponent()).OfType().FirstOrDefault(); + } + + internal void Update() + { + if (!IsPlaying) + throw new InvalidOperationException("Scene is not playing"); + + ProcessChanges(); + + foreach (var gameObject in Hierarchy) + gameObject.Update(); + } + + internal void Render() + { + if (!IsPlaying) + throw new InvalidOperationException("Scene is not playing"); + + foreach (var gameObject in Hierarchy) + { + gameObject.Render(); + } + } + + internal void Exit() + { + if (!IsPlaying) + throw new InvalidOperationException("Scene is not playing"); + + foreach (var gameObject in Hierarchy) + { + gameObject.Destroy(); + } + + IsPlaying = false; + } + + public void Add(GameObject gameObject) + { + Hierarchy.Add(gameObject); + + _sceneActions.Enqueue(() => + { + gameObject.Scene = this; + + gameObject.Awake(); + gameObject.Start(); + }); + } + + public void Remove(GameObject gameObject) + { + Hierarchy.Remove(gameObject); + + _sceneActions.Enqueue(() => + { + foreach (var child in Hierarchy.GetAllChildren(gameObject)) + { + child.Destroy(); + child.Scene = null; + } + + gameObject.Destroy(); + + gameObject.Scene = null; + }); + } + + private void ProcessChanges() + { + Hierarchy.ProcessChanges(); + + while (_sceneActions.TryDequeue(out var action)) + action(); + + foreach (var gameObject in Hierarchy) + gameObject.ProcessChanges(); + } +} \ No newline at end of file diff --git a/Engine/Util/Math.cs b/Engine/src/Util/Math.cs similarity index 100% rename from Engine/Util/Math.cs rename to Engine/src/Util/Math.cs diff --git a/Engine/src/Util/NullableObject.cs b/Engine/src/Util/NullableObject.cs new file mode 100644 index 0000000..25c7e24 --- /dev/null +++ b/Engine/src/Util/NullableObject.cs @@ -0,0 +1,55 @@ +namespace Engine.Util; + +public readonly struct NullableObject + where T : class +{ + public bool IsNull => _value == null; + public T? Value => _value; + + private readonly T? _value; + + public NullableObject() + { + _value = null; + } + + public NullableObject(T? value) + { + _value = value; + } + + public static implicit operator T?(NullableObject nullableObject) => nullableObject.Value; + public static implicit operator NullableObject(T? value) => new(value); + + public override string ToString() + { + return _value?.ToString() ?? "null"; + } + + public override bool Equals(object? obj) + { + if (obj == null) + return IsNull; + + if (obj is not NullableObject other) + return false; + + if (IsNull && other.IsNull) + return true; + + return _value!.Equals(other._value); + } + + public override int GetHashCode() + { + if (IsNull) + return 0; + + var hashCode = _value!.GetHashCode(); + + if (hashCode >= 0) + hashCode += 1; + + return hashCode; + } +} \ No newline at end of file diff --git a/Engine/Window.cs b/Engine/src/Window.cs similarity index 86% rename from Engine/Window.cs rename to Engine/src/Window.cs index 28d39bf..16adaca 100644 --- a/Engine/Window.cs +++ b/Engine/src/Window.cs @@ -10,9 +10,9 @@ namespace Engine; public class Window : IPresenter { public bool IsExiting => _window.IsExiting; - public int Width => _window.ClientSize.X; - public int Height => _window.ClientSize.Y; - + public int Width { get; private set; } + public int Height { get; private set; } + private readonly Engine _engine; private readonly NativeWindow _window; private readonly bool _headless; @@ -22,9 +22,15 @@ public class Window : IPresenter _engine = engine; _window = window; _headless = headless; - + + (Width, Height) = _window.ClientSize; + _window.MakeCurrent(); - _window.Resize += args => GL.Viewport(0, 0, args.Width, args.Height); + _window.Resize += args => + { + Width = args.Width; + Height = args.Height; + }; } public void Update() @@ -42,30 +48,30 @@ public class Window : IPresenter return; GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - + _engine.Renderer.TextureInternal.Bind(); - + GL.Enable(EnableCap.Texture2D); GL.Begin(PrimitiveType.Quads); GL.Color3(1, 1, 1); - + GL.TexCoord2(0, 0); GL.Vertex2(0, 0); - + GL.TexCoord2(1, 0); GL.Vertex2(1, 0); - + GL.TexCoord2(1, 1); GL.Vertex2(1, 1); - + GL.TexCoord2(0, 1); GL.Vertex2(0, 1); - + GL.End(); - + GL.Disable(EnableCap.Texture2D); GL.Flush(); - + _engine.Renderer.TextureInternal.Unbind(); } } diff --git a/EngineTests/src/Scene/GameObjectTests.cs b/EngineTests/src/Scene/GameObjectTests.cs new file mode 100644 index 0000000..c559edf --- /dev/null +++ b/EngineTests/src/Scene/GameObjectTests.cs @@ -0,0 +1,71 @@ +using Engine.Scene; +using Engine.Scene.Component.BuiltIn; + +namespace EngineTests.Scene; + +public class GameObjectTests +{ + private GameObject _gameObject; + + [SetUp] + public void Setup() + { + _gameObject = new GameObject(); + } + + [Test] + public void Constructor_ShouldInitializeWithTransformComponent() + { + Assert.Multiple(() => + { + Assert.That(_gameObject.Transform, Is.Not.Null); + Assert.That(_gameObject.HasComponent(), Is.True); + }); + } + + [Test] + public void AddComponent_ShouldThrowIfComponentAlreadyExists() + { + _gameObject.AddComponent(); + + Assert.Throws(() => _gameObject.ProcessChanges()); + } + + [Test] + public void AddComponent_ShouldAddComponentToGameObject() + { + _gameObject.AddComponent(); + _gameObject.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_gameObject.GetComponent(), Is.Not.Null); + Assert.That(_gameObject.HasComponent(), Is.True); + }); + } + + [Test] + public void AddComponent_ShouldAddComponentToGameObjectWithArgs() + { + _gameObject.AddComponent(99, 0.2f, 1001f); + _gameObject.ProcessChanges(); + + var camera = _gameObject.GetComponent()!; + + Assert.Multiple(() => + { + Assert.That(camera.FieldOfView, Is.EqualTo(99)); + Assert.That(camera.NearPlane, Is.EqualTo(0.2f)); + Assert.That(camera.FarPlane, Is.EqualTo(1001f)); + }); + } + + [Test] + public void AddComponent_ShouldThrowIfComponentBaseTypeAlreadyExists() + { + _gameObject.AddComponent(); + _gameObject.AddComponent(); + + Assert.Throws(() => _gameObject.ProcessChanges()); + } +} \ No newline at end of file diff --git a/EngineTests/src/Scene/HierarchyTests.cs b/EngineTests/src/Scene/HierarchyTests.cs new file mode 100644 index 0000000..2b9e426 --- /dev/null +++ b/EngineTests/src/Scene/HierarchyTests.cs @@ -0,0 +1,566 @@ +using Engine.Scene; + +namespace EngineTests.Scene; + +public class HierarchyTests +{ + private Hierarchy _hierarchy; + + [SetUp] + public void Setup() + { + _hierarchy = new Hierarchy(); + } + + [Test] + public void AddToRoot() + { + var obj = new object(); + _hierarchy.Add(obj); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren().Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.GetParent(obj), Is.Null); + }); + } + + [Test] + public void AddToChild() + { + var parent = new object(); + var child = new object(); + + _hierarchy.Add(parent); + _hierarchy.Add(child); + + _hierarchy.AddChild(parent, child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(parent).Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.GetParent(parent), Is.Null); + Assert.That(_hierarchy.GetParent(child), Is.EqualTo(parent)); + }); + } + + [Test] + public void RemoveChild() + { + var parent = new object(); + var child = new object(); + + _hierarchy.Add(parent); + _hierarchy.Add(child); + + _hierarchy.AddChild(parent, child); + _hierarchy.ProcessChanges(); + + _hierarchy.Remove(child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(parent).Count(), Is.EqualTo(0)); + Assert.That(_hierarchy.Contains(child), Is.False); + }); + } + + [Test] + public void RemoveParentAndAllChildren() + { + var parent = new object(); + var child1 = new object(); + var child2 = new object(); + + _hierarchy.Add(parent); + _hierarchy.Add(child1); + _hierarchy.Add(child2); + + _hierarchy.AddChild(parent, child1); + _hierarchy.AddChild(parent, child2); + _hierarchy.ProcessChanges(); + + _hierarchy.Remove(parent); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren().Count(), Is.EqualTo(0)); + Assert.That(_hierarchy.Contains(child1), Is.False); + Assert.That(_hierarchy.Contains(child2), Is.False); + Assert.That(_hierarchy.Contains(parent), Is.False); + }); + } + + [Test] + public void DetectCycleInHierarchy() + { + var parent = new object(); + var child = new object(); + + _hierarchy.Add(parent); + _hierarchy.AddChild(parent, child); + _hierarchy.AddChild(child, parent); + + Assert.Throws(() => _hierarchy.ProcessChanges()); + } + + [Test] + public void IsInHierarchy_PositiveCase() + { + var ancestor = new object(); + var child = new object(); + + _hierarchy.Add(ancestor); + _hierarchy.Add(child); + + _hierarchy.AddChild(ancestor, child); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.IsInHierarchy(ancestor, child), Is.True); + } + + [Test] + public void IsInHierarchy_NegativeCase() + { + var obj1 = new object(); + var obj2 = new object(); + + _hierarchy.Add(obj1); + _hierarchy.Add(obj2); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.IsInHierarchy(obj1, obj2), Is.False); + } + + [Test] + public void MultipleChildrenHandling() + { + var parent = new object(); + var child1 = new object(); + var child2 = new object(); + + _hierarchy.Add(parent); + _hierarchy.Add(child1); + _hierarchy.Add(child2); + + _hierarchy.AddChild(parent, child1); + _hierarchy.AddChild(parent, child2); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(parent).Count(), Is.EqualTo(2)); + Assert.That(_hierarchy.GetParent(child1), Is.EqualTo(parent)); + Assert.That(_hierarchy.GetParent(child2), Is.EqualTo(parent)); + }); + } + + [Test] + public void GetChildren_EmptyCase() + { + var obj = new object(); + _hierarchy.Add(obj); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.GetChildren(obj).Count(), Is.EqualTo(0)); + } + + [Test] + public void AddDuplicateObjectThrows() + { + var obj = new object(); + + _hierarchy.Add(obj); + _hierarchy.Add(obj); + + Assert.Throws(() => _hierarchy.ProcessChanges()); + } + + [Test] + public void AddChild_ParentNotInHierarchyThrows() + { + var parent = new object(); + var child = new object(); + + _hierarchy.Add(child); + _hierarchy.AddChild(parent, child); + + Assert.Throws(() => _hierarchy.ProcessChanges()); + } + + [Test] + public void GetParent_ObjectNotInHierarchyThrows() + { + var obj = new object(); + + Assert.That(_hierarchy.Contains(obj), Is.False); + } + + [Test] + public void NestedHierarchyTest() + { + var grandParent = new object(); + var parent = new object(); + var child = new object(); + + _hierarchy.Add(grandParent); + _hierarchy.Add(parent); + _hierarchy.Add(child); + + _hierarchy.AddChild(grandParent, parent); + _hierarchy.AddChild(parent, child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetParent(parent), Is.EqualTo(grandParent)); + Assert.That(_hierarchy.GetParent(child), Is.EqualTo(parent)); + Assert.That(_hierarchy.IsInHierarchy(grandParent, child), Is.True); + }); + } + + [Test] + public void ReparentObjectTest() + { + var parent1 = new object(); + var parent2 = new object(); + var child = new object(); + + _hierarchy.Add(parent1); + _hierarchy.Add(parent2); + _hierarchy.Add(child); + + _hierarchy.AddChild(parent1, child); + _hierarchy.ProcessChanges(); + + // Reparent the child to parent2 + _hierarchy.AddChild(parent2, child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(parent1).Count(), Is.EqualTo(0)); + Assert.That(_hierarchy.GetChildren(parent2).Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.GetParent(child), Is.EqualTo(parent2)); + }); + } + + [Test] + public void RemoveRootObjectsTest() + { + var obj1 = new object(); + var obj2 = new object(); + + _hierarchy.Add(obj1); + _hierarchy.Add(obj2); + _hierarchy.ProcessChanges(); + + _hierarchy.Remove(obj1); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren().Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.Contains(obj1), Is.False); + }); + } + + [Test] + public void IsInHierarchy_SelfTest() + { + var obj = new object(); + _hierarchy.Add(obj); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.IsInHierarchy(obj, obj), Is.True); + } + + [Test] + public void ComplexHierarchyRemovalTest() + { + var grandParent = new object(); + var parent1 = new object(); + var parent2 = new object(); + var child1 = new object(); + var child2 = new object(); + + _hierarchy.Add(grandParent); + _hierarchy.Add(parent1); + _hierarchy.Add(parent2); + _hierarchy.Add(child1); + _hierarchy.Add(child2); + + _hierarchy.AddChild(grandParent, parent1); + _hierarchy.AddChild(grandParent, parent2); + _hierarchy.AddChild(parent1, child1); + _hierarchy.AddChild(parent2, child2); + _hierarchy.ProcessChanges(); + + _hierarchy.Remove(parent1); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(grandParent).Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.Contains(child1), Is.False); + Assert.That(_hierarchy.Contains(parent1), Is.False); + }); + } + + [Test] + public void AddChild_SelfAsParentThrowsException() + { + var obj = new object(); + _hierarchy.Add(obj); + + Assert.Throws(() => _hierarchy.AddChild(obj, obj)); + } + + [Test] + public void MultiLevelReparentingTest() + { + var grandParent = new object(); + var parent = new object(); + var child1 = new object(); + var child2 = new object(); + + _hierarchy.Add(grandParent); + _hierarchy.Add(parent); + _hierarchy.Add(child1); + _hierarchy.Add(child2); + + // Initial hierarchy setup + _hierarchy.AddChild(grandParent, parent); + _hierarchy.AddChild(parent, child1); + _hierarchy.AddChild(parent, child2); + _hierarchy.ProcessChanges(); + + // Reparent child1 to child2 + _hierarchy.AddChild(child2, child1); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(parent).Count(), Is.EqualTo(1)); + Assert.That(_hierarchy.GetParent(child1), Is.EqualTo(child2)); + Assert.That(_hierarchy.IsInHierarchy(grandParent, child1), Is.True); + }); + } + + [Test] + public void DeepHierarchyTest() + { + const int hierarchyDepth = 100; + var objects = new object[hierarchyDepth]; + + // Create objects + for (var i = 0; i < hierarchyDepth; i++) + { + objects[i] = new object(); + _hierarchy.Add(objects[i]); + } + + // Create a deep hierarchy + for (var i = 1; i < hierarchyDepth; i++) + { + _hierarchy.AddChild(objects[i - 1], objects[i]); + } + + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + // Verify parent-child relationships + for (var i = 1; i < hierarchyDepth; i++) + { + Assert.That(_hierarchy.GetParent(objects[i]), Is.EqualTo(objects[i - 1])); + Assert.That(_hierarchy.IsInHierarchy(objects[0], objects[i]), Is.True); + } + + // Verify children + for (var i = 0; i < hierarchyDepth - 1; i++) + { + var children = _hierarchy.GetChildren(objects[i]).ToList(); + Assert.That(children, Has.Count.EqualTo(1)); + Assert.That(children[0], Is.EqualTo(objects[i + 1])); + } + }); + } + + [Test] + public void RemoveFromMiddleOfHierarchyTest() + { + var grandParent = new object(); + var parent = new object(); + var child1 = new object(); + var child2 = new object(); + + _hierarchy.Add(grandParent); + _hierarchy.Add(parent); + _hierarchy.Add(child1); + _hierarchy.Add(child2); + + _hierarchy.AddChild(grandParent, parent); + _hierarchy.AddChild(parent, child1); + _hierarchy.AddChild(parent, child2); + _hierarchy.ProcessChanges(); + + // Remove parent, which will also remove its children + _hierarchy.Remove(parent); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.GetChildren(grandParent).Count(), Is.EqualTo(0)); + Assert.That(_hierarchy.Contains(child1), Is.False); + Assert.That(_hierarchy.Contains(child2), Is.False); + Assert.That(_hierarchy.Contains(parent), Is.False); + }); + } + + [Test] + public void ConcurrentModificationTest() + { + var parent = new object(); + var children = Enumerable.Range(0, 10) + .Select(_ => new object()) + .ToList(); + + _hierarchy.Add(parent); + children.ForEach(child => _hierarchy.Add(child)); + + // Simulate concurrent child additions + children.AsParallel().ForAll(child => { _hierarchy.AddChild(parent, child); }); + + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.GetChildren(parent).Count(), Is.EqualTo(children.Count)); + } + + [Test] + public void IsInHierarchy_NullInputs() + { + Assert.Multiple(() => + { + // Null child + Assert.That(_hierarchy.IsInHierarchy(new object(), null), Is.False); + + // Null ancestor + Assert.That(_hierarchy.IsInHierarchy(null, new object()), Is.False); + + // Both null + Assert.That(_hierarchy.IsInHierarchy(null, null), Is.False); + }); + } + + [Test] + public void IsInHierarchy_SameObject() + { + var obj = new object(); + _hierarchy.Add(obj); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.IsInHierarchy(obj, obj), Is.True); + } + + [Test] + public void IsInHierarchy_DirectParentChild() + { + var parent = new object(); + var child = new object(); + + _hierarchy.Add(parent); + _hierarchy.Add(child); + _hierarchy.AddChild(parent, child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_hierarchy.IsInHierarchy(parent, child), Is.True); + Assert.That(_hierarchy.IsInHierarchy(child, parent), Is.False); + }); + } + + [Test] + public void IsInHierarchy_DeepHierarchy() + { + var grandParent = new object(); + var parent = new object(); + var child = new object(); + var grandChild = new object(); + + _hierarchy.Add(grandParent); + _hierarchy.Add(parent); + _hierarchy.Add(child); + _hierarchy.Add(grandChild); + + _hierarchy.AddChild(grandParent, parent); + _hierarchy.AddChild(parent, child); + _hierarchy.AddChild(child, grandChild); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + // Verify deep hierarchy relationships + Assert.That(_hierarchy.IsInHierarchy(grandParent, grandChild), Is.True); + Assert.That(_hierarchy.IsInHierarchy(parent, grandChild), Is.True); + Assert.That(_hierarchy.IsInHierarchy(child, grandChild), Is.True); + + // Verify inverse relationships are false + Assert.That(_hierarchy.IsInHierarchy(grandChild, grandParent), Is.False); + Assert.That(_hierarchy.IsInHierarchy(grandChild, parent), Is.False); + Assert.That(_hierarchy.IsInHierarchy(grandChild, child), Is.False); + }); + } + + [Test] + public void IsInHierarchy_SeparateBranches() + { + var root = new object(); + var branch1Parent = new object(); + var branch1Child = new object(); + var branch2Parent = new object(); + var branch2Child = new object(); + + _hierarchy.Add(root); + _hierarchy.Add(branch1Parent); + _hierarchy.Add(branch1Child); + _hierarchy.Add(branch2Parent); + _hierarchy.Add(branch2Child); + + _hierarchy.AddChild(root, branch1Parent); + _hierarchy.AddChild(root, branch2Parent); + _hierarchy.AddChild(branch1Parent, branch1Child); + _hierarchy.AddChild(branch2Parent, branch2Child); + _hierarchy.ProcessChanges(); + + Assert.Multiple(() => + { + // Verify within same branch + Assert.That(_hierarchy.IsInHierarchy(root, branch1Parent), Is.True); + Assert.That(_hierarchy.IsInHierarchy(root, branch1Child), Is.True); + Assert.That(_hierarchy.IsInHierarchy(branch1Parent, branch1Child), Is.True); + + // Verify between different branches + Assert.That(_hierarchy.IsInHierarchy(branch1Parent, branch2Child), Is.False); + Assert.That(_hierarchy.IsInHierarchy(branch2Parent, branch1Child), Is.False); + }); + } + + [Test] + public void IsInHierarchy_ObjectsNotInHierarchy() + { + var obj1 = new object(); + var obj2 = new object(); + + _hierarchy.Add(obj1); + _hierarchy.Add(obj2); + _hierarchy.ProcessChanges(); + + Assert.That(_hierarchy.IsInHierarchy(obj1, obj2), Is.False); + } +} \ No newline at end of file