swb_player/PlayerBase.Movement.cs
using Sandbox.Citizen;
using SWB.Shared;
using System;
using System.Collections.Generic;
using static Sandbox.SceneModel;

namespace SWB.Player;

public partial class PlayerBase
{
	[Property] public float GroundControl { get; set; } = 4.0f;
	[Property] public float AirControl { get; set; } = 0.1f;
	[Property] public float MaxForce { get; set; } = 50f;
	[Property] public float RunSpeed { get; set; } = 290f;
	[Property] public float WalkSpeed { get; set; } = 160f;
	[Property] public float CrouchSpeed { get; set; } = 90f;
	[Property] public float JumpForce { get; set; } = 350f;
	[Property] public float NoclipSpeed { get; set; } = 5f;

	/// <summary>Blocks jump when jumping quickly in succession</summary>
	[Property] public bool JumpSpamPrevention { get; set; } = true;

		/// <summary>Blocks jump when jumping quickly in succession</summary>
	[Property] public bool CrouchSpamPrevention { get; set; } = true;

	[Property, Category( "Falling" )] public float SafeFallSpeed { get; set; } = 500f;
	[Property, Category( "Falling" )] public float LethalFallSpeed { get; set; } = 700f;
	[Property, Category( "Falling" )] public float MaxFallDamage { get; set; } = 100f;
	[Property, Category( "Falling" )] public float FallDamageExponent { get; set; } = 2f;

	[Sync] public Vector3 WishVelocity { get; set; } = Vector3.Zero;
	[Sync] public Vector3 FallingVelocity { get; set; } = Vector3.Zero;
	[Sync] public Angles EyeAngles { get; set; }
	[Sync] public Vector3 EyeOffset { get; set; } = Vector3.Zero;
	[Sync] public bool IsCrouching { get; set; } = false;
	[Sync] public new bool IsRunning { get; set; } = false;
	[Sync] public bool CanMove { get; set; } = true;
	[Sync] public bool Noclip { get; private set; } = false;
	[Sync] public bool IsUsingController { get; set; }

	public TimeSince TimeSinceAirborne { get; set; }
	public TimeSince TimeSinceCrouch { get; set; }
	public bool IsOnGround => CharacterController?.IsOnGround ?? true;
	public Vector3 Velocity => CharacterController?.Velocity ?? Vector3.Zero;
	public Vector3 EyePos => Head.WorldPosition + EyeOffset;
	public Angles EyeAnglesNormal => EyeAngles.Normal;

	public CharacterController CharacterController { get; set; }
	public CitizenAnimationHelper AnimationHelper { get; set; }
	public HoldTypes HoldType
	{
		set { AnimationHelper.HoldType = (CitizenAnimationHelper.HoldTypes)value; }
	}

	public CapsuleCollider BodyCollider { get; set; }

	HashSet<string> stickyButtons = new( StringComparer.Ordinal );
	HashSet<string> stickyActiveButtons = new( StringComparer.Ordinal );
	TimeSince timeSinceStickyRunStart = 0;

	TimeSince timeSinceLastFootstep = 0;
	bool groundedCheck = true;

	public void ToggleNoclip()
	{
		Noclip = !Noclip;

		if ( Noclip ) Tags.Add( TagsHelper.Trigger );
		else Tags.Remove( TagsHelper.Trigger );

		BodyRenderer.Set( "b_noclip", Noclip );
	}

	public virtual void OnInputDeviceSwitch()
	{
		IsUsingController = Input.UsingController;
		stickyButtons.Clear();
		stickyActiveButtons.Clear();

		if ( IsUsingController )
		{
			stickyButtons.Add( InputButtonHelper.Duck );
			stickyButtons.Add( InputButtonHelper.Run );
		}
	}

	bool InputIsDownOrPressed( string button )
	{
		return !IsUsingController ? Input.Down( button ) : Input.Pressed( button );
	}

