diff --git a/Movement tests.csproj b/Movement tests.csproj
index 11a9afa1..6d6d122a 100644
--- a/Movement tests.csproj
+++ b/Movement tests.csproj
@@ -125,6 +125,7 @@
+
diff --git a/tests/components/DamageComponentUnitTest.cs b/tests/components/DamageComponentUnitTest.cs
new file mode 100644
index 00000000..984a78c0
--- /dev/null
+++ b/tests/components/DamageComponentUnitTest.cs
@@ -0,0 +1,101 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class DamageComponentUnitTest
+{
+ [TestCase]
+ public void DamageModifierAppliesWhenTypeMatches()
+ {
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var modifier = new RDamageModifier(EDamageTypes.Normal, 2.0f);
+ var signalTriggered = false;
+ modifier.DamageTaken += (_, _) => signalTriggered = true;
+
+ var result = modifier.TakeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(20.0f);
+ AssertBool(signalTriggered).IsTrue();
+ }
+
+ [TestCase]
+ public void DamageModifierIgnoresWhenTypeDifferent()
+ {
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var modifier = new RDamageModifier(EDamageTypes.Fire, 3.0f);
+ var signalTriggered = false;
+ modifier.DamageTaken += (_, _) => signalTriggered = true;
+
+ var result = modifier.TakeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(0.0f);
+ AssertBool(signalTriggered).IsFalse(); // No damage actually taken
+ }
+
+ [TestCase]
+ public void DamageableSumsAllModifiers()
+ {
+ var mod1 = new RDamageModifier(EDamageTypes.Normal, 1.0f);
+ var mod2 = new RDamageModifier(EDamageTypes.Normal, 0.5f);
+
+ var damageable = new CDamageable();
+ damageable.DamageModifiers = new[] { mod1, mod2 };
+ var signalTriggered = false;
+ damageable.DamageTaken += (_, _) => signalTriggered = true;
+
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var result = damageable.TakeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(15.0f);
+ AssertBool(signalTriggered).IsTrue();
+ }
+
+ [TestCase]
+ public void ComputeDamageModifierAppliesWhenTypeMatches()
+ {
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var modifier = new RDamageModifier(EDamageTypes.Normal, 2.0f);
+ var signalTriggered = false;
+ modifier.DamageTaken += (_, _) => signalTriggered = true;
+
+ var result = modifier.ComputeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(20.0f);
+ AssertBool(signalTriggered).IsFalse();
+ }
+ [TestCase]
+ public void ComputeDamageModifierIgnoresWhenTypeDifferent()
+ {
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var modifier = new RDamageModifier(EDamageTypes.Fire, 3.0f);
+ var signalTriggered = false;
+ modifier.DamageTaken += (_, _) => signalTriggered = true;
+
+ var result = modifier.ComputeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(0.0f);
+ AssertBool(signalTriggered).IsFalse();
+ }
+ [TestCase]
+ public void ComputeDamageableSumsAllModifiers()
+ {
+ var mod1 = new RDamageModifier(EDamageTypes.Normal, 1.0f);
+ var mod2 = new RDamageModifier(EDamageTypes.Normal, 0.5f);
+
+ var cDamageable = new CDamageable();
+ cDamageable.DamageModifiers = new[] { mod1, mod2 };
+ var signalTriggered = false;
+ cDamageable.DamageTaken += (_, _) => signalTriggered = true;
+
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+ var result = cDamageable.ComputeDamage(input);
+
+ AssertFloat(result.Damage.DamageDealt).IsEqual(15.0f);
+ AssertBool(signalTriggered).IsFalse();
+ }
+}
diff --git a/tests/components/DamageComponentUnitTest.cs.uid b/tests/components/DamageComponentUnitTest.cs.uid
new file mode 100644
index 00000000..1351834c
--- /dev/null
+++ b/tests/components/DamageComponentUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://db6rva7uccppc
diff --git a/tests/components/HealthComponentUnitTest.cs b/tests/components/HealthComponentUnitTest.cs
new file mode 100644
index 00000000..63d1cb0e
--- /dev/null
+++ b/tests/components/HealthComponentUnitTest.cs
@@ -0,0 +1,55 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class HealthComponentUnitTest
+{
+ [TestCase]
+ public void ReadyInitializesCurrentHealth()
+ {
+ var cHealth = new CHealth();
+ cHealth.RHealth = new RHealth(150.0f);
+ cHealth._Ready();
+ AssertFloat(cHealth.CurrentHealth).IsEqual(150.0f);
+ }
+
+ [TestCase]
+ public void ReduceHealthDecreasesAndDoesNotDeplete()
+ {
+ var cHealth = new CHealth();
+ cHealth.RHealth = new RHealth(100.0f);
+ cHealth.CurrentHealth = 100.0f;
+
+ var damage = new DamageRecord(Vector3.Zero, new RDamage(25.0f, EDamageTypes.Normal));
+ var record = cHealth.ReduceHealth(source: null!, damageRecord: damage);
+
+ AssertFloat(cHealth.CurrentHealth).IsEqual(75.0f);
+ AssertFloat(record.CurrentHealth).IsEqual(75.0f);
+ AssertFloat(record.PreviousHealth).IsEqual(100.0f);
+ AssertFloat(record.MaxHealth).IsEqual(100.0f);
+ }
+
+ [TestCase]
+ public void ReduceHealthTriggersDepletionToZero()
+ {
+ var cHealth = new CHealth();
+ cHealth.RHealth = new RHealth(50.0f);
+ cHealth.CurrentHealth = 50.0f;
+
+ bool depleted = false;
+ cHealth.HealthDepleted += _ => depleted = true;
+
+ var damage = new DamageRecord(Vector3.Zero, new RDamage(100.0f, EDamageTypes.Normal));
+ var record = cHealth.ReduceHealth(source: null!, damageRecord: damage);
+
+ AssertBool(depleted).IsTrue();
+ AssertFloat(cHealth.CurrentHealth).IsEqual(0.0f);
+ AssertFloat(record.CurrentHealth).IsEqual(-50.0f);
+ AssertFloat(record.MaxHealth).IsEqual(50.0f);
+ }
+}
diff --git a/tests/components/HealthComponentUnitTest.cs.uid b/tests/components/HealthComponentUnitTest.cs.uid
new file mode 100644
index 00000000..00cee7a1
--- /dev/null
+++ b/tests/components/HealthComponentUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://bd52i51hncgmf
diff --git a/tests/components/KnockbackComponentUnitTest.cs b/tests/components/KnockbackComponentUnitTest.cs
new file mode 100644
index 00000000..605d7f75
--- /dev/null
+++ b/tests/components/KnockbackComponentUnitTest.cs
@@ -0,0 +1,32 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class KnockbackComponentUnitTest
+{
+ [TestCase]
+ public void RegisterAndComputeKnockback()
+ {
+ var cKnock = new CKnockback();
+ cKnock.RKnockback = new RKnockback(2.0f);
+ cKnock.GlobalPosition = Vector3.Zero;
+
+ var damage = new DamageRecord(new Vector3(10, 0, 0), new RDamage(0, EDamageTypes.Normal));
+ var record = new KnockbackRecord(damage, 1.5f);
+
+ cKnock.RegisterKnockback(record);
+ var force = cKnock.ComputeKnockback();
+
+ // Direction from source(10,0,0) to target(0,0,0) is (-1,0,0), scaled by modifier(2) and multiplier(1.5) => (-3,0,0)
+ AssertVector(force).IsEqual(new Vector3(-3, 0, 0));
+
+ // Second call returns zero since internal state resets
+ var second = cKnock.ComputeKnockback();
+ AssertVector(second).IsEqual(Vector3.Zero);
+ }
+}
diff --git a/tests/components/KnockbackComponentUnitTest.cs.uid b/tests/components/KnockbackComponentUnitTest.cs.uid
new file mode 100644
index 00000000..c5795539
--- /dev/null
+++ b/tests/components/KnockbackComponentUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://bv0eionbgbig5
diff --git a/tests/components/MovementSystemUnitTest.cs b/tests/components/MovementSystemUnitTest.cs
new file mode 100644
index 00000000..4e075eef
--- /dev/null
+++ b/tests/components/MovementSystemUnitTest.cs
@@ -0,0 +1,32 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.scenes.movement;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class MovementSystemUnitTest
+{
+ [TestCase]
+ public void GroundedMovementAcceleratesAndAppliesGravity()
+ {
+ var move = new CGroundedMovement();
+ move.RMovement = new RMovement(speed: 10.0f, acceleration: 1.0f, gravityModifier: 0.5f, targetHeight: 0.0f);
+ move.WallInFrontRayCast = new RayCast3D();
+ //move.GlobalPosition = Vector3.Zero;
+
+ var inputs = new MovementInputs(
+ Velocity: Vector3.Zero,
+ TargetLocation: new Vector3(10, 0, 0),
+ isOnFloor: false,
+ gravity: Vector3.Down * 9.8f,
+ delta: 1.0
+ );
+
+ var v = move.ComputeVelocity(inputs);
+
+ AssertVector(v).IsEqualApprox(new Vector3(10, -4.9f, 0), new Vector3(0.001f, 0.001f, 0.001f));
+ }
+}
diff --git a/tests/components/MovementSystemUnitTest.cs.uid b/tests/components/MovementSystemUnitTest.cs.uid
new file mode 100644
index 00000000..83493b96
--- /dev/null
+++ b/tests/components/MovementSystemUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://cofj5s4x74ay
diff --git a/tests/enemies/EnemyUnitTest.cs b/tests/enemies/EnemyUnitTest.cs
new file mode 100644
index 00000000..cbd73410
--- /dev/null
+++ b/tests/enemies/EnemyUnitTest.cs
@@ -0,0 +1,30 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class EnemyUnitTest
+{
+ [TestCase]
+ public void ComputeDamageNoComponent()
+ {
+ var enemy = new Enemy();
+ var input = new DamageRecord(Vector3.Zero, new RDamage(10.0f, EDamageTypes.Normal));
+
+ var result = enemy.ComputeDamage(input);
+ AssertFloat(result.Damage.DamageDealt).IsEqual(0.0f);
+ }
+
+ [TestCase]
+ public void Unstun()
+ {
+ var enemy = new Enemy();
+ enemy.IsStunned = true;
+ enemy.Unstun();
+ AssertBool(enemy.IsStunned).IsFalse();
+ }
+}
diff --git a/tests/enemies/EnemyUnitTest.cs.uid b/tests/enemies/EnemyUnitTest.cs.uid
new file mode 100644
index 00000000..1c82d5ec
--- /dev/null
+++ b/tests/enemies/EnemyUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://cojxgcs6xqqoq
diff --git a/tests/player/DashSystemUnitTest.cs b/tests/player/DashSystemUnitTest.cs
new file mode 100644
index 00000000..1da71c91
--- /dev/null
+++ b/tests/player/DashSystemUnitTest.cs
@@ -0,0 +1,55 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.systems;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class DashSystemUnitTest
+{
+ private DashSystem _dashSystem;
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _dashSystem = new DashSystem();
+
+ _dashSystem.DashCast3D = new ShapeCast3D();
+ _dashSystem.AddChild(_dashSystem.DashCast3D);
+
+ _dashSystem.DashCastDrop = new ShapeCast3D();
+ _dashSystem.AddChild(_dashSystem.DashCastDrop);
+
+ _dashSystem.DashTarget = new MeshInstance3D();
+ _dashSystem.AddChild(_dashSystem.DashTarget);
+
+ _dashSystem.DashDropIndicator = new MeshInstance3D();
+ _dashSystem.AddChild(_dashSystem.DashDropIndicator);
+
+ _dashSystem.DashDropLocationIndicator = new MeshInstance3D();
+ _dashSystem.AddChild(_dashSystem.DashDropLocationIndicator);
+ }
+
+ [AfterTest]
+ public void CleanupTest()
+ {
+ _dashSystem?.Free();
+ }
+
+ [TestCase]
+ public void TestStopPreparingDash()
+ {
+ _dashSystem.CanDashThroughTarget = true;
+ _dashSystem.DashTarget.Visible = true;
+ _dashSystem.DashDropIndicator.Visible = true;
+ _dashSystem.DashDropLocationIndicator.Visible = true;
+
+ _dashSystem.StopPreparingDash();
+
+ AssertBool(_dashSystem.CanDashThroughTarget).IsFalse();
+ AssertBool(_dashSystem.DashTarget.Visible).IsFalse();
+ AssertBool(_dashSystem.DashDropIndicator.Visible).IsFalse();
+ AssertBool(_dashSystem.DashDropLocationIndicator.Visible).IsFalse();
+ }
+}
diff --git a/tests/player/DashSystemUnitTest.cs.uid b/tests/player/DashSystemUnitTest.cs.uid
new file mode 100644
index 00000000..545620a4
--- /dev/null
+++ b/tests/player/DashSystemUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://pv570go4cxws
diff --git a/tests/player/HeadSystemUnitTest.cs b/tests/player/HeadSystemUnitTest.cs
new file mode 100644
index 00000000..07d06790
--- /dev/null
+++ b/tests/player/HeadSystemUnitTest.cs
@@ -0,0 +1,91 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.systems;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class HeadSystemUnitTest
+{
+ private HeadSystem _head;
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _head = new HeadSystem();
+ _head._camera = new Camera3D();
+ _head.AddChild(_head._camera);
+
+ _head._cameraAnchor = new Marker3D();
+ _head.AddChild(_head._cameraAnchor);
+
+ _head._fpRig = new Node3D();
+ _head.AddChild(_head._fpRig);
+
+ _head._fpDisplacedRig = new Node3D();
+ _head.AddChild(_head._fpDisplacedRig);
+ }
+
+ [AfterTest]
+ public void CleanupTest()
+ {
+ _head?.Free();
+ }
+
+ [TestCase]
+ public void TestResetHeadBobbing()
+ {
+ _head._bobbingAccumulator = 10.0f;
+ _head.ResetHeadBobbing();
+ AssertFloat(_head._bobbingAccumulator).IsEqual(0.0f);
+ }
+
+ [TestCase]
+ public void TestComputeHowMuchInputForward()
+ {
+ Vector3 forwardInput = new Vector3(0, 0, -1);
+ AssertFloat(_head.ComputeHowMuchInputForward(forwardInput)).IsEqual(1.0f);
+
+ Vector3 backwardInput = new Vector3(0, 0, 1);
+ AssertFloat(_head.ComputeHowMuchInputForward(backwardInput)).IsEqual(-1.0f);
+ }
+
+ [TestCase]
+ public void TestComputeHowMuchInputSideways()
+ {
+ Vector3 rightInput = new Vector3(1, 0, 0);
+ AssertFloat(_head.ComputeHowMuchInputSideways(rightInput)).IsEqual(1.0f);
+
+ Vector3 leftInput = new Vector3(-1, 0, 0);
+ AssertFloat(_head.ComputeHowMuchInputSideways(leftInput)).IsEqual(-1.0f);
+ }
+
+ [TestCase]
+ public void TestGetForwardHorizontalVector()
+ {
+ Vector3 forward = _head.GetForwardHorizontalVector();
+ AssertVector(forward).IsEqualApprox(Vector3.Back, new Vector3(0.001f, 0.001f, 0.001f));
+ }
+
+ [TestCase]
+ public void TestLookAroundRotation()
+ {
+ var inputs = new HeadSystem.CameraParameters(
+ Delta: 0.016,
+ LookDir: new Vector2(1, 0),
+ PlayerInput: Vector3.Zero,
+ PlayerVelocity: Vector3.Zero,
+ WallContactPoint: Vector3.Zero,
+ SensitivitMultiplier: 1.0f,
+ WithCameraJitter: false,
+ WithCameraBobbing: false,
+ BobbingMultiplier: 1.0f,
+ FovMultiplier: 1.0f
+ );
+
+ float initialY = _head.Rotation.Y;
+ _head.LookAround(inputs);
+ AssertFloat(_head.Rotation.Y).IsEqual(initialY + 1.0f);
+ }
+}
diff --git a/tests/player/HeadSystemUnitTest.cs.uid b/tests/player/HeadSystemUnitTest.cs.uid
new file mode 100644
index 00000000..110c87c4
--- /dev/null
+++ b/tests/player/HeadSystemUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://bp0xn8k3dmfkg
diff --git a/tests/player/PlayerControllerUnitTest.cs b/tests/player/PlayerControllerUnitTest.cs
new file mode 100644
index 00000000..c41fe8fc
--- /dev/null
+++ b/tests/player/PlayerControllerUnitTest.cs
@@ -0,0 +1,149 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.interfaces;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class PlayerControllerUnitTest
+{
+ private PlayerController _player;
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _player = new PlayerController();
+ _player.TargetSpeed = 7.0f;
+ _player.Gravity = 9.8f;
+
+ var rHealth = new RHealth(100.0f);
+ _player.RHealth = rHealth;
+ _player.CHealth = new CHealth { RHealth = rHealth, CurrentHealth = 100.0f };
+ }
+
+ [AfterTest]
+ public void CleanupTest()
+ {
+ _player?.Free();
+ }
+
+ [TestCase]
+ public void TestCalculateGravityForce()
+ {
+ _player.Weight = 3.0f;
+ // gravity is 9.8f
+ AssertFloat(_player.CalculateGravityForce()).IsEqualApprox(29.4f, 0.001f);
+ }
+
+ [TestCase]
+ public void TestIsPlayerInputtingForward()
+ {
+ // Test Keyboard Input
+ _player.InputDeviceChanged(false);
+ _player.OnInputMoveKeyboard(Vector3.Forward);
+ AssertBool(_player.IsPlayerInputtingForward()).IsTrue();
+
+ _player.OnInputMoveKeyboard(Vector3.Back);
+ AssertBool(_player.IsPlayerInputtingForward()).IsFalse();
+
+ // Test Gamepad Input
+ _player.InputDeviceChanged(true);
+ _player.OnInputMove(new Vector3(0, 0, -1));
+ AssertBool(_player.IsPlayerInputtingForward()).IsTrue();
+ }
+
+ [TestCase]
+ public void TestSetVerticalVelocity()
+ {
+ _player.Velocity = new Vector3(1, 0, 2);
+ _player.SetVerticalVelocity(5.0f);
+ AssertVector(_player.Velocity).IsEqual(new Vector3(1, 5, 2));
+ }
+
+ [TestCase]
+ public void TestComputeHVelocityGround()
+ {
+ _player.Velocity = Vector3.Zero;
+ _player.AccelerationFloor = 10.0f;
+
+ float delta = 0.1f;
+ Vector3 newVelocity = _player.ComputeHVelocity(delta, _player.AccelerationFloor, _player.DecelerationFloor, Vector3.Forward);
+ AssertVector(newVelocity).IsEqual(new Vector3(0, 0, -7.0f));
+ }
+
+ [TestCase]
+ public void TestComputeHVelocityAir()
+ {
+ _player.Velocity = new Vector3(5, 0, 0);
+ _player.AccelerationAir = 2.0f;
+ _player.DecelerationAir = 2.0f;
+
+ float delta = 0.5f;
+ Vector3 newVelocity = _player.ComputeHVelocity(delta, _player.AccelerationAir, _player.DecelerationAir, Vector3.Zero);
+
+ AssertVector(newVelocity).IsEqual(Vector3.Zero);
+ }
+
+ [TestCase]
+ public void TestReduceHealth()
+ {
+ var damageRecord = new DamageRecord(Vector3.Zero, new RDamage(25.0f, EDamageTypes.Normal));
+ _player.ReduceHealth(_player, damageRecord);
+ AssertFloat(_player.CHealth.CurrentHealth).IsEqual(75.0f);
+ }
+
+ [TestCase]
+ public void TestEmpoweredActionsLeft()
+ {
+ var mockUi = new PlayerUi();
+ var dashIcons = new TextureRect[3] { new TextureRect(), new TextureRect(), new TextureRect() };
+ mockUi._dashIcons = dashIcons;
+
+ _player.PlayerUi = mockUi;
+
+ _player.EmpoweredActionsLeft = 2;
+ AssertInt(_player.EmpoweredActionsLeft).IsEqual(2);
+ AssertBool(dashIcons[0].Visible).IsTrue();
+ AssertBool(dashIcons[1].Visible).IsTrue();
+ AssertBool(dashIcons[2].Visible).IsFalse();
+ }
+
+ [TestCase]
+ public void TestDashCooldownTimeout()
+ {
+ _player.CanDash = false;
+ _player.DashCooldownTimeout();
+ AssertBool(_player.CanDash).IsTrue();
+ }
+
+ [TestCase]
+ public void TestGetInputLocalHDirection()
+ {
+ _player.InputDeviceChanged(false);
+ _player.OnInputMoveKeyboard(new Vector3(1, 0, 1));
+
+ Vector3 expected = new Vector3(1, 0, 1).Normalized();
+ AssertVector(_player.GetInputLocalHDirection()).IsEqualApprox(expected, new Vector3(0.001f, 0.001f, 0.001f));
+ }
+
+ [TestCase]
+ public void TestComputeKnockback()
+ {
+ var cKnockback = new CKnockback();
+ cKnockback.RKnockback = new RKnockback(10.0f);
+ _player.CKnockback = cKnockback;
+
+ var damageRecord = new DamageRecord(new Vector3(10, 0, 0), new RDamage(0, EDamageTypes.Normal));
+ var knockbackRecord = new KnockbackRecord(damageRecord, 1.0f);
+
+ _player.GlobalPosition = Vector3.Zero;
+ cKnockback.GlobalPosition = Vector3.Zero;
+
+ _player.RegisterKnockback(knockbackRecord);
+
+ Vector3 knockback = cKnockback.ComputeKnockback();
+ AssertVector(knockback).IsEqual(new Vector3(-10, 0, 0));
+ }
+}
diff --git a/tests/player/PlayerControllerUnitTest.cs.uid b/tests/player/PlayerControllerUnitTest.cs.uid
new file mode 100644
index 00000000..16a906a1
--- /dev/null
+++ b/tests/player/PlayerControllerUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://kmphtu0ovixi
diff --git a/tests/player/WeaponSystemUnitTest.cs b/tests/player/WeaponSystemUnitTest.cs
new file mode 100644
index 00000000..cd575190
--- /dev/null
+++ b/tests/player/WeaponSystemUnitTest.cs
@@ -0,0 +1,51 @@
+using Godot;
+using GdUnit4;
+using static GdUnit4.Assertions;
+using Movementtests.systems;
+using Movementtests.systems.damage;
+
+namespace Movementtests.tests;
+
+[TestSuite, RequireGodotRuntime]
+public class WeaponSystemUnitTest
+{
+ private WeaponSystem _weapon;
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _weapon = new WeaponSystem();
+ _weapon.RDamage = new RDamage(5.0f, EDamageTypes.Normal);
+
+ _weapon.WeaponMesh = new MeshInstance3D();
+ _weapon.AddChild(_weapon.WeaponMesh);
+ _weapon.WeaponLocationIndicator = new MeshInstance3D();
+ _weapon.AddChild(_weapon.WeaponLocationIndicator);
+ }
+
+ [AfterTest]
+ public void CleanupTest()
+ {
+ _weapon?.Free();
+ }
+
+ [TestCase]
+ public void TestWeaponLeftAndBackVisibility()
+ {
+ _weapon.Visible = false;
+
+ _weapon.WeaponLeft();
+ AssertBool(_weapon.Visible).IsTrue();
+
+ _weapon.WeaponBack();
+ AssertBool(_weapon.Visible).IsFalse();
+ }
+
+ [TestCase]
+ public void TestThrowWeaponOnCurveSetsUnfrozen()
+ {
+ _weapon.Freeze = true;
+ _weapon.ThrowWeaponOnCurve();
+ AssertBool(_weapon.Freeze).IsFalse();
+ }
+}
diff --git a/tests/player/WeaponSystemUnitTest.cs.uid b/tests/player/WeaponSystemUnitTest.cs.uid
new file mode 100644
index 00000000..487b0dea
--- /dev/null
+++ b/tests/player/WeaponSystemUnitTest.cs.uid
@@ -0,0 +1 @@
+uid://vkv8aderakcb
diff --git a/tests/player/interactions/PlayerInteractionsTest.cs b/tests/player/interactions/PlayerInteractionsTest.cs
new file mode 100644
index 00000000..fcd83f87
--- /dev/null
+++ b/tests/player/interactions/PlayerInteractionsTest.cs
@@ -0,0 +1,50 @@
+using System.Threading.Tasks;
+using Godot;
+using GodotStateCharts;
+
+namespace Movementtests.tests;
+
+using GdUnit4;
+using static GdUnit4.Assertions;
+
+[TestSuite, RequireGodotRuntime]
+public class PlayerInteractionsTest
+{
+ private ISceneRunner _runner;
+ private Node _scene;
+ private PlayerController _player;
+
+ private readonly float _tolerance = 0.01f;
+ private readonly Vector3 _vectorTolerance = new Vector3(0.01f, 0.01f, 0.01f);
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _runner = ISceneRunner.Load("res://tests/player/interactions/player_interactions_scene.tscn");
+
+ _scene = _runner.Scene()!;
+ var player = _scene.FindChild("Player") as PlayerController;
+ _player = player!;
+ }
+ [AfterTest]
+ public void CleanupTest() {}
+
+ [TestCase("BaseLocation")]
+ public async Task PlayerMoveForward(string markerName)
+ {
+ var marker = _scene.FindChild(markerName) as Marker3D;
+ AssertObject(marker).IsNotNull();
+ _player.GlobalPosition = marker!.GlobalPosition;
+ await _runner.AwaitIdleFrame();
+
+ var startPos = _player.GlobalPosition;
+
+ _runner.SimulateKeyPress(Key.W);
+ await _runner.AwaitMillis(300);
+ _runner.SimulateKeyRelease(Key.W);
+
+ var endPos = _player.GlobalPosition;
+ var direction = startPos.DirectionTo(endPos);
+ AssertVector(direction).IsEqualApprox(Vector3.Forward, _vectorTolerance);
+ }
+}
\ No newline at end of file
diff --git a/tests/player/interactions/PlayerInteractionsTest.cs.uid b/tests/player/interactions/PlayerInteractionsTest.cs.uid
new file mode 100644
index 00000000..75e43ca5
--- /dev/null
+++ b/tests/player/interactions/PlayerInteractionsTest.cs.uid
@@ -0,0 +1 @@
+uid://denedm5b8rmhh
diff --git a/tests/player/interactions/player_interactions_scene.tscn b/tests/player/interactions/player_interactions_scene.tscn
new file mode 100644
index 00000000..0a7887de
--- /dev/null
+++ b/tests/player/interactions/player_interactions_scene.tscn
@@ -0,0 +1,35 @@
+[gd_scene format=3 uid="uid://l0lflvsjbyvs"]
+
+[ext_resource type="Material" uid="uid://31aulub2nqov" path="res://assets/materials/greybox/m_greybox.tres" id="1_dv0re"]
+[ext_resource type="PackedScene" uid="uid://bei4nhkf8lwdo" path="res://scenes/player_controller/PlayerController.tscn" id="2_52d52"]
+
+[node name="PlayerMovementScene" type="Node3D" unique_id=231040688]
+
+[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=241909240]
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+
+[node name="Ground" type="CSGBox3D" parent="CSGCombiner3D" unique_id=432200143]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5, -0.5, -3.25)
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+size = Vector3(1000, 1, 1000)
+material = ExtResource("1_dv0re")
+
+[node name="Ground2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=854660236]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.75, 0.5, -1.75)
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+size = Vector3(1.5, 1, 1.5)
+material = ExtResource("1_dv0re")
+
+[node name="Player" parent="." unique_id=709076448 instance=ExtResource("2_52d52")]
+TutorialDone = true
+
+[node name="BaseLocation" type="Marker3D" parent="." unique_id=1793710692]
+
+[node name="MantleLocation1" type="Marker3D" parent="." unique_id=550080845]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.75, 0, 0)
diff --git a/tests/player/movement/PlayerMovementTest.cs b/tests/player/movement/PlayerMovementTest.cs
new file mode 100644
index 00000000..1ffbe76d
--- /dev/null
+++ b/tests/player/movement/PlayerMovementTest.cs
@@ -0,0 +1,109 @@
+using System.Threading.Tasks;
+using Godot;
+using GodotStateCharts;
+
+namespace Movementtests.tests;
+
+using GdUnit4;
+using static GdUnit4.Assertions;
+
+[TestSuite, RequireGodotRuntime]
+public class PlayerMovementTest
+{
+ private ISceneRunner _runner;
+ private Node _scene;
+ private PlayerController _player;
+
+ private readonly float _tolerance = 0.01f;
+ private readonly Vector3 _vectorTolerance = new Vector3(0.01f, 0.01f, 0.01f);
+
+ [Before]
+ public void Setup() {}
+ [After]
+ public void Cleanup() {}
+
+ [BeforeTest]
+ public void SetupTest()
+ {
+ _runner = ISceneRunner.Load("res://tests/player/movement/player_movement_scene.tscn");
+
+ _scene = _runner.Scene()!;
+ var player = _scene.FindChild("Player") as PlayerController;
+ _player = player!;
+ }
+ [AfterTest]
+ public void CleanupTest() {}
+
+ [TestCase("BaseLocation")]
+ public async Task PlayerMoveForward(string markerName)
+ {
+ var marker = _scene.FindChild(markerName) as Marker3D;
+ AssertObject(marker).IsNotNull();
+ _player.GlobalPosition = marker!.GlobalPosition;
+ await _runner.AwaitIdleFrame();
+
+ var startPos = _player.GlobalPosition;
+
+ _runner.SimulateKeyPress(Key.W);
+ await _runner.AwaitMillis(100);
+ _runner.SimulateKeyRelease(Key.W);
+
+ var endPos = _player.GlobalPosition;
+ var direction = startPos.DirectionTo(endPos);
+ AssertVector(direction).IsEqualApprox(Vector3.Forward, _vectorTolerance);
+ }
+
+ [TestCase("BaseLocation")]
+ public async Task PlayerJump(string markerName)
+ {
+ var marker = _scene.FindChild(markerName) as Marker3D;
+ AssertObject(marker).IsNotNull();
+ _player.GlobalPosition = marker!.GlobalPosition;
+ await _runner.AwaitIdleFrame();
+
+ var startPos = _player.GlobalPosition;
+
+ _runner.SimulateKeyPress(Key.Space);
+ await _runner.AwaitIdleFrame();
+
+ var jumping = StateChartState.Of(_player.GetNode("StateChart/Root/Movement/Jump"));
+ AssertBool(jumping.Active).IsTrue();
+
+ _runner.SimulateKeyRelease(Key.Space);
+ await _runner.AwaitIdleFrame();
+ var endPos = _player.GlobalPosition;
+ var direction = startPos.DirectionTo(endPos);
+ AssertVector(direction).IsEqualApprox(Vector3.Up, _vectorTolerance);
+ AssertVector(_player.Velocity.Normalized()).IsEqualApprox(Vector3.Up, _vectorTolerance);
+
+ await _runner.AwaitMillis(600);
+ endPos = _player.GlobalPosition;
+ AssertVector(endPos - startPos).IsEqualApprox(Vector3.Zero, _vectorTolerance);
+
+ var grounded = StateChartState.Of(_player.GetNode("StateChart/Root/Movement/Grounded"));
+ AssertBool(grounded.Active).IsTrue();
+ }
+
+ [TestCase("MantleLocation1")]
+ public async Task PlayerMantle(string markerName)
+ {
+ var marker = _scene.FindChild(markerName) as Marker3D;
+ AssertObject(marker).IsNotNull();
+ _player.GlobalPosition = marker!.GlobalPosition;
+ await _runner.AwaitMillis(100);
+
+ var startPos = _player.GlobalPosition;
+
+ _runner.SimulateKeyPress(Key.Space);
+ await _runner.AwaitMillis(100);
+ var mantling = StateChartState.Of(_player.GetNode("StateChart/Root/Movement/Mantling"));
+ AssertBool(mantling.Active).IsTrue();
+
+ _runner.SimulateKeyRelease(Key.Space);
+ await _runner.AwaitMillis(500);
+
+ var endPos = _player.GlobalPosition;
+ AssertFloat((endPos - startPos).Length()).IsGreater(_tolerance);
+ AssertFloat(endPos.Y).IsEqualApprox(1.0f, _tolerance);
+ }
+}
\ No newline at end of file
diff --git a/tests/player/movement/PlayerMovementTest.cs.uid b/tests/player/movement/PlayerMovementTest.cs.uid
new file mode 100644
index 00000000..a5061437
--- /dev/null
+++ b/tests/player/movement/PlayerMovementTest.cs.uid
@@ -0,0 +1 @@
+uid://x5pj2ymam2gg
diff --git a/tests/player/movement/player_movement_scene.tscn b/tests/player/movement/player_movement_scene.tscn
new file mode 100644
index 00000000..e40a34fc
--- /dev/null
+++ b/tests/player/movement/player_movement_scene.tscn
@@ -0,0 +1,35 @@
+[gd_scene format=3 uid="uid://i8kb38q7bdfk"]
+
+[ext_resource type="Material" uid="uid://31aulub2nqov" path="res://assets/materials/greybox/m_greybox.tres" id="1_bdfhg"]
+[ext_resource type="PackedScene" uid="uid://bei4nhkf8lwdo" path="res://scenes/player_controller/PlayerController.tscn" id="1_hg1sy"]
+
+[node name="PlayerMovementScene" type="Node3D" unique_id=231040688]
+
+[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=241909240]
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+
+[node name="Ground" type="CSGBox3D" parent="CSGCombiner3D" unique_id=432200143]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5, -0.5, -3.25)
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+size = Vector3(1000, 1, 1000)
+material = ExtResource("1_bdfhg")
+
+[node name="Ground2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=854660236]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.75, 0.5, -1.75)
+use_collision = true
+collision_layer = 256
+collision_mask = 65553
+size = Vector3(1.5, 1, 1.5)
+material = ExtResource("1_bdfhg")
+
+[node name="Player" parent="." unique_id=709076448 instance=ExtResource("1_hg1sy")]
+TutorialDone = true
+
+[node name="BaseLocation" type="Marker3D" parent="." unique_id=1793710692]
+
+[node name="MantleLocation1" type="Marker3D" parent="." unique_id=550080845]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.75, 0, 0)