From 34fb901fe6751200c12b5a9c5c702018d1f490da Mon Sep 17 00:00:00 2001 From: meelstorm Date: Fri, 17 Jul 2026 16:44:15 +0000 Subject: [PATCH] feat(gamecli): implement pure Physics.Step with unit tests --- GameCli.Tests/PhysicsTests.cs | 64 ++++++++++++++++++++++++++++ GameCli/Physics.cs | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 GameCli.Tests/PhysicsTests.cs create mode 100644 GameCli/Physics.cs diff --git a/GameCli.Tests/PhysicsTests.cs b/GameCli.Tests/PhysicsTests.cs new file mode 100644 index 0000000..9e2ec9a --- /dev/null +++ b/GameCli.Tests/PhysicsTests.cs @@ -0,0 +1,64 @@ +using GameCli; +using Xunit; + +namespace GameCli.Tests; + +public class PhysicsTests +{ + [Fact] + public void Noop_UnderGravity_ShipFallsDownward() + { + var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f); + var next = Physics.Step(start, action: 0); + Assert.True(next.VY > 0, $"expected VY > 0 (gravity down), got {next.VY}"); + Assert.True(next.Y > start.Y, $"expected Y to increase (fall), got {next.Y}"); + Assert.Equal(start.X, next.X, precision: 5); + Assert.Equal(0f, next.Angle); + } + + [Fact] + public void MainEngine_WhenUpright_CancelsThenReversesGravity() + { + // Main engine thrust > gravity, so acceleration is net upward (VY becomes negative). + var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f); + var next = Physics.Step(start, action: 2); + Assert.True(next.VY < 0, $"expected VY < 0 (net upward), got {next.VY}"); + } + + [Fact] + public void LeftThruster_AppliesNegativeTorque() + { + var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f); + var next = Physics.Step(start, action: 1); + Assert.True(next.AngularVelocity < 0, + $"expected angular velocity < 0, got {next.AngularVelocity}"); + } + + [Fact] + public void RightThruster_AppliesPositiveTorque() + { + var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f); + var next = Physics.Step(start, action: 3); + Assert.True(next.AngularVelocity > 0, + $"expected angular velocity > 0, got {next.AngularVelocity}"); + } + + [Fact] + public void MainEngine_WhenRotated90Right_ThrustsRight() + { + // Angle = +pi/2 means ship points to the +x direction (right). + // Main engine pushes along body-up, which is now world +x. + var start = new ShipState(0.5f, 0.5f, 0f, 0f, MathF.PI / 2f, 0f); + var next = Physics.Step(start, action: 2); + Assert.True(next.VX > 0, $"expected VX > 0 (thrust right), got {next.VX}"); + } + + [Fact] + public void Step_IsDeterministic() + { + var start = new ShipState(0.3f, 0.4f, 0.1f, -0.05f, 0.2f, 0.3f); + var a = Physics.Step(start, action: 2); + var b = Physics.Step(start, action: 2); + Assert.Equal(a, b); + } +} diff --git a/GameCli/Physics.cs b/GameCli/Physics.cs new file mode 100644 index 0000000..d1f0477 --- /dev/null +++ b/GameCli/Physics.cs @@ -0,0 +1,78 @@ +namespace GameCli; + +/// +/// Pure physics stepper. All constants are public so training/tuning can inspect them. +/// Explicit Euler integration at fixed dt. +/// +public static class Physics +{ + public const float Dt = 0.02f; // 50 Hz + public const float Gravity = 0.5f; // world units / s^2, +Y (downward) + public const float MainThrust = 1.2f; // world units / s^2 along body-up + public const float SideTorque = 4.0f; // rad / s^2 + public const float SideLateralImpulse = 0.15f; // world units / s^2 along body-x + public const float LinearDrag = 0.10f; // per-second + public const float AngularDrag = 0.50f; // per-second + + public const int ActionNoop = 0; + public const int ActionLeft = 1; + public const int ActionMain = 2; + public const int ActionRight = 3; + + /// + /// Advance the ship state by one dt given a discrete action. + /// Gravity is always applied. Thrusters add to acceleration/torque. + /// + public static ShipState Step(ShipState s, int action) + { + // Start with gravity. + float ax = 0f; + float ay = Gravity; + float torque = 0f; + + // Body-up direction in world coords, given angle: + // angle=0 → (0, -1) "up" on screen (Y is down) + // angle=+π/2 → (+1, 0) right + // angle=+π → (0, +1) down + float sinA = MathF.Sin(s.Angle); + float cosA = MathF.Cos(s.Angle); + float bodyUpX = sinA; + float bodyUpY = -cosA; + // Body-right direction (perpendicular, rotated +90°): + float bodyRightX = cosA; + float bodyRightY = sinA; + + switch (action) + { + case ActionMain: + ax += bodyUpX * MainThrust; + ay += bodyUpY * MainThrust; + break; + case ActionLeft: + torque -= SideTorque; + ax += bodyRightX * SideLateralImpulse; + ay += bodyRightY * SideLateralImpulse; + break; + case ActionRight: + torque += SideTorque; + ax -= bodyRightX * SideLateralImpulse; + ay -= bodyRightY * SideLateralImpulse; + break; + case ActionNoop: + default: + break; + } + + // Integrate velocity, apply linear drag, integrate position. + float vx = (s.VX + ax * Dt) * (1f - LinearDrag * Dt); + float vy = (s.VY + ay * Dt) * (1f - LinearDrag * Dt); + float x = s.X + vx * Dt; + float y = s.Y + vy * Dt; + + // Angular: same pattern. + float w = (s.AngularVelocity + torque * Dt) * (1f - AngularDrag * Dt); + float angle = s.Angle + w * Dt; + + return new ShipState(x, y, vx, vy, angle, w); + } +}