Pawn/SkateController.cs
using Skateboard.Entities;
using Skateboard.Physics;
using Skateboard.Tricks;
using Skateboard.Utils;
using System;

namespace Skateboard.Player;

[Title( "Pro Skater physics and movement controller" )]
public sealed class SkateController : Component
{
	private struct GrindCandidate
	{
		public Vector3 Start;
		public Vector3 End;
		public MathLD.NearestPoint Nearest;
	}

	public static float StoppedVelocity { get; set; } = 0.01f;

	[Property] public SkatePawn Pawn { get; set; }
	[Property] public SkateInput Input { get; set; }
	[Property] public TrickScoreHolder TrickScores { get; set; }
	[Property] public SkateAnimator Animator { get; set; }

	[Property] public float JumpForce { get; set; } = 300f;
	[Property] public float JumpMaxSpeed { get; set; } = 600f;

	[Property] public float Gravity { get; set; } = 900f;

	[Property] public float Acceleration { get; set; } = 400f;
	[Property] public float TopSpeed { get; set; } = 1300f;
	[Property] public float AirTopSpeed { get; set; } = 2000f;

	[Property] public float BrakeForce { get; set; } = 600f;
	[Property] public float TractionForce { get; set; } = 0f;
	[Property] public float SteerSpeed { get; set; } = 150f;
	[Property] public float BrakeSteerMultiplier { get; set; } = 1.5f;
	[Property] public float PushMaxSpeed { get; set; } = 450f;
	[Property] public float CrouchAcceleration { get; set; } = 600f;
	[Property] public float CrouchMaxSpeed { get; set; } = 700f;

	[Property] public float SmoothingSpeed { get; set; } = 10f;

	[Property] public float MaxStandableAngle { get; set; } = 70f;
	[Property] public float MinFallableAngle { get; set; } = 70f;
	[Property] public float MaxSlope { get; set; } = 50f;

	[Property] public float BodyGirth { get; set; } = 8f;
	[Property] public float BodyHeight { get; set; } = 50f;
	[Property] public float EyeHeight { get; set; } = 64f;

	[Property] public float AirSpinSpeed { get; set; } = 325f;
	[Property] public float AirPitchSpeed { get; set; } = 50f;

	[Property] public float SidewaysBailSpeed { get; set; } = 600f;
	[Property] public float LandBailMaxAngle { get; set; } = 60f;
	[Property] public float LandSpeedMultiplier { get; set; } = 0.98f;
	[Property] public float HitForce { get; set; } = 200f;

	[Property] public float GrindCooldown { get; set; } = 0.2f;
	[Property] public float GrindDeacceleration { get; set; } = 10f;
	[Property] public float GrindSpeedBoost { get; set; } = 100f;
	[Property] public float MinimumGrindTime { get; set; } = 0.3f;
	[Property] public float GrindScoreInterval { get; set; } = 0.002f;

	[Property] public float VertMinAngle { get; set; } = 45f;

	[Property] public float AirOutSpeed { get; set; } = 2f;

	[Property] public float CoyoteTime { get; set; } = 0.4f;
	[Property] public float JumpStrengthMinimum { get; set; } = 0.75f;
	[Property] public float JumpStrengthSpeed { get; set; } = 0.3f;

	[Sync] public Vector3 Velocity { get; private set; }
	[Sync] public Angles AngularVelocity { get; private set; }
	[Sync] public Vector3 GroundNormal { get; private set; } = Vector3.Up;
	[Sync] public bool OnGrind { get; private set; }
	[Sync] public Vector3 GrindNormal { get; private set; }
	[Sync] public Rotation GrindRotation { get; private set; }
	[Sync] public Vector3 GrindStart { get; private set; }
	[Sync] public Vector3 GrindEnd { get; private set; }
	[Sync] public bool OnVert { get; private set; }
	[Sync] public Vector3 VertNormal { get; private set; } = Vector3.Zero;
	[Sync] public bool HasVertBelow { get; private set; }
	[Sync] public bool AiringOut { get; private set; }
	[Sync] public float NudgeAmount { get; private set; }
	[Sync] public float SpunAmount { get; private set; }
	[Sync] public bool Pushing { get; private set; }
	[Sync] public bool JumpReady { get; private set; }
	[Sync] public bool IsGrounded { get; private set; }

	private float _currentCoyoteTime;
	private bool _jumped;
	private float _currentJumpStrength;
	private bool GroundVert;
	private Rotation LastGroundRotation = Rotation.Identity;
	private GameObject GroundObject;
	private Collider GroundCollider;
	private Vector3 GroundVelocity;
	private bool SnapRotation;

