Code/PlayerMovement.Physics.cs
using Sandbox;
using System;
namespace XMovement;

public partial class PlayerMovement : Component
{
	[Range( 0, 200 )]
	[Property] public float Radius { get; set; } = 16.0f;

	[Range( 0, 200 )]
	[Property] public float Height { get; set; } = 72.0f;

	[Range( 0, 50 )]
	[Property] public float StepHeight { get; set; } = 18.0f;

	[Range( 0, 90 )]
	[Property] public float GroundAngle { get; set; } = 45.5734f;

	[Range( 0, 64 )]
	[Property] public float Acceleration { get; set; } = 10.0f;

	/// <summary>
	/// When jumping into walls, should we bounce off or just stop dead?
	/// </summary>
	[Range( 0, 1 )]
	[Property] public float Bounciness { get; set; } = 0.0f;

	/// <summary>
	/// If enabled, determine what to collide with using current project's collision rules for the <see cref="GameObject.Tags"/>
	/// of the containing <see cref="GameObject"/>.
	/// </summary>
	[Property, Group( "Collision" ), Title( "Use Project Collision Rules" )] public bool UseCollisionRules { get; set; } = false;

	[Property, Group( "Collision" ), HideIf( nameof( UseCollisionRules ), true )]
	public TagSet IgnoreLayers { get; set; } = new();

	public BBox BoundingBox => new BBox( new Vector3( -Radius, -Radius, 0 ), new Vector3( Radius, Radius, Height ) );

	[ReadOnly, Property, Sync]
	public Vector3 Velocity { get; set; }

	[ReadOnly, Property, Sync]
	public Vector3 BaseVelocity { get; set; }

	[ReadOnly, Property, Sync]
	public bool IsOnGround { get; set; }

	public Vector3 PreviousPosition { get; set; }

	public GameObject PreviousGroundObject { get; set; }
	public GameObject GroundObject { get; set; }
	public Collider GroundCollider { get; set; }
	public Vector3 GroundNormal { get; set; }
	public float SurfaceFriction { get; set; } = 1.0f;

	/// <summary>
	/// Use a physics body for the shape of the player's movement trace, uses bbox if unset.
	/// </summary> 
	[Property] ModelCollider PlayerColliderModel { get; set; }

	protected override void DrawGizmos()
	{
		Gizmo.Draw.LineBBox( BoundingBox );
	}

	/// <summary>
	/// Move up and leave the ground, great for jumping.
	/// </summary>
	public void LaunchUpwards( float amount )
	{
		ClearGround();
		Velocity += Vector3.Up * amount;
		Velocity -= Gravity * Time.Delta * 0.5f;
	}

	/// <summary>
	/// Add acceleration to the current velocity. 
	/// No need to scale by time delta - it will be done inside.
	/// </summary>
	public void Accelerate( Vector3 vector )
	{
		Accelerate( vector, Acceleration );
	}

	/// <summary>
	/// Add our wish direction and speed onto our velocity
	/// </summary>
	public virtual void Accelerate( Vector3 vector, float acceleration )
	{
		// This gets overridden because some games (CSPort) want to allow dead (observer) players
		// to be able to move around.
		// if ( !CanAccelerate() )
		//     return; 

		var wishdir = vector.Normal;
		var wishspeed = vector.Length;

		// See if we are changing direction a bit
		var currentspeed = Velocity.Dot( wishdir );

		// Reduce wishspeed by the amount of veer.
		var addspeed = wishspeed - currentspeed;

		// If not going to add any speed, done.
		if ( addspeed <= 0 )
			return;

		// Determine amount of acceleration.
		var accelspeed = acceleration * wishspeed * Time.Delta * SurfaceFriction;

		// Cap at addspeed
		if ( accelspeed > addspeed )
			accelspeed = addspeed;

		Velocity += wishdir * accelspeed;
	}