	void OnMovementAwake()
	{
		CharacterController = Components.Get<CharacterController>();
		AnimationHelper = Components.Get<CitizenAnimationHelper>();
		BodyCollider = Body.Components.Get<CapsuleCollider>();

		if ( BodyRenderer is not null )
			BodyRenderer.OnFootstepEvent += OnAnimEventFootstep;
	}

	public virtual void OnMovementUpdate()
	{
		if ( !IsProxy && !IsBot )
		{
			UpdateRun();

			if ( Input.Pressed( InputButtonHelper.Jump ) )
			{
				if ( IsClimbingLadder )
					LadderJump();
				else
					Jump();
			}

			UpdateCrouch();
		}

		if ( !IsOnGround )
		{
			TimeSinceAirborne = 0;
			FallingVelocity = Velocity;
		}

		if ( IsOnGround && groundedCheck != IsOnGround )
		{
			groundedCheck = IsOnGround;
			OnGrounded( FallingVelocity );
		}
		else if ( !IsOnGround )
		{
			groundedCheck = false;
		}

		RotateBody();
		UpdateAnimations();
	}

	public virtual void OnMovementFixedUpdate()
	{
		if ( IsProxy ) return;
		if ( !IsBot )
			BuildWishVelocity();

		if ( Noclip )
			NoclipMove();
		else if ( IsClimbingLadder )
			LadderMove();
		else
			Move();
	}

	void BuildWishVelocity()
	{
		WishVelocity = 0;
		if ( !CanMove ) return;

		var rot = Camera.WorldRotation; // = EyeAngles in firstperson | = Camera.WorldRotation in thirdperson
		WishVelocity += rot * Input.AnalogMove;

		if ( !Noclip )
			WishVelocity = WishVelocity.WithZ( 0 );

		if ( !WishVelocity.IsNearZeroLength ) WishVelocity = WishVelocity.Normal;

		if ( IsCrouching ) WishVelocity *= CrouchSpeed;
		else if ( IsRunning ) WishVelocity *= RunSpeed;
		else WishVelocity *= WalkSpeed;

		// Mobility from item
		if ( Inventory.ActiveItem is not null )
			WishVelocity *= Inventory.ActiveItem.Mobility;

		// Impact from bullets, etc.
		WishVelocity *= movementImpact;
	}

	void NoclipMove()
	{
		var speedMod = 1f;

		if ( Input.Down( InputButtonHelper.Run ) )
			speedMod = 2f;
		if ( Input.Down( InputButtonHelper.Duck ) )
			speedMod = 0.5f;

		WishVelocity *= NoclipSpeed * speedMod;

		if ( Input.Down( InputButtonHelper.Jump ) )
			WishVelocity += Vector3.Up * NoclipSpeed * speedMod * 100;

		CharacterController.Velocity = WishVelocity;

		if ( !CharacterController.Velocity.IsNearZeroLength )
			CharacterController.Move();
	}

	void Move()
	{
		var gravity = Scene.PhysicsWorld.Gravity;

		if ( IsOnGround )
		{
			// Friction / Acceleration
			CharacterController.Velocity = CharacterController.Velocity.WithZ( 0 );
			CharacterController.Accelerate( WishVelocity );
			CharacterController.ApplyFriction( GroundControl );
		}
		else
		{
			// Air control / Gravity
			CharacterController.Velocity += gravity * Time.Delta * 0.5f;
			CharacterController.Accelerate( WishVelocity.ClampLength( MaxForce ) );
			CharacterController.ApplyFriction( AirControl );
		}

		if ( !(CharacterController.Velocity.IsNearZeroLength && WishVelocity.IsNearZeroLength) )
			CharacterController.Move();

		// Second half of gravity after movement (to stay accurate)
		if ( IsOnGround )
		{
			CharacterController.Velocity = CharacterController.Velocity.WithZ( 0 );
		}
		else
		{
			CharacterController.Velocity += gravity * Time.Delta * 0.5f;
		}
	}

	void RotateBody()
	{
		var targetRot = new Angles( 0, EyeAngles.ToRotation().Yaw(), 0 ).ToRotation();
		float rotateDiff = Body.WorldRotation.Distance( targetRot );

		if ( rotateDiff > 20f || CharacterController.Velocity.Length > 10f )
		{
			Body.WorldRotation = Rotation.Lerp( Body.WorldRotation, targetRot, Time.Delta * 2f );
		}
	}

