diff --git a/DoomDeathmatch.sln b/DoomDeathmatch.sln index 3dc2ea0..ebe97a0 100644 --- a/DoomDeathmatch.sln +++ b/DoomDeathmatch.sln @@ -12,6 +12,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PresenterNative", "Presente EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DoomDeathmatch", "DoomDeathmatch\DoomDeathmatch.csproj", "{4FA5E1F8-B647-4764-9147-B6F5A4E9D1D0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtil", "TestUtil\TestUtil.csproj", "{923DEAFC-55C6-426A-8EE8-D74142FDC342}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameTests", "GameTests\GameTests.csproj", "{C2CE10BB-DB8C-4283-BC22-88CDA8B54DEB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,5 +46,13 @@ Global {4FA5E1F8-B647-4764-9147-B6F5A4E9D1D0}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FA5E1F8-B647-4764-9147-B6F5A4E9D1D0}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FA5E1F8-B647-4764-9147-B6F5A4E9D1D0}.Release|Any CPU.Build.0 = Release|Any CPU + {923DEAFC-55C6-426A-8EE8-D74142FDC342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {923DEAFC-55C6-426A-8EE8-D74142FDC342}.Debug|Any CPU.Build.0 = Debug|Any CPU + {923DEAFC-55C6-426A-8EE8-D74142FDC342}.Release|Any CPU.ActiveCfg = Release|Any CPU + {923DEAFC-55C6-426A-8EE8-D74142FDC342}.Release|Any CPU.Build.0 = Release|Any CPU + {C2CE10BB-DB8C-4283-BC22-88CDA8B54DEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2CE10BB-DB8C-4283-BC22-88CDA8B54DEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2CE10BB-DB8C-4283-BC22-88CDA8B54DEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2CE10BB-DB8C-4283-BC22-88CDA8B54DEB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/DoomDeathmatch/DoomDeathmatch.csproj b/DoomDeathmatch/DoomDeathmatch.csproj index 7763b52..da0f068 100644 --- a/DoomDeathmatch/DoomDeathmatch.csproj +++ b/DoomDeathmatch/DoomDeathmatch.csproj @@ -6,6 +6,14 @@ enable + + + + + + + + diff --git a/DoomDeathmatch/src/Component/MVC/Health/HealthController.cs b/DoomDeathmatch/src/Component/MVC/Health/HealthController.cs index 2c2780b..35d1032 100644 --- a/DoomDeathmatch/src/Component/MVC/Health/HealthController.cs +++ b/DoomDeathmatch/src/Component/MVC/Health/HealthController.cs @@ -18,6 +18,11 @@ public class HealthController : Engine.Scene.Component.Component /// public bool IsAlive => _healthModel.Health > 0; + /// + /// The current health value of the entity. + /// + public float Health => _healthModel.Health; + /// /// The health model containing the current and maximum health. /// diff --git a/DoomDeathmatch/src/Component/MVC/Health/HealthView.cs b/DoomDeathmatch/src/Component/MVC/Health/HealthView.cs index 176a90a..0ff011a 100644 --- a/DoomDeathmatch/src/Component/MVC/Health/HealthView.cs +++ b/DoomDeathmatch/src/Component/MVC/Health/HealthView.cs @@ -24,7 +24,7 @@ public class HealthView : Engine.Scene.Component.Component, IView } /// - public virtual void UpdateView(HealthModel parHealthModel) + public void UpdateView(HealthModel parHealthModel) { var percentage = parHealthModel.Health / parHealthModel.MaxHealth * 100; if (parHealthModel.Health != 0) diff --git a/DoomDeathmatch/src/Component/MVC/MovementComponent.cs b/DoomDeathmatch/src/Component/MVC/MovementComponent.cs index 2e287e6..8aeb202 100644 --- a/DoomDeathmatch/src/Component/MVC/MovementComponent.cs +++ b/DoomDeathmatch/src/Component/MVC/MovementComponent.cs @@ -26,7 +26,7 @@ public class MovementComponent : Engine.Scene.Component.Component /// /// The drag component for the game object. /// - private DragComponent _dragComponent = null!; + private DragComponent? _dragComponent; public override void Awake() { @@ -34,7 +34,6 @@ public class MovementComponent : Engine.Scene.Component.Component _dragComponent = GameObject.GetComponent()!; ArgumentNullException.ThrowIfNull(_rigidbody); - ArgumentNullException.ThrowIfNull(_dragComponent); } /// @@ -43,7 +42,7 @@ public class MovementComponent : Engine.Scene.Component.Component /// The direction of movement. public void ApplyMovement(Vector3 parDirection) { - _rigidbody.Force += _dragComponent.Drag * Speed * parDirection.Normalized(); + _rigidbody.Force += (_dragComponent?.Drag ?? 1) * Speed * parDirection.Normalized(); } /// @@ -53,6 +52,6 @@ public class MovementComponent : Engine.Scene.Component.Component public void ApplyRotation(Vector3 parAxis) { var radiansPerSecond = MathHelper.DegreesToRadians(RotationSpeed); - _rigidbody.Torque += _dragComponent.RotationalDrag * radiansPerSecond * parAxis.Normalized(); + _rigidbody.Torque += (_dragComponent?.RotationalDrag ?? 1) * radiansPerSecond * parAxis.Normalized(); } } \ No newline at end of file diff --git a/DoomDeathmatch/src/Component/MVC/Weapon/WeaponController.cs b/DoomDeathmatch/src/Component/MVC/Weapon/WeaponController.cs index 090d5e3..8712475 100644 --- a/DoomDeathmatch/src/Component/MVC/Weapon/WeaponController.cs +++ b/DoomDeathmatch/src/Component/MVC/Weapon/WeaponController.cs @@ -30,7 +30,7 @@ public class WeaponController : Engine.Scene.Component.Component /// /// View responsible for displaying weapon information and animations. /// - private IWeaponView _weaponView = null!; + private IWeaponView? _weaponView; /// /// Initializes a new instance of the class with an initial weapon. @@ -43,7 +43,7 @@ public class WeaponController : Engine.Scene.Component.Component public override void Awake() { - _weaponView = GameObject.GetComponent()!; + _weaponView = GameObject.GetComponent(); _weaponModel.OnWeaponSelected += WeaponSelected; WeaponSelected(null, _weaponModel.SelectedWeapon); @@ -63,7 +63,7 @@ public class WeaponController : Engine.Scene.Component.Component _weaponModel.SelectedWeapon.Ammo--; OnWeaponShot?.Invoke(_weaponModel.SelectedWeapon); - _weaponView.PlayFireAnimation(); + _weaponView?.PlayFireAnimation(); return true; } @@ -178,7 +178,7 @@ public class WeaponController : Engine.Scene.Component.Component } parNewWeapon.OnAmmoChanged += AmmoChanged; - _weaponView.UpdateView(parNewWeapon); + _weaponView?.UpdateView(parNewWeapon); AmmoChanged(parNewWeapon); } @@ -190,6 +190,6 @@ public class WeaponController : Engine.Scene.Component.Component { var ammoData = new AmmoData { Ammo = parWeapon.Ammo, MaxAmmo = parWeapon.MaxAmmo }; - _weaponView.UpdateView(ammoData); + _weaponView?.UpdateView(ammoData); } } \ No newline at end of file diff --git a/DoomDeathmatch/src/Component/Physics/RigidbodyComponent.cs b/DoomDeathmatch/src/Component/Physics/RigidbodyComponent.cs index 837ebde..4a3e368 100644 --- a/DoomDeathmatch/src/Component/Physics/RigidbodyComponent.cs +++ b/DoomDeathmatch/src/Component/Physics/RigidbodyComponent.cs @@ -154,16 +154,9 @@ public class RigidbodyComponent : Engine.Scene.Component.Component _angularAcceleration = Torque / MomentOfInertia; AngularVelocity += _angularAcceleration * (float)parDeltaTime; - // Update rotation using quaternion math - var rotation = GameObject.Transform.Rotation; - var angularVelocityQuat = new Quaternion(AngularVelocity, 0.0f); - - // Quaternion rotation integration: Δq = 0.5 * angularVelocityQuat * rotation - var deltaRotation = 0.5f * angularVelocityQuat * rotation; - rotation += deltaRotation * (float)parDeltaTime; - rotation.Normalize(); // Ensure the quaternion remains normalized - - GameObject.Transform.Rotation = rotation; + GameObject.Transform.Rotation *= Quaternion.FromAxisAngle(Vector3.UnitX, AngularVelocity.X * (float)parDeltaTime); + GameObject.Transform.Rotation *= Quaternion.FromAxisAngle(Vector3.UnitY, AngularVelocity.Y * (float)parDeltaTime); + GameObject.Transform.Rotation *= Quaternion.FromAxisAngle(Vector3.UnitZ, AngularVelocity.Z * (float)parDeltaTime); Torque = Vector3.Zero; } diff --git a/DoomDeathmatch/src/Component/UI/TextAlignComponent.cs b/DoomDeathmatch/src/Component/UI/TextAlignComponent.cs index eaff416..40aae68 100644 --- a/DoomDeathmatch/src/Component/UI/TextAlignComponent.cs +++ b/DoomDeathmatch/src/Component/UI/TextAlignComponent.cs @@ -58,9 +58,9 @@ public class TextAlignComponent : Engine.Scene.Component.Component { return Alignment switch { - Align.Left => new Vector2(0, -parSize.Y / 2), - Align.Center => new Vector2(-parSize.X / 2, -parSize.Y / 2), - Align.Right => new Vector2(-parSize.X, -parSize.Y / 2), + Align.Left => new Vector2(0, parSize.Y / 2 - _textRenderer.Font.Metadata.Metrics.Ascender), + Align.Center => new Vector2(-parSize.X / 2, parSize.Y / 2 - _textRenderer.Font.Metadata.Metrics.Ascender), + Align.Right => new Vector2(-parSize.X, parSize.Y / 2 - _textRenderer.Font.Metadata.Metrics.Ascender), _ => throw new ArgumentOutOfRangeException(nameof(Alignment), Alignment, null) }; } diff --git a/DoomDeathmatch/src/Scene/Main/MainScene.cs b/DoomDeathmatch/src/Scene/Main/MainScene.cs index 1bf3471..5d38ff1 100644 --- a/DoomDeathmatch/src/Scene/Main/MainScene.cs +++ b/DoomDeathmatch/src/Scene/Main/MainScene.cs @@ -180,9 +180,14 @@ public static class MainScene var (rulesObject, rulesUi, _) = UiPrefabs.CreateTextUi(parScene, parUiContainer, UiPrefabs.GetDoomFont(), "Правила"); + var (actualRulesObject, actualRulesUi, _) = UiPrefabs.CreateTextUi(parScene, parUiContainer, + UiPrefabs.GetDoomFont(), "Игрок управляет персонажем, который может передвигаться, собирать предметы и использовать оружие.\nВ игре два вида оружия: пистолет и дробовик, каждое с уникальными характеристиками и ограничением по боеприпасам.\nПротивники делятся на демонов, которые преследуют игрока и наносят ближний урон, и импом,\nкоторые атакуют издалека и создают огненные шары.\nНа уровне случайно появляются предметы, такие как оружие и аптечки, которые восполняют здоровье или боеприпасы.\nЗа уничтожение врагов игрок получает очки, отображаемые в счетчике.\nЛучшие результаты сохраняются в таблице рекордов."); + + actualRulesObject.Transform.Scale.Xy = new Vector2(0.5f); + var (stackObject, stack) = UiPrefabs.CreateStackUi(parScene, - new StackComponent { Offset = new Vector2(0, -1f), Container = parUiContainer, Children = { rulesUi, backUi } }); - stackObject.Transform.Size.Xy = new Vector2(1f, 6f); + new StackComponent { Offset = new Vector2(0, -1.5f), Container = parUiContainer, Children = { rulesUi, actualRulesUi, backUi } }); + stackObject.Transform.Size.Xy = new Vector2(1f, 9f); var (selectorObject, selector) = UiPrefabs.CreateSelectorUi(parScene, new SelectorComponent { Children = { backUi } }); @@ -191,6 +196,7 @@ public static class MainScene parScene.AddChild(parentObject, stackObject); parScene.AddChild(stackObject, rulesObject); + parScene.AddChild(stackObject, actualRulesObject); parScene.AddChild(stackObject, backUiObject); return parentObject; diff --git a/DoomDeathmatch/src/Scene/Play/Prefab/PlayerPrefab.cs b/DoomDeathmatch/src/Scene/Play/Prefab/PlayerPrefab.cs index edd578a..9e430ed 100644 --- a/DoomDeathmatch/src/Scene/Play/Prefab/PlayerPrefab.cs +++ b/DoomDeathmatch/src/Scene/Play/Prefab/PlayerPrefab.cs @@ -19,7 +19,7 @@ public static class PlayerPrefab perspectiveCameraObject.Transform.Translation.Z = 2; var playerObject = GameObjectUtil.CreateGameObject(parScene, [ new RigidbodyComponent(), - new DragComponent { Drag = 10f, RotationalDrag = 10f, }, + new DragComponent { Drag = 10f, RotationalDrag = 20f, }, new AABBColliderComponent { diff --git a/DoomDeathmatch/src/Scene/UiPrefabs.cs b/DoomDeathmatch/src/Scene/UiPrefabs.cs index b69face..786a432 100644 --- a/DoomDeathmatch/src/Scene/UiPrefabs.cs +++ b/DoomDeathmatch/src/Scene/UiPrefabs.cs @@ -42,10 +42,10 @@ public static class UiPrefabs public static (GameObject, UiContainerComponent, (GameObject, TextRenderer)) CreateTextUi(Engine.Scene.Scene parScene, UiContainerComponent parContainer, - Font parFont, string parText, Align parAlign = Align.Center, + Font? parFont, string parText, Align parAlign = Align.Center, RenderLayer? parRenderLayer = null, float parScale = 1) { - var size = parFont.Measure(parText); + var size = parFont?.Measure(parText) ?? Vector2.Zero; var outerObject = new GameObject { Transform = diff --git a/Engine/Engine.csproj b/Engine/Engine.csproj index 73950f4..e86f59e 100644 --- a/Engine/Engine.csproj +++ b/Engine/Engine.csproj @@ -15,6 +15,7 @@ + diff --git a/Engine/src/Context/EngineContext.cs b/Engine/src/Context/EngineContext.cs new file mode 100644 index 0000000..dc51489 --- /dev/null +++ b/Engine/src/Context/EngineContext.cs @@ -0,0 +1,41 @@ +using Engine.Graphics; +using Engine.Input; +using Engine.Resource; +using Engine.Scene; + +namespace Engine.Context; + +/// +/// A context for the engine, providing access to the engine's services. +/// +public class EngineContext : IContext +{ + public IInputHandler InputHandler => _engine.InputHandler!; + + public IResourceManager AssetResourceManager => _engine.AssetResourceManager; + + public ISceneManager SceneManager => _engine.SceneManager; + + public IRenderer Renderer => _engine.Renderer; + + public string DataFolder => _engine.DataFolder; + + /// + /// The engine instance associated with this context. + /// + private readonly Engine _engine; + + /// + /// Initializes a new instance of the class. + /// + /// The engine instance to use for this context. + public EngineContext(Engine parEngine) + { + _engine = parEngine; + } + + public void Close() + { + _engine.Close(); + } +} \ No newline at end of file diff --git a/Engine/src/Context/IContext.cs b/Engine/src/Context/IContext.cs new file mode 100644 index 0000000..0ff9cc8 --- /dev/null +++ b/Engine/src/Context/IContext.cs @@ -0,0 +1,42 @@ +using Engine.Graphics; +using Engine.Input; +using Engine.Resource; +using Engine.Scene; + +namespace Engine.Context; + +/// +/// Defines an interface for the engine's context, providing access to the engine's services. +/// +public interface IContext +{ + /// + /// The input handler for the engine. + /// + public IInputHandler InputHandler { get; } + + /// + /// The resource manager for the engine. + /// + public IResourceManager AssetResourceManager { get; } + + /// + /// The scene manager for the engine. + /// + public ISceneManager SceneManager { get; } + + /// + /// The renderer for the engine. + /// + public IRenderer Renderer { get; } + + /// + /// The data folder for the engine. + /// + public string DataFolder { get; } + + /// + /// Closes the engine, shutting down any running systems and freeing resources. + /// + public void Close(); +} \ No newline at end of file diff --git a/Engine/src/Engine.cs b/Engine/src/Engine.cs index c21964d..4e3acbe 100644 --- a/Engine/src/Engine.cs +++ b/Engine/src/Engine.cs @@ -3,6 +3,7 @@ using System.Text; using Engine.Asset; using Engine.Asset.Font; using Engine.Asset.Mesh; +using Engine.Context; using Engine.Graphics; using Engine.Graphics.Pipeline; using Engine.Graphics.Pixel; @@ -12,6 +13,7 @@ using Engine.Input; using Engine.Resource; using Engine.Resource.Loader; using Engine.Scene; +using Engine.Util; using OpenTK.Mathematics; using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; @@ -33,7 +35,7 @@ public sealed class Engine /// /// The scene manager for managing and updating scenes. /// - public SceneManager SceneManager { get; } = new(); + public ISceneManager SceneManager => _sceneManager; /// /// The resource manager responsible for asset management. @@ -87,6 +89,11 @@ public sealed class Engine /// internal Window Window { get; } + /// + /// The scene manager for managing and updating scenes. + /// + private readonly SceneManager _sceneManager = new(); + /// /// The logger instance used by the engine. /// @@ -154,6 +161,8 @@ public sealed class Engine Renderer = new Renderer(this, parWidth, parHeight, settings); Window = new Window(this, Renderer.NativeWindow, parHeadless); + + EngineUtil.SetContext(new EngineContext(this)); } /// @@ -250,7 +259,7 @@ public sealed class Engine } } - SceneManager.Render(); + _sceneManager.Render(); } Monitor.Exit(_sceneLock); @@ -283,7 +292,7 @@ public sealed class Engine { try { - SceneManager.Update(deltaTime); + _sceneManager.Update(deltaTime); } catch (Exception ex) { diff --git a/Engine/src/Graphics/GenericRenderer.cs b/Engine/src/Graphics/GenericRenderer.cs index c019ab7..3eb987f 100644 --- a/Engine/src/Graphics/GenericRenderer.cs +++ b/Engine/src/Graphics/GenericRenderer.cs @@ -10,22 +10,22 @@ namespace Engine.Graphics; /// /// A generic renderer that supports rendering quads, meshes, and text. /// -public class GenericRenderer : IRenderer +public class GenericRenderer : IGenericRenderer { /// /// Provides functionality to render quads. /// - public QuadRenderer QuadRenderer => _quadRenderer ??= new QuadRenderer(_engine, 1024 * 8); + public IQuadRenderer QuadRenderer => _quadRenderer ??= new QuadRenderer(_engine, 1024 * 8); /// /// Provides functionality to render any type of mesh. /// - public AnyMeshRenderer AnyMeshRenderer => _anyMeshRenderer ??= new AnyMeshRenderer(_engine, 1024); + public IAnyMeshRenderer AnyMeshRenderer => _anyMeshRenderer ??= new AnyMeshRenderer(_engine, 1024); /// /// Provides functionality to render text. /// - public TextRenderer TextRenderer => _textRenderer ??= new TextRenderer(_engine, 1024 * 8); + public ITextRenderer TextRenderer => _textRenderer ??= new TextRenderer(_engine, 1024 * 8); /// /// The framebuffer used for rendering. @@ -73,13 +73,19 @@ public class GenericRenderer : IRenderer .Build(); } - /// + /// + /// Prepares the renderer for a new frame. + /// public void StartFrame() { _frameStarted = true; } - /// + /// + /// Finalizes the rendering pipeline for the current frame. + /// + /// The projection matrix to use for rendering. + /// The view matrix to use for rendering. public void EndFrame(in Matrix4 parProjectionMatrix, in Matrix4 parViewMatrix) { if (!_frameStarted) @@ -115,7 +121,11 @@ public class GenericRenderer : IRenderer _frameStarted = false; } - /// + /// + /// Resizes the renderer to accommodate changes in viewport dimensions. + /// + /// The new width of the viewport. + /// The new height of the viewport. public void Resize(int parWidth, int parHeight) { _framebuffer.Resize(parWidth, parHeight); diff --git a/Engine/src/Graphics/IGenericRenderer.cs b/Engine/src/Graphics/IGenericRenderer.cs new file mode 100644 index 0000000..de345f7 --- /dev/null +++ b/Engine/src/Graphics/IGenericRenderer.cs @@ -0,0 +1,26 @@ +using Engine.Graphics.Render.Mesh; +using Engine.Graphics.Render.Quad; +using Engine.Graphics.Render.Text; + +namespace Engine.Graphics; + +/// +/// Interface defining the essential functionality for a renderer. +/// +public interface IGenericRenderer +{ + /// + /// Provides functionality to render quads. + /// + public IQuadRenderer QuadRenderer { get; } + + /// + /// Provides functionality to render any type of mesh. + /// + public IAnyMeshRenderer AnyMeshRenderer { get; } + + /// + /// Provides functionality to render text. + /// + public ITextRenderer TextRenderer { get; } +} \ No newline at end of file diff --git a/Engine/src/Graphics/IRenderer.cs b/Engine/src/Graphics/IRenderer.cs index 1c9f9f6..aaaf015 100644 --- a/Engine/src/Graphics/IRenderer.cs +++ b/Engine/src/Graphics/IRenderer.cs @@ -1,28 +1,14 @@ -using OpenTK.Mathematics; +using Engine.Graphics.Pipeline; namespace Engine.Graphics; -/// -/// Interface defining the essential functionality for a renderer. -/// -internal interface IRenderer +public interface IRenderer { /// - /// Prepares the renderer for a new frame. + /// Retrieves the renderer for the specified render layer. /// - public void StartFrame(); - - /// - /// Finalizes the rendering pipeline for the current frame. - /// - /// The projection matrix to use for rendering. - /// The view matrix to use for rendering. - public void EndFrame(in Matrix4 parProjectionMatrix, in Matrix4 parViewMatrix); - - /// - /// Resizes the renderer to accommodate changes in viewport dimensions. - /// - /// The new width of the viewport. - /// The new height of the viewport. - public void Resize(int parWidth, int parHeight); + /// The render layer to retrieve. + /// The for the specified render layer. + /// Thrown if the render layer does not exist. + public IGenericRenderer this[RenderLayer parRenderLayer] { get; } } \ No newline at end of file diff --git a/Engine/src/Graphics/Render/Mesh/AnyMeshRenderer.cs b/Engine/src/Graphics/Render/Mesh/AnyMeshRenderer.cs index aec0807..c30be33 100644 --- a/Engine/src/Graphics/Render/Mesh/AnyMeshRenderer.cs +++ b/Engine/src/Graphics/Render/Mesh/AnyMeshRenderer.cs @@ -7,7 +7,7 @@ namespace Engine.Graphics.Render.Mesh; /// A renderer class that manages multiple meshes and delegates rendering to individual mesh renderers. /// Handles batching of mesh instances and ensures that only the necessary mesh renderers are created. /// -public class AnyMeshRenderer +public class AnyMeshRenderer : IAnyMeshRenderer { /// /// A dictionary that maps each mesh to its corresponding . @@ -40,14 +40,8 @@ public class AnyMeshRenderer _program = parEngine.EngineResourceManager.Load("shader/mesh"); } - /// - /// Commits an instance of a mesh to the renderer, adding it to the render queue with the specified model matrix and optional texture. - /// If the mesh is not already being tracked, a new will be created for it. - /// - /// The mesh to render. - /// The model transformation matrix to apply to the mesh. - /// An optional texture to apply to the mesh. If null, no texture is applied. - public void Commit(Asset.Mesh.Mesh parMesh, Matrix4 parModelMatrix, Texture.Texture? parAlbedo = null) + /// + public void Commit(Asset.Mesh.Mesh parMesh, in Matrix4 parModelMatrix, Texture.Texture? parAlbedo = null) { if (_meshRenderers.TryGetValue(parMesh, out var meshRenderer)) { diff --git a/Engine/src/Graphics/Render/Mesh/IAnyMeshRenderer.cs b/Engine/src/Graphics/Render/Mesh/IAnyMeshRenderer.cs new file mode 100644 index 0000000..7c3988b --- /dev/null +++ b/Engine/src/Graphics/Render/Mesh/IAnyMeshRenderer.cs @@ -0,0 +1,18 @@ +using OpenTK.Mathematics; + +namespace Engine.Graphics.Render.Mesh; + +/// +/// Defines an interface for a renderer that can render any type of mesh. +/// +public interface IAnyMeshRenderer +{ + /// + /// Commits an instance of a mesh to the renderer, adding it to the render queue with the specified model matrix and optional texture. + /// If the mesh is not already being tracked, a new will be created for it. + /// + /// The mesh to render. + /// The model transformation matrix to apply to the mesh. + /// An optional texture to apply to the mesh. If null, no texture is applied. + public void Commit(Asset.Mesh.Mesh parMesh, in Matrix4 parModelMatrix, Texture.Texture? parAlbedo = null); +} \ No newline at end of file diff --git a/Engine/src/Graphics/Render/Mesh/MeshRenderer.cs b/Engine/src/Graphics/Render/Mesh/MeshRenderer.cs index 975268a..7b769d7 100644 --- a/Engine/src/Graphics/Render/Mesh/MeshRenderer.cs +++ b/Engine/src/Graphics/Render/Mesh/MeshRenderer.cs @@ -52,7 +52,7 @@ public class MeshRenderer : InstancedRenderer /// The model transformation matrix for this instance. /// An optional texture to apply to the mesh. If null, no texture is applied. - public void Commit(Matrix4 parModelMatrix, Texture.Texture? parTexture = null) + public void Commit(in Matrix4 parModelMatrix, Texture.Texture? parTexture = null) { if (_queuedInstanceCount >= _instanceCount) { diff --git a/Engine/src/Graphics/Render/Quad/IQuadRenderer.cs b/Engine/src/Graphics/Render/Quad/IQuadRenderer.cs new file mode 100644 index 0000000..4c9035d --- /dev/null +++ b/Engine/src/Graphics/Render/Quad/IQuadRenderer.cs @@ -0,0 +1,17 @@ +using OpenTK.Mathematics; + +namespace Engine.Graphics.Render.Quad; + +/// +/// Defines an interface for a renderer that can render quads. +/// +public interface IQuadRenderer +{ + /// + /// Commits an instance to the renderer, adding it to the queue with the specified model matrix, color, and optional texture. + /// + /// The model transformation matrix for this instance. + /// The color to apply to this instance. + /// An optional texture to apply to the quad. If null, no texture is applied. + public void Commit(in Matrix4 parModelMatrix, in Vector4 parColor, Texture.Texture? parTexture = null); +} \ No newline at end of file diff --git a/Engine/src/Graphics/Render/Quad/QuadRenderer.cs b/Engine/src/Graphics/Render/Quad/QuadRenderer.cs index 79fc185..d37f475 100644 --- a/Engine/src/Graphics/Render/Quad/QuadRenderer.cs +++ b/Engine/src/Graphics/Render/Quad/QuadRenderer.cs @@ -9,7 +9,7 @@ namespace Engine.Graphics.Render.Quad; /// A renderer class for rendering quadrilaterals (quads) using instancing. /// Supports dynamic texture binding and manages the state for rendering multiple instances of quads. /// -public class QuadRenderer : InstancedRenderer +public class QuadRenderer : InstancedRenderer, IQuadRenderer { /// /// Maps textures to texture units with a limit of 16 texture units. @@ -47,12 +47,7 @@ public class QuadRenderer : InstancedRenderer - /// Commits an instance to the renderer, adding it to the queue with the specified model matrix, color, and optional texture. - /// - /// The model transformation matrix for this instance. - /// The color to apply to this instance. - /// An optional texture to apply to the quad. If null, no texture is applied. + /// public void Commit(in Matrix4 parModelMatrix, in Vector4 parColor, Texture.Texture? parTexture = null) { if (_queuedInstanceCount >= _instanceCount) diff --git a/Engine/src/Graphics/Render/Text/ITextRenderer.cs b/Engine/src/Graphics/Render/Text/ITextRenderer.cs new file mode 100644 index 0000000..c75e388 --- /dev/null +++ b/Engine/src/Graphics/Render/Text/ITextRenderer.cs @@ -0,0 +1,19 @@ +using Engine.Asset.Font; +using OpenTK.Mathematics; + +namespace Engine.Graphics.Render.Text; + +/// +/// Defines an interface for a renderer that can render text. +/// +public interface ITextRenderer +{ + /// + /// Commits a string of text to the renderer, creating the necessary glyphs and adding them to the render queue. + /// + /// The font to use for rendering the text. + /// The text string to render. + /// The color to apply to the text. + /// The model transformation matrix to apply to the text. + public void Commit(Font parFont, string parText, in Vector4 parColor, in Matrix4 parModelMatrix); +} \ No newline at end of file diff --git a/Engine/src/Graphics/Render/Text/TextRenderer.cs b/Engine/src/Graphics/Render/Text/TextRenderer.cs index 41ad085..757062c 100644 --- a/Engine/src/Graphics/Render/Text/TextRenderer.cs +++ b/Engine/src/Graphics/Render/Text/TextRenderer.cs @@ -11,7 +11,7 @@ namespace Engine.Graphics.Render.Text; /// A renderer class for rendering text using glyphs from a font atlas. /// Handles dynamic font rendering with support for textures. /// -public class TextRenderer +public class TextRenderer : ITextRenderer { /// /// The shader program used for rendering the text. @@ -80,14 +80,8 @@ public class TextRenderer _vertexArray.BindVertexBuffer(_glyphVertexBuffer); } - /// - /// Commits a string of text to the renderer, creating the necessary glyphs and adding them to the render queue. - /// - /// The font to use for rendering the text. - /// The text string to render. - /// The color to apply to the text. - /// The model transformation matrix to apply to the text. - public void Commit(Font parFont, string parText, Vector4 parColor, in Matrix4 parModelMatrix) + /// + public void Commit(Font parFont, string parText, in Vector4 parColor, in Matrix4 parModelMatrix) { if (_queuedCharacterCount >= _characterCount) { diff --git a/Engine/src/Graphics/Renderer.cs b/Engine/src/Graphics/Renderer.cs index 235e533..592abdd 100644 --- a/Engine/src/Graphics/Renderer.cs +++ b/Engine/src/Graphics/Renderer.cs @@ -10,7 +10,7 @@ namespace Engine.Graphics; /// /// Handles the rendering pipeline, manages render layers, and provides tools for rendering graphics in the engine. /// -public class Renderer +public class Renderer : IRenderer { /// /// The width of the viewport. @@ -89,13 +89,8 @@ public class Renderer } } - /// - /// Retrieves the renderer for the specified render layer. - /// - /// The render layer to retrieve. - /// The for the specified render layer. - /// Thrown if the render layer does not exist. - public GenericRenderer this[RenderLayer parRenderLayer] + /// + public IGenericRenderer this[RenderLayer parRenderLayer] { get { diff --git a/Engine/src/Scene/Component/BuiltIn/Renderer/Box2DRenderer.cs b/Engine/src/Scene/Component/BuiltIn/Renderer/Box2DRenderer.cs index 6e1636e..5b3f164 100644 --- a/Engine/src/Scene/Component/BuiltIn/Renderer/Box2DRenderer.cs +++ b/Engine/src/Scene/Component/BuiltIn/Renderer/Box2DRenderer.cs @@ -1,5 +1,6 @@ using Engine.Graphics.Pipeline; using Engine.Graphics.Texture; +using Engine.Util; using OpenTK.Mathematics; namespace Engine.Scene.Component.BuiltIn.Renderer; @@ -32,7 +33,7 @@ public class Box2DRenderer : Component /// public override void Render() { - Engine.Instance.Renderer[RenderLayer].QuadRenderer + EngineUtil.Renderer[RenderLayer].QuadRenderer .Commit(GameObject.Transform.FullTransformMatrix, Color, Texture); } } \ No newline at end of file diff --git a/Engine/src/Scene/Component/BuiltIn/Renderer/MeshRenderer.cs b/Engine/src/Scene/Component/BuiltIn/Renderer/MeshRenderer.cs index 9d73e6e..63b4a2f 100644 --- a/Engine/src/Scene/Component/BuiltIn/Renderer/MeshRenderer.cs +++ b/Engine/src/Scene/Component/BuiltIn/Renderer/MeshRenderer.cs @@ -1,6 +1,7 @@ using Engine.Asset.Mesh; using Engine.Graphics.Pipeline; using Engine.Graphics.Texture; +using Engine.Util; namespace Engine.Scene.Component.BuiltIn.Renderer; @@ -27,7 +28,7 @@ public class MeshRenderer : Component /// public override void Render() { - Engine.Instance.Renderer[RenderLayer].AnyMeshRenderer + EngineUtil.Renderer[RenderLayer].AnyMeshRenderer .Commit(Mesh, GameObject.Transform.FullTransformMatrix, Albedo); } } \ No newline at end of file diff --git a/Engine/src/Scene/Component/BuiltIn/Renderer/TextRenderer.cs b/Engine/src/Scene/Component/BuiltIn/Renderer/TextRenderer.cs index 8eecd02..7839e03 100644 --- a/Engine/src/Scene/Component/BuiltIn/Renderer/TextRenderer.cs +++ b/Engine/src/Scene/Component/BuiltIn/Renderer/TextRenderer.cs @@ -1,5 +1,6 @@ using Engine.Asset.Font; using Engine.Graphics.Pipeline; +using Engine.Util; using OpenTK.Mathematics; namespace Engine.Scene.Component.BuiltIn.Renderer; @@ -12,7 +13,7 @@ public class TextRenderer : Component /// /// The font used for rendering the text. /// - public Font Font { get; set; } = null!; + public Font? Font { get; set; } /// /// The color of the text. @@ -37,12 +38,12 @@ public class TextRenderer : Component /// public override void Render() { - if (Text == null) + if (Text == null || Font == null) { return; } - Engine.Instance.Renderer[RenderLayer].TextRenderer + EngineUtil.Renderer[RenderLayer].TextRenderer .Commit(Font, Text, Color, GameObject.Transform.FullTransformMatrix); } } \ No newline at end of file diff --git a/Engine/src/Scene/ISceneManager.cs b/Engine/src/Scene/ISceneManager.cs new file mode 100644 index 0000000..f48d810 --- /dev/null +++ b/Engine/src/Scene/ISceneManager.cs @@ -0,0 +1,18 @@ +namespace Engine.Scene; + +/// +/// Defines an interface for a scene manager that handles the current scene and scene transitions. +/// +public interface ISceneManager +{ + /// + /// The current scene, or null if no scene is active. + /// + public Scene? CurrentScene { get; } + + /// + /// Transitions to the specified scene. + /// + /// The generator function for the scene to transition to. + public void TransitionTo(Func? parScene); +} \ No newline at end of file diff --git a/Engine/src/Scene/Scene.cs b/Engine/src/Scene/Scene.cs index 7b79b32..b772068 100644 --- a/Engine/src/Scene/Scene.cs +++ b/Engine/src/Scene/Scene.cs @@ -17,7 +17,7 @@ public class Scene : IUpdate, IRender /// /// The time scale for updating the scene. A value of 1.0 means normal speed. /// - public float TimeScale { get; set; } = 1.0f; + public double TimeScale { get; set; } = 1.0; /// /// A hierarchy of game objects in the scene. @@ -206,7 +206,7 @@ public class Scene : IUpdate, IRender /// /// Processes changes in the hierarchy and scene actions. /// - private void ProcessChanges() + internal void ProcessChanges() { Hierarchy.ProcessChanges(); diff --git a/Engine/src/Scene/SceneManager.cs b/Engine/src/Scene/SceneManager.cs index 5910237..001f369 100644 --- a/Engine/src/Scene/SceneManager.cs +++ b/Engine/src/Scene/SceneManager.cs @@ -3,7 +3,7 @@ /// /// Manages the current scene in the game, handles scene transitions, and facilitates updating and rendering the current scene. /// -public class SceneManager : IUpdate, IRender +public class SceneManager : ISceneManager, IUpdate, IRender { /// /// The current scene being managed by the scene manager. diff --git a/Engine/src/Util/EngineUtil.cs b/Engine/src/Util/EngineUtil.cs index 4f44094..434750f 100644 --- a/Engine/src/Util/EngineUtil.cs +++ b/Engine/src/Util/EngineUtil.cs @@ -1,4 +1,6 @@ -using Engine.Input; +using Engine.Context; +using Engine.Graphics; +using Engine.Input; using Engine.Resource; using Engine.Scene; @@ -13,38 +15,47 @@ public static class EngineUtil /// /// The engine's input handler, which processes user input. /// - public static IInputHandler InputHandler => Engine.Instance.InputHandler!; + public static IInputHandler InputHandler => CONTEXT_INSTANCE.InputHandler; /// /// The engine's scene manager, which handles the current scene and scene transitions. /// - public static SceneManager SceneManager => Engine.Instance.SceneManager; + public static ISceneManager SceneManager => CONTEXT_INSTANCE.SceneManager; /// /// The engine's asset resource manager, which handles loading and caching of assets. /// - public static IResourceManager AssetResourceManager => Engine.Instance.AssetResourceManager; + public static IResourceManager AssetResourceManager => CONTEXT_INSTANCE.AssetResourceManager; + + /// + /// The engine's renderer, which handles rendering operations. + /// + public static IRenderer Renderer => CONTEXT_INSTANCE.Renderer; /// /// The engine's data folder, which contains assets and other data files. /// - public static string DataFolder => Engine.Instance.DataFolder; - - /// - /// Creates a game object and adds it to the current scene. - /// - /// The game object to be added to the scene. - public static void CreateObject(GameObject parGameObject) - { - var scene = Engine.Instance.SceneManager.CurrentScene!; - scene.Add(parGameObject); - } + public static string DataFolder => CONTEXT_INSTANCE.DataFolder; /// /// Closes the engine, shutting down any running systems and freeing resources. /// public static void Close() { - Engine.Instance.Close(); + CONTEXT_INSTANCE.Close(); + } + + /// + /// The engine's context, which provides access to the engine's services. + /// + private static IContext CONTEXT_INSTANCE; + + /// + /// Sets the engine's context, allowing access to the engine's services. + /// + /// The context to use for the engine. + internal static void SetContext(IContext parContext) + { + CONTEXT_INSTANCE = parContext; } } \ No newline at end of file diff --git a/EngineTests/EngineTests.csproj b/EngineTests/EngineTests.csproj index a5e5c2e..50cd526 100644 --- a/EngineTests/EngineTests.csproj +++ b/EngineTests/EngineTests.csproj @@ -23,10 +23,7 @@ - - - - + diff --git a/EngineTests/src/Scene/GameObjectTests.cs b/EngineTests/src/Scene/GameObjectTests.cs index b186134..da57a57 100644 --- a/EngineTests/src/Scene/GameObjectTests.cs +++ b/EngineTests/src/Scene/GameObjectTests.cs @@ -68,4 +68,220 @@ public class GameObjectTests Assert.Throws(() => _gameObject.ProcessChanges()); } + + [Test] + public void AddComponent_ShouldSetGameObjectForAddedComponent() + { + var testComponent = new TestComponent(); + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(testComponent.GameObject, Is.EqualTo(_gameObject)); + }); + } + + [Test] + public void AddComponent_ShouldCallAwakeOnAddedComponentOnPreUpdate() + { + var testComponent = new TestComponent(); + var wasAwakeCalled = false; + testComponent.OnAwake += () => wasAwakeCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.PreUpdate(0); + + Assert.Multiple(() => + { + Assert.That(wasAwakeCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallStartOnAddedComponentOnPreUpdate() + { + var testComponent = new TestComponent(); + var wasStartCalled = false; + testComponent.OnStart += () => wasStartCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.PreUpdate(0); + + Assert.Multiple(() => + { + Assert.That(wasStartCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallPreUpdateOnAddedComponentOnPreUpdate() + { + var testComponent = new TestComponent(); + var wasPreUpdateCalled = false; + testComponent.OnPreUpdate += _ => wasPreUpdateCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.PreUpdate(0); + + Assert.Multiple(() => + { + Assert.That(wasPreUpdateCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallUpdateOnAddedComponentOnUpdate() + { + var testComponent = new TestComponent(); + var wasUpdateCalled = false; + testComponent.OnUpdate += _ => wasUpdateCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.Update(0); + + Assert.Multiple(() => + { + Assert.That(wasUpdateCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallPostUpdateOnAddedComponentOnPostUpdate() + { + var testComponent = new TestComponent(); + var wasPostUpdateCalled = false; + testComponent.OnPostUpdate += _ => wasPostUpdateCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.PostUpdate(0); + + Assert.Multiple(() => + { + Assert.That(wasPostUpdateCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallRenderOnAddedComponentOnRender() + { + var testComponent = new TestComponent(); + var wasRenderCalled = false; + testComponent.OnRender += () => wasRenderCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.Render(); + + Assert.Multiple(() => + { + Assert.That(wasRenderCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallDestroyOnAddedComponentOnDestroy() + { + var testComponent = new TestComponent(); + var wasDestroyCalled = false; + testComponent.OnDestroy += () => wasDestroyCalled = true; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.Destroy(); + + Assert.Multiple(() => + { + Assert.That(wasDestroyCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallDisableOnAddedComponentOnDisable() + { + var testComponent = new TestComponent(); + var wasDisableCalled = false; + testComponent.OnDisable += () => wasDisableCalled = true; + + _gameObject.IsEnabled = false; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.Update(0); + + Assert.Multiple(() => + { + Assert.That(wasDisableCalled, Is.True); + }); + } + + [Test] + public void AddComponent_ShouldCallEnableOnAddedComponentOnEnable() + { + var testComponent = new TestComponent(); + var wasEnableCalled = false; + testComponent.OnEnable += () => wasEnableCalled = true; + + _gameObject.IsEnabled = false; + + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + _gameObject.Update(0); + + _gameObject.IsEnabled = true; + _gameObject.ProcessChanges(); + _gameObject.Update(0); + + Assert.Multiple(() => + { + Assert.That(wasEnableCalled, Is.True); + }); + } + + [Test] + public void RemoveComponent_ShouldThrowIfComponentIsTransform() + { + Assert.Throws(() => _gameObject.RemoveComponent()); + } + + [Test] + public void RemoveComponent_ShouldThrowIfComponentDoesNotExist() + { + _gameObject.RemoveComponent(); + + Assert.Throws(() => _gameObject.ProcessChanges()); + } + + [Test] + public void GetComponent_ShouldReturnComponentIfExists() + { + var testComponent = new TestComponent(); + _gameObject.AddComponent(testComponent); + _gameObject.ProcessChanges(); + + var component = _gameObject.GetComponent(); + + Assert.Multiple(() => + { + Assert.That(component, Is.EqualTo(testComponent)); + }); + } + + [Test] + public void GetComponent_ShouldReturnNullIfComponentDoesNotExist() + { + _gameObject.ProcessChanges(); + + var component = _gameObject.GetComponent(); + + Assert.Multiple(() => + { + Assert.That(component, Is.Null); + }); + } } \ No newline at end of file diff --git a/EngineTests/src/Scene/SceneTests.cs b/EngineTests/src/Scene/SceneTests.cs new file mode 100644 index 0000000..333f665 --- /dev/null +++ b/EngineTests/src/Scene/SceneTests.cs @@ -0,0 +1,199 @@ +using Engine.Scene; + +namespace EngineTests.Scene; + +public class SceneTests +{ + private Engine.Scene.Scene _scene; + + [SetUp] + public void Setup() + { + _scene = new Engine.Scene.Scene(); + } + + [Test] + public void Enter_ShouldSetSceneToPlaying() + { + Assert.Multiple(() => + { + Assert.That(_scene.IsPlaying, Is.False); + }); + + _scene.Enter(); + Assert.Multiple(() => + { + Assert.That(_scene.IsPlaying, Is.True); + }); + } + + [Test] + public void Exit_ShouldUnsetSceneToPlaying() + { + _scene.Enter(); + Assert.Multiple(() => + { + Assert.That(_scene.IsPlaying, Is.True); + }); + + _scene.Exit(); + Assert.Multiple(() => + { + Assert.That(_scene.IsPlaying, Is.False); + }); + } + + [Test] + public void Update_ShouldRespectTimeScale() + { + const double INITIAL_DELTA_TIME = 1.0; + const double TIME_SCALE = 2.0; + + var (gameObject, testComponent) = CreateTestGameObject(); + var actualDeltaTime = 0.0; + testComponent.OnUpdate += parDeltaTime => actualDeltaTime = parDeltaTime; + + _scene.Add(gameObject); + _scene.Enter(); + + _scene.TimeScale = TIME_SCALE; + _scene.Update(INITIAL_DELTA_TIME); + + Assert.Multiple(() => + { + Assert.That(actualDeltaTime, Is.EqualTo(INITIAL_DELTA_TIME * TIME_SCALE)); + }); + } + + [Test] + public void Add_ShouldSetSceneForAddedGameObject() + { + var (gameObject, _) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(gameObject.Scene, Is.EqualTo(_scene)); + }); + } + + [Test] + public void Add_ShouldAddGameObjectToHierarchy() + { + var (gameObject, _) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_scene.Hierarchy.Objects, Contains.Item(gameObject)); + }); + } + + [Test] + public void Remove_ShouldUnsetSceneForRemovedGameObject() + { + var (gameObject, _) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + _scene.Remove(gameObject); + _scene.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(gameObject.Scene, Is.Null); + }); + } + + [Test] + public void Remove_ShouldRemoveGameObjectFromHierarchy() + { + var (gameObject, _) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + _scene.Remove(gameObject); + _scene.ProcessChanges(); + + Assert.Multiple(() => + { + Assert.That(_scene.Hierarchy.Objects, Is.Not.Contains(gameObject)); + }); + } + + [Test] + public void FindAllComponents_ShouldReturnAllComponentsCount1() + { + var (gameObject, testComponent) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + var components = _scene.FindAllComponents(); + + Assert.Multiple(() => + { + Assert.That(components, Contains.Item(testComponent)); + Assert.That(components, Has.Count.EqualTo(1)); + }); + } + + [Test] + public void FindAllComponents_ShouldReturnAllComponentsCount2() + { + var (gameObject1, testComponent1) = CreateTestGameObject(); + var (gameObject2, testComponent2) = CreateTestGameObject(); + _scene.Add(gameObject1); + _scene.Add(gameObject2); + _scene.ProcessChanges(); + + var components = _scene.FindAllComponents(); + + Assert.Multiple(() => + { + Assert.That(components, Contains.Item(testComponent1)); + Assert.That(components, Contains.Item(testComponent2)); + Assert.That(components, Has.Count.EqualTo(2)); + }); + } + + [Test] + public void FindFirstComponent_ShouldReturnFirstComponent() + { + var (gameObject, testComponent) = CreateTestGameObject(); + _scene.Add(gameObject); + _scene.ProcessChanges(); + + var component = _scene.FindFirstComponent(); + + Assert.Multiple(() => + { + Assert.That(component, Is.EqualTo(testComponent)); + }); + } + + [Test] + public void FindFirstComponent_ShouldReturnNullIfComponentDoesNotExist() + { + _scene.ProcessChanges(); + + var component = _scene.FindFirstComponent(); + + Assert.Multiple(() => + { + Assert.That(component, Is.Null); + }); + } + + private static (GameObject, TestComponent) CreateTestGameObject() + { + var gameObject = new GameObject(); + var testComponent = new TestComponent(); + + gameObject.AddComponent(testComponent); + gameObject.ProcessChanges(); + + return (gameObject, testComponent); + } +} \ No newline at end of file diff --git a/EngineTests/src/Scene/TestComponent.cs b/EngineTests/src/Scene/TestComponent.cs new file mode 100644 index 0000000..6e4efce --- /dev/null +++ b/EngineTests/src/Scene/TestComponent.cs @@ -0,0 +1,61 @@ +using Engine.Scene.Component; + +namespace EngineTests.Scene; + +public class TestComponent : Component +{ + public event Action? OnAwake; + public event Action? OnStart; + public event Action? OnPreUpdate; + public event Action? OnUpdate; + public event Action? OnPostUpdate; + public event Action? OnRender; + public event Action? OnDestroy; + public event Action? OnEnable; + public event Action? OnDisable; + + public override void Awake() + { + OnAwake?.Invoke(); + } + + public override void Start() + { + OnStart?.Invoke(); + } + + public override void PreUpdate(double parDeltaTime) + { + OnPreUpdate?.Invoke(parDeltaTime); + } + + public override void Update(double parDeltaTime) + { + OnUpdate?.Invoke(parDeltaTime); + } + + public override void PostUpdate(double parDeltaTime) + { + OnPostUpdate?.Invoke(parDeltaTime); + } + + public override void Render() + { + OnRender?.Invoke(); + } + + public override void Destroy() + { + OnDestroy?.Invoke(); + } + + public override void Enable() + { + OnEnable?.Invoke(); + } + + public override void Disable() + { + OnDisable?.Invoke(); + } +} \ No newline at end of file diff --git a/GameTests/GameTests.csproj b/GameTests/GameTests.csproj new file mode 100644 index 0000000..48a106a --- /dev/null +++ b/GameTests/GameTests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/GameTests/src/HealthControllerTest.cs b/GameTests/src/HealthControllerTest.cs new file mode 100644 index 0000000..f0cc6c4 --- /dev/null +++ b/GameTests/src/HealthControllerTest.cs @@ -0,0 +1,52 @@ +using DoomDeathmatch; +using DoomDeathmatch.Component.MVC.Health; +using DoomDeathmatch.Component.MVC.Weapon; + +namespace GameTests; + +public class HealthControllerTest : SceneMockTest +{ + [Test] + public void HealthController_HealthShouldBeCappedToMaxHealthWhenHealed() + { + const float MAX_HEALTH = 100; + + var healthController = new HealthController(MAX_HEALTH); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + healthController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + healthController.Heal(MAX_HEALTH * 2); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(healthController.Health, Is.EqualTo(MAX_HEALTH)); + }); + } + + [Test] + public void HealthController_HealthShouldBeCappedToZeroWhenHealedBelowZero() + { + const float MAX_HEALTH = 100; + + var healthController = new HealthController(MAX_HEALTH); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + healthController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + healthController.TakeDamage(MAX_HEALTH * 2); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(healthController.Health, Is.EqualTo(0)); + }); + } +} \ No newline at end of file diff --git a/GameTests/src/MovementComponentTest.cs b/GameTests/src/MovementComponentTest.cs new file mode 100644 index 0000000..d4029ea --- /dev/null +++ b/GameTests/src/MovementComponentTest.cs @@ -0,0 +1,154 @@ +using DoomDeathmatch; +using DoomDeathmatch.Component.MVC; +using DoomDeathmatch.Component.Physics; +using DoomDeathmatch.Component.Util; +using DoomDeathmatch.Scene.Play.Prefab; +using Engine.Input; +using OpenTK.Mathematics; + +namespace GameTests; + +public class MovementComponentTest : SceneMockTest +{ + [Test] + public void PlayerMovement_ShouldMovePlayerLeftWithKeyboardA() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.A); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Translation.X, Is.EqualTo(-movementComponent.Speed)); + }); + } + + [Test] + public void PlayerMovement_ShouldMovePlayerRightWithKeyboardD() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.D); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Translation.X, Is.EqualTo(movementComponent.Speed)); + }); + } + + [Test] + public void PlayerMovement_ShouldMovePlayerUpWithKeyboardW() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.W); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Translation.Y, Is.EqualTo(movementComponent.Speed)); + }); + } + + [Test] + public void PlayerMovement_ShouldMovePlayerDownWithKeyboardS() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.S); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Translation.Y, Is.EqualTo(-movementComponent.Speed)); + }); + } + + [Test] + public void PlayerMovement_ShouldRotatePlayerRightWithKeyboardQ() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.Q); + SceneManager.Update(1); + + Console.WriteLine(playerObject.Transform.Rotation.ToEulerAngles()); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Rotation.ToEulerAngles().Z, + Is.EqualTo(MathHelper.DegreesToRadians(movementComponent.RotationSpeed))); + }); + } + + [Test] + public void PlayerMovement_ShouldRotatePlayerRightWithKeyboardE() + { + var playerMovementComponent = new PlayerMovementComponent(); + var movementComponent = new MovementComponent(); + var playerObject = GameObjectUtil.CreateGameObject(Scene, [ + new RigidbodyComponent(), + movementComponent, + playerMovementComponent + ]); + + Scene.Add(playerObject); + SceneManager.Update(1); + + InputHandler.PressedKeyboardButtons.Add(KeyboardButtonCode.E); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(playerObject.Transform.Rotation.ToEulerAngles().Z, + Is.EqualTo(-MathHelper.DegreesToRadians(movementComponent.RotationSpeed))); + }); + } +} \ No newline at end of file diff --git a/GameTests/src/SceneMockTest.cs b/GameTests/src/SceneMockTest.cs new file mode 100644 index 0000000..a2a59f0 --- /dev/null +++ b/GameTests/src/SceneMockTest.cs @@ -0,0 +1,51 @@ +using Engine.Graphics; +using Engine.Graphics.Pipeline; +using Engine.Scene; +using TestUtil; +using TestUtil.Renderer; + +namespace GameTests; + +public abstract class SceneMockTest +{ + public Scene Scene => _scene; + public MockContext Context => _context; + public MockInputHandler InputHandler => _inputHandler; + public MockResourceManager ResourceManager => _resourceManager; + public SceneManager SceneManager => _sceneManager; + public MockRenderer Renderer => _renderer; + + private Scene _scene; + + private MockContext _context; + private MockInputHandler _inputHandler; + private MockResourceManager _resourceManager; + private SceneManager _sceneManager; + private MockRenderer _renderer; + + [SetUp] + public void SetUp() + { + _scene = new Scene(); + + _inputHandler = new MockInputHandler(); + _resourceManager = new MockResourceManager(); + _sceneManager = new SceneManager(); + _renderer = new MockRenderer(new Dictionary + { + [RenderLayer.DEFAULT] = new MockGenericRenderer(new MockQuadRenderer(), new MockAnyMeshRenderer(), + new MockTextRenderer()), + [RenderLayer.OVERLAY] = new MockGenericRenderer(new MockQuadRenderer(), new MockAnyMeshRenderer(), + new MockTextRenderer()), + [RenderLayer.HUD] = new MockGenericRenderer(new MockQuadRenderer(), new MockAnyMeshRenderer(), + new MockTextRenderer()) + }); + + _context = new MockContext(_inputHandler, _resourceManager, _sceneManager, _renderer, ""); + + MockContext.SetMockContext(_context); + + _sceneManager.TransitionTo(() => Scene); + _sceneManager.Update(0); + } +} \ No newline at end of file diff --git a/GameTests/src/WeaponControllerTest.cs b/GameTests/src/WeaponControllerTest.cs new file mode 100644 index 0000000..48c70c4 --- /dev/null +++ b/GameTests/src/WeaponControllerTest.cs @@ -0,0 +1,104 @@ +using DoomDeathmatch; +using DoomDeathmatch.Component.MVC.Weapon; +using DoomDeathmatch.Script.Model.Weapon; + +namespace GameTests; + +public class WeaponControllerTest : SceneMockTest +{ + [Test] + public void WeaponController_DefaultWeaponShouldBeSelected() + { + var weapon = WeaponData.Pistol; + var weaponController = new WeaponController(weapon); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + weaponController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + weaponController.SelectWeapon(0); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(weaponController.WeaponData, Is.EqualTo(weapon)); + }); + } + + [Test] + public void WeaponController_CannotRemoveDefaultWeapon() + { + var weapon = WeaponData.Pistol; + var weaponController = new WeaponController(weapon); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + weaponController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + weaponController.RemoveWeapon(0); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(weaponController.WeaponData, Is.EqualTo(weapon)); + }); + } + + [Test] + public void WeaponController_CannotSelectNonExistentWeapon() + { + var weapon = WeaponData.Pistol; + var weaponController = new WeaponController(weapon); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + weaponController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + weaponController.SelectWeapon(1); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(weaponController.WeaponData, Is.EqualTo(weapon)); + }); + } + + [Test] + public void WeaponController_ShouldAddAmmoOnWeaponMerge() + { + const int INITIAL_AMMO1 = 10; + const int INITIAL_AMMO2 = 10; + + var weapon1 = WeaponData.Pistol; + var weapon2 = WeaponData.Pistol; + weapon1.Ammo = INITIAL_AMMO1; + weapon2.Ammo = INITIAL_AMMO2; + + var weaponController = new WeaponController(weapon1); + var gameObject = GameObjectUtil.CreateGameObject(Scene, [ + weaponController + ]); + + Scene.Add(gameObject); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(weaponController.WeaponData.Ammo, Is.EqualTo(INITIAL_AMMO1)); + }); + + weaponController.AddOrMergeWeapon(weapon2); + SceneManager.Update(1); + + Assert.Multiple(() => + { + Assert.That(weaponController.WeaponData.Ammo, Is.EqualTo(INITIAL_AMMO1 + INITIAL_AMMO2)); + }); + } +} \ No newline at end of file diff --git a/PresenterConsole/assets/shader/ascii.shader b/PresenterConsole/assets/shader/ascii.shader index 6e79aac..c58eef7 100644 --- a/PresenterConsole/assets/shader/ascii.shader +++ b/PresenterConsole/assets/shader/ascii.shader @@ -81,36 +81,36 @@ float perceptualColorDistance(vec3 color1, vec3 color2) { return dot(delta, delta); } -//int findMostPerceptuallyAccurateColor(vec3 color) { -// int bestMatchIndex = 0; -// float minDistance = perceptualColorDistance(color, ConsoleColorVec3[0]); -// -// for (int i = 1; i < 16; i++) { -// float currentDistance = perceptualColorDistance(color, ConsoleColorVec3[i]); -// if (currentDistance < minDistance) { -// minDistance = currentDistance; -// bestMatchIndex = i; -// } -// } -// -// return bestMatchIndex; -//} - int findMostPerceptuallyAccurateColor(vec3 color) { - int closestIndex = 0; - float minDistance = distance(color, ConsoleColorVec3[0]); + int bestMatchIndex = 0; + float minDistance = perceptualColorDistance(color, ConsoleColorVec3[0]); for (int i = 1; i < 16; i++) { - float dist = distance(color, ConsoleColorVec3[i]); - if (dist < minDistance) { - minDistance = dist; - closestIndex = i; + float currentDistance = perceptualColorDistance(color, ConsoleColorVec3[i]); + if (currentDistance < minDistance) { + minDistance = currentDistance; + bestMatchIndex = i; } } - return closestIndex; + return bestMatchIndex; } +//int findMostPerceptuallyAccurateColor(vec3 color) { +// int closestIndex = 0; +// float minDistance = distance(color, ConsoleColorVec3[0]); +// +// for (int i = 1; i < 16; i++) { +// float dist = distance(color, ConsoleColorVec3[i]); +// if (dist < minDistance) { +// minDistance = dist; +// closestIndex = i; +// } +// } +// +// return closestIndex; +//} + // Enhanced luminosity calculation considering human perception float calculatePerceptualLuminance(vec3 color) { // BT.709 luminance coefficients with slight adjustment diff --git a/TestUtil/TestUtil.csproj b/TestUtil/TestUtil.csproj new file mode 100644 index 0000000..e1baa34 --- /dev/null +++ b/TestUtil/TestUtil.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/TestUtil/src/MockContext.cs b/TestUtil/src/MockContext.cs new file mode 100644 index 0000000..e65e4cb --- /dev/null +++ b/TestUtil/src/MockContext.cs @@ -0,0 +1,40 @@ +using Engine.Context; +using Engine.Graphics; +using Engine.Input; +using Engine.Resource; +using Engine.Scene; +using Engine.Util; + +namespace TestUtil; + +public class MockContext : IContext +{ + public Action? OnClose; + + public IInputHandler InputHandler { get; } + public IResourceManager AssetResourceManager { get; } + public ISceneManager SceneManager { get; } + public IRenderer Renderer { get; } + public string DataFolder { get; } + + public MockContext(IInputHandler parInputHandler, IResourceManager parAssetResourceManager, + ISceneManager parSceneManager, IRenderer parRenderer, + string parDataFolder) + { + InputHandler = parInputHandler; + AssetResourceManager = parAssetResourceManager; + SceneManager = parSceneManager; + Renderer = parRenderer; + DataFolder = parDataFolder; + } + + public static void SetMockContext(MockContext parContext) + { + EngineUtil.SetContext(parContext); + } + + public void Close() + { + OnClose?.Invoke(); + } +} \ No newline at end of file diff --git a/TestUtil/src/MockInputHandler.cs b/TestUtil/src/MockInputHandler.cs new file mode 100644 index 0000000..1f2a083 --- /dev/null +++ b/TestUtil/src/MockInputHandler.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using Engine.Input; +using OpenTK.Mathematics; + +namespace TestUtil; + +public class MockInputHandler : IInputHandler +{ + public CultureInfo CurrentInputLanguage { get; set; } = new(1033); + + public Vector2 MousePosition { get; set; } = Vector2.Zero; + + public ISet PressedKeyboardButtons => _pressedKeyboardButtons; + public ISet JustPressedKeyboardButtons => _justPressedKeyboardButtons; + public ISet PressedMouseButtons => _pressedMouseButtons; + public ISet JustPressedMouseButtons => _justPressedMouseButtons; + + private readonly HashSet _pressedKeyboardButtons = []; + private readonly HashSet _justPressedKeyboardButtons = []; + private readonly HashSet _pressedMouseButtons = []; + private readonly HashSet _justPressedMouseButtons = []; + + public void Update(double parDeltaTime) + { + } + + public bool IsKeyPressed(KeyboardButtonCode parKeyboardButtonCode) + { + return _pressedKeyboardButtons.Contains(parKeyboardButtonCode); + } + + public bool IsKeyJustPressed(KeyboardButtonCode parKeyboardButtonCode) + { + return _justPressedKeyboardButtons.Contains(parKeyboardButtonCode); + } + + public bool IsMouseButtonPressed(MouseButtonCode parButtonCode) + { + return _pressedMouseButtons.Contains(parButtonCode); + } + + public bool IsMouseButtonJustPressed(MouseButtonCode parButtonCode) + { + return _justPressedMouseButtons.Contains(parButtonCode); + } +} \ No newline at end of file diff --git a/TestUtil/src/MockResourceManager.cs b/TestUtil/src/MockResourceManager.cs new file mode 100644 index 0000000..6db4633 --- /dev/null +++ b/TestUtil/src/MockResourceManager.cs @@ -0,0 +1,15 @@ +using Engine.Resource; + +namespace TestUtil; + +public class MockResourceManager : IResourceManager +{ + public event Action? OnLoad; + + public T Load(string parPath) where T : class + { + OnLoad?.Invoke(typeof(T), parPath); + + return null!; + } +} \ No newline at end of file diff --git a/TestUtil/src/Renderer/MockAnyMeshRenderer.cs b/TestUtil/src/Renderer/MockAnyMeshRenderer.cs new file mode 100644 index 0000000..12859c8 --- /dev/null +++ b/TestUtil/src/Renderer/MockAnyMeshRenderer.cs @@ -0,0 +1,16 @@ +using Engine.Asset.Mesh; +using Engine.Graphics.Render.Mesh; +using Engine.Graphics.Texture; +using OpenTK.Mathematics; + +namespace TestUtil.Renderer; + +public class MockAnyMeshRenderer : IAnyMeshRenderer +{ + public event Action? OnCommit; + + public void Commit(Mesh parMesh, in Matrix4 parModelMatrix, Texture? parAlbedo = null) + { + OnCommit?.Invoke(parMesh, parModelMatrix, parAlbedo); + } +} \ No newline at end of file diff --git a/TestUtil/src/Renderer/MockGenericRenderer.cs b/TestUtil/src/Renderer/MockGenericRenderer.cs new file mode 100644 index 0000000..1e2e563 --- /dev/null +++ b/TestUtil/src/Renderer/MockGenericRenderer.cs @@ -0,0 +1,21 @@ +using Engine.Graphics; +using Engine.Graphics.Render.Mesh; +using Engine.Graphics.Render.Quad; +using Engine.Graphics.Render.Text; + +namespace TestUtil.Renderer; + +public class MockGenericRenderer : IGenericRenderer +{ + public IQuadRenderer QuadRenderer { get; } + public IAnyMeshRenderer AnyMeshRenderer { get; } + public ITextRenderer TextRenderer { get; } + + public MockGenericRenderer(IQuadRenderer parQuadRenderer, IAnyMeshRenderer parAnyMeshRenderer, + ITextRenderer parTextRenderer) + { + QuadRenderer = parQuadRenderer; + AnyMeshRenderer = parAnyMeshRenderer; + TextRenderer = parTextRenderer; + } +} \ No newline at end of file diff --git a/TestUtil/src/Renderer/MockQuadRenderer.cs b/TestUtil/src/Renderer/MockQuadRenderer.cs new file mode 100644 index 0000000..6fcb11a --- /dev/null +++ b/TestUtil/src/Renderer/MockQuadRenderer.cs @@ -0,0 +1,15 @@ +using Engine.Graphics.Render.Quad; +using Engine.Graphics.Texture; +using OpenTK.Mathematics; + +namespace TestUtil.Renderer; + +public class MockQuadRenderer : IQuadRenderer +{ + public event Action? OnCommit; + + public void Commit(in Matrix4 parModelMatrix, in Vector4 parColor, Texture? parTexture = null) + { + OnCommit?.Invoke(parModelMatrix, parColor, parTexture); + } +} \ No newline at end of file diff --git a/TestUtil/src/Renderer/MockRenderer.cs b/TestUtil/src/Renderer/MockRenderer.cs new file mode 100644 index 0000000..63c838e --- /dev/null +++ b/TestUtil/src/Renderer/MockRenderer.cs @@ -0,0 +1,16 @@ +using Engine.Graphics; +using Engine.Graphics.Pipeline; + +namespace TestUtil.Renderer; + +public class MockRenderer : IRenderer +{ + public IGenericRenderer this[RenderLayer parRenderLayer] => _renderers[parRenderLayer]; + + private readonly Dictionary _renderers; + + public MockRenderer(Dictionary parRenderers) + { + _renderers = parRenderers; + } +} \ No newline at end of file diff --git a/TestUtil/src/Renderer/MockTextRenderer.cs b/TestUtil/src/Renderer/MockTextRenderer.cs new file mode 100644 index 0000000..7999ae2 --- /dev/null +++ b/TestUtil/src/Renderer/MockTextRenderer.cs @@ -0,0 +1,15 @@ +using Engine.Asset.Font; +using Engine.Graphics.Render.Text; +using OpenTK.Mathematics; + +namespace TestUtil.Renderer; + +public class MockTextRenderer : ITextRenderer +{ + public event Action? OnCommit; + + public void Commit(Font parFont, string parText, in Vector4 parColor, in Matrix4 parModelMatrix) + { + OnCommit?.Invoke(parFont, parText, parColor, parModelMatrix); + } +} \ No newline at end of file