	/// <summary>
	/// Source-correct air acceleration. Equivalent to CGameMovement::AirAccelerate.
	/// 
	/// The key difference from ground Accelerate: wishspeed is capped to AirSpeedCap
	/// (equivalent to GetAirSpeedCap() = 30 in HL2/CS) for the dot-product and addspeed
	/// check, but the FULL uncapped wishspeed is used in the accelspeed formula.
	/// This is what produces proper strafing feel — you can keep gaining speed because
	/// the 30-unit cap means currentspeed almost never exceeds wishspd at large velocities.
	/// </summary>
	public virtual void AirAccelerate( Vector3 wishVelocity, float acceleration )
	{
		var wishdir = wishVelocity.Normal;
		var wishspeed = wishVelocity.Length;        // full speed, used for accelspeed formula
		var wishspd = MathF.Min( wishspeed, AirSpeedCap ); // capped speed, used for dot check

		// How much of our current velocity is already in the wish direction?
		var currentspeed = Velocity.Dot( wishdir );

		// How much more can we add before exceeding the cap?
		var addspeed = wishspd - currentspeed;
		if ( addspeed <= 0 )
			return;

		// Note: uses full wishspeed (not capped wishspd) — this is correct and intentional.
		var accelspeed = acceleration * wishspeed * Time.Delta * SurfaceFriction;
		if ( accelspeed > addspeed )
			accelspeed = addspeed;

		Velocity += wishdir * accelspeed;
	}

	/// <summary>
	/// Apply an amount of friction to the current velocity.
	/// No need to scale by time delta - it will be done inside.
	/// </summary>
	public Vector3 ApplyFriction( Vector3 velocity, float friction, float stopSpeed = 140.0f )
	{
		friction *= SurfaceFriction;

		var speed = velocity.Length;

		// Bleed off some speed, but if we have less than the bleed
		//  threshold, bleed the threshold amount.
		float control = (speed < stopSpeed) ? stopSpeed : speed;

		// Add the amount to the drop amount.
		var drop = control * friction * Time.Delta;

		// scale the velocity
		float newspeed = speed - drop;
		if ( newspeed < 0 ) newspeed = 0;
		if ( newspeed == speed ) return velocity;

		newspeed /= speed;
		return velocity * newspeed;
	}

	public virtual SceneTrace BuildTrace( Vector3 from, Vector3 to, float liftFeet = 0.0f )
	{
		var box = BoundingBox;
		if ( liftFeet > 0 )
		{
			from += Vector3.Up * liftFeet;
			box.Maxs = box.Maxs.WithZ( box.Maxs.z - liftFeet );
		}
		box.Mins *= WorldScale;
		box.Maxs *= WorldScale;

		var source = Scene.Trace.Ray( from, to ).Size( box );
		if ( PlayerColliderModel.IsValid() )
		{
			var scale = Height / PlayerColliderModel.Model.PhysicsBounds.Maxs.z;
			PlayerColliderModel.GameObject.WorldScale = new Vector3( 1, 1, scale );
			source = Scene.Trace.Body( PlayerColliderModel.Rigidbody.PhysicsBody, new Transform( from ), to );
		}
		var trace = source.IgnoreGameObjectHierarchy( GameObject );

		return UseCollisionRules ? trace.WithCollisionRules( Tags ) : trace.WithoutTags( IgnoreLayers );
	}

