Player/PlayerPawn.Movement.cs

PlayerPawn movement component. Handles input parsing, crouch/noclip, jumping, gravity, movement acceleration and friction, collision traces, fall damage, and broadcasting jump/land events.

NetworkingFile Access
using Sandbox.Diagnostics;
using Sandbox.Events;

namespace KOTH;

public partial class PlayerPawn
{
	[Property] public Action OnJump { get; set; } // TODO : remove
	[Property] public float NoclipSpeed { get; set; } = 1000f;

	//////////////////////////////////////////////////////////////

	[Sync(SyncFlags.FromHost)] public bool IsFrozen { get; set; }

	[Sync] public bool IsCrouching { get; set; }
	public float CrouchAmount { get; set; }
	[Sync] public bool IsNoclipping { get; set; }
	[Sync] public TimeSince TimeSinceLastInput { get; private set; }
	// [Sync] AnimationHelper.HoldTypes CurrentHoldType { get; set; } = AnimationHelper.HoldTypes.None;

	private Vector3 WishVelocity { get; set; }
	public Vector3 WishMove { get; private set; }
	public bool IsGrounded { get; set; }

	[Sync] private float _eyeHeightOffset { get; set; }
	private float _smoothEyeHeight;
	private Vector3 _previousVelocity;
	private Vector3 _jumpPosition;

	//////////////////////////////////////////////////////////////

	public bool SetMovementVariables(CharacterDefinition CharacterDefinitionIn)
	{
		if (!CharacterDefinitionIn.IsValid())
		{
			return false;
		}

		WeightFactor = CharacterDefinitionIn.WeightKnockbackFactor;
		AirMaxAcceleration = CharacterDefinitionIn.AirMaxAcceleration;
		MaxAcceleration = CharacterDefinitionIn.MaxAcceleration;
		SlowCrouchLerpSpeed = CharacterDefinitionIn.SlowCrouchLerpSpeed;
		CrouchLerpSpeed = CharacterDefinitionIn.CrouchLerpSpeed;
		JumpPower = CharacterDefinitionIn.JumpPower;
		CrouchingFriction = CharacterDefinitionIn.CrouchingFriction;
		WalkFriction = CharacterDefinitionIn.WalkFriction;
		AirAcceleration = CharacterDefinitionIn.AirAcceleration;
		CrouchingAcceleration = CharacterDefinitionIn.CrouchingAcceleration;
		BaseAcceleration = CharacterDefinitionIn.BaseAcceleration;
		WalkSpeed = CharacterDefinitionIn.WalkSpeed;

		return true;
	}

	public float WeightFactor;
	float AirMaxAcceleration;
	float MaxAcceleration;
	float SlowCrouchLerpSpeed;
	float CrouchLerpSpeed;
	float JumpPower;
	float CrouchingFriction;
	float WalkFriction;
	float AirAcceleration;
	float CrouchingAcceleration;
	float BaseAcceleration;
	float WalkSpeed;

	//////////////////////////////////////////////////////////////

	public Vector3 CenterPosition { get => PlayerBoxCollider.Center + WorldPosition; }
	[Sync] public Angles EyeAngles { get; set; }

	//////////////////////////////////////////////////////////////

	private void ApplyAcceleration()
	{
		CharacterController.Acceleration = GetAcceleration();
	}

	// TODO : revisit
	private void UpdateCrouch()
	{
		CrouchAmount = CrouchAmount.LerpTo(IsCrouching ? 1 : 0, Time.Delta * GetCrouchLerpSpeed());
		_smoothEyeHeight = _smoothEyeHeight.LerpTo(_eyeHeightOffset * (IsCrouching ? CrouchAmount : 1), Time.Delta * 10f);
		CharacterController.Height = 64 + _smoothEyeHeight;
	}

	private float GetMaxAcceleration()
	{
		return CharacterController.IsOnGround ? MaxAcceleration : AirMaxAcceleration;
	}

