PlayerCharacterController.cs
using Sandbox;
using Sandbox.Citizen;
using Sandbox.VR;
using System;

public sealed class PlayerCharacterController : CharacterController
{
	public static PlayerCharacterController Instance { get; private set; }
	/// <summary>VR camera/hands root prefab cloned for local VR players.</summary>
	[Property] public GameObject VRRootPrefab { get; set; }
	[Property] private GameObject DesktopRootPrefab { get; set; }
	[Property] public SkinnedModelRenderer Avatar { get; set; }
	[Property] public CitizenAnimationHelper AnimationHelper { get; set; }
	[Property] public GameObject AnimationLeftHand { get; set; }
	[Property] public GameObject AnimationRightHand { get; set; }
	/// <summary>World-space IK look target; follows the player camera forward each frame.</summary>
	[Property] public GameObject AnimationLookAtTarget { get; set; }
	[Property] public float MoveSpeed { get; set; } = 200f;
	[Property] public float TurnSpeed { get; set; } = 120f;
	[Property] public float Gravity { get; set; } = 800f;
	[Property] public float TerminalVelocity { get; set; } = 1200f;

	/// <summary>Upward speed applied when jumping while standing.</summary>
	[Property] public float JumpSpeed { get; set; } = 300f;

	/// <summary>Upward speed applied when jumping while fully crouched.</summary>
	[Property] public float CrouchJumpSpeed { get; set; } = 200f;

	/// <summary>Downward speed (units/sec) that must be exceeded on landing before fall damage applies.</summary>
	[Property] public float MinFallSpeedForDamage { get; set; } = 500f;

	/// <summary>Downward speed (units/sec) at which a fall deals lethal damage (MaxHealth).</summary>
	[Property] public float LethalFallSpeed { get; set; } = 1100f;

	/// <summary>Seconds continuously airborne before the player is killed as out of bounds.</summary>
	[Property] public float MaxAirTimeBeforeDeath { get; set; } = 10f;

	/// <summary>CharacterController capsule height when fully ducked; standing height is captured from the initial Height value.</summary>
	[Property] public float DuckHeight { get; set; } = 36f;

	/// <summary>Used when the surface under the foot has no footstep sounds assigned.</summary>
	[Property] public SoundEvent FallbackFootstepSound { get; set; }

	[Sync] public Rotation PlayerLookRotation { get; set; }
	[Sync] public Vector3 SyncedVelocity { get; set; }
	[Sync] public float DuckLevel { get; set; }

	public CameraComponent Camera { get; set; }

	private Rotation _targetYaw;
	private HealthComponent _health;
	private TimeSince _timeSinceStep;
	private float _standingHeight;
	private bool _wasOnGround = true;
	private float _airTime;
	private float _peakFallSpeed;
	private const float LookAtTargetDistance = 250f;
	private const float MinStepInterval = 0.2f;
	/// <summary>Citizen reference standing eye height; calibrated height / this = avatar scale.</summary>
	public const float ReferenceStandingEyeHeight = 64f;
	private const float MinHeightScale = 40f / ReferenceStandingEyeHeight;
	private const float MaxHeightScale = 80f / ReferenceStandingEyeHeight;
	private PlayerAnimationBroadcast AnimBroadcaster { get; set; }

	/// <summary>Uniform scale applied from calibrated standing eye height (1 = reference citizen).</summary>
	public float HeightScale { get; private set; } = 1f;

	/// <summary>True while a hook gun is pulling this player toward a latch point.</summary>
	public bool IsGrappling { get; private set; }

	/// <summary>World position the player is being pulled toward while grappling.</summary>
	public Vector3 GrappleTarget { get; set; }

	private float _grapplePullSpeed;
	private float _grappleStopDistance;

	protected override void OnStart()
	{
		Instance = this;

		_standingHeight = Height;
		SetupController();
		ApplyCalibratedHeightScale();
		_health = GetComponent<HealthComponent>();
		AnimBroadcaster = Avatar.GetComponent<PlayerAnimationBroadcast>();

		if ( Avatar.IsValid() )
			Avatar.OnFootstepEvent += OnFootstep;
	}

	protected override void OnDestroy()
	{
		if ( Instance == this )
			Instance = null;

		if ( Avatar.IsValid() )
			Avatar.OnFootstepEvent -= OnFootstep;
	}

	protected override void OnUpdate()
	{
		UpdateAvatar();
	}

	private void SetupController()
	{
		if ( !IsProxy )
		{
			if ( Game.IsRunningInVR )
			{
				VRRootPrefab.Clone( transform: global::Transform.Zero, parent: GameObject );
				AnimationLeftHand.Enabled = true;
				AnimationRightHand.Enabled = true;
			}
			else
			{
				DesktopRootPrefab.Clone( transform: global::Transform.Zero, parent: GameObject );
			}
		}
	}