	private float CurrentGrindCooldown;
	private float CurrentGrindTime;
	private float CurrentGrindScoreInterval;
	private bool HitGrindTrick;
	private TrickScoreEntry _currentGrindScoreEntry;
	private TrickScoreEntry GrindTrick;

	private float wallCollisionSphereRadius = 6f;
	private float wallCollisionSphereHeight = 15f;
	private float wallCollisionSphereGroundDistance = 5f;
	private float wallCollisionSphereForce = 10f;

	private float upsideDownDotThreshold = 0.1f;
	private float upsideDownTraceOffset = 70f;
	private float rotationTraceOffset = 2f;

	protected override void OnStart()
	{
		if ( skate_sim_mode )
			SetSimStats();
	}

	protected override void OnFixedUpdate()
	{
		Pawn ??= Components.Get<SkatePawn>();
		Input ??= Components.Get<SkateInput>();
		TrickScores ??= Components.Get<TrickScoreHolder>();
		Animator ??= Components.Get<SkateAnimator>();

		if ( Pawn is null || Input is null || TrickScores is null )
			return;

		if ( Networking.IsActive && !GameObject.Network.IsOwner && Pawn.OwningConnectionId != Connection.Local.Id )
			return;

		if ( Pawn.Bailed )
			return;

		Simulate();
	}

	[ConVar( ConVarFlags.Replicated )] public static bool skate_sim_mode { get; set; } = false;

	private Vector3 Position
	{
		get => WorldPosition;
		set => WorldPosition = value;
	}

	private Rotation Rotation
	{
		get => WorldRotation;
		set => WorldRotation = value;
	}

	private Vector3 GetWishDir( Rotation facing )
	{
		var move = Input.Move;
		var wish = (facing.Forward * move.y) + (facing.Right * move.x);
		wish = wish.WithZ( 0f );

		return wish.Length > 0.001f ? wish.Normal : Vector3.Zero;
	}

	private const float DigitalDeadzone = 0.75f;
	private float DigitalForwardInput => Math.Abs( Input.Move.x ) <= DigitalDeadzone ? 0f : Math.Sign( Input.Move.x );
	private float DigitalLeftInput => Math.Abs( Input.Move.y ) <= DigitalDeadzone ? 0f : Math.Sign( Input.Move.y );
	private bool ForwardDown => DigitalForwardInput > 0f;
	private bool BackDown => DigitalForwardInput < 0f;

	private void SimulateGrind( SkatePawn pawn, ref Rotation realRot )
	{
		if ( CurrentGrindCooldown > 0f )
			CurrentGrindCooldown -= Time.Delta;

		if ( OnGrind )
		{
			CurrentGrindTime += Time.Delta;

			if ( CurrentGrindTime >= MinimumGrindTime )
			{
				if ( _currentGrindScoreEntry != null )
				{
					CurrentGrindScoreInterval += Time.Delta;
					if ( CurrentGrindScoreInterval >= GrindScoreInterval )
					{
						_currentGrindScoreEntry.Score += (int)Math.Floor( CurrentGrindScoreInterval / GrindScoreInterval );
						CurrentGrindScoreInterval = 0f;
						TrickScores.Refresh();
					}
				}
				else
				{
					CurrentGrindScoreInterval = 0f;
				}
			}

			if ( CurrentGrindTime >= MinimumGrindTime && !HitGrindTrick )
			{
				HitGrindTrick = true;
				_currentGrindScoreEntry = new TrickScoreEntry( "50-50", 100 );
				TrickScores.Add( _currentGrindScoreEntry );
			}

			Velocity -= GrindDeacceleration * Velocity.Normal * Time.Delta;
			Velocity = Vector3.Dot( Velocity, GrindNormal ) * GrindNormal;

			var closest = MathLD.NearestPointOnLine( Position, GrindStart, GrindEnd, 1f );
			if ( closest.Outside && !TryStartGrind( ref realRot, connect: true ) )
			{
				StopGrind( pawn, manual: false );
				_currentCoyoteTime = CoyoteTime;
			}

			Position = closest.Point;

			if ( Velocity.Length <= 1f )
			{
				StopGrind( pawn, manual: false );
				_currentCoyoteTime = CoyoteTime;
			}

			SpunAmount = 0f;
		}
		else
		{
			CurrentGrindTime = 0f;
			HitGrindTrick = false;
		}

		if ( Input.GrindHeld && !OnGrind )
			TryStartGrind( ref realRot );
	}

	private void StopGrind( SkatePawn pawn, bool manual )
	{
		if ( !OnGrind )
			return;

		OnGrind = false;
		CurrentGrindCooldown = GrindCooldown;

		if ( _currentGrindScoreEntry == null && !manual )
			TrickScores.Add( new TrickScoreEntry( "Kissed the Rail", 50 ) );

		_currentGrindScoreEntry = null;
	}