	/// <summary>
	/// Trace the controller's current position to the specified delta
	/// </summary>
	public SceneTraceResult TraceDirection( Vector3 direction )
	{
		return BuildTrace( GameObject.WorldPosition, GameObject.WorldPosition + direction ).Run();
	}
	public void MoveBy( Vector3 delta, bool step )
	{
		if ( step && IsOnGround )
		{
			//Velocity = Velocity.WithZ( 0 );
		}


		var pos = GameObject.WorldPosition;

		var mover = new CharacterControllerHelper( BuildTrace( pos, pos ), pos, delta );
		mover.Bounce = Bounciness;
		mover.MaxStandableAngle = GroundAngle;

		if ( step && IsOnGround )
		{
			mover.TryMoveWithStep( Time.Delta, StepHeight );
		}
		else
		{
			mover.TryMove( Time.Delta );
		}

		WorldPosition = mover.Position;
	}
	void Move( bool step )
	{
		if ( step && IsOnGround )
		{
			//Velocity = Velocity.WithZ( 0 );
		}


		var pos = GameObject.WorldPosition;

		Velocity *= WorldScale;

		Velocity += BaseVelocity;

		Velocity += PhysicsBodyVelocity;

		var mover = new CharacterControllerHelper( BuildTrace( pos, pos ), pos, Velocity );
		mover.Bounce = Bounciness;
		mover.MaxStandableAngle = GroundAngle;

		if ( step && IsOnGround )
		{
			mover.TryMoveWithStep( Time.Delta, StepHeight * WorldScale.z );
		}
		else
		{
			mover.TryMove( Time.Delta );
		}

		WorldPosition = mover.Position;
		Velocity = mover.Velocity;
		Velocity -= BaseVelocity;
		Velocity -= PhysicsBodyVelocity;

		Velocity /= WorldScale;
	}

	void CategorizePosition()
	{
		SurfaceFriction = 1.0f;
		var point = WorldPosition + ((Vector3.Down * 2f) * WorldScale.z);
		var vBumpOrigin = WorldPosition;
		var wasOnGround = IsOnGround;

		// We're flying upwards too fast, never land on ground
		if ( Velocity.z - PhysicsBodyVelocity.z > 140.0f )
		{
			ClearGround();
			return;
		}

		//point.z -= (IsOnGround && PreviousGroundObject != null ? StepHeight : 0.1f) / 32f;

		var pm = BuildTrace( vBumpOrigin, point, 0.0f ).Run();

		//
		// we didn't hit - or the ground is too steep to be ground
		//

		if ( IsOnGround && !pm.Hit || Vector3.GetAngle( Vector3.Up, pm.Normal ) > GroundAngle )
		{
			ClearGround();

			if ( wasOnGround && Velocity.z > 0.0f )
				SurfaceFriction = 0.25f;

			return;
		}

		//
		// we are on ground
		//
		ChangeGround( pm );

		var posDelta = (WorldPosition - PreviousPosition);

		//
		// move to this ground position, if we moved, and hit
		//
		if ( wasOnGround && !pm.StartedSolid && pm.Fraction > 0.0f && pm.Fraction < 1.0f && posDelta.z <= 3f )
		{
			WorldPosition = pm.EndPosition;
		}
	}

	public void StayOnGround()
	{
		var start = WorldPosition;
		var end = WorldPosition;
		start.z += 2;
		end.z -= StepHeight;

		var tr = BuildTrace( start, end, 0.0f ).Run();

		//
		// we didn't hit - or the ground is too steep to be ground
		//

		if ( tr.Fraction > 0.0f && tr.Fraction < 1.0f && !tr.StartedSolid && Vector3.GetAngle( Vector3.Up, tr.Normal ) <= GroundAngle )
		{
			float zDelta = MathF.Abs( WorldPosition.z - tr.EndPosition.z );
			if ( zDelta > 0.5f ) WorldPosition = tr.EndPosition;
		}
	}

	/// <summary>
	/// Disconnect from ground and punch our velocity. This is useful if you want the player to jump or something.
	/// </summary>
	public void Punch( in Vector3 amount )
	{
		ClearGround();
		Velocity += amount;
	}

	/// <summary>
	/// <summary>
	/// Fired when the player lands on the ground after being airborne.
	/// Parameters: fall distance, impact velocity.
	/// </summary>
	public event Action<float, Vector3> OnLanded;

	private Vector3 _preLandVelocity;