	/// <summary>
	/// Scales the avatar mesh and capsule so body height matches the session's calibrated eye height.
	/// </summary>
	private void ApplyCalibratedHeightScale()
	{
		var session = ResolveSession();
		var eyeHeight = session.IsValid()
			? session.StandingEyeHeight
			: ReferenceStandingEyeHeight;

		HeightScale = (eyeHeight / ReferenceStandingEyeHeight).Clamp( MinHeightScale, MaxHeightScale );

		if ( Avatar.IsValid() )
			Avatar.GameObject.LocalScale = Vector3.One * HeightScale;

		_standingHeight = Height * HeightScale;
		DuckHeight *= HeightScale;
		Height = _standingHeight;
	}

	private PlayerSession ResolveSession()
	{
		if ( !IsProxy && PlayerSession.CurrentSession.IsValid() )
			return PlayerSession.CurrentSession;

		if ( NetworkManager.Instance is null )
			return null;

		return NetworkManager.Instance.GetPlayerSession( Network.OwnerId );
	}

	private void UpdateAvatar()
	{
		if ( IsProxy )
		{
			Avatar.WorldPosition = WorldPosition;
			Avatar.WorldRotation = PlayerLookRotation;
		}
		else if ( Camera.IsValid() )
		{
			var yaw = Camera.WorldRotation.Angles().yaw;
			_targetYaw = Rotation.FromYaw( yaw );

			Avatar.WorldRotation = Rotation.Slerp(
				Avatar.WorldRotation,
				_targetYaw,
				Time.Delta * 10f
			);
			PlayerLookRotation = Avatar.WorldRotation;
			UpdateAnimationLookAtTarget();
		}
		AnimationHelper.WithVelocity( SyncedVelocity );
		AnimationHelper.DuckLevel = DuckLevel;
		AnimationHelper.IsGrounded = IsOnGround;
	}

	/// <summary>
	/// Applies fall damage on landing and kills the player if they stay airborne
	/// too long (likely out of bounds). Call after Move() on the owning client.
	/// </summary>
	public void UpdateFallAndBoundsDamage()
	{
		if ( IsProxy || _health is null || _health.IsDead )
			return;

		// Grapple hangs would otherwise trip air-time death and landing fall damage.
		if ( IsGrappling )
		{
			_airTime = 0f;
			_peakFallSpeed = 0f;
			_wasOnGround = IsOnGround;
			return;
		}

		if ( !IsOnGround )
		{
			if ( _wasOnGround )
			{
				_airTime = 0f;
				_peakFallSpeed = 0f;
			}

			_airTime += Time.Delta;
			_peakFallSpeed = MathF.Min( _peakFallSpeed, Velocity.z );
			_wasOnGround = false;

			if ( _airTime >= MaxAirTimeBeforeDeath )
				_health.TakeDamage( _health.MaxHealth, Vector3.Down * TerminalVelocity, false );

			return;
		}

		if ( !_wasOnGround )
			ApplyFallDamage( -_peakFallSpeed );

		_airTime = 0f;
		_peakFallSpeed = 0f;
		_wasOnGround = true;
	}

	/// <summary>Starts pulling the player toward a world point (used by the hook gun).</summary>
	public void BeginGrapple( Vector3 target, float pullSpeed, float stopDistance )
	{
		if ( IsProxy )
			return;

		IsGrappling = true;
		GrappleTarget = target;
		_grapplePullSpeed = pullSpeed;
		_grappleStopDistance = stopDistance;

		// Punch disconnects from the ground so Move() can leave the floor on an upward pull.
		if ( IsOnGround )
		{
			var toTarget = GrappleTarget - WorldPosition;
			if ( toTarget.Length > 0.01f )
				Punch( toTarget.Normal * _grapplePullSpeed );
		}
	}

	/// <summary>Stops any active hook-gun pull.</summary>
	public void EndGrapple()
	{
		if ( IsProxy )
			return;

		IsGrappling = false;
		_grapplePullSpeed = 0f;
		_grappleStopDistance = 0f;
	}

	/// <summary>Sets CharacterController velocity toward GrappleTarget. Call from locomotion instead of stick input.</summary>
	public void ApplyGrappleVelocity()
	{
		if ( IsProxy || !IsGrappling )
			return;

		var toTarget = GrappleTarget - WorldPosition;
		var distance = toTarget.Length;

		if ( distance <= _grappleStopDistance )
		{
			Velocity = Vector3.Zero;
			SyncedVelocity = Velocity;
			return;
		}

		Velocity = toTarget.Normal * _grapplePullSpeed;
		SyncedVelocity = Velocity;
	}