	private bool TryStartGrind( ref Rotation realRot, bool connect = false )
	{
		if ( CurrentGrindCooldown > 0f && !connect )
			return false;

		float closeDist = (IsGrounded || OnGrind) ? 10f : 75f;

		var candidates = new List<GrindCandidate>();
		foreach ( var ent in Scene.GetAllComponents<GrindPathEntity>() )
		{
			ent.RefreshPathNodes();
			if ( ent.PathNodes is null || ent.PathNodes.Count < 2 )
				continue;

			for ( int i = 0; i < ent.PathNodes.Count - 1; i++ )
			{
				var nodeA = ent.PathNodes[i];
				var nodeB = ent.PathNodes[i + 1];
				if ( !nodeA.IsValid() || !nodeB.IsValid() )
					continue;

				var a = nodeA.WorldPosition;
				var b = nodeB.WorldPosition;

				var closest = MathLD.NearestPointOnLine( Position, a, b );
				var dist = Vector3.DistanceBetween( Position, closest.Point );

				if ( !connect )
				{
					float candidateHeading = (0f - (Math.Abs( Vector3.Dot( Velocity.Normal, closest.Direction ) ) - 1f)) * 10f;
					dist += candidateHeading;
				}

				if ( dist > closeDist )
					continue;

				if ( OnGrind && a == GrindStart && b == GrindEnd )
					continue;

				if ( !connect && Vector3.Dot( (closest.Point - Position).Normal, Velocity.Normal ) < 0f )
					continue;

				if ( connect && Math.Abs( Vector3.Dot( Velocity.Normal, closest.Direction ) ) < 0.5f )
					continue;

				candidates.Add( new GrindCandidate
				{
					Start = a,
					End = b,
					Nearest = closest
				} );
			}
		}

		if ( candidates.Count == 0 )
			return false;

		var best = candidates[0];
		var bestScore = ScoreCandidate( best, connect );

		for ( int i = 1; i < candidates.Count; i++ )
		{
			var s = ScoreCandidate( candidates[i], connect );
			if ( s < bestScore )
			{
				bestScore = s;
				best = candidates[i];
			}
		}

		ClearGroundEntity( ref realRot );

		Position = best.Nearest.Point;
		GrindStart = best.Start;
		GrindEnd = best.End;
		GrindNormal = best.Nearest.Direction;

		OnGrind = true;
		OnVert = false;

		StartGrind( ref realRot, connect );
		return true;

		float ScoreCandidate( GrindCandidate c, bool isConnect )
		{
			var dist = Vector3.DistanceBetween( Position, c.Nearest.Point );
			if ( !isConnect )
			{
				float heading = (0f - (Math.Abs( Vector3.Dot( Velocity.Normal, c.Nearest.Direction ) ) - 1f)) * 10f;
				dist += heading;
			}
			return dist;
		}
	}

	private void StartGrind( ref Rotation realRot, bool connect )
	{
		float oldSpeed = Velocity.Length;
		float minSpeed = 500f;

		Velocity = Vector3.Dot( Velocity, GrindNormal ) * GrindNormal;

		if ( !connect )
			FinishSpinTrick();
		else
			Velocity = oldSpeed * Velocity.Normal;

		var grindDir = Velocity.Normal;
		if ( Velocity.Length <= 5f )
			grindDir = (realRot.Forward * GrindNormal);

		GrindRotation = Rotation.LookAt( grindDir, Vector3.Up );
		realRot = GrindRotation;

		if ( Velocity.Length < minSpeed && !connect )
			Velocity = realRot.Forward * minSpeed;

		if ( !connect )
		{
			Velocity += realRot.Forward * GrindSpeedBoost;
			Rotation = GrindRotation;
			SnapRotation = true;
		}

		AngularVelocity = Angles.Zero;
	}

	private void CalculateSpinTrick()
	{
		if ( !IsGrounded )
			SpunAmount += AngularVelocity.yaw * Time.Delta;
	}

	private void FinishSpinTrick()
	{
		double finalAmount = Math.Ceiling( Math.Abs( SpunAmount ) / 180f ) * 180.0;
		if ( Math.Abs( SpunAmount ) % 180f <= 90f )
			finalAmount -= 180.0;

		if ( finalAmount <= 0.0 )
			return;

		string side = (SpunAmount < 0f) ? "FS" : "BS";
		string name = $"{side} {finalAmount} Ollie";

		double spins = finalAmount / 180.0 - 1.0;
		var trick = new TrickScoreEntry( name, (int)(100.0 + spins * 50.0) );

		TrickScores.Add( trick );
	}

