diff --git a/samples/pong/Car.cs b/samples/pong/Car.cs new file mode 100644 index 0000000..6dec343 --- /dev/null +++ b/samples/pong/Car.cs @@ -0,0 +1,132 @@ +using rasdaq; +using rasdaq.Core.ECS; +using rasdaq.Graphics; +using rasdaq.Inputs; +using rasdaq.Logging; +using rasdaq.Transformations; + +public class Car : Component +{ + public float speed; + PhysicsBody? body; + + public override void Start() + { + base.Start(); + + body = Entity?.GetComponent(); + } + + Vector2 currentMovement = Vector2.Zero; + + public override void Update(double deltaTime) + { + base.Update(deltaTime); + Accelerate(deltaTime); + Turn((float)deltaTime); + CameraFollow(deltaTime); + } + + private void CameraFollow(double deltaTime) + { + Vector2 lerpedCamPosition = Lerp.Linear( + Renderer.Instance.Camera.Position, + (Vector2)Entity.Transform.position, + 10 * (float)deltaTime + ); + + Renderer.Instance.Camera.Position = lerpedCamPosition; + } + + private float _currentSpeed; + public float maxSpeed = 2000; + + private void Accelerate(double deltaTime) + { + if (body == null) + { + return; + } + + if (currentMovement.Y > 0) + { + _currentSpeed += speed * (float)deltaTime; + } + else if (currentMovement.Y < 0) + { + _currentSpeed -= speed * (float)deltaTime; + } + else + { + _currentSpeed = Lerp.Linear(_currentSpeed, 0, 1 * (float)deltaTime); + } + + _currentSpeed = Math.Clamp(_currentSpeed, -maxSpeed, maxSpeed); + + Vector2 velocity = Entity.Transform.Forward * _currentSpeed; + body.MoveOnce(velocity); + } + + public override void FrameUpdate(double deltaTime) + { + base.FrameUpdate(deltaTime); + + Vertical(); + Horizontal(); + } + + public float rotationSpeed = 100; + + private void Turn(float deltaTime) + { + // Prevent turning when stopped + float speedRatio = Math.Abs(_currentSpeed) / maxSpeed; + if (speedRatio < 0.001f) + { + return; + } + + float turnFactor = 0.5f - speedRatio; + + Entity.Transform.rotation += (float)( + currentMovement.X * rotationSpeed * deltaTime * turnFactor + ); + } + + private void Horizontal() + { + if (Input.IsKeyDown(Keys.D)) + { + currentMovement.X = -1; + } + else if (Input.IsKeyDown(Keys.A)) + { + currentMovement.X = 1; + } + else + { + currentMovement.X = 0; + } + + if (_currentSpeed < 0) + { + currentMovement.X *= -1; + } + } + + private void Vertical() + { + if (Input.IsKeyDown(Keys.W)) + { + currentMovement.Y = 1; + } + else if (Input.IsKeyDown(Keys.S)) + { + currentMovement.Y = -1; + } + else + { + currentMovement.Y = 0; + } + } +} diff --git a/samples/pong/Game.cs b/samples/pong/Game.cs index 571b2cb..2918f0e 100644 --- a/samples/pong/Game.cs +++ b/samples/pong/Game.cs @@ -1,42 +1,70 @@ using rasdaq; using rasdaq.Core.ECS; using rasdaq.Graphics; +using rasdaq.Logging; using rasdaq.Resources; using rasdaq.Transformations; +using System.Drawing; namespace pong; public class Game : Application { - public Texture andrei; - public Texture evilDiv; + public Texture carTexture; + public Texture background; public override void Init() { - andrei = ResourceManager.Load("assets/andrei.png"); - evilDiv = ResourceManager.Load("assets/evil_enemy.jpg"); + carTexture = ResourceManager.Load("assets/sport_red.png"); + background = ResourceManager.Load("assets/rug.png"); } public override void Start() { World world = new(); - Entity player = new(new Vector3(456, 228, 1)); - Entity enemy = new(); + Entity player = new(new Vector3(500, 500, 1)); - Sprite spr = new(456, 456, andrei); + world.AddEntity(player); + + Sprite spr = new(50, 25, carTexture); player.AddComponent(spr); - player.AddComponent(new PhysicsBody()); + PhysicsBody body = new() { ApplyGravity = false }; + player.AddComponent(body); + BoxCollider carCollider = new(50, 25); + player.AddComponent(carCollider); + Car car = new(); + player.AddComponent(car); + car.speed = 1000; + car.rotationSpeed = 250; + car.maxSpeed = 5000; - Sprite enemySprite = new(456, 456, evilDiv); - enemy.AddComponent(enemySprite); + Entity collisionBox = new(new Vector3(400, 400, 0)); + world.AddEntity(collisionBox); - Soldier sold = new(); - player.AddComponent(sold); + Sprite collisionBoxSprite = new(200, 200, background); + collisionBox.AddComponent(collisionBoxSprite); - Enemy enem = new(); - enemy.AddComponent(enem); + PhysicsBody physicsBody = new() { ApplyGravity = false }; + collisionBox.AddComponent(physicsBody); - world.AddEntity(player); - world.AddEntity(enemy); + BoxCollider boxCollider = new(200, 200); + collisionBox.AddComponent(boxCollider); + + boxCollider.OnCollisionEnter += (other) => + { + Log.Info($"Collision detected with {other.Entity}"); + collisionBoxSprite.Color = Color.Red; + }; + + boxCollider.OnCollisionExit += (other) => + { + Log.Info($"Collision ended with {other.Entity}"); + collisionBoxSprite.Color = Color.White; + }; + + boxCollider.OnCollisionStay += (other) => + { + Log.Info($"Collision ongoing with {other.Entity}"); + }; } } diff --git a/samples/pong/Soldier.cs b/samples/pong/Soldier.cs index 6a7850d..b47e1c7 100644 --- a/samples/pong/Soldier.cs +++ b/samples/pong/Soldier.cs @@ -5,71 +5,59 @@ using rasdaq.Logging; using rasdaq.Resources; using rasdaq.Transformations; +using System.Drawing; namespace pong; internal class Soldier : Component { + public BoxCollider? collider; + public Sprite? sprite; + public override void Start() { base.Start(); + sprite = Entity?.GetComponent(); } public override void Update(double deltaTime) { base.Update(deltaTime); + + if (collider == null) + { + collider = Entity?.GetComponent(); + } + + if (collider != null) + { + sprite?.Color = collider.Collisions.Count > 0 ? Color.Red : Color.WhiteSmoke; + } } + public float moveSpeed = 100; + public override void FrameUpdate(double deltaTime) { base.Update(deltaTime); - if (Input.IsKeyPressed(Keys.V)) - { - Log.Info("V pressed"); - } - PhysicsBody? body = Entity?.GetComponent(); if (Input.IsKeyDown(Keys.W)) { - body?.MoveOnce(new Vector2(0, 100)); + body?.MoveOnce(new Vector2(0, moveSpeed)); } - if (Input.IsKeyDown(Keys.S)) { - body?.MoveOnce(new Vector2(0, -100)); + body?.MoveOnce(new Vector2(0, -moveSpeed)); } if (Input.IsKeyDown(Keys.A)) { - body?.MoveOnce(new Vector2(-100, 0)); + body?.MoveOnce(new Vector2(-moveSpeed, 0)); } if (Input.IsKeyDown(Keys.D)) { - body?.MoveOnce(new Vector2(100, 0)); - } - if (Input.IsKeyDown(Keys.H)) - { - // track camera to entity - var x = Entity?.Transform.position.X - (Application.WindowSize?.X / 2) ?? 0; - var y = Entity?.Transform.position.Y - (Application.WindowSize?.Y / 2) ?? 0; - Renderer.Instance.Camera.SetPosition(x, y); - } - } -} - -internal class Enemy : Component -{ - public override void FrameUpdate(double deltaTime) - { - base.Update(deltaTime); - - if (Input.IsKeyDown(Keys.I)) - { - // track camera to entity - var x = Entity?.Transform.position.X - (Application.WindowSize?.X / 2) ?? 0; - var y = Entity?.Transform.position.Y - (Application.WindowSize?.Y / 2) ?? 0; - Renderer.Instance.Camera.SetPosition(x, y); + body?.MoveOnce(new Vector2(moveSpeed, 0)); } } } diff --git a/samples/pong/assets/rug.png b/samples/pong/assets/rug.png new file mode 100644 index 0000000..b987b38 Binary files /dev/null and b/samples/pong/assets/rug.png differ diff --git a/samples/pong/assets/sport_red.png b/samples/pong/assets/sport_red.png new file mode 100644 index 0000000..2ee916f Binary files /dev/null and b/samples/pong/assets/sport_red.png differ diff --git a/src/rasdaq/Application.cs b/src/rasdaq/Application.cs index fb04ec6..041112f 100644 --- a/src/rasdaq/Application.cs +++ b/src/rasdaq/Application.cs @@ -22,11 +22,14 @@ public class Application : IDisposable /// public static Application Instance { - get => _instance ?? throw new InvalidOperationException("Application not started. Call Run() first."); + get => + _instance + ?? throw new InvalidOperationException("Application not started. Call Run() first."); private set => _instance = value; } private static Application? _instance; public static Vector2? WindowSize => Instance._gameWindow?.Size; + /// /// Return list of instantiated Worlds. /// @@ -44,7 +47,9 @@ internal InputManager InputManager private GameWindow? _gameWindow; internal GameWindow GameWindow { - get => _gameWindow ?? throw new InvalidOperationException("Game window not found. Call Run() first."); + get => + _gameWindow + ?? throw new InvalidOperationException("Game window not found. Call Run() first."); private set => _gameWindow = value; } @@ -71,14 +76,11 @@ public void Run(int width, int height, string title) Instance = this; GameWindow = new( - new GameWindowSettings() - { - UpdateFrequency = 0 - }, + new GameWindowSettings() { UpdateFrequency = 0 }, new NativeWindowSettings() { ClientSize = (width, height), Title = title } ) { - VSync = VSyncMode.Off + VSync = VSyncMode.Off, }; GameWindow.UpdateFrame += OnUpdateFrame; GameWindow.Load += OnLoad; diff --git a/src/rasdaq/Core/ECS/Component.cs b/src/rasdaq/Core/ECS/Component.cs index 364d5a6..603783f 100644 --- a/src/rasdaq/Core/ECS/Component.cs +++ b/src/rasdaq/Core/ECS/Component.cs @@ -9,16 +9,22 @@ public abstract class Component /// /// Get the associated entity. /// - public Entity Entity => _entity == null ? throw new InvalidOperationException($"{GetType().Name} is not attached to an entity.") : _entity; + public Entity Entity => + _entity == null + ? throw new InvalidOperationException($"{GetType().Name} is not attached to an entity.") + : _entity; /// /// Get the world of the associated entity. /// - public World World => _entity == null - ? throw new InvalidOperationException($"{GetType().Name} is not attached to an entity.") - : _entity.World == null - ? throw new InvalidOperationException($"{GetType().Name} Entity is not attached to a a world.") - : _entity.World; + public World World => + _entity == null + ? throw new InvalidOperationException($"{GetType().Name} is not attached to an entity.") + : _entity.World == null + ? throw new InvalidOperationException( + $"{GetType().Name} Entity is not attached to a a world." + ) + : _entity.World; /// /// Determines if component has performed its start method. @@ -51,21 +57,25 @@ internal virtual void Init() { } /// Called at the start of a Component's life. /// public virtual void Start() { } + /// /// Called on every physics update. /// /// public virtual void Update(double deltaTime) { } + /// /// Called on every frame. /// /// public virtual void FrameUpdate(double deltaTime) { } + /// /// Called on every frame, but guaranteed to be after FrameUpdate. /// /// public virtual void LateUpdate(double deltaTime) { } + /// /// Called before the component is destroyed. /// diff --git a/src/rasdaq/Core/ECS/Entity.cs b/src/rasdaq/Core/ECS/Entity.cs index 3a68afe..24c1ede 100644 --- a/src/rasdaq/Core/ECS/Entity.cs +++ b/src/rasdaq/Core/ECS/Entity.cs @@ -1,4 +1,5 @@ using rasdaq.Interfaces; +using rasdaq.Logging; using rasdaq.Transformations; namespace rasdaq.Core.ECS; @@ -13,7 +14,7 @@ namespace rasdaq.Core.ECS; /// /// The x and y coordinates is the position of the centre of the Entity /// -public class Entity(Vector3 spawnPosition) +public class Entity { /// /// Return the world which contains this entity. @@ -22,7 +23,7 @@ public class Entity(Vector3 spawnPosition) private readonly string _id; private FlushEnumerable _components = new(); - private Transform _transform = new(spawnPosition); + private Transform _transform = new(Vector3.Zero); /// /// Determines if entity has performed its start method. @@ -38,9 +39,16 @@ public class Entity(Vector3 spawnPosition) /// /// Create a new entity. /// - public Entity() : this(Vector3.Zero) + public Entity() { _id = Guid.NewGuid().ToString("N"); + _transform.position = Vector3.Zero; + } + + public Entity(Vector3 spawnPosition) + { + _id = Guid.NewGuid().ToString("N"); + _transform.position = spawnPosition; } internal void Attach(World w) @@ -74,7 +82,8 @@ public void RemoveComponent(Component c) /// /// /// The first component of type T on the entity - public T? GetComponent() where T : Component + public T? GetComponent() + where T : Component { return _components.Objects.OfType().FirstOrDefault(); } @@ -82,7 +91,8 @@ public void RemoveComponent(Component c) /// /// Return all components of a certain type attached to the entity. /// - public IEnumerable GetComponents() where T : Component + public IEnumerable GetComponents() + where T : Component { return _components.Objects.OfType(); } diff --git a/src/rasdaq/Core/ECS/World.cs b/src/rasdaq/Core/ECS/World.cs index b44e119..b8533a6 100644 --- a/src/rasdaq/Core/ECS/World.cs +++ b/src/rasdaq/Core/ECS/World.cs @@ -29,6 +29,11 @@ public class World /// public List Entities => _entities.Objects; + /// + /// Physics engine for the world. This is where physics bodies are managed and updated. + /// + public Physics Physics { get; } = new(); + /// /// Create an instance of a World. An ID is generated automatically once the World is created. /// @@ -84,6 +89,8 @@ internal void Update(double deltaTime) Utils.EnsureStartableStart(e); e.Update(deltaTime); } + + Physics.Update(); } internal void FrameUpdate(double deltaTime) diff --git a/src/rasdaq/Core/GameLoop.cs b/src/rasdaq/Core/GameLoop.cs index c1c8abd..27f623e 100644 --- a/src/rasdaq/Core/GameLoop.cs +++ b/src/rasdaq/Core/GameLoop.cs @@ -8,21 +8,15 @@ namespace rasdaq.Core; /// internal class GameLoop(World world) { - private double _msPerUpdate = 0.01f; private double _lag = 0.0f; private double _maxUpdates = 10; private World _world = world; /// - /// Maximum amount of updates before the game loop gives up and moves on to the next frame. + /// Maximum amount of updates before the game loop gives up and moves on to the next frame. /// This is a safety measure to prevent the game from freezing if the update logic takes too long. /// internal double MaxUpdates => _maxUpdates; - /// - /// Time interval for updates to aim to occur at. - /// For example, if this is set to 0.01, the game will aim to update every 10 milliseconds (100 updates per second). - /// - internal double MsPerUpdate => _msPerUpdate; /// /// Ticks the game loop by a certain amount of time. @@ -36,11 +30,11 @@ public void Tick(double deltaTime) _maxUpdates = 10; - while (_lag >= _msPerUpdate && _maxUpdates-- > 0) + while (_lag >= _world.Physics.FixedUpdateTime && _maxUpdates-- > 0) { - _world.Update(_msPerUpdate); + _world.Update(_world.Physics.FixedUpdateTime); - _lag -= _msPerUpdate; + _lag -= _world.Physics.FixedUpdateTime; } _world.FrameUpdate(deltaTime); diff --git a/src/rasdaq/Graphics/Shaders/color_shader.vert b/src/rasdaq/Graphics/Shaders/color_shader.vert index 5d79af2..d88dcb0 100644 --- a/src/rasdaq/Graphics/Shaders/color_shader.vert +++ b/src/rasdaq/Graphics/Shaders/color_shader.vert @@ -1,14 +1,16 @@ #version 330 core in vec3 aPosition; - in vec4 aColor; out vec4 VertColor; +uniform mat4 transform; +uniform mat4 projection; +uniform mat4 view; + void main() { - gl_Position = vec4(aPosition, 1.0); - + gl_Position = vec4(aPosition, 1.0) * transform * view * projection; VertColor = aColor; } \ No newline at end of file diff --git a/src/rasdaq/Graphics/Sprite.cs b/src/rasdaq/Graphics/Sprite.cs index d3d18f1..22fea32 100644 --- a/src/rasdaq/Graphics/Sprite.cs +++ b/src/rasdaq/Graphics/Sprite.cs @@ -9,7 +9,6 @@ public class Sprite : Component private Shader _shader; private float[] _ndcVertices; private float[] _uvs; - private Color _color; private Texture? _texture; /// @@ -25,7 +24,7 @@ public class Sprite : Component /// /// Colour of Sprite. /// - public Color Color => _color; + public Color Color { get; set; } public float[] UVs => _uvs; public float[] NdcVertices => _ndcVertices; public float width; @@ -80,7 +79,7 @@ public Sprite( _ndcVertices = BuildVertices(); this.width = width; this.height = height; - _color = color ?? Color.White; + this.Color = color ?? Color.White; _texture = texture; _shader = diff --git a/src/rasdaq/Inputs/GameWindowWrapper.cs b/src/rasdaq/Inputs/GameWindowWrapper.cs index 0a5ff1c..badd4c7 100644 --- a/src/rasdaq/Inputs/GameWindowWrapper.cs +++ b/src/rasdaq/Inputs/GameWindowWrapper.cs @@ -10,13 +10,27 @@ internal class GameWindowWrapper : IGameWindow { private readonly GameWindow gameWindow; + internal GameWindowWrapper(GameWindow gameWindow) { this.gameWindow = gameWindow; } - public CursorState CursorState { get => gameWindow.CursorState; set => gameWindow.CursorState = value; } - public Vector2 MousePosition { get => gameWindow.MousePosition; set => gameWindow.MousePosition = value; } + public CursorState CursorState + { + get => gameWindow.CursorState; + set => gameWindow.CursorState = value; + } + public Vector2 MousePosition + { + get => gameWindow.MousePosition; + set => gameWindow.MousePosition = value; + } + public Vector2? WindowSize + { + get => gameWindow.Size; + set => gameWindow.Size = (Vector2i)value; + } public bool IsKeyDown(Keys key) { @@ -50,28 +64,29 @@ public bool IsMouseButtonReleased(MouseButton button) public event Action KeyDown { - add => gameWindow.KeyDown += value; remove => gameWindow.KeyDown -= value; + add => gameWindow.KeyDown += value; + remove => gameWindow.KeyDown -= value; } public event Action KeyUp { - add => gameWindow.KeyUp += value; remove => gameWindow.KeyUp -= value; + add => gameWindow.KeyUp += value; + remove => gameWindow.KeyUp -= value; } - private readonly Dictionary, Action> _mouseMoveWrappers = new(); + private readonly Dictionary< + Action, + Action + > _mouseMoveWrappers = new(); public event Action MouseMove { add { - Action wrapper = args => value(new MouseMoveEvent - { - dx = args.DeltaX, - dy = args.DeltaY - }); + Action wrapper = args => + value(new MouseMoveEvent { dx = args.DeltaX, dy = args.DeltaY }); _mouseMoveWrappers[value] = wrapper; gameWindow.MouseMove += wrapper; } - remove { if (_mouseMoveWrappers.TryGetValue(value, out Action? wrapper)) @@ -84,10 +99,12 @@ public event Action MouseMove public event Action MouseDown { - add => gameWindow.MouseDown += value; remove => gameWindow.MouseDown -= value; + add => gameWindow.MouseDown += value; + remove => gameWindow.MouseDown -= value; } public event Action MouseUp { - add => gameWindow.MouseUp += value; remove => gameWindow.MouseUp -= value; + add => gameWindow.MouseUp += value; + remove => gameWindow.MouseUp -= value; } } diff --git a/src/rasdaq/Interfaces/IGameWindow.cs b/src/rasdaq/Interfaces/IGameWindow.cs index 96e4061..f5792d1 100644 --- a/src/rasdaq/Interfaces/IGameWindow.cs +++ b/src/rasdaq/Interfaces/IGameWindow.cs @@ -20,4 +20,6 @@ internal interface IGameWindow public bool IsMouseButtonDown(MouseButton button); public bool IsMouseButtonPressed(MouseButton button); public bool IsMouseButtonReleased(MouseButton button); + + public Vector2? WindowSize { get; set; } } diff --git a/src/rasdaq/Math/Lerp.cs b/src/rasdaq/Math/Lerp.cs new file mode 100644 index 0000000..7668eb6 --- /dev/null +++ b/src/rasdaq/Math/Lerp.cs @@ -0,0 +1,20 @@ +public static class Lerp +{ + /// + /// Linearly interpolate between two values. + /// + public static double Linear(double a, double b, double t) + { + return a + ((b - a) * t); + } + + public static float Linear(float a, float b, float t) + { + return a + ((b - a) * t); + } + + public static Vector3 Linear(Vector3 a, Vector3 b, float t) + { + return a + ((b - a) * t); + } +} diff --git a/src/rasdaq/Transformations/Vector2.cs b/src/rasdaq/Math/Vector2.cs similarity index 51% rename from src/rasdaq/Transformations/Vector2.cs rename to src/rasdaq/Math/Vector2.cs index 9996518..28b3683 100644 --- a/src/rasdaq/Transformations/Vector2.cs +++ b/src/rasdaq/Math/Vector2.cs @@ -1,4 +1,3 @@ - /// /// A simple 2D vector struct. Does not support math operations currently /// @@ -27,4 +26,34 @@ public static implicit operator Vector2(OpenTK.Mathematics.Vector2i v) { return new Vector2(v.X, v.Y); } + + public static implicit operator OpenTK.Mathematics.Vector2i(Vector2 v) + { + return new OpenTK.Mathematics.Vector2i((int)v.X, (int)v.Y); + } + + public static Vector2 operator -(Vector2 v1, Vector2 v2) + { + return new Vector2(v1.X - v2.X, v1.Y - v2.Y); + } + + public static Vector2 operator +(Vector2 v1, Vector2 v2) + { + return new Vector2(v1.X + v2.X, v1.Y + v2.Y); + } + + public static Vector2 operator *(Vector2 v, double scalar) + { + return new Vector2(v.X * scalar, v.Y * scalar); + } + + public static Vector2 operator *(double scalar, Vector2 v) + { + return new Vector2(v.X * scalar, v.Y * scalar); + } + + public static Vector2 operator /(Vector2 v, double scalar) + { + return new Vector2(v.X / scalar, v.Y / scalar); + } } diff --git a/src/rasdaq/Math/Vector3.cs b/src/rasdaq/Math/Vector3.cs new file mode 100644 index 0000000..c3c4e94 --- /dev/null +++ b/src/rasdaq/Math/Vector3.cs @@ -0,0 +1,65 @@ +/// +/// A simple 3D vector struct. Does not support math operations currently +/// +/// X component +/// Y component +/// Z component +public struct Vector3(double x = 0, double y = 0, double z = 0) +{ + public double X { get; set; } = x; + public double Y { get; set; } = y; + public double Z { get; set; } = z; + public static readonly Vector3 Zero = new(0, 0, 0); + + public override readonly string ToString() => $"({X}, {Y}, {Z})"; + + public static implicit operator OpenTK.Mathematics.Vector3(Vector3 v) + { + return new OpenTK.Mathematics.Vector3((float)v.X, (float)v.Y, (float)v.Z); + } + + public static implicit operator Vector3(OpenTK.Mathematics.Vector3 v) + { + return new Vector3(v.X, v.Y, v.Z); + } + + public static implicit operator Vector2(Vector3 v) + { + return new Vector2(v.X, v.Y); + } + + public static implicit operator Vector3(Vector2 v) + { + return new Vector3(v.X, v.Y, 0); + } + + public static implicit operator OpenTK.Mathematics.Vector2(Vector3 v) + { + return new OpenTK.Mathematics.Vector2((float)v.X, (float)v.Y); + } + + public static Vector3 operator -(Vector3 v1, Vector3 v2) + { + return new Vector3(v1.X - v2.X, v1.Y - v2.Y, v1.Z - v2.Z); + } + + public static Vector3 operator +(Vector3 v1, Vector3 v2) + { + return new Vector3(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z); + } + + public static Vector3 operator *(Vector3 v, double scalar) + { + return new Vector3(v.X * scalar, v.Y * scalar, v.Z * scalar); + } + + public static Vector3 operator *(double scalar, Vector3 v) + { + return new Vector3(v.X * scalar, v.Y * scalar, v.Z * scalar); + } + + public static Vector3 operator /(Vector3 v, double scalar) + { + return new Vector3(v.X / scalar, v.Y / scalar, v.Z / scalar); + } +} diff --git a/src/rasdaq/Physics/BoxCollider.cs b/src/rasdaq/Physics/BoxCollider.cs new file mode 100644 index 0000000..db58f74 --- /dev/null +++ b/src/rasdaq/Physics/BoxCollider.cs @@ -0,0 +1,43 @@ +using rasdaq.Core.ECS; + +public class BoxCollider : Component +{ + public float Width { get; set; } + public float Height { get; set; } + + /// + /// List of colliders that this collider is currently colliding with. This list is updated every physics update. + /// + public List Collisions { get; internal set; } = new(); + + /// + /// Event triggered when a collision is started. + /// + public Action? OnCollisionEnter { get; set; } + + /// + /// Event triggered when a collision is ended. + /// + public Action? OnCollisionExit { get; set; } + + /// + /// Event triggered when a collision is ongoing. + /// + public Action? OnCollisionStay { get; set; } + + public BoxCollider(float width, float height) + { + Width = width; + Height = height; + } + + /// + /// Checks if this collider is currently colliding with another. + /// + /// + /// + public bool IsCollidingWith(BoxCollider other) + { + return Collisions.Contains(other); + } +} diff --git a/src/rasdaq/Physics/Physics.cs b/src/rasdaq/Physics/Physics.cs new file mode 100644 index 0000000..e9f7965 --- /dev/null +++ b/src/rasdaq/Physics/Physics.cs @@ -0,0 +1,112 @@ +using rasdaq.Logging; +using rasdaq.Transformations; + +public class Physics +{ + private readonly List _bodies = new(); + internal List Bodies => _bodies; + + /// + /// The gravity value to apply to all physics bodies that have ApplyGravity set to true. + /// + /// Default: 9.81 + /// + public double Gravity { get; set; } = 9.81; + + /// + /// Time interval for updates to aim to occur at. + /// For example, if this is set to 0.01, the game will aim to update every 10 milliseconds (100 updates per second). + /// + /// Default: 0.01 + /// + public double FixedUpdateTime { get; set; } = 0.01f; + + internal void AddBody(PhysicsBody body) + { + _bodies.Add(body); + } + + internal void RemoveBody(PhysicsBody body) + { + _bodies.Remove(body); + } + + internal void Update() + { + // Process each unique pair once + for (int i = 0; i < _bodies.Count; i++) + { + for (int j = i + 1; j < _bodies.Count; j++) + { + CheckCollisions(_bodies[i], _bodies[j]); + } + } + + for (int i = 0; i < _bodies.Count; i++) + { + if (_bodies[i].ApplyGravity) + { + ApplyGravity(_bodies[i]); + } + } + } + + /// + /// Handles collisions and sends updates. The order is on purpose: + /// - Check for any collisions that are no longer happening and need to be removed - send their event + /// - Send collision stay events for collisions that are currently happening + /// - Check for any new collisions that need to be added - send their event + /// + /// Physics body to check. + private void CheckCollisions(PhysicsBody bodyA, PhysicsBody bodyB) + { + BoxCollider? colliderA = bodyA.Entity?.GetComponent(); + BoxCollider? colliderB = bodyB.Entity?.GetComponent(); + + if (colliderA == null || colliderB == null) + return; + + bool isColliding = CheckCollision(colliderA, colliderB); + bool wasColliding = colliderA.Collisions.Contains(colliderB); + + if (wasColliding && !isColliding) + { + // Collision ended - notify both sides + colliderA.Collisions.Remove(colliderB); + colliderB.Collisions.Remove(colliderA); + colliderA.OnCollisionExit?.Invoke(colliderB); + colliderB.OnCollisionExit?.Invoke(colliderA); + } + else if (wasColliding && isColliding) + { + // Collision ongoing - notify both sides once + colliderA.OnCollisionStay?.Invoke(colliderB); + colliderB.OnCollisionStay?.Invoke(colliderA); + } + else if (!wasColliding && isColliding) + { + // New collision - notify both sides + colliderA.Collisions.Add(colliderB); + colliderB.Collisions.Add(colliderA); + colliderA.OnCollisionEnter?.Invoke(colliderB); + colliderB.OnCollisionEnter?.Invoke(colliderA); + } + } + + private void ApplyGravity(PhysicsBody body) + { + body.Velocity = new Vector2( + body.Velocity.X, + body.Velocity.Y - (float)(Gravity * FixedUpdateTime) + ); + } + + private static bool CheckCollision(BoxCollider a, BoxCollider b) + { + Vector2 aPos = a.Entity.Transform.position; + Vector2 bPos = b.Entity.Transform.position; + + return (Math.Abs(aPos.X - bPos.X) * 2 < (a.Width + b.Width)) + && (Math.Abs(aPos.Y - bPos.Y) * 2 < (a.Height + b.Height)); + } +} diff --git a/src/rasdaq/Transformations/PhysicsBody.cs b/src/rasdaq/Physics/PhysicsBody.cs similarity index 84% rename from src/rasdaq/Transformations/PhysicsBody.cs rename to src/rasdaq/Physics/PhysicsBody.cs index 07233e6..26c94fb 100644 --- a/src/rasdaq/Transformations/PhysicsBody.cs +++ b/src/rasdaq/Physics/PhysicsBody.cs @@ -1,4 +1,5 @@ using rasdaq.Core.ECS; +using rasdaq.Logging; using OVector2 = OpenTK.Mathematics.Vector2; namespace rasdaq.Transformations; @@ -14,6 +15,12 @@ public class PhysicsBody : Component private OVector2 _distanceVelocity = OVector2.Zero; private OVector2 _distanceCovered; private OVector2 _velocity = OVector2.Zero; + + /// + /// Whether the entity is affected by gravity. + /// + public bool ApplyGravity { get; set; } = true; + /// /// Constant velocity of the entity. This velocity is applied every physics update /// @@ -23,6 +30,17 @@ public Vector2 Velocity set => _velocity = (OVector2)value; } + public override void Start() + { + World.Physics.AddBody(this); + } + + public override void Destroy() + { + base.Destroy(); + World.Physics.RemoveBody(this); + } + public override void Update(double deltaTime) { var delta = GetDeltaVector((float)deltaTime); @@ -80,7 +98,10 @@ internal OVector2 GetDeltaVector(float elapsedTime) private OVector2 MoveFrameDistance(float elapsedTime) { // if no distance to cover - if (_distanceToCover == OVector2.Zero) { return OVector2.Zero; } + if (_distanceToCover == OVector2.Zero) + { + return OVector2.Zero; + } OVector2 distanceThisFrame = _distanceVelocity * elapsedTime; OVector2 deltaVector; @@ -105,19 +126,21 @@ private void ProcessRemainingDistance(OVector2 deltaVector, float elapsedTime) _distanceCovered += deltaVector + (_velocity * elapsedTime); else // net distance covered this frame, then vectorized - _distanceCovered += (deltaVector + (_velocity * elapsedTime)).Length * OVector2.Normalize(_distanceVelocity); + _distanceCovered += + (deltaVector + (_velocity * elapsedTime)).Length + * OVector2.Normalize(_distanceVelocity); if ( - _distanceToCover.X > 0 && _distanceCovered.X >= _distanceToCover.X || - _distanceToCover.X < 0 && _distanceCovered.X <= _distanceToCover.X + _distanceToCover.X > 0 && _distanceCovered.X >= _distanceToCover.X + || _distanceToCover.X < 0 && _distanceCovered.X <= _distanceToCover.X ) { _distanceVelocity.X = 0; _distanceToCover.X = 0; } if ( - _distanceToCover.Y > 0 && _distanceCovered.Y >= _distanceToCover.Y || - _distanceToCover.Y < 0 && _distanceCovered.Y <= _distanceToCover.Y + _distanceToCover.Y > 0 && _distanceCovered.Y >= _distanceToCover.Y + || _distanceToCover.Y < 0 && _distanceCovered.Y <= _distanceToCover.Y ) { _distanceVelocity.Y = 0; diff --git a/src/rasdaq/Transformations/Camera.cs b/src/rasdaq/Transformations/Camera.cs index bd925b9..c2678af 100644 --- a/src/rasdaq/Transformations/Camera.cs +++ b/src/rasdaq/Transformations/Camera.cs @@ -1,4 +1,5 @@ using OpenTK.Mathematics; +using rasdaq.Logging; using OVector3 = OpenTK.Mathematics.Vector3; namespace rasdaq.Transformations; @@ -9,26 +10,42 @@ namespace rasdaq.Transformations; /// public class Camera { + private Vector3 _targetCenter = new(0.0f, 0.0f, 1000.0f); + + /// + /// Center of the camera's view + /// + public Vector3 Position + { + get { return _targetCenter; } + set { _targetCenter = new Vector3(value.X, value.Y, 1000.0f); } + } + /// /// Bottom left corner of the camera's view /// - public Vector3 Position { get; private set; } = new(0.0f, 0.0f, 1000.0f); + internal Vector3 _Position + { + get + { + try + { + Vector2 windowSize = Application.WindowSize ?? Vector2.Zero; + return _targetCenter - new Vector3(windowSize.X / 2f, windowSize.Y / 2f, 0f); + } + catch (Exception e) + { + Log.Exception(e, "Camera position calculation error"); + return _targetCenter; + } + } + } internal Matrix4 GetView() { var front = new OVector3(0.0f, 0.0f, -1.0f); var up = new OVector3(0.0f, 1.0f, 0.0f); - return Matrix4.LookAt((OVector3)Position, (OVector3)Position + front, up); - } - - /// - /// Sets the camera's position - /// - /// X coordinate of the bottom left corner of the camera's view - /// Y coordinate of the bottom left corner of the camera's view - public void SetPosition(double x, double y) - { - Position = new(x, y, 1000.0f); + return Matrix4.LookAt((OVector3)_Position, (OVector3)_Position + front, up); } } diff --git a/src/rasdaq/Transformations/Transform.cs b/src/rasdaq/Transformations/Transform.cs index 5e3e7b6..c62d994 100644 --- a/src/rasdaq/Transformations/Transform.cs +++ b/src/rasdaq/Transformations/Transform.cs @@ -16,16 +16,27 @@ public class Transform(Vector3 spawnPosition) /// position of the centre of the entity /// public Vector3 position = spawnPosition; + /// /// Scale of the entity. (1,1) is the default, and means the image will be rendered at its original size /// The X and Y components of the scale are applied to the width and height of the entity respectively /// public Vector2 scale = new(1, 1); + /// /// Rotation in degrees. Used for 2D rotation around Z axis. /// public float rotation = 0.0f; + public Vector2 Forward + { + get + { + var radians = MathHelper.DegreesToRadians(rotation); + return new Vector2((float)Math.Cos(radians), (float)Math.Sin(radians)); + } + } + internal void CoordUpdate(OVector2 delta) { position.X += delta.X; @@ -45,7 +56,8 @@ internal Matrix4 Get2DRotation() /// Matrix transformation of scale internal Matrix4 ScaleFromNDC(float imageWidth, float imageHeight) { - return Matrix4.CreateScale(imageWidth, imageHeight, 1.0f) * Scale((float)scale.X, (float)scale.Y); + return Matrix4.CreateScale(imageWidth, imageHeight, 1.0f) + * Scale((float)scale.X, (float)scale.Y); } internal static Matrix4 Scale(float xScale, float yScale) diff --git a/src/rasdaq/Transformations/Vector3.cs b/src/rasdaq/Transformations/Vector3.cs deleted file mode 100644 index ed7c61a..0000000 --- a/src/rasdaq/Transformations/Vector3.cs +++ /dev/null @@ -1,25 +0,0 @@ - -/// -/// A simple 3D vector struct. Does not support math operations currently -/// -/// X component -/// Y component -/// Z component -public struct Vector3(double x = 0, double y = 0, double z = 0) -{ - public double X { get; set; } = x; - public double Y { get; set; } = y; - public double Z { get; set; } = z; - public static readonly Vector3 Zero = new(0, 0, 0); - public override readonly string ToString() => $"({X}, {Y}, {Z})"; - - public static implicit operator OpenTK.Mathematics.Vector3(Vector3 v) - { - return new OpenTK.Mathematics.Vector3((float)v.X, (float)v.Y, (float)v.Z); - } - - public static implicit operator Vector3(OpenTK.Mathematics.Vector3 v) - { - return new Vector3(v.X, v.Y, v.Z); - } -} diff --git a/tests/EntityTests.cs b/tests/Core/ECS/EntityTests.cs similarity index 100% rename from tests/EntityTests.cs rename to tests/Core/ECS/EntityTests.cs diff --git a/tests/WorldTests.cs b/tests/Core/ECS/WorldTests.cs similarity index 100% rename from tests/WorldTests.cs rename to tests/Core/ECS/WorldTests.cs diff --git a/tests/GameLoopTests.cs b/tests/Core/GameLoopTests.cs similarity index 85% rename from tests/GameLoopTests.cs rename to tests/Core/GameLoopTests.cs index 7a9291d..8e4dcf9 100644 --- a/tests/GameLoopTests.cs +++ b/tests/Core/GameLoopTests.cs @@ -19,14 +19,14 @@ public void GameLoop_Ticks() world.AddEntity(trackerEntity); - loop.Tick(loop.MsPerUpdate); + loop.Tick(0.01f); Assert.That(tracker.updateCounter, Is.EqualTo(1)); Assert.That(tracker.frameUpdateCounter, Is.EqualTo(1)); Assert.That(tracker.lateUpdateCounter, Is.EqualTo(1)); tracker.Reset(); - loop.Tick(loop.MsPerUpdate * 10); + loop.Tick(0.01f * 11); Assert.That(tracker.updateCounter, Is.EqualTo(10)); Assert.That(tracker.frameUpdateCounter, Is.EqualTo(1)); Assert.That(tracker.lateUpdateCounter, Is.EqualTo(1)); @@ -36,7 +36,6 @@ public void GameLoop_Ticks() public void GameLoop_MaxUpdates() { World world = new(); - GameLoop loop = world.GameLoop; Entity trackerEntity = new(Vector3.Zero); LifecycleTracker tracker = new(); @@ -44,7 +43,7 @@ public void GameLoop_MaxUpdates() world.AddEntity(trackerEntity); - world.GameLoop.Tick(loop.MsPerUpdate * 100); + world.GameLoop.Tick(0.01f * 100); Assert.That(tracker.updateCounter, Is.EqualTo(10)); } @@ -53,7 +52,6 @@ public void GameLoop_MaxUpdates() public void GameLoop_CorrectOrderOfLifecycle() { World world = new(); - GameLoop loop = world.GameLoop; Entity trackerEntity = new(); LifecycleTracker tracker = new(); @@ -61,7 +59,7 @@ public void GameLoop_CorrectOrderOfLifecycle() world.AddEntity(trackerEntity); - world.GameLoop.Tick(loop.MsPerUpdate); + world.GameLoop.Tick(0.01f); Assert.That(tracker.startCounter, Is.EqualTo(1)); Assert.That(tracker.updateCounter, Is.EqualTo(1)); @@ -73,7 +71,6 @@ public void GameLoop_CorrectOrderOfLifecycle() public void GameLoop_StartRunsOnlyOnce() { World world = new(); - GameLoop loop = world.GameLoop; Entity trackerEntity = new(); LifecycleTracker tracker = new(); @@ -81,10 +78,10 @@ public void GameLoop_StartRunsOnlyOnce() world.AddEntity(trackerEntity); - world.GameLoop.Tick(loop.MsPerUpdate); - world.GameLoop.Tick(loop.MsPerUpdate); - world.GameLoop.Tick(loop.MsPerUpdate); - world.GameLoop.Tick(loop.MsPerUpdate); + world.GameLoop.Tick(0.01f); + world.GameLoop.Tick(0.01f); + world.GameLoop.Tick(0.01f); + world.GameLoop.Tick(0.01f); Assert.That(tracker.startCounter, Is.EqualTo(1)); Assert.That(tracker.updateCounter, Is.EqualTo(4)); diff --git a/tests/SpriteTests.cs b/tests/Graphics/SpriteTests.cs similarity index 100% rename from tests/SpriteTests.cs rename to tests/Graphics/SpriteTests.cs diff --git a/tests/InputManagerTests.cs b/tests/Inputs/InputManagerTests.cs similarity index 64% rename from tests/InputManagerTests.cs rename to tests/Inputs/InputManagerTests.cs index 19d7392..a51a6e0 100644 --- a/tests/InputManagerTests.cs +++ b/tests/Inputs/InputManagerTests.cs @@ -10,10 +10,7 @@ namespace tests; internal class MockCallbacks { - public static void MockCallback() - { - - } + public static void MockCallback() { } } internal class MouseMoveData(float X, float Y, float DeltaX, float DeltaY) @@ -32,10 +29,7 @@ public override bool Equals(object? obj) MouseMoveData other = (MouseMoveData)obj; - return X == other.X && - Y == other.Y && - deltaX == other.deltaX && - deltaY == other.deltaY; + return X == other.X && Y == other.Y && deltaX == other.deltaX && deltaY == other.deltaY; } public override int GetHashCode() @@ -44,89 +38,6 @@ public override int GetHashCode() } } -internal class MockApplication : IGameWindow -{ - public CursorState CursorState { get; set; } = CursorState.Normal; - - public Vector2 MousePosition { get; set; } - - public event Action? KeyDown; - - public event Action? KeyUp; - - public event Action? MouseMove; - public event Action? MouseDown; - public event Action? MouseUp; - - public List currentKeysBeingPressed = new(); - public List currentMouseButtonsBeingPressed = new(); - - public void SetPressedKeys(Keys newKeys) - { - currentKeysBeingPressed.Add(newKeys); - } - - public void SetMouseButtonPressed(MouseButton newButton) - { - currentMouseButtonsBeingPressed.Add(newButton); - } - - public bool IsKeyDown(Keys key) - { - return currentKeysBeingPressed.Contains(key); - } - - public bool IsKeyPressed(Keys key) - { - return currentKeysBeingPressed.Contains(key); - } - - public bool IsKeyReleased(Keys key) - { - return !currentKeysBeingPressed.Contains(key); - } - - public bool IsMouseButtonDown(MouseButton button) - { - return currentMouseButtonsBeingPressed.Contains(button); - } - - public bool IsMouseButtonPressed(MouseButton button) - { - return currentMouseButtonsBeingPressed.Contains(button); - } - - public bool IsMouseButtonReleased(MouseButton button) - { - return !currentMouseButtonsBeingPressed.Contains(button); - } - - public void TriggerKeyDown(Keys key) - { - KeyDown?.Invoke(new KeyboardKeyEventArgs((OpenTK.Windowing.GraphicsLibraryFramework.Keys)key, 0, 0, false)); - } - - public void TriggerKeyUp(Keys key) - { - KeyUp?.Invoke(new KeyboardKeyEventArgs((OpenTK.Windowing.GraphicsLibraryFramework.Keys)key, 0, 0, false)); - } - - public void TriggerMouseButtonDown(MouseButton mouseButton) - { - MouseDown?.Invoke(new MouseButtonEventArgs((OpenTK.Windowing.GraphicsLibraryFramework.MouseButton)mouseButton, InputAction.Press, 0)); - } - - public void TriggerMouseButtonUp(MouseButton mouseButton) - { - MouseUp?.Invoke(new MouseButtonEventArgs((OpenTK.Windowing.GraphicsLibraryFramework.MouseButton)mouseButton, InputAction.Release, 0)); - } - - public void TriggerMouseMove(MouseMoveEvent mouseMoveData) - { - MouseMove?.Invoke(mouseMoveData); - } -} - [TestFixture] public class InputManagerTests { @@ -207,7 +118,7 @@ public void SetEventListeners_InvokeMouseMove_InvokeMouseMoveCallback() { inputMouseMoveData.dx = e.dx; inputMouseMoveData.dy = e.dy; - } + }, }; inputManager.SetEventListeners(); diff --git a/tests/Physics/BoxColliderTests.cs b/tests/Physics/BoxColliderTests.cs new file mode 100644 index 0000000..cfe3d5b --- /dev/null +++ b/tests/Physics/BoxColliderTests.cs @@ -0,0 +1,151 @@ +using NUnit.Framework; +using rasdaq.Core.ECS; +using rasdaq.Logging; +using rasdaq.Transformations; + +namespace tests; + +[TestFixture] +public class BoxColliderTests +{ + World world; + + [SetUp] + public void BoxCollider_Setup_ResetState() + { + world = new World(); + } + + private void RunFrames(int frames = 1, float deltaTime = 1) + { + for (int frame = 0; frame < frames; frame++) + { + world.Update(deltaTime); + } + } + + [Test] + public void Collision_Stay() + { + Entity mockEntity = new(Vector3.Zero); + BoxCollider collider = new(100, 100); + mockEntity.AddComponent(new PhysicsBody() { ApplyGravity = false }); + mockEntity.AddComponent(collider); + + int collisionStayCount = 0; + collider.OnCollisionStay += (other) => + { + collisionStayCount++; + }; + + Entity otherEntity = new(Vector3.Zero); + BoxCollider otherCollider = new(100, 100); + otherEntity.AddComponent(new PhysicsBody() { ApplyGravity = false }); + otherEntity.AddComponent(otherCollider); + + int otherColliderStayCount = 0; + otherCollider.OnCollisionStay += (other) => + { + otherColliderStayCount++; + }; + + world.AddEntity(mockEntity); + world.AddEntity(otherEntity); + + // This adds entities + RunFrames(1); + + RunFrames(5); + + Assert.That(collisionStayCount, Is.EqualTo(5)); + Assert.That(otherColliderStayCount, Is.EqualTo(5)); + } + + [Test] + public void Collision_Enter() + { + Entity mockEntity = new(Vector3.Zero); + BoxCollider collider = new(100, 100); + mockEntity.AddComponent(new PhysicsBody() { ApplyGravity = false }); + mockEntity.AddComponent(collider); + + int collisionEnterCount = 0; + collider.OnCollisionEnter += (other) => + { + collisionEnterCount++; + }; + + Entity otherEntity = new(Vector3.Zero); + BoxCollider otherCollider = new(100, 100); + otherEntity.AddComponent(new PhysicsBody() { ApplyGravity = false }); + otherEntity.AddComponent(otherCollider); + + int otherColliderEnterCount = 0; + otherCollider.OnCollisionEnter += (other) => + { + otherColliderEnterCount++; + }; + + world.AddEntity(mockEntity); + world.AddEntity(otherEntity); + + // This adds entities + RunFrames(1); + + // Enter should fire exactly once regardless of how many frames pass + RunFrames(5); + + Assert.That(collisionEnterCount, Is.EqualTo(1)); + Assert.That(otherColliderEnterCount, Is.EqualTo(1)); + } + + [Test] + public void Collision_Exit() + { + Entity mockEntity = new(Vector3.Zero); + BoxCollider collider = new(100, 100); + PhysicsBody body = new() { ApplyGravity = false }; + mockEntity.AddComponent(body); + mockEntity.AddComponent(collider); + + int collisionExitCount = 0; + collider.OnCollisionExit += (other) => + { + collisionExitCount++; + }; + + Entity otherEntity = new(Vector3.Zero); + BoxCollider otherCollider = new(100, 100); + otherEntity.AddComponent(new PhysicsBody() { ApplyGravity = false }); + otherEntity.AddComponent(otherCollider); + + int otherColliderExitCount = 0; + otherCollider.OnCollisionExit += (other) => + { + otherColliderExitCount++; + }; + + world.AddEntity(mockEntity); + world.AddEntity(otherEntity); + + // This adds entities + RunFrames(1); + + // Let them collide for a few frames + RunFrames(3); + + // Move mockEntity far away so collision ends + mockEntity.Transform.position = new Vector3(1000, 1000, 0); + + RunFrames(1); + + Assert.That(collisionExitCount, Is.EqualTo(1)); + Assert.That(otherColliderExitCount, Is.EqualTo(1)); + + // Ensure exit doesn't fire again on subsequent frames + RunFrames(5); + + Assert.That(collisionExitCount, Is.EqualTo(1)); + Assert.That(otherColliderExitCount, Is.EqualTo(1)); + } +} diff --git a/tests/PhysicsBodyTests.cs b/tests/Physics/PhysicsBodyTests.cs similarity index 100% rename from tests/PhysicsBodyTests.cs rename to tests/Physics/PhysicsBodyTests.cs diff --git a/tests/Physics/PhysicsTests.cs b/tests/Physics/PhysicsTests.cs new file mode 100644 index 0000000..32d1e0d --- /dev/null +++ b/tests/Physics/PhysicsTests.cs @@ -0,0 +1,130 @@ +using NUnit.Framework; +using rasdaq.Core.ECS; +using rasdaq.Transformations; + +namespace tests; + +public class PhysicsTests +{ + [Test] + public void Physics_AppliesGravityToBodies() + { + World world = new(); + Entity entity = new(Vector3.Zero); + var physics = new Physics(); + var body = new PhysicsBody(); + + entity.AddComponent(body); + world.AddEntity(entity); + + physics.AddBody(body); + + double initialVelocityY = body.Velocity.Y; + + physics.Update(); + + Assert.That( + body.Velocity.Y, + Is.EqualTo(initialVelocityY - (float)(physics.Gravity * physics.FixedUpdateTime)) + .Within(0.001) + ); + } + + [Test] + public void Physics_CollisionDetects() + { + var physics = new Physics(); + + var entityA = new Entity(); + var bodyA = new PhysicsBody(); + var colliderA = new BoxCollider(1, 1); + entityA.AddComponent(bodyA); + entityA.AddComponent(colliderA); + + var entityB = new Entity(); + var bodyB = new PhysicsBody(); + var colliderB = new BoxCollider(1, 1); + entityB.AddComponent(bodyB); + entityB.AddComponent(colliderB); + + physics.AddBody(bodyA); + physics.AddBody(bodyB); + + // Position entities to collide + entityA.Transform.position = new Vector2(0, 0); + entityB.Transform.position = new Vector2(0.5f, 0.5f); + + physics.Update(); + + Assert.That(colliderA.Collisions, Contains.Item(colliderB)); + Assert.That(colliderB.Collisions, Contains.Item(colliderA)); + } + + [Test] + public void Physics_CollisionDoesntDetectNonCollision() + { + var physics = new Physics(); + + var entityA = new Entity(); + var bodyA = new PhysicsBody(); + var colliderA = new BoxCollider(1, 1); + entityA.AddComponent(bodyA); + entityA.AddComponent(colliderA); + + var entityB = new Entity(); + var bodyB = new PhysicsBody(); + var colliderB = new BoxCollider(1, 1); + entityB.AddComponent(bodyB); + entityB.AddComponent(colliderB); + + physics.AddBody(bodyA); + physics.AddBody(bodyB); + + // Position entities to not collide + entityA.Transform.position = new Vector2(0, 0); + entityB.Transform.position = new Vector2(2, 2); + + physics.Update(); + + Assert.That(colliderA.Collisions, Does.Not.Contain(colliderB)); + Assert.That(colliderB.Collisions, Does.Not.Contain(colliderA)); + } + + [Test] + public void Physics_DetectsMoreThanTwo() + { + var physics = new Physics(); + + var entityA = new Entity(); + var bodyA = new PhysicsBody(); + var colliderA = new BoxCollider(1, 1); + entityA.AddComponent(bodyA); + entityA.AddComponent(colliderA); + + var entityB = new Entity(); + var bodyB = new PhysicsBody(); + var colliderB = new BoxCollider(1, 1); + entityB.AddComponent(bodyB); + entityB.AddComponent(colliderB); + + var entityC = new Entity(); + var bodyC = new PhysicsBody(); + var colliderC = new BoxCollider(1, 1); + entityC.AddComponent(bodyC); + entityC.AddComponent(colliderC); + + physics.AddBody(bodyA); + physics.AddBody(bodyB); + physics.AddBody(bodyC); + + // Position entities to collide + entityA.Transform.position = new Vector2(0, 0); + entityB.Transform.position = new Vector2(0f, 0f); + entityC.Transform.position = new Vector2(0f, 0f); + + physics.Update(); + + Assert.That(colliderA.Collisions, Contains.Item(colliderB)); + Assert.That(colliderA.Collisions, Contains.Item(colliderC)); + } +} diff --git a/tests/CameraTests.cs b/tests/Transformations/CameraTests.cs similarity index 69% rename from tests/CameraTests.cs rename to tests/Transformations/CameraTests.cs index 93e510e..9f6a97b 100644 --- a/tests/CameraTests.cs +++ b/tests/Transformations/CameraTests.cs @@ -17,13 +17,12 @@ public void Camera_Setup_ResetState() [Test] public void SetCameraPosition_SetValidCoordinates_UpdateCameraPosition() { - camera.SetPosition(10f, 20f); + camera.Position = new Vector2(10f, 20f); Assert.Multiple(() => { - Assert.That(camera.Position.X, Is.EqualTo(10f)); - Assert.That(camera.Position.Y, Is.EqualTo(20f)); + Assert.That(camera._Position.X, Is.EqualTo(10f)); + Assert.That(camera._Position.Y, Is.EqualTo(20f)); }); - } } diff --git a/tests/TransformTests.cs b/tests/Transformations/TransformTests.cs similarity index 100% rename from tests/TransformTests.cs rename to tests/Transformations/TransformTests.cs diff --git a/tests/Utils/MockApplication.cs b/tests/Utils/MockApplication.cs new file mode 100644 index 0000000..afb4bc0 --- /dev/null +++ b/tests/Utils/MockApplication.cs @@ -0,0 +1,123 @@ +using OpenTK.Windowing.Common; +using OpenTK.Windowing.GraphicsLibraryFramework; +using rasdaq.Inputs; +using Keys = rasdaq.Inputs.Keys; +using MouseButton = rasdaq.Inputs.MouseButton; + +namespace tests; + +internal class MockApplication : IGameWindow +{ + public CursorState CursorState { get; set; } = CursorState.Normal; + + public Vector2 MousePosition { get; set; } + + private Vector2? _windowSize = new(800, 600); + public Vector2? WindowSize + { + get => _windowSize; + set => _windowSize = value; + } + + public event Action? KeyDown; + + public event Action? KeyUp; + + public event Action? MouseMove; + public event Action? MouseDown; + public event Action? MouseUp; + + public List currentKeysBeingPressed = new(); + public List currentMouseButtonsBeingPressed = new(); + + public void SetPressedKeys(Keys newKeys) + { + currentKeysBeingPressed.Add(newKeys); + } + + public void SetMouseButtonPressed(MouseButton newButton) + { + currentMouseButtonsBeingPressed.Add(newButton); + } + + public bool IsKeyDown(Keys key) + { + return currentKeysBeingPressed.Contains(key); + } + + public bool IsKeyPressed(Keys key) + { + return currentKeysBeingPressed.Contains(key); + } + + public bool IsKeyReleased(Keys key) + { + return !currentKeysBeingPressed.Contains(key); + } + + public bool IsMouseButtonDown(MouseButton button) + { + return currentMouseButtonsBeingPressed.Contains(button); + } + + public bool IsMouseButtonPressed(MouseButton button) + { + return currentMouseButtonsBeingPressed.Contains(button); + } + + public bool IsMouseButtonReleased(MouseButton button) + { + return !currentMouseButtonsBeingPressed.Contains(button); + } + + public void TriggerKeyDown(Keys key) + { + KeyDown?.Invoke( + new KeyboardKeyEventArgs( + (OpenTK.Windowing.GraphicsLibraryFramework.Keys)key, + 0, + 0, + false + ) + ); + } + + public void TriggerKeyUp(Keys key) + { + KeyUp?.Invoke( + new KeyboardKeyEventArgs( + (OpenTK.Windowing.GraphicsLibraryFramework.Keys)key, + 0, + 0, + false + ) + ); + } + + public void TriggerMouseButtonDown(MouseButton mouseButton) + { + MouseDown?.Invoke( + new MouseButtonEventArgs( + (OpenTK.Windowing.GraphicsLibraryFramework.MouseButton)mouseButton, + InputAction.Press, + 0 + ) + ); + } + + public void TriggerMouseButtonUp(MouseButton mouseButton) + { + MouseUp?.Invoke( + new MouseButtonEventArgs( + (OpenTK.Windowing.GraphicsLibraryFramework.MouseButton)mouseButton, + InputAction.Release, + 0 + ) + ); + } + + public void TriggerMouseMove(MouseMoveEvent mouseMoveData) + { + MouseMove?.Invoke(mouseMoveData); + } +}