	/// We're no longer on the ground, remove it
	/// </summary>
	public virtual void ClearGround()
	{
		if ( IsOnGround )
		{
			Velocity += PhysicsBodyVelocity;
			PhysicsBodyVelocity = Vector3.Zero;
			PhysicsBodyRigidbody.Velocity = Vector3.Zero;
		}

		_preLandVelocity = Velocity;
		PreviousGroundObject = GroundObject;
		IsOnGround = false;
		GroundObject = default;
		GroundCollider = default;
		GroundNormal = Vector3.Up;
		SurfaceFriction = 1.0f;
	}

	/// <summary>
	/// We have a new ground
	/// </summary>
	public virtual void ChangeGround( SceneTraceResult pm )
	{
		bool wasOnGround = IsOnGround;
		PreviousGroundObject = GroundObject;
		IsOnGround = pm.Hit;
		GroundObject = pm.GameObject;
		GroundCollider = pm.Shape?.Collider as Collider;
		GroundNormal = pm.Normal;

		BaseVelocity = Vector3.Zero;

		if ( pm.Hit )
		{
			CatergorizeGroundSurface( pm );

			// Just landed
			if ( !wasOnGround )
			{
				var fallDistance = -_preLandVelocity.z;
				OnLanded?.Invoke( fallDistance, _preLandVelocity );
			}
		}
	}

	public virtual void CatergorizeGroundSurface( SceneTraceResult pm )
	{
		if ( GroundCollider.IsValid() ) BaseVelocity = GroundCollider.SurfaceVelocity * GroundCollider.WorldRotation;

		// VALVE HACKHACK: Scale this to fudge the relationship between vphysics friction values and player friction values.
		// A value of 0.8f feels pretty normal for vphysics, whereas 1.0f is normal for players.
		// This scaling trivially makes them equivalent.  REVISIT if this affects low friction surfaces too much.
		SurfaceFriction = (pm.Surface?.Friction ?? 0.8f) * 1.25f;
		if ( SurfaceFriction > 1.0f ) SurfaceFriction = 1.0f;
	}

	/// <summary>
	/// Move from our current position to this target position, but using tracing an sliding.
	/// This is good for different control modes like ladders and stuff.
	/// </summary>
	public void MoveTo( Vector3 targetPosition, bool useStep )
	{
		if ( TryUnstuck() )
			return;

		var pos = WorldPosition;
		var delta = targetPosition - pos;

		var mover = new CharacterControllerHelper( BuildTrace( pos, pos ), pos, delta );
		mover.MaxStandableAngle = GroundAngle;

		if ( useStep )
		{
			mover.TryMoveWithStep( 1.0f, StepHeight );
		}
		else
		{
			mover.TryMove( 1.0f );
		}

		WorldPosition = mover.Position;
	}

	int _stuckTries;