	void Jump()
	{
		if ( !IsOnGround ) return;

		if ( JumpSpamPrevention && TimeSinceAirborne < 0.2f )
			return;

		var jumpVelocity = Vector3.Up * JumpForce;

		// Sound
		var tr = GetSurfaceTrace();
		if ( tr.Hit )
			PlayFootLaunchSound( tr.Surface, jumpVelocity );

		CharacterController.Punch( jumpVelocity );
		AnimationHelper?.TriggerJump();

		// Unstick crouch
		if ( IsCrouching )
			stickyActiveButtons.Remove( InputButtonHelper.Duck );

		// Unstick run
		if ( IsRunning )
			stickyActiveButtons.Remove( InputButtonHelper.Run );
	}

	/// <summary>Called once when the player lands</summary>
	public virtual void OnGrounded( Vector3 velocity )
	{
		var tr = GetSurfaceTrace();
		if ( tr.Hit )
			PlayFootLandSound( tr.Surface, velocity );

		if ( !IsProxy )
		{
			ShakeScreen( new()
			{
				Size = 0.3f,
				Rotation = 0.3f,
				Duration = 0.1f,
			} );

			DoFallDamage( velocity );
		}
	}

	void UpdateAnimations()
	{
		if ( AnimationHelper is null ) return;

		AnimationHelper.WithWishVelocity( WishVelocity );
		AnimationHelper.WithVelocity( CharacterController.Velocity );
		AnimationHelper.AimAngle = EyeAngles.ToRotation();
		AnimationHelper.IsGrounded = IsOnGround || IsClimbingLadder;
		AnimationHelper.WithLook( EyeAngles.ToRotation().Forward, 1f, 0.75f, 0.5f );
		AnimationHelper.MoveStyle = CitizenAnimationHelper.MoveStyles.Run;
		AnimationHelper.DuckLevel = IsCrouching ? 1 : 0;
	}

	void UpdateRun()
	{
		var runIsDownOrPressed = InputIsDownOrPressed( InputButtonHelper.Run );
		var runIsStickyActive = stickyActiveButtons.Contains( InputButtonHelper.Run );

		// Velocity unstick
		var speed = Velocity.WithZ( 0 );
		if ( !IsCrouching && timeSinceStickyRunStart > 0.2 && speed.LengthSquared < 20000 )
		{
			runIsStickyActive = false;
			stickyActiveButtons.Remove( InputButtonHelper.Run );
		}

		// Stick
		if ( IsUsingController && runIsDownOrPressed )
		{
			runIsStickyActive = !runIsStickyActive;

			if ( runIsStickyActive )
			{
				timeSinceStickyRunStart = 0;
				stickyActiveButtons.Add( InputButtonHelper.Run );
			}
			else
				stickyActiveButtons.Remove( InputButtonHelper.Run );
		}

		// Unstick crouch
		if ( runIsStickyActive && IsCrouching )
		{
			stickyActiveButtons.Remove( InputButtonHelper.Duck );
		}

		IsRunning = runIsDownOrPressed || runIsStickyActive;
	}