	public void UpdateGroundEntity( SceneTraceResult tr )
	{
		FinishSpinTrick();
		SpunAmount = 0f;

		OnVert = false;

		GroundNormal = tr.Normal;
		GroundObject = tr.GameObject;
		GroundCollider = tr.Collider;
		IsGrounded = tr.Hit;
		UpdateGroundVelocity();
	}

	public void ClearGroundEntity( ref Rotation realRot )
	{
		if ( !IsGrounded )
		{
			GroundObject = null;
			GroundCollider = null;
			GroundNormal = Vector3.Up;
			IsGrounded = false;
			return;
		}

		NudgeAmount = 0f;

		if ( GroundVert )
		{
			GroundVert = false;
			OnVert = true;

			VertNormal = new Vector3( GroundNormal.x, GroundNormal.y, 0f ).Normal;
			Pawn.VertNormal = VertNormal;

			Velocity -= Velocity.ProjectOnNormal( VertNormal );
			Position += VertNormal * 4f;

			realRot = MathLD.FromToRotation( Vector3.Up * realRot, VertNormal ) * realRot;
		}

		GroundObject = null;
		GroundCollider = null;
		GroundNormal = Vector3.Up;
		IsGrounded = false;
	}

	private void UpdateGroundVelocity()
	{
		if ( GroundCollider.IsValid() )
		{
			GroundVelocity = GroundCollider.GetVelocityAtPoint( WorldPosition );
			return;
		}

		GroundVelocity = Vector3.Zero;
	}

	private bool CanJump()
	{
		if ( IsGrounded ) return true;
		if ( _currentCoyoteTime > 0f && !_jumped ) return true;
		if ( OnGrind ) return true;
		return false;
	}

	private void AirOut( SkatePawn pawn, bool force = false )
	{
		bool shouldAirOut = !IsGrounded;

		if ( OnVert && Velocity.Dot( Vector3.Up ) <= 0f ) shouldAirOut = false;
		if ( OnVert && !HasVertBelow ) shouldAirOut = true;

		if ( !force && !shouldAirOut )
			return;

		if ( OnVert )
		{
			Velocity -= VertNormal * 25f;
			Position += pawn.RealRotation.Up * BodyGirth;
		}

		AiringOut = true;
		OnVert = false;
		pawn.OnVert = false;
	}

	private bool IsValidVert( Vector3 normal ) => normal.Angle( Vector3.Up ) >= VertMinAngle;

	private void SimulateVert( SkatePawn pawn, ref Rotation realRot )
	{
		HasVertBelow = false;

		const float traceLen = 100000f;
		float inside = 0f;
		const float insideOffset = 2f;

		var from = Position + realRot.Up * inside;
		var to = from - Vector3.Up * traceLen;

		var vertTrace = Scene.Trace.Ray( from, to ).WithAnyTags( "vert" );

		var result = vertTrace.Run();

		if ( result.GameObject != null && IsValidVert( result.Normal ) )
		{
			HasVertBelow = true;

			var vertNormal = (result.Normal - result.Normal.ProjectOnNormal( Vector3.Up )).Normal;
			var lastResult = result;

			while ( true )
			{
				inside -= 0.5f;

				from = Position + realRot.Up * inside;
				to = from - Vector3.Up * traceLen;

				var r2 = Scene.Trace.Ray( from, to ).WithAnyTags( "vert" ).Run();
				var v2 = (r2.Normal - r2.Normal.ProjectOnNormal( Vector3.Up )).Normal;

				if ( r2.GameObject != null && v2 == vertNormal )
				{
					vertNormal = v2;
					lastResult = r2;
					continue;
				}

				break;
			}

			result = lastResult;

			Position = new Vector3( result.HitPosition.x, result.HitPosition.y, Position.z );
			realRot = MathLD.FromToRotation( Vector3.Up * realRot, vertNormal ) * realRot;

			Position += vertNormal * insideOffset;
			Position += realRot.Up * NudgeAmount;

			VertNormal = vertNormal;
			pawn.VertNormal = vertNormal;
		}

		Velocity -= Velocity.ProjectOnNormal( VertNormal );
	}

	private void ClampTopSpeed()
	{
		float top = IsGrounded ? TopSpeed : AirTopSpeed;
		if ( Velocity.Length > top )
			Velocity = Velocity.Normal * top;
	}

	private void SetSimStats()
	{
		Gravity = 850f;
		TopSpeed = 1000f;
		JumpForce = 240f;
		CoyoteTime = 0.1f;
		SidewaysBailSpeed = 320f;
		LandBailMaxAngle = 50f;
		AirOutSpeed = 1f;
	}

