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