Physics/SkateHelper.cs
using System;
using System.Buffers;

namespace Skateboard.Physics;

public struct SkateHelper
{
	public Vector3 Position;
	public Vector3 Velocity;

	public bool HitWall;
	public bool HitFloor;
	public bool HitPhysics;

	public Vector3 HitNormal;

	public float GroundBounce;
	public float WallBounce;
	public float MaxStandableAngle;

	public SceneTrace Trace;
	public GameObject Myself;

	public SkateHelper( SceneTrace trace, GameObject myself, Vector3 position, Vector3 velocity, params string[] solidTags )
	{
		this = default;

		Myself = myself;
		Position = position;
		Velocity = velocity;

		GroundBounce = 0f;
		WallBounce = 0f;
		MaxStandableAngle = 75f;

		Trace = trace
			.WithAnyTags( solidTags )
			.IgnoreGameObjectHierarchy( myself );
	}

	public SkateHelper( SceneTrace trace, GameObject myself, Vector3 position, Vector3 velocity )
		: this(
			trace,
			myself,
			position,
			velocity,
			"solid", "playerclip", "player",
			"passbullets", "unskateable",
			"skateable", "vert"
		)
	{
	}

	public SceneTraceResult TraceFromTo( Vector3 start, Vector3 end )
		=> Trace.FromTo( start, end ).Run();

	public SceneTraceResult TraceMove( Vector3 delta )
	{
		var tr = TraceFromTo( Position, Position + delta );
		Position = tr.EndPosition;
		return tr;
	}

	public float TryMove( float timestep, bool inAir )
	{
		var timeLeft = timestep;
		var travelFraction = 0f;

		HitWall = HitFloor = HitPhysics = false;

		using var moveplanes = new VelocityClipPlanes( Velocity );

		for ( int bump = 0; bump < moveplanes.Max; bump++ )
		{
			if ( Velocity.Length.AlmostEqual( 0 ) )
				break;

			var tr = TraceFromTo( Position, Position + Velocity * timeLeft );

			if ( bump == 0 && tr.Hit )
			{
				HitPhysics = tr.GameObject?.Tags.Has( "dynamicprop" ) ?? false;

				if ( tr.Normal.Angle( Vector3.Up ) >= MaxStandableAngle )
				{
					HitWall = true;

					if ( !inAir && (HasTag( tr, "skateable" ) || HasTag( tr, "vert" )) )
					{
						HitWall = false;
						HitFloor = true;
					}
				}
				else
				{
					HitFloor = true;
				}
			}

			travelFraction += tr.Fraction;

			if ( tr.Fraction > 1f / 32f )
			{
				Position = tr.EndPosition + tr.Normal * 0.001f;
				if ( tr.Fraction == 1f )
					break;

				moveplanes.StartBump( Velocity );
			}

			if ( travelFraction == 0f )
			{
				Position += tr.Normal * Time.Delta;
			}

			if ( bump == 0 && tr.Hit )
				HitNormal = tr.Normal;

			if ( bump == 0 && tr.Hit && HasTag( tr, "unskateable" ) )
			{
				HitWall = true;
			}

			timeLeft -= timeLeft * tr.Fraction;

			if ( !moveplanes.TryAdd(
				tr.Normal,
				ref Velocity,
				IsFloor( tr ) ? GroundBounce : WallBounce
			) )
				break;
		}

		if ( travelFraction == 0 )
			Velocity = 0;

		return travelFraction;
	}

	public float TryMoveWithStep( float timeDelta, float stepSize, bool inAir )
	{
		var startPosition = Position;
		var stepMove = this;

		var fraction = TryMove( timeDelta, inAir );

		stepMove.TraceMove( Vector3.Up * stepSize );
		var stepFraction = stepMove.TryMove( timeDelta, inAir );
		var tr = stepMove.TraceMove( Vector3.Down * stepSize );

		if ( !tr.Hit )
			return fraction;

		if ( tr.Normal.Angle( Vector3.Up ) > MaxStandableAngle )
			return fraction;

		if ( startPosition.Distance( Position.WithZ( startPosition.z ) ) > startPosition.Distance( stepMove.Position.WithZ( startPosition.z ) ) )
			return fraction;

		Position = stepMove.Position;
		Velocity = stepMove.Velocity;
		HitWall = stepMove.HitWall;

		return stepFraction;
	}