	private void DoUpsideDownFall( ref Rotation realRot, ref bool isFalling )
	{
		isFalling = false;

		if ( !IsGrounded )
			return;

		float angle = GroundNormal.Angle( Vector3.Up );
		if ( angle < MinFallableAngle )
			return;

		float requiredSpeed = angle * 4f;
		float vel = Vector3.Dot( realRot.Forward, Velocity );
		float downRotationSpeed = angle * 0.025f;
		float downForwardSpeed = angle * 2f;

		var downAngle = Rotation.LookAt( Vector3.Down, GroundNormal );
		var downRotation = MathLD.FromToRotation( Vector3.Up * downAngle, GroundNormal ) * downAngle;

		if ( realRot.Forward.Dot( downRotation.Forward ) <= 0f )
			downForwardSpeed = angle * 2f;

		Velocity += downRotation.Forward * downForwardSpeed * Time.Delta;

		if ( vel >= requiredSpeed )
			return;

		isFalling = true;
		realRot = Rotation.Lerp( realRot, downRotation, downRotationSpeed * Time.Delta );

		if ( angle > 90f )
		{
			requiredSpeed = angle * 4f;
			Debug( requiredSpeed );

			if ( vel < requiredSpeed )
			{
				Position += GroundNormal * 15f;
				ClearGroundEntity( ref realRot );
				Debug( "you fell off" );
			}
		}
	}

	private void Debug( object log )
	{
		if ( SkateGame.skate_debug )
			Log.Info( log.ToString() );
	}

	private void Simulate()
	{
		var pawn = Pawn;

		pawn.TurnRight = 0f - DigitalLeftInput;
		pawn.OnVert = OnVert;
		var realRot = pawn.RealRotation;
		bool stopped = Velocity.Length <= StoppedVelocity;

		SimulateGrind( pawn, ref realRot );

		bool jump = false;

		if ( Input.JumpHeld )
		{
			JumpReady = true;
		}
		else
		{
			if ( JumpReady && CanJump() )
				jump = true;
			else
				_currentJumpStrength = JumpStrengthMinimum;

			JumpReady = false;
		}

		if ( stopped )
		{
			if ( ForwardDown )
				Pushing = true;
		}
		else if ( IsGrounded )
		{
			Pushing = !BackDown;
		}

		bool hardTurn = false;
		bool braking = BackDown;
		if ( braking )
			Pushing = false;

		if ( JumpReady && !braking )
			Pushing = true;

		if ( DigitalLeftInput != 0f && braking )
		{
			braking = false;
			hardTurn = true;
		}

		bool falling = false;
		DoUpsideDownFall( ref realRot, ref falling );
		if ( falling )
			braking = false;

		pawn.Crouch = JumpReady;

		pawn.EyeRotation = realRot;

		realRot = realRot
			.RotateAroundAxis( Vector3.Left, AngularVelocity.pitch * Time.Delta )
			.RotateAroundAxis( Vector3.Left, AngularVelocity.roll * Time.Delta )
			.RotateAroundAxis( Vector3.Up, AngularVelocity.yaw * Time.Delta );

		Rotation = Rotation
			.RotateAroundAxis( Vector3.Left, AngularVelocity.pitch * Time.Delta )
			.RotateAroundAxis( Vector3.Left, AngularVelocity.roll * Time.Delta )
			.RotateAroundAxis( Vector3.Up, AngularVelocity.yaw * Time.Delta );

		if ( IsGrounded )
			AiringOut = false;

		if ( AiringOut && !IsGrounded )
		{
			var desired = MathLD.FromToRotation( Vector3.Up * realRot, Vector3.Up ) * realRot;
			realRot = Rotation.Slerp( realRot, desired, AirOutSpeed * Time.Delta );
		}

		if ( Input.CrouchPressed )
			AirOut( pawn );

		HasVertBelow = false;
		if ( OnVert )
			SimulateVert( pawn, ref realRot );

		if ( !IsGrounded )
		{
			if ( !OnGrind )
			{
				if ( !OnVert )
				{
					AngularVelocity = new Angles( DigitalForwardInput * AirPitchSpeed, DigitalLeftInput * AirSpinSpeed, 0f );
				}
				else
				{
					AngularVelocity = new Angles( 0f, DigitalLeftInput * AirSpinSpeed, 0f );
				}

				CalculateSpinTrick();
			}

			Velocity += Gravity * Vector3.Down * Time.Delta;
			if ( OnGrind )
				Velocity = Velocity.Dot( GrindNormal ) * GrindNormal;
		}
		else
		{
			AngularVelocity = new Angles( 0f, DigitalLeftInput * SteerSpeed * (hardTurn ? BrakeSteerMultiplier : 1f), 0f );
			Velocity -= Velocity.ProjectOnNormal( GroundNormal );

			var forwardOnly = Velocity.ProjectOnNormal( realRot.Forward );
			var sideOnly = Velocity.ProjectOnNormal( realRot.Right );

			float leftToTraction = sideOnly.Length;
			float traction = TractionForce * Time.Delta;
			if ( traction > leftToTraction ) traction = leftToTraction;

			sideOnly -= traction * sideOnly.Normal;
			Velocity = forwardOnly;

			if ( Pushing )
			{
				float pushAccel = Acceleration;
				float pushSpeed = PushMaxSpeed;

				if ( JumpReady )
				{
					pushAccel = CrouchAcceleration;
					pushSpeed = CrouchMaxSpeed;
				}

				if ( Velocity.Length < pushSpeed )
				{
					Velocity += Vector3.Forward * realRot * pushAccel * Time.Delta;
					if ( Velocity.Length > pushSpeed )
						Velocity = Velocity.Normal * pushSpeed;
				}
			}
			else if ( braking )
			{
				float leftToStop = Velocity.Length;
				if ( leftToStop > 0f )
				{
					float deaccel = BrakeForce * Time.Delta;
					if ( deaccel > leftToStop ) deaccel = leftToStop;
					Velocity -= deaccel * Velocity.Normal;
				}
			}
		}

		var helper = new SkateHelper( Scene.Trace, GameObject, Position, Velocity )
		{
			MaxStandableAngle = MaxStandableAngle
		};

		float collHeight = 15f;
		float girth = BodyGirth;

		if ( IsGrounded )
			LastGroundRotation = realRot;

		var mins = new Vector3( -girth, -girth, 0f );
		var maxs = new Vector3( girth, girth, BodyHeight );

		if ( !OnVert )
		{
			mins += LastGroundRotation.Up * collHeight;
			maxs += LastGroundRotation.Up * collHeight;

			float upsideDownAmount = Math.Min( 1f, Math.Abs( Vector3.Dot( LastGroundRotation.Up, Vector3.Up ) - 1f ) );
			maxs -= BodyHeight * Vector3.Up * upsideDownAmount * 1.5f;
			mins += BodyHeight * Vector3.Up * upsideDownAmount * 0.75f;
		}
		else
		{
			mins += Vector3.Up * collHeight;
			maxs += Vector3.Up * collHeight;

			mins += realRot.Up * BodyGirth;
			maxs += realRot.Up * BodyGirth;
			LastGroundRotation = Rotation.Identity;
		}

		helper.Trace = helper.Trace.Size( mins, maxs );

		if ( helper.TryMove( Time.Delta, inAir: false ) > 0f )
		{
			if ( (helper.HitFloor || helper.Velocity.Length <= float.Epsilon) && !IsGrounded && !OnGrind )
			{
				if ( OnVert )
				{
					NudgeAmount += 4f;
				}
				else
				{
					var traceResults = new SceneTraceResult[4];
					var testPosition = Position + Vector3.Up * 50f;

					var pos = testPosition + realRot.Right * 10f;
					var to = pos + Vector3.Down * 100f;
					traceResults[0] = Scene.Trace.Ray( pos, to )
						.WithAnyTags( "solid", "skateable" )
						.WithoutTags( "unskateable" )
						.Run();

					pos = testPosition + realRot.Left * 10f;
					to = pos + Vector3.Down * 100f;
					traceResults[1] = Scene.Trace.Ray( pos, to )
						.WithAnyTags( "solid", "skateable" )
						.WithoutTags( "unskateable" )
						.Run();

					pos = testPosition + realRot.Forward * 10f;
					to = pos + Vector3.Down * 100f;
					traceResults[2] = Scene.Trace.Ray( pos, to )
						.WithAnyTags( "solid", "skateable" )
						.WithoutTags( "unskateable" )
						.Run();

					pos = testPosition + realRot.Backward * 10f;
					to = pos + Vector3.Down * 100f;
					traceResults[3] = Scene.Trace.Ray( pos, to )
						.WithAnyTags( "solid", "skateable" )
						.WithoutTags( "unskateable" )
						.Run();

					bool closestSet = false;
					float closestDistance = 0f;
					var closestPosition = Vector3.Zero;

					for ( int i = 0; i < traceResults.Length; i++ )
					{
						var traceResult = traceResults[i];
						if ( !traceResult.Hit )
							continue;

						var upFrom = Position + Vector3.Up * 50f;
						var upTo = traceResult.EndPosition + Vector3.Up * 50f;

						var blocked = Scene.Trace.Ray( upFrom, upTo )
							.WithAnyTags( "solid", "playerclip", "passbullets", "unskateable" )
							.Run()
							.Hit;

						if ( blocked )
							continue;

						float dist = Vector3.DistanceBetween( Position, traceResult.EndPosition );
						if ( !closestSet || dist < closestDistance )
						{
							closestSet = true;
							closestDistance = dist;
							closestPosition = traceResult.EndPosition;
						}
					}

					if ( closestSet )
						helper.Position = closestPosition;
				}

				helper.Velocity = Velocity;
			}

			if ( helper.HitWall && !OnGrind && !helper.HitPhysics )
			{
				if ( IsGrounded )
				{
					realRot = Rotation.LookAt( helper.Velocity.WithZ( 0f ), realRot.Up );
					realRot = MathLD.FromToRotation( Vector3.Up * realRot, GroundNormal ) * realRot;
				}

				if ( Velocity.Dot( -helper.HitNormal ) >= HitForce )
				{
					if ( IsGrounded )
					{
						Rotation = realRot;
						SnapRotation = true;
					}

					if ( Velocity.Length > StoppedVelocity )
					{
						var hitForward = Vector3.Dot( Velocity.Normal, realRot.Forward ) >= 0f;
						Animator?.HandleEvent( hitForward ? "front" : "back" );
					}

					Pawn.PlaySound( "body_hit", WorldPosition );
					if ( Pawn.HasHelmet() )
						Pawn.PlaySound( "helmet_hit", WorldPosition );
				}
			}

			Position = helper.Position;
			Velocity = helper.Velocity;
		}
		else
		{
			var a = Position + Vector3.Up * 10f;
			var b = a + Velocity * Time.Delta;

			var sanity = Scene.Trace.Ray( a, b )
				.WithAnyTags( "solid", "playerclip", "passbullets", "unskateable" )
				.Run();

			if ( !sanity.Hit )
			{
				Position += Velocity * Time.Delta;
			}
			else
			{
				Position += sanity.Normal * 20f;
				Velocity -= Vector3.VectorPlaneProject( Velocity, -sanity.Normal );
			}
		}

		Rotation = Rotation.Lerp( Rotation, realRot, SmoothingSpeed * Time.Delta );

		if ( IsGrounded )
		{
			var tracePos = Position + realRot.Up * wallCollisionSphereHeight + Vector3.Up * wallCollisionSphereGroundDistance;
			var sphereResult = Scene.Trace.Sphere( wallCollisionSphereRadius, tracePos, tracePos )
				.WithAnyTags( "solid", "playerclip", "passbullets", "unskateable" )
				.Run();

			if ( sphereResult.Hit )
			{
				var heading = (sphereResult.HitPosition - tracePos).Normal;
				heading -= heading.ProjectOnNormal( GroundNormal );
				heading = heading.Normal;

				var distance = Vector3.DistanceBetween( tracePos, sphereResult.HitPosition );
				Position -= heading * (distance + wallCollisionSphereForce);

				if ( Velocity.Dot( heading ) > 0f )
					Velocity -= Velocity.ProjectOnNormal( heading );
			}
		}

		var groundVector = IsGrounded ? GroundNormal : Vector3.Up;
		bool wasOnGround = IsGrounded;

		float currentTrace = IsGrounded ? 3f : 2f;

		var traceOffset1 = groundVector * 25f;
		var traceOffset2 = -(groundVector * currentTrace);

		if ( !IsGrounded )
		{
			var rotationOffset = realRot.Up * rotationTraceOffset;
			rotationOffset.z = 0f;

			traceOffset1 = groundVector * 25f + rotationOffset;

			if ( Vector3.Dot( realRot.Up, Vector3.Down ) >= upsideDownDotThreshold )
				currentTrace += upsideDownTraceOffset;

			traceOffset2 = -(groundVector * currentTrace) + rotationOffset;
		}

		if ( !OnGrind )
		{
			var from = Position + traceOffset1;
			var to = Position + traceOffset2;

			var floorTrace = Scene.Trace.Ray( from, to )
				.WithAnyTags( "solid", "playerclip", "passbullets", "vert", "skateable", "unskateable" )
				.WithoutTags( "dynamicprop" );

			var floor = floorTrace.Run();

			if ( floor.GameObject == null || HasTag( floor, "unskateable" ) )
			{
				ClearGroundEntity( ref realRot );
			}
			else
			{
				bool isVert = HasTag( floor, "vert" ) && IsValidVert( floor.Normal );

				bool canStand = false;

				if ( !floor.StartedSolid && floor.Fraction > 0f && floor.Fraction < 1f )
				{
					if ( floor.Normal.Angle( Vector3.Up ) < MaxStandableAngle )
						canStand = true;

					if ( HasTag( floor, "vert" ) || HasTag( floor, "skateable" ) )
						canStand = true;
				}

				if ( !IsGrounded && Velocity.Normal.Dot( -floor.Normal ) < 0f )
					canStand = false;

				if ( canStand && IsGrounded )
				{
					float slopeChange = floor.Normal.Angle( GroundNormal );
					float dotFw = Vector3.Dot( floor.Normal, Velocity.Normal );
					if ( slopeChange >= MaxSlope && dotFw > 0f )
						canStand = false;
				}

				if ( canStand )
				{
					float oldForwardSpeed = Velocity.Dot( realRot.Forward );
					var prevGround = GroundObject;

					UpdateGroundEntity( floor );

					if ( Vector3.Dot( Velocity, -GroundNormal ) >= 100f && prevGround == null )
						Animator?.HandleEvent( "land" );

					var oldRot = realRot;
					realRot = MathLD.FromToRotation( Vector3.Up * realRot, GroundNormal ) * realRot;

					if ( prevGround == null )
					{
						bool bailed = false;
						bool awkward = false;

						float angleDiff = oldRot.Up.Normal.Angle( floor.Normal );
						if ( angleDiff > LandBailMaxAngle )
						{
							awkward = true;
							Pawn.Bail( SkatePawn.BailType.Landing );
							bailed = true;
						}

						float sidewaysVel = Math.Abs( Vector3.Dot( (Velocity.Length > 800f) ? (800f * Velocity.Normal) : Velocity, realRot.Right ) );
						if ( sidewaysVel >= SidewaysBailSpeed && !bailed )
							Pawn.Bail();

						if ( Vector3.Dot( Velocity, realRot.Forward ) < 0f && Velocity.Length > StoppedVelocity )
							realRot = Rotation.LookAt( realRot.Backward, GroundNormal );

						if ( !awkward )
							Pawn.PlaySound( "skate_land", WorldPosition );

						SnapRotation = true;
						Rotation = realRot;

						float ang = Vector3.Dot( GroundNormal, Vector3.Up );
						Velocity *= MathX.Lerp( LandSpeedMultiplier, 1f, ang );
					}
					else
					{
						Velocity = oldForwardSpeed * realRot.Forward;
					}

					realRot = Rotation.LookAt( realRot.Forward, GroundNormal );

					Velocity -= Velocity.ProjectOnNormal( floor.Normal );
					Position = floor.EndPosition + floor.Normal * 1f;

					GroundVert = isVert;
					TrickScores.Finished = true;
				}
				else
				{
					ClearGroundEntity( ref realRot );
				}
			}
		}

		pawn.RealRotation = realRot;
		pawn.OnVert = OnVert;
		Pawn.Velocity = Velocity;

		if ( _currentCoyoteTime > 0f )
			_currentCoyoteTime -= Time.Delta;
		else
			_jumped = false;

		if ( IsGrounded )
		{
			_currentCoyoteTime = 0f;
			_jumped = false;
		}

		if ( wasOnGround && !IsGrounded )
			_currentCoyoteTime = CoyoteTime;

		if ( jump )
		{
			var jumpDir = Vector3.Up;
			var angle = GroundNormal.Angle( Vector3.Up );
			var jumpOffset = Vector3.Zero;

			if ( angle > 90f )
			{
				jumpDir *= -1f;
				jumpOffset = jumpDir * 20f;
			}

			if ( OnGrind )
			{
				jumpOffset = jumpDir * 20f;
				Velocity += 200f * realRot.Left * DigitalLeftInput;
			}

			ClearGroundEntity( ref realRot );

			_currentJumpStrength = MathX.Lerp( _currentJumpStrength, JumpStrengthMinimum, Velocity.z / JumpMaxSpeed );
			Velocity += jumpDir * JumpForce * _currentJumpStrength;
			Position += jumpOffset;

			_jumped = true;
			_currentJumpStrength = JumpStrengthMinimum;

			Animator?.HandleEvent( "jump" );
			Pawn.PlaySound( "ollie", WorldPosition );
			Pawn.PlaySound( "jump", WorldPosition );

			StopGrind( pawn, manual: true );
		}

		if ( JumpReady )
		{
			_currentJumpStrength = (_currentJumpStrength < 1f)
				? (_currentJumpStrength + Time.Delta * JumpStrengthSpeed)
				: 1f;
		}

		ClampTopSpeed();
	}

	private static bool HasTag( SceneTraceResult tr, string tag )
	{
		if ( tr.Tags is not null && tr.Tags.Contains( tag, StringComparer.OrdinalIgnoreCase ) )
			return true;

		return tr.GameObject?.Tags.Has( tag ) ?? false;
	}

	public void ResetState()
	{
		if ( Pawn is null )
			return;

		var r = Pawn.RealRotation;
		ClearGroundEntity( ref r );

		OnVert = false;
		HasVertBelow = false;
		Pushing = false;
		SpunAmount = 0f;
		OnGrind = false;

		Velocity = Vector3.Zero;
		AngularVelocity = Angles.Zero;

		Pawn.RealRotation = Rotation;
		Pawn.Velocity = Vector3.Zero;
	}
}