	bool IsStuck()
	{
		var result = BuildTrace( WorldPosition, WorldPosition ).Run();
		return result.StartedSolid || IsOutOfBounds( WorldPosition );
	}
	[ConVar] public static bool debug_playermovement_unstick { get; set; } = false;
	Transform _previousTransform;
	bool TryUnstuck()
	{

		var result = BuildTrace( WorldPosition, WorldPosition ).Run();

		// Not stuck, we cool
		if ( !result.StartedSolid && !IsOutOfBounds( WorldPosition ) )
		{
			_stuckTries = 0;
			_previousTransform = Transform.World;
			return false;
		}

		var wasOutOfBounds = IsOutOfBounds( WorldPosition );

		/*using ( Gizmo.Scope( "unstuck", Transform.World ) )
		{
			Gizmo.Draw.Color = Color.Red;
			Gizmo.Draw.LineBBox( BoundingBox );
		}*/

		int AttemptsPerTick = 150;

		var normal = Vector3.Zero;
		var pos = WorldPosition;
		var startpos = WorldPosition;
		for ( int i = 0; i < AttemptsPerTick; i++ )
		{

			// First try the where ever the physics body is, if we have one.
			/*if ( i <= 1 && PhysicsBodyRigidbody.IsValid() )
			{
				pos = PhysicsBodyRigidbody.WorldPosition + ((PhysicsBodyRigidbody.Velocity * Time.Delta) * i);
				if ( debug_playermovement_unstick ) DebugOverlay.Box( BoundingBox, Color.Cyan, 2, Transform.World.WithRotation( Rotation.Identity ) );
			}*/

			// this can solve so many issues super quickly so do this first.
			if ( i <= 2 && !wasOutOfBounds )
			{
				pos = WorldPosition + Vector3.Up * ((i) * 0.2f);
				if ( debug_playermovement_unstick ) DebugOverlay.Box( BoundingBox, Color.Cyan, 2, Transform.World.WithRotation( Rotation.Identity ) );
			}
			// Try base velocity 
			if ( (PhysicsBodyVelocity.Length > 0 || (PhysicsBodyRigidbody.IsValid() && PhysicsBodyRigidbody.WorldPosition != WorldPosition)) && i < 80 && !wasOutOfBounds )
			{
				normal = PhysicsBodyVelocity.Normal * Time.Delta;
				normal.z = Math.Max( 0, normal.z );
				normal *= 1f;
				if ( i < 0 )
				{
					pos = PhysicsBodyRigidbody.WorldPosition;
				}
				else
				{
					var searchdistance = 0.2f;
					if ( i > 70 ) searchdistance = 1f;
					if ( i > 75 ) searchdistance = 3f;
					normal *= searchdistance;
					pos += normal;
					if ( debug_playermovement_unstick ) DebugOverlay.Line( WorldPosition, pos, Color.Yellow, 2 );
					/*using ( Gizmo.Scope( "unstuck2", new Transform() ) )
					{
						Gizmo.Draw.Color = Color.Yellow;
						Gizmo.Draw.Line( WorldPosition, WorldPosition + normal * 12 );
					}*/

				}
				if ( debug_playermovement_unstick ) DebugOverlay.Box( BoundingBox, Color.Green, 2, Transform.World.WithRotation( Rotation.Identity ) );

				/*using ( Gizmo.Scope( "unstuck3", pos ) )
				{
					Gizmo.Draw.Color = Color.Green;
					Gizmo.Draw.LineBBox( BoundingBox );
				}*/
			}
			// Second try the up direction for moving platforms
			else if ( i < 4 && !wasOutOfBounds )
			{
				pos = WorldPosition + Vector3.Up * ((i) * 3f);
				if ( debug_playermovement_unstick ) DebugOverlay.Box( BoundingBox, Color.Yellow, 2, Transform.World.WithRotation( Rotation.Identity ) );
			}
			else
			{
				var pushscale = 1.25f;
				if ( wasOutOfBounds ) pushscale = 3f;
				normal = Vector3.Random.Normal * (((float)_stuckTries) * pushscale);

				if ( debug_playermovement_unstick ) DebugOverlay.Line( WorldPosition, pos, Color.Blue, 2 );
				pos = WorldPosition + normal;
				normal *= 0.25f;
				//normal.ClampLength( 0, 10 );
				if ( debug_playermovement_unstick ) DebugOverlay.Box( BoundingBox, Color.Magenta, 2, Transform.World.WithRotation( Rotation.Identity ) );
			}
			/*using ( Gizmo.Scope( "unstuck4", new Transform() ) )
			{
				Gizmo.Draw.Color = Color.Blue;
				Gizmo.Draw.Line( WorldPosition, pos );
			}*/
			result = BuildTrace( pos, pos ).Run();

			if ( !result.StartedSolid && !IsOutOfBoundsForUnstick( pos ) )
			{
				//Log.Info( $"unstuck after {_stuckTries} tries ({_stuckTries * AttemptsPerTick} tests)" );
				if (!wasOutOfBounds) Velocity += normal / Time.Delta;
				WorldPosition = pos;
				_previousTransform = Transform.World;
				return false;
			}
		}

		_stuckTries++;

		_previousTransform = Transform.World;
		return true;
	}
}