	public bool IsFloor( SceneTraceResult tr )
		=> tr.Hit && tr.Normal.Angle( Vector3.Up ) < MaxStandableAngle;

	public void ApplyFriction( float friction, float delta )
	{
		const float StopSpeed = 100f;

		var speed = Velocity.Length;
		if ( speed < 0.1f )
			return;

		var drop = (speed < StopSpeed ? StopSpeed : speed) * delta * friction;
		var newSpeed = MathF.Max( speed - drop, 0f );

		if ( newSpeed != speed )
			Velocity *= newSpeed / speed;
	}

	public bool TryUnstuck()
	{
		if ( !TraceFromTo( Position, Position ).StartedSolid )
			return true;

		for ( int i = 1; i < 20; i++ )
			if ( TryUnstuckAt( Position + Vector3.Up * i ) )
				return true;

		for ( int i = 1; i < 100; i++ )
			if ( TryUnstuckAt( Position + Vector3.Random * i ) )
				return true;

		return false;
	}

	private bool TryUnstuckAt( Vector3 tryPos )
	{
		var tr = TraceFromTo( tryPos, Position );
		if ( tr.StartedSolid )
			return false;

		Position = tryPos + tr.Direction.Normal * (tr.Distance - 0.5f);
		Velocity = 0;
		return true;
	}

	private static bool HasTag( SceneTraceResult tr, string tag )
	{
		if ( tr.Tags is null )
			return false;

		return tr.Tags.Contains( tag, StringComparer.OrdinalIgnoreCase );
	}

	private struct VelocityClipPlanes : IDisposable
	{
		private Vector3 originalVelocity;
		private Vector3 bumpVelocity;
		private Vector3[] planes;

		public int Max { get; private set; }
		public int Count { get; private set; }

		public VelocityClipPlanes( Vector3 originalVelocity, int max = 5 )
		{
			Max = max;
			this.originalVelocity = originalVelocity;
			bumpVelocity = originalVelocity;
			planes = ArrayPool<Vector3>.Shared.Rent( max );
			Count = 0;
		}

		public bool TryAdd( Vector3 normal, ref Vector3 velocity, float bounce )
		{
			if ( Count == Max )
			{
				velocity = 0f;
				return false;
			}

			planes[Count++] = normal;

			if ( Count == 1 )
			{
				bumpVelocity = ClipVelocity( bumpVelocity, normal, 1f + bounce );
				velocity = bumpVelocity;
				return true;
			}

			velocity = bumpVelocity;

			if ( TryClip( ref velocity ) )
			{
				if ( Count != 2 )
				{
					velocity = Vector3.Zero;
					return true;
				}

				var dir = Vector3.Cross( planes[0], planes[1] );
				velocity = dir.Normal * dir.Dot( velocity );
			}

			if ( velocity.Dot( originalVelocity ) < 0f )
				velocity = 0f;

			return true;
		}

		private bool TryClip( ref Vector3 velocity )
		{
			for ( int i = 0; i < Count; i++ )
			{
				velocity = ClipVelocity( bumpVelocity, planes[i] );
				if ( MovingTowardsAnyPlane( velocity, i ) )
					return false;
			}

			return true;
		}

		private bool MovingTowardsAnyPlane( Vector3 velocity, int iSkip )
		{
			for ( int j = 0; j < Count; j++ )
			{
				if ( j == iSkip )
					continue;
				if ( velocity.Dot( planes[j] ) < 0f )
					return false;
			}

			return true;
		}

		public void StartBump( Vector3 velocity )
		{
			bumpVelocity = velocity;
			Count = 0;
		}

		private static Vector3 ClipVelocity( Vector3 vel, Vector3 norm, float overbounce = 1f )
		{
			var backoff = Vector3.Dot( vel, norm ) * overbounce;
			var o = vel - norm * backoff;
			var adjust = Vector3.Dot( o, norm );

			if ( adjust >= 1f )
				return o;

			adjust = MathF.Min( adjust, -1f );
			return o - norm * adjust;
		}

		public void Dispose()
		{
			ArrayPool<Vector3>.Shared.Return( planes );
		}
	}
}