	void UpdateCrouch()
	{
		if ( !IsCrouching && CrouchSpamPrevention && TimeSinceCrouch < 0.5f )
			return;
		
		var duckIsDownOrPressed = InputIsDownOrPressed( InputButtonHelper.Duck );
		var duckIsStickyActive = stickyActiveButtons.Contains( InputButtonHelper.Duck );

		// Unstick
		if ( duckIsDownOrPressed && duckIsStickyActive )
		{
			duckIsStickyActive = false;
			stickyActiveButtons.Remove( InputButtonHelper.Duck );
		}

		if ( duckIsDownOrPressed && !IsCrouching && !IsRunning && IsOnGround && !IsClimbingLadder )
		{
			IsCrouching = true;
			TimeSinceCrouch = 0;
			CharacterController.Height /= 2f;
			BodyCollider.End = BodyCollider.End.WithZ( BodyCollider.End.z / 2f );

			if ( stickyButtons.Contains( InputButtonHelper.Duck ) )
			{
				stickyActiveButtons.Add( InputButtonHelper.Duck );
			}
		}

		if ( duckIsStickyActive ) return;

		if ( IsCrouching && (!duckIsDownOrPressed || !IsOnGround) )
		{
			// Check we have space to uncrouch
			var targetHeight = CharacterController.Height + 4;
			var upTrace = CharacterController.TraceDirection( Vector3.Up * targetHeight );

			if ( !upTrace.Hit )
			{
				IsCrouching = false;
				CharacterController.Height *= 2;
				BodyCollider.End = BodyCollider.End.WithZ( BodyCollider.End.z * 2f );
			}
		}
	}

	void OnAnimEventFootstep( SceneModel.FootstepEvent footstepEvent )
	{
		if ( !IsAlive || !IsOnGround ) return;

		// Walk
		var stepDelay = 0.25f;
		var speed = Velocity.WithZ( 0 );

		// Standing still
		if ( speed.IsNearlyZero( 0.01f ) && TimeSinceAirborne > 0.1f ) return;

		// Running
		if ( Velocity.WithZ( 0 ).Length >= 200 )
		{
			stepDelay = 0.2f;
		}
		// Crouching
		else if ( IsCrouching )
		{
			stepDelay = 0.4f;
		}

		if ( timeSinceLastFootstep < stepDelay )
			return;

		var tr = Scene.Trace.Ray( footstepEvent.Transform.Position, footstepEvent.Transform.Position + Vector3.Down * 20 )
			.Radius( 1 )
			.IgnoreGameObjectHierarchy( this.GameObject )
			.Run();

		if ( !tr.Hit ) return;

		PlayFootstepSound( tr.Surface, footstepEvent );
		timeSinceLastFootstep = 0;
	}

	public SceneTraceResult GetSurfaceTrace()
	{
		return Scene.Trace.Ray( WorldPosition, WorldPosition + Vector3.Down * 1000 )
			.Radius( 1 )
			.IgnoreGameObjectHierarchy( this.GameObject )
			.Run();
	}

	public void PlaySoundEvent( Sandbox.SoundEvent soundEvent, string fallback, float dist )
	{
		SoundHandle soundHandle = null;

		if ( soundEvent is not null )
			soundHandle = Sound.Play( soundEvent );

		soundHandle ??= Sound.Play( fallback );
		soundHandle.Distance = dist;
		soundHandle.Position = WorldPosition;
	}

	public virtual void PlayFootstepSound( Surface surface, FootstepEvent footstepEvent )
	{
		PlaySoundEvent( surface?.SoundCollection.FootRight, "footstep-concrete", 7500 );
	}

	[Rpc.Broadcast( NetFlags.Unreliable )]
	public virtual void PlayFootLaunchSound( Surface surface, Vector3 velocity )
	{
		PlaySoundEvent( surface?.SoundCollection.FootLaunch, "footstep-concrete-jump", 7500 );
	}

	public virtual void PlayFootLandSound( Surface surface, Vector3 velocity )
	{
		PlaySoundEvent( surface?.SoundCollection.FootLand, "footstep-concrete-land", 10000 );
	}

	public virtual void DoFallDamage( Vector3 impactVelocity )
	{
		var downSpeed = -impactVelocity.z;
		if ( downSpeed <= SafeFallSpeed ) return;

		// Normalize to 0..1 between safe and lethal
		var t = (downSpeed - SafeFallSpeed) / Math.Max( 1f, (LethalFallSpeed - SafeFallSpeed) );
		t = Math.Clamp( t, 0f, 1f );

		var curved = MathF.Pow( t, FallDamageExponent );
		var damage = curved * MaxFallDamage;
		var damageInfo = new Shared.DamageInfo
		{
			Damage = damage,
			Force = impactVelocity,
		};

		TakeDamage( damageInfo );
	}
}