diff --git a/HillLike.csproj b/HillLike.csproj
new file mode 100644
index 0000000..2101a7a
--- /dev/null
+++ b/HillLike.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/HillLike.slnx b/HillLike.slnx
new file mode 100644
index 0000000..c04b170
--- /dev/null
+++ b/HillLike.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/HillLikeGame.cs b/HillLikeGame.cs
new file mode 100644
index 0000000..3c73bce
--- /dev/null
+++ b/HillLikeGame.cs
@@ -0,0 +1,90 @@
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Framework.Platform;
+using osuTK;
+using osuTK.Graphics;
+
+namespace HillLike;
+
+public class HillLikeGame : Game
+{
+ private World _world = null!;
+
+ [BackgroundDependencyLoader]
+ private void Load(GameHost host)
+ {
+ Add(new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children =
+ [
+ _world = new World(),
+ new Hud(_world)
+ ]
+ });
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+ _world.Step((float)(Clock.ElapsedFrameTime / 1000.0));
+ }
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ _world.HandleKeyDown(e);
+ return base.OnKeyDown(e);
+ }
+
+ protected override void OnKeyUp(KeyUpEvent e)
+ {
+ _world.HandleKeyUp(e);
+ base.OnKeyUp(e);
+ }
+}
+
+public class Hud : CompositeDrawable
+{
+ private readonly Box _speedBar;
+ private readonly Box _speedBarBg;
+ private readonly World _world;
+
+ public Hud(World world)
+ {
+ this._world = world;
+
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChildren =
+ [
+ _speedBarBg = new Box
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Position = new Vector2(20, 42),
+ Size = new Vector2(260, 10),
+ Colour = new Color4(30, 30, 30, 220)
+ },
+ _speedBar = new Box
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Position = new Vector2(20, 42),
+ Size = new Vector2(0, 10),
+ Colour = new Color4(120, 180, 255, 255)
+ }
+ ];
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ var speed = _world.Car.Velocity.X;
+ _speedBar.Width = 260 * Math.Clamp(Math.Abs(speed) / 25f, 0, 1);
+ }
+}
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..7698ca1
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,13 @@
+using osu.Framework;
+using osu.Framework.Platform;
+
+namespace HillLike;
+
+public static class Program
+{
+ public static void Main()
+ {
+ using GameHost host = Host.GetSuitableDesktopHost("hill-like");
+ host.Run(new HillLikeGame());
+ }
+}
\ No newline at end of file
diff --git a/SimpleCar.cs b/SimpleCar.cs
new file mode 100644
index 0000000..e7d7c05
--- /dev/null
+++ b/SimpleCar.cs
@@ -0,0 +1,269 @@
+using osu.Framework.Input.Events;
+using osuTK;
+using osuTK.Input;
+
+namespace HillLike;
+
+public class SimpleCar
+{
+ public const float WheelRadius = 0.45f;
+
+ private const float Gravity = -10f;
+ private const float EngineForce = 34f;
+ private const float BrakeForce = 34f;
+ private const float RollingFriction = 2f;
+ private const float AirDrag = 0.1f;
+
+ private const float SuspensionRest = 0f;
+ private const float SuspensionK = 140f;
+ private const float SuspensionD = 18f;
+
+ private const float AngularDamp = 2f;
+ private const float ChassisInertia = 2.4f;
+ private const float SuspensionTorqueInfluence = 0.35f;
+ private const float GroundAlignStrength = 8f;
+ private const float GroundAlignDamping = 2f;
+ private const float WheelAngularAccel = 60f;
+ private const float WheelAngularDamping = 1.2f;
+ private const float WheelGroundGrip = 14f;
+ private const float AirControlFromWheelSpin = 0.12f;
+
+ private readonly Vector2 _wheelBackLocal = new(-0.85f, -0.35f);
+ private readonly Vector2 _wheelFrontLocal = new(0.85f, -0.35f);
+ private bool _brake;
+
+ private bool _throttle;
+ private float _wheelSpin;
+ public float AngularVelocity;
+ public Vector2 Position;
+
+ public float Rotation;
+ public Vector2 Velocity;
+
+ public Vector2 WheelBackPos { get; private set; }
+ public Vector2 WheelFrontPos { get; private set; }
+
+ public float RotationDegrees => Rotation * 180f / (float)Math.PI;
+
+ public void Reset(Vector2 startPos)
+ {
+ Position = startPos;
+ Velocity = Vector2.Zero;
+ Rotation = 0;
+ AngularVelocity = 0;
+ _wheelSpin = 0;
+ _throttle = _brake = false;
+ UpdateWheelWorldPositions();
+ }
+
+ public void HandleKeyDown(KeyDownEvent e)
+ {
+ switch (e.Key)
+ {
+ case Key.D or Key.Right:
+ _throttle = true;
+ break;
+ case Key.A or Key.Left:
+ _brake = true;
+ break;
+ }
+ }
+
+ public void HandleKeyUp(KeyUpEvent e)
+ {
+ switch (e.Key)
+ {
+ case Key.D or Key.Right:
+ _throttle = false;
+ break;
+ case Key.A or Key.Left:
+ _brake = false;
+ break;
+ }
+ }
+
+ public void Step(float dt)
+ {
+ var accel = new Vector2(0, Gravity);
+ var torque = 0f;
+
+ accel += -AirDrag * Velocity;
+
+ UpdateWheelWorldPositions();
+
+ var backGrounded = SolveWheelSuspension(dt, WheelBackPos, out var backForce, out var backTangent,
+ out _);
+ var frontGrounded = SolveWheelSuspension(dt, WheelFrontPos, out var frontForce, out var frontTangent,
+ out _);
+
+ accel += backForce + frontForce;
+ if (backGrounded)
+ torque += TorqueFromForce(WheelBackPos, backForce) * SuspensionTorqueInfluence;
+
+ if (frontGrounded)
+ torque += TorqueFromForce(WheelFrontPos, frontForce) * SuspensionTorqueInfluence;
+
+ var grounded = backGrounded || frontGrounded;
+
+ var driveInput = 0f;
+
+ if (_throttle) driveInput += 1f;
+ if (_brake) driveInput -= 1f;
+
+ _wheelSpin += driveInput * WheelAngularAccel * dt;
+
+ //if (grounded)
+ //{
+ // var driveForce = backTangent * EngineForce;
+ // accel += driveForce;
+ // torque += TorqueFromForce(WheelBackPos, driveForce);
+ //}
+
+ if (grounded)
+ accel += new Vector2(-RollingFriction * Velocity.X, 0);
+
+ if (grounded)
+ {
+ var targetNormal = Vector2.Zero;
+ var groundedCount = 0;
+
+ if (backGrounded)
+ {
+ targetNormal += new Vector2(-backTangent.Y, backTangent.X);
+ groundedCount++;
+ }
+
+ if (frontGrounded)
+ {
+ targetNormal += new Vector2(-frontTangent.Y, frontTangent.X);
+ groundedCount++;
+ }
+
+ if (groundedCount > 0)
+ {
+ targetNormal /= groundedCount;
+ targetNormal = targetNormal.LengthSquared > 0 ? targetNormal.Normalized() : Vector2.UnitY;
+
+ var bodyUpDot = Vector2.Dot(BodyUp(), targetNormal);
+ if (bodyUpDot > 0f)
+ {
+ var angleError = SignedAngle(BodyUp(), targetNormal);
+ torque += angleError * GroundAlignStrength - AngularVelocity * GroundAlignDamping;
+ }
+ }
+
+ var groundedTangentSpeed = 0f;
+ if (backGrounded) groundedTangentSpeed += Vector2.Dot(Velocity, backTangent);
+ if (frontGrounded) groundedTangentSpeed += Vector2.Dot(Velocity, frontTangent);
+ groundedTangentSpeed /= groundedCount;
+
+ var targetSpin = groundedTangentSpeed / WheelRadius;
+ var gripLerp = 1f - MathF.Exp(-WheelGroundGrip * dt);
+ _wheelSpin = _wheelSpin + (targetSpin - _wheelSpin) * gripLerp;
+ }
+ else if (driveInput != 0f)
+ {
+ torque += driveInput * (2.0f + MathF.Abs(_wheelSpin) * AirControlFromWheelSpin);
+ }
+
+ _wheelSpin *= (float)Math.Exp(-WheelAngularDamping * dt);
+
+ AngularVelocity += (torque / ChassisInertia) * dt;
+
+ AngularVelocity *= (float)Math.Exp(-AngularDamp * dt);
+
+ Velocity += accel * dt;
+ Position += Velocity * dt;
+ Rotation += AngularVelocity * dt;
+
+ if (Rotation > MathF.PI) Rotation -= 2 * MathF.PI;
+ if (Rotation < -MathF.PI) Rotation += 2 * MathF.PI;
+
+ UpdateWheelWorldPositions();
+
+ if (!grounded)
+ return;
+ }
+
+ private void ApplyBrake(bool grounded, Vector2 wheelPos, Vector2 tangent, ref Vector2 accel, ref float torque)
+ {
+ if (!grounded)
+ return;
+
+ var tangentSpeed = Vector2.Dot(Velocity, tangent);
+ if (MathF.Abs(tangentSpeed) < 0.02f)
+ return;
+
+ var brakeForce = -Math.Sign(tangentSpeed) * BrakeForce * tangent;
+ accel += brakeForce;
+ torque += TorqueFromForce(wheelPos, brakeForce);
+ }
+
+ private float TorqueFromForce(Vector2 applicationPoint, Vector2 force)
+ {
+ var r = applicationPoint - Position;
+ return r.X * force.Y - r.Y * force.X;
+ }
+
+ private static float SignedAngle(Vector2 from, Vector2 to)
+ {
+ var cross = from.X * to.Y - from.Y * to.X;
+ var dot = Vector2.Dot(from, to);
+ return MathF.Atan2(cross, dot);
+ }
+
+ private void UpdateWheelWorldPositions()
+ {
+ WheelBackPos = Position + Rotate(_wheelBackLocal, Rotation);
+ WheelFrontPos = Position + Rotate(_wheelFrontLocal, Rotation);
+ }
+
+ private bool SolveWheelSuspension(
+ float dt,
+ Vector2 wheelCenter,
+ out Vector2 suspensionForce,
+ out Vector2 tangent,
+ out float compression01)
+ {
+ var groundY = Terrain.HeightAt(wheelCenter.X);
+ var normal = Terrain.NormalAt(wheelCenter.X);
+ tangent = new Vector2(normal.Y, -normal.X);
+
+ var wheelBottomY = wheelCenter.Y - WheelRadius;
+ var penetration = groundY - wheelBottomY;
+
+ var compression = penetration - SuspensionRest;
+
+ if (penetration <= 0)
+ {
+ suspensionForce = Vector2.Zero;
+ compression01 = 0;
+ return false;
+ }
+
+ var spring = SuspensionK * compression;
+ var vAlongN = Vector2.Dot(Velocity, normal);
+ var damper = -SuspensionD * vAlongN;
+
+ var forceMag = spring + damper;
+
+ forceMag = Math.Clamp(forceMag, 0, 250);
+
+ suspensionForce = normal * forceMag;
+
+ compression01 = Math.Clamp(penetration / (WheelRadius * 2f), 0, 1);
+ return true;
+ }
+
+ private Vector2 BodyUp()
+ {
+ return Rotate(Vector2.UnitY, Rotation);
+ }
+
+ private static Vector2 Rotate(Vector2 v, float radians)
+ {
+ var c = MathF.Cos(radians);
+ var s = MathF.Sin(radians);
+ return new Vector2(v.X * c - v.Y * s, v.X * s + v.Y * c);
+ }
+}
\ No newline at end of file
diff --git a/World.cs b/World.cs
new file mode 100644
index 0000000..f54d87f
--- /dev/null
+++ b/World.cs
@@ -0,0 +1,286 @@
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osuTK;
+using osuTK.Graphics;
+using osuTK.Input;
+using Triangle = osu.Framework.Graphics.Shapes.Triangle;
+
+namespace HillLike;
+
+public class World : CompositeDrawable
+{
+ private readonly Camera _camera = new();
+ private readonly CarDrawable _carDrawable;
+ private readonly TerrainDrawable _terrainDrawable;
+
+ private readonly Container _worldLayer;
+ public readonly SimpleCar Car = new();
+
+ public World()
+ {
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChildren =
+ [
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = new Color4(150, 210, 255, 255)
+ },
+ _worldLayer = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children =
+ [
+ _terrainDrawable = new TerrainDrawable(),
+ _carDrawable = new CarDrawable(Car)
+ ]
+ }
+ ];
+
+ Reset();
+ }
+
+ public sealed override Axes RelativeSizeAxes
+ {
+ get => base.RelativeSizeAxes;
+ set => base.RelativeSizeAxes = value;
+ }
+
+ public void Reset()
+ {
+ Car.Reset(new Vector2(0, Terrain.HeightAt(0) + 3f));
+ _camera.Reset(Car.Position);
+ }
+
+ public void Step(float dt)
+ {
+ dt = Math.Clamp(dt, 0, 1 / 20f);
+ Car.Step(dt);
+ _camera.Follow(dt, Car.Position);
+
+ var pixelsPerUnit = _camera.PixelsPerUnit;
+ var viewCenter = DrawSize / 2;
+
+ _terrainDrawable.SetCamera(_camera, viewCenter);
+ _carDrawable.SetCamera(_camera, viewCenter);
+ }
+
+ public void HandleKeyDown(KeyDownEvent e)
+ {
+ Car.HandleKeyDown(e);
+ if (e.Key is Key.R)
+ Reset();
+ }
+
+ public void HandleKeyUp(KeyUpEvent e)
+ {
+ Car.HandleKeyUp(e);
+ }
+
+
+ private class Camera
+ {
+ public readonly float PixelsPerUnit = 60f;
+ public Vector2 Position;
+
+ public void Reset(Vector2 pos)
+ {
+ Position = pos;
+ }
+
+ public void Follow(float dt, Vector2 target)
+ {
+ var desired = target + new Vector2(5f, 2f);
+ var smooth = 1f - MathF.Exp(-dt * 4.5f);
+ Position = Vector2.Lerp(Position, desired, smooth);
+ }
+ }
+
+ private abstract class WorldDrawable : CompositeDrawable
+ {
+ protected Camera? Camera;
+ protected Vector2 ViewCenter;
+
+ public void SetCamera(Camera cam, Vector2 viewCenter)
+ {
+ Camera = cam;
+ ViewCenter = viewCenter;
+ UpdateFromWorld();
+ }
+
+ protected Vector2 ToScreen(Vector2 worldPos)
+ {
+ var rel = worldPos - Camera.Position;
+ return new Vector2(
+ ViewCenter.X + rel.X * Camera.PixelsPerUnit,
+ ViewCenter.Y - rel.Y * Camera.PixelsPerUnit
+ );
+ }
+
+ protected float ToScreen(float worldLen)
+ {
+ return worldLen * Camera.PixelsPerUnit;
+ }
+
+ protected abstract void UpdateFromWorld();
+ }
+
+ private class TerrainDrawable : WorldDrawable
+ {
+ private readonly Container _segments = new() { RelativeSizeAxes = Axes.Both };
+ public TerrainDrawable()
+ {
+ RelativeSizeAxes = Axes.Both;
+ AddInternal(_segments);
+ }
+
+ protected override void UpdateFromWorld()
+ {
+ if (Camera is null) return;
+
+ _segments.Clear();
+
+ var halfWidthWorld = DrawWidth / Camera.PixelsPerUnit;
+ var left = Camera.Position.X - halfWidthWorld - 2f;
+ var right = Camera.Position.X + halfWidthWorld + 2f;
+
+ var step = 0.6f;
+ var count = (int)Math.Ceiling((right - left) / step);
+
+ for (var i = 0; i < count; i += 1)
+ {
+ var x0 = left + i * step;
+ var x1 = x0 + step;
+
+ var y0 = Terrain.HeightAt(x0);
+ var y1 = Terrain.HeightAt(x1);
+ var y = MathF.Min(y0, y1);
+
+ var depth = 200f;
+ var p = ToScreen(new Vector2(x0, y));
+ var w = ToScreen(step);
+ var h = ToScreen(depth);
+
+ //_segments.Add(new Box
+ //{
+ // Anchor = Anchor.TopLeft,
+ // Origin = Anchor.TopLeft,
+ // Position = p,
+ // Size = new Vector2(w + 1, h),
+ // Colour = new Color4(80, 200, 120, 255)
+ //});
+
+ _segments.Add(new Triangle
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.BottomLeft,
+ Position = p,
+ Size = new Vector2(w + 1, -h),
+ Shear = new Vector2(x1 - x0, y1 - y0),
+ Colour = new Color4(80, 200, 120, 255)
+ });
+ }
+
+ for (var i = 0; i < count; i += 1)
+ {
+ var x0 = left + i * step;
+ var x1 = x0 + step;
+
+ var y0 = Terrain.HeightAt(x0);
+ var y1 = Terrain.HeightAt(x1);
+ var y = (y0 + y1) / 2f;
+
+ var p = ToScreen(new Vector2(x0, y));
+ _segments.Add(new Box
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Position = p,
+ Size = new Vector2(ToScreen(step) + 1, 3),
+ Colour = new Color4(20, 120, 60, 255)
+ });
+ }
+ }
+ }
+
+ private class CarDrawable : WorldDrawable
+ {
+ private readonly Box _body;
+ private readonly SimpleCar _car;
+ private readonly Circle _wheelBack;
+ private readonly Circle _wheelFront;
+
+ public CarDrawable(SimpleCar car)
+ {
+ _car = car;
+
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChildren = new Drawable[]
+ {
+ _wheelBack = new Circle { Colour = new Color4(25, 25, 25, 255) },
+ _wheelFront = new Circle { Colour = new Color4(25, 25, 25, 255) },
+ _body = new Box { Colour = new Color4(240, 70, 70, 255) }
+ };
+ }
+
+ protected override void UpdateFromWorld()
+ {
+ if (Camera is null) return;
+
+ var wheelR = SimpleCar.WheelRadius;
+ var bodyW = 2.2f;
+ var bodyH = 0.7f;
+
+ var wb = ToScreen(_car.WheelBackPos);
+ var wf = ToScreen(_car.WheelFrontPos);
+
+ var rPx = ToScreen(wheelR);
+ _wheelBack.Size = new Vector2(rPx * 2);
+ _wheelBack.Position = wb - new Vector2(rPx);
+ _wheelFront.Size = new Vector2(rPx * 2);
+ _wheelFront.Position = wf - new Vector2(rPx);
+
+ var bp = ToScreen(_car.Position);
+ _body.Size = new Vector2(ToScreen(bodyW), ToScreen(bodyH));
+ _body.Origin = Anchor.Centre;
+ _body.Anchor = Anchor.TopLeft;
+ _body.Position = bp;
+ _body.Rotation = -_car.RotationDegrees;
+ }
+ }
+}
+
+public class Terrain
+{
+ public static float HeightAt(float x)
+ {
+ const float baseLine = 0f;
+
+ var h =
+ 0.9f * MathF.Sin(x * 0.55f) +
+ 0.6f * MathF.Sin(x * 0.18f + 1.3f) +
+ 0.25f * MathF.Sin(x * 1.15f);
+
+ var drift = 0.015f * x;
+
+ return baseLine + h + drift;
+ }
+
+ public static float SlopeAt(float x)
+ {
+ const float eps = 0.02f;
+ return (HeightAt(x + eps) - HeightAt(x - eps)) / (2 * eps);
+ }
+
+ public static Vector2 NormalAt(float x)
+ {
+ var slope = SlopeAt(x);
+ var n = new Vector2(-slope, 1f);
+ return n.Normalized();
+ }
+}
\ No newline at end of file