	static Vector3 Gravity = new Vector3(0, 0, 800); // TODO : move me
	private void ApplyMovement()
	{
		CharacterController.ApplyFriction(GetFriction());

		// just do noclip if we're in that mode
		if (DEBUGNoclipCheck())
		{
			return;
		}

		// set our velocity
		if (CharacterController.IsOnGround)
		{
			CharacterController.Velocity = CharacterController.Velocity.WithZ(0);
			CharacterController.Accelerate(WishVelocity);
			CharacterController.Velocity = CharacterController.Velocity.ClampLength(GetWishSpeed());
		}
		else
		{
			CharacterController.Velocity -= Gravity * Time.Delta;
			CharacterController.Accelerate(WishVelocity.ClampLength(GetMaxAcceleration()));
		}

		CharacterController.Move();
	}

	[Rpc.Broadcast]
	public void DoKnockback(Vector3 Knockback)
	{
		CharacterController.Punch(Knockback);
	}

	private bool DEBUGNoclipCheck()
	{
		if (!IsNoclipping)
		{
			return false;
		}

		var vertical = 0f;
		if (Input.Down("Jump")) vertical = 1f;
		if (Input.Down("Duck")) vertical = -1f;

		CharacterController.IsOnGround = false;
		CharacterController.Velocity = WishMove.Normal * EyeAngles.ToRotation() * NoclipSpeed;
		CharacterController.Velocity += Vector3.Up * vertical * NoclipSpeed;
		CharacterController.Move();

		return true;
	}

	TimeSince TimeSinceCrouchPressed = 10f;
	TimeSince TimeSinceCrouchReleased = 10f;

	private float GetCrouchLerpSpeed()
	{
		if (TimeSinceCrouchPressed < 1f && TimeSinceCrouchReleased < 1f)
			return SlowCrouchLerpSpeed;

		return CrouchLerpSpeed;
	}

	const int CrouchOffset = 16;
	const int CrouchOffset2 = CrouchOffset / 2;

	float RequestedCenterZ = 32f;
	float RequestedScaleZ = 64f;

	float RealCenterZ = 32f;
	float RealScaleZ = 64f;

	private void DoCrouch()
	{
		IsCrouching = Input.Down("Duck") && !IsNoclipping;

		if (IsCrouching)
		{
			TimeSinceCrouchPressed = 0;
			if (IsGrounded)
			{
				RequestedCenterZ = 32f - CrouchOffset2;
				RequestedScaleZ = 64 - CrouchOffset;
			}
			else
			{
				RequestedCenterZ = 32f + CrouchOffset2;
				RequestedScaleZ = 64 - CrouchOffset;
			}
		}

		if (Input.Released("Duck"))
		{
			TimeSinceCrouchReleased = 0;
			RequestedCenterZ = 32f;
			RequestedScaleZ = 64f;
		}

		var Lerp = Time.Delta * 5f;
		if (RealCenterZ != RequestedCenterZ)
		{
			RealCenterZ = MathX.Lerp(RealCenterZ, RequestedCenterZ, Lerp);
		}
		if (RealScaleZ != RequestedScaleZ)
		{
			RealScaleZ = MathX.Lerp(RealScaleZ, RequestedScaleZ, Lerp);
		}

		PlayerBoxCollider.Center = new(0, 0, RealCenterZ);
		PlayerBoxCollider.Scale = new(32, 32, RealScaleZ);
	}

	private void BuildInput()
	{
		DoCrouch();

		if (Input.Pressed("Noclip") && Game.IsEditor)
		{
			IsNoclipping = !IsNoclipping;
		}

		if (WishMove.LengthSquared > 0.01f || Input.Down("Attack1"))
		{
			TimeSinceLastInput = 0f;
		}

		if (CharacterController.IsOnGround && !IsFrozen)
		{
			if (Input.Pressed("Jump"))
			{
				Jump();
			}
		}
	}

	public void Jump()
	{
		Punch(Vector3.Up * JumpPower);
	}

	public void Punch(Vector3 Vector)
	{
		CharacterController.Punch(Vector);
		BroadcastPlayerJumped();
	}

	public SceneTraceResult TraceBBox(Vector3 start, Vector3 end, float liftFeet = 0.0f, float liftHead = 0.0f)
	{
		var bbox = CharacterController.BoundingBox;
		var mins = bbox.Mins;
		var maxs = bbox.Maxs;

		if (liftFeet > 0)
		{
			start += Vector3.Up * liftFeet;
			maxs = maxs.WithZ(maxs.z - liftFeet);
		}

		if (liftHead > 0)
		{
			end += Vector3.Up * liftHead;
		}

		var tr = Scene.Trace.Ray(start, end)
					.Size(mins, maxs)
					.WithoutTags(CharacterController.IgnoreLayers)
					.IgnoreGameObjectHierarchy(GameObject.Root)
					.Run();
		return tr;
	}

	[Rpc.Broadcast]
	public void BroadcastPlayerJumped()
	{
		AnimationHelper?.TriggerJump();
		OnJump?.Invoke();
	}

	public TimeSince TimeSinceGroundedChanged { get; private set; }

	const int MinimumFallDamage = 15;
	private void GroundedChanged(bool WasOnGround, bool IsOnGround)
	{
		if (!IsLocallyControlled)
			return;

		TimeSinceGroundedChanged = 0;

		if (WasOnGround && !IsOnGround)
		{
			_jumpPosition = WorldPosition;
		}

		if (!WasOnGround && IsOnGround && !IsNoclipping)
		{
			var Velocity = MathF.Abs(_previousVelocity.z);
			var FallDamage = (Velocity * 0.0225f).FloorToInt(); // 15/0.0225 = 666.6

			if (FallDamage > MinimumFallDamage)
			{
				PlayFallSound();
				FDamageRequest DamageRequest = new()
				{
					TargetDamageComponent = DamageComponent,
					AttackerPlayerPawn = this,
					TargetPlayerPawn = this,
					DamageOrigin = WorldPosition,
					TargetOrigin = CenterPosition,
					BaseDamage = FallDamage,
					BaseKnockbackStrength = 0,
					DirectImpact = true,
					DamageType = EDamageType.Melee,
				};
				Scene.Dispatch(new DamageRequestEvent(DamageRequest));
			}
		}
	}

	[Property, Group("Effects")] public SoundEvent LandSound { get; set; }

	[Rpc.Broadcast]
	private void PlayFallSound()
	{
		var handle = Sound.Play(LandSound, WorldPosition);
		// handle.ListenLocal = IsViewer;
	}

	private void BuildWishInput()
	{
		WishMove = 0f;

		if (IsFrozen)
			return;

		WishMove += Input.AnalogMove;
	}

	private void BuildWishVelocity()
	{
		WishVelocity = 0f;

		var rot = EyeAngles.WithPitch(0f).ToRotation();

		var wishDirection = WishMove.Normal * rot;
		wishDirection = wishDirection.WithZ(0);
		WishVelocity = wishDirection * GetWishSpeed();
	}

	private float GetFriction()
	{
		if (!CharacterController.IsOnGround) return 0.1f;
		if (IsCrouching) return CrouchingFriction;
		return WalkFriction;
	}

	private float GetAcceleration()
	{
		if (!CharacterController.IsOnGround) return AirAcceleration;
		else if (IsCrouching) return CrouchingAcceleration;

		return BaseAcceleration;
	}

	float GetEyeHeightOffset()
	{
		if (IsCrouching) return -16f;
		if (!IsAlive) return -48f;
		return 0f;
	}

	const float CrouchFactor = 0.75f;
	private float GetWishSpeed()
	{
		if (IsCrouching) return WalkSpeed * CrouchFactor;
		return WalkSpeed;
	}

	Vector3 LastVelocity = Vector3.Zero;
	private void DebugUpdate()
	{
		DebugText.Update();
		DebugText.Write($"Player", Color.White, 20);
		DebugText.Write($"Velocity: {CharacterController.Velocity}");
		var Speed = CharacterController.Velocity.Length;
		var HSpeed = CharacterController.Velocity.WithZ(0).Length;
		var VSpeed = CharacterController.Velocity.z;
		DebugText.Write($"Speed: {Speed}", Speed >= LastVelocity.Length ? Color.Green : Color.Red);
		DebugText.Write($"HSpeed: {HSpeed}", HSpeed >= LastVelocity.WithZ(0).Length ? Color.Green : Color.Red);
		DebugText.Write($"VSpeed: {VSpeed}", VSpeed >= LastVelocity.z ? Color.Green : Color.Red);

		LastVelocity = CharacterController.Velocity;
	}
}