	private void ApplyFallDamage( float fallSpeed )
	{
		if ( fallSpeed <= MinFallSpeedForDamage || LethalFallSpeed <= MinFallSpeedForDamage )
			return;

		var t = ((fallSpeed - MinFallSpeedForDamage) / (LethalFallSpeed - MinFallSpeedForDamage))
			.Clamp( 0f, 1f );
		var damage = t * _health.MaxHealth;
		if ( damage <= 0f )
			return;

		_health.TakeDamage( damage, Vector3.Down * fallSpeed, false );
	}

	/// <summary>
	/// Launches the player upward if they are on the ground, or unhooks an active
	/// grapple and jumps from mid-air so a wall latch can still reach a rooftop.
	/// Jump height scales with the current duck level so crouch jumps are lower.
	/// </summary>
	public bool TryJump( Vector3 horizontalVelocity )
	{
		if ( IsProxy )
			return false;

		if ( IsGrappling )
			EndGrapple();
		else if ( !IsOnGround )
			return false;

		var vel = horizontalVelocity;
		vel.z = MathX.Lerp( JumpSpeed, CrouchJumpSpeed, DuckLevel );

		// Setting Velocity alone is not enough — Punch disconnects from the ground
		// and applies the velocity so Move() actually leaves the floor.
		Punch( vel );
		SyncedVelocity = Velocity;

		AnimBroadcaster.TriggerJump();
		return true;
	}

	/// <summary>
	/// Updates animation ducking and collision height together. A value of 0 uses
	/// the controller's initial standing height; 1 uses DuckHeight. Returns the
	/// effective level after overhead-clearance checks.
	/// </summary>
	public float SetDuckLevel( float duckLevel )
	{
		var requestedDuckLevel = duckLevel.Clamp( 0f, 1f );
		var requestedHeight = MathX.Lerp( _standingHeight, DuckHeight, requestedDuckLevel );

		if ( IsProxy )
		{
			DuckLevel = requestedDuckLevel;
			return DuckLevel;
		}

		// Clamp to the tallest capsule that fits here. This blocks standing up
		// under a low ceiling and also shrinks players who are already too tall
		// for the space they are in, instead of letting physics push them down.
		var maxHeight = FindTallestFittingHeight();
		Height = MathF.Min( requestedHeight, maxHeight );

		DuckLevel = MathX.Remap( Height, DuckHeight, _standingHeight, 1f, 0f )
			.Clamp( 0f, 1f );

		return DuckLevel;
	}

	/// <summary>
	/// Uses the controller's own collision rules and capsule shape to find the
	/// tallest height that does not start inside geometry at the current position.
	/// </summary>
	private float FindTallestFittingHeight()
	{
		var savedHeight = Height;
		var minHeight = MathF.Max( DuckHeight, Radius * 2f + 1f );
		float low = minHeight;
		float high = _standingHeight;

		for ( int i = 0; i < 8; i++ )
		{
			float mid = (low + high) * 0.5f;
			Height = mid;

			var trace = TraceDirection( Vector3.Up );
			if ( trace.StartedSolid )
				high = mid;
			else
				low = mid;
		}

		Height = savedHeight;
		return low;
	}

	private void UpdateAnimationLookAtTarget()
	{
		AnimationLookAtTarget.WorldPosition = Camera.WorldPosition + Camera.WorldRotation.Forward * LookAtTargetDistance;
	}

	private void OnFootstep( SceneModel.FootstepEvent e )
	{
		if ( _timeSinceStep < MinStepInterval )
			return;

		if ( !IsOnGround )
			return;

		if ( _health is not null && _health.IsDead )
			return;

		var tr = Scene.Trace
			.Ray( e.Transform.Position + Vector3.Up * 20f, e.Transform.Position + Vector3.Up * -20f )
			.Run();

		if ( !tr.Hit )
			return;

		_timeSinceStep = 0f;

		var sound = GetFootstepSound( e, tr );
		if ( sound is null )
			return;

		var handle = Sound.Play( sound, tr.HitPosition + tr.Normal * 5f );
		handle.Volume *= e.Volume;
	}

	private SoundEvent GetFootstepSound( SceneModel.FootstepEvent e, SceneTraceResult tr )
	{
		if ( tr.Surface is not null )
		{
			var surfaceSound = e.FootId == 0 ? tr.Surface.SoundCollection.FootLeft : tr.Surface.SoundCollection.FootRight;
			if ( surfaceSound is not null )
				return surfaceSound;
		}

		return FallbackFootstepSound;
	}
}