Pawn/SkateController.cs

Player component that implements skateboarding physics and movement. It reads input, applies acceleration, jumping, grinding and vert behavior, handles collisions via scene traces, updates Pawn state, and awards trick scores.

NetworkingFile Access
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; } = 460f;
	[Property] public float LandBailMaxAngle { get; set; } = 48f;
	[Property] public float LandSpeedMultiplier { get; set; } = 0.98f;
	[Property] public float HitForce { get; set; } = 200f;

	[Property] public float AirTrickCooldown { get; set; } = 0.3f;
	[Property] public int AirTrickScore { get; set; } = 100;
	[Property] public float ManualMinSpeed { get; set; } = 60f;

	[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 float _airTrickCooldown;
	private string _lastAirTrick;
	private bool _manualing;
	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;

	// Analog steer axis for grounded turning: a gamepad stick steers proportionally
	// while keyboard still reads as full lock. Air spins stay digital/snappy.
	[Property] public float SteerDeadzone { get; set; } = 0.1f;
	private float AnalogLeftInput => Math.Abs( Input.Move.y ) <= SteerDeadzone ? 0f : Input.Move.y;

	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 );
	}

	private void HandleAirTricks()
	{
		if ( _airTrickCooldown > 0f )
			_airTrickCooldown -= Time.Delta;

		if ( _airTrickCooldown > 0f )
			return;

		if ( Input.FlipPressed )
		{
			var name = FlipTrickName();
			AddAirTrick( name );
			Pawn.PlayTrickAnim( name, isGrab: false );
			Pawn.PlaySound( "ollie", WorldPosition );
		}
		else if ( Input.GrabPressed )
		{
			var name = GrabTrickName();
			AddAirTrick( name );
			Pawn.PlayTrickAnim( name, isGrab: true );
		}
	}

	private void AddAirTrick( string name )
	{
		// Light anti-spam: don't let the exact same trick score twice in a row.
		int score = (name == _lastAirTrick) ? AirTrickScore / 2 : AirTrickScore;
		TrickScores.Add( new TrickScoreEntry( name, score ) );
		_lastAirTrick = name;
		_airTrickCooldown = AirTrickCooldown;
	}

	private string FlipTrickName()
	{
		if ( DigitalLeftInput > 0f ) return "Heelflip";
		if ( DigitalLeftInput < 0f ) return "Kickflip";
		if ( DigitalForwardInput > 0f ) return "Pop Shove-it";
		if ( DigitalForwardInput < 0f ) return "360 Flip";
		return "Kickflip";
	}

	private string GrabTrickName()
	{
		if ( DigitalForwardInput > 0f ) return "Nosegrab";
		if ( DigitalForwardInput < 0f ) return "Tailgrab";
		if ( DigitalLeftInput < 0f ) return "Melon";
		if ( DigitalLeftInput > 0f ) return "Indy";
		return "Indy";
	}

	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 - AnalogLeftInput;
		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 )
		{
			_manualing = false;

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

				CalculateSpinTrick();
				HandleAirTricks();
			}

			Velocity += Gravity * Vector3.Down * Time.Delta;
			if ( OnGrind )
				Velocity = Velocity.Dot( GrindNormal ) * GrindNormal;
		}
		else
		{
			AngularVelocity = new Angles( 0f, AnalogLeftInput * 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;

					if ( prevGround == null )
						_lastAirTrick = null;

					// Revert on landing scores and, together with a held manual,
					// keeps the combo open so tricks can be linked over the ground.
					if ( prevGround == null && Input.RevertPressed && !TrickScores.Empty && !TrickScores.Finished )
						TrickScores.Add( new TrickScoreEntry( "Revert", AirTrickScore ) );

					bool canManual = !TrickScores.Finished && !TrickScores.Empty
						&& Input.RevertHeld && !BackDown && Velocity.Length > ManualMinSpeed;

					if ( canManual )
					{
						if ( !_manualing )
							TrickScores.Add( new TrickScoreEntry( "Manual", AirTrickScore ) );
						_manualing = true;
					}
					else
					{
						_manualing = false;
						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;
	}

	/// <summary>
	/// Apply an external velocity change (push pads, launch volumes, etc). If the push has
	/// meaningful upward force we leave the ground so it actually launches.
	/// </summary>
	public void AddExternalVelocity( Vector3 velocity )
	{
		if ( Pawn is null )
			return;

		Velocity += velocity;

		if ( velocity.z > 1f && IsGrounded )
		{
			var realRot = Pawn.RealRotation;
			ClearGroundEntity( ref realRot );
			Pawn.RealRotation = realRot;
		}
	}

	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;
	}
}