swb_player/PlayerBase.Ladders.cs
using System;

namespace SWB.Player;

public partial class PlayerBase
{
	/// <summary>Enable the ability to climb ladders</summary>
	[Property] public bool LadderClimbing { get; set; } = true;
	[Sync] public bool IsClimbingLadder { get; set; }

	Collider activeLadder;
	float activeLadderTopZ;
	float activeLadderBottomZ;
	bool isFacingLadder;
	TimeSince timesinceLastClimbSound;

	public virtual void OnLadderEnter( Collider ladder )
	{
		if ( !LadderClimbing ) return;
		IsClimbingLadder = true;
		CharacterController.IsOnGround = true; // Jumping on and off ladder
		CharacterController.Velocity = Vector3.Zero;
		WishVelocity = Vector3.Zero;
		activeLadder = ladder;
		activeLadderTopZ = activeLadder.GetWorldBounds().Maxs.z;
		activeLadderBottomZ = activeLadder.GetWorldBounds().Mins.z;

		if ( IsAtTopOfLadder() )
			SnapToLadderFarFrontFace();
	}

	public virtual void OnLadderExit( Collider ladder )
	{
		if ( !LadderClimbing || !IsClimbingLadder ) return;
		IsClimbingLadder = false;
		activeLadder = null;
	}

	void LadderJump()
	{
		if ( activeLadder is null ) return;

		var viewFwd = Camera.WorldRotation.Forward.WithZ( 0 ).Normal;
		var horizDir = isFacingLadder ? -viewFwd : viewFwd;
		var jumpVelocity = (Vector3.Up * (JumpForce / 2)) + (horizDir * 100);

		PlayLadderJumpSound( activeLadder.Surface, jumpVelocity );

		// Exit ladder first
		OnLadderExit( activeLadder );

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

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

	[Rpc.Broadcast( NetFlags.Unreliable )]
	public virtual void PlayLadderClimbSound( Surface surface, Vector3 velocity )
	{
		PlaySoundEvent( surface?.SoundCollection.FootRight, "footstep-metal", 7500 );
	}

	void LadderMove()
	{
		if ( activeLadder is null ) return;

		isFacingLadder = IsFacingLadder();
		var rot = Camera.WorldRotation;
		var fwd = rot.Forward.WithZ( 0 );
		var right = rot.Right.WithZ( 0 );

		if ( fwd.IsNearZeroLength || right.IsNearZeroLength )
			return;

		fwd = fwd.Normal;
		right = right.Normal;

		// Decompose WishVelocity
		var forwardSpeed = WishVelocity.Dot( fwd );
		var strafeSpeed = WishVelocity.Dot( right );

		// Normalize those speeds into -1..1 intent
		var maxPlanar = MathF.Max( 1f, WalkSpeed );
		var forwardIntent = (forwardSpeed / maxPlanar).Clamp( -1f, 1f );

		// Switch direction if looking up/down
		if ( !isFacingLadder )
			forwardIntent = -forwardIntent;

		var strafeIntent = (strafeSpeed / maxPlanar).Clamp( -1f, 1f );
		var climbSpeed = WalkSpeed;
		var climbVelocity =
			(Vector3.Up * (forwardIntent * climbSpeed)) +
			(right * (strafeIntent * climbSpeed));

		// Stop if not moving
		if ( climbVelocity.IsNearZeroLength ) return;

		// Top-out check before moving
		if ( TryTopOut( forwardIntent ) ) return;

		// Bottom-out check before moving
		if ( TryBottomOut( forwardIntent ) ) return;

		// Sound
		if ( timesinceLastClimbSound >= 0.25f )
		{
			PlayLadderClimbSound( activeLadder.Surface, climbVelocity );
			timesinceLastClimbSound = 0;
		}

		// MoveTo: bypasses grounded Z-cancels
		var target = WorldPosition + climbVelocity * Time.Delta;
		CharacterController.MoveTo( target, useStep: false );

		// Keep Velocity coherent for animations/other systems
		CharacterController.Velocity = climbVelocity;
	}

	bool TryBottomOut( float downIntent )
	{
		// Only when climbing down
		if ( downIntent >= -0.1f ) return false;

		var groundTr = Scene.Trace
			.Ray( WorldPosition + Vector3.Up * 4f, WorldPosition + Vector3.Down * 10f )
			.Radius( CharacterController.Radius * 0.9f )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !groundTr.Hit ) return false;

		PlayFootLandSound( groundTr.Surface, Vector3.Zero );
		OnLadderExit( activeLadder );
		CharacterController.MoveTo( groundTr.HitPosition, useStep: true );

		// Realistic walk off velocity
		CharacterController.Velocity = CharacterController.Velocity.WithZ( 0 );

		return true;
	}

	bool TryTopOut( float forwardIntent )
	{
		if ( IsAtBottomOfLadder() || IsAtTopOfLadder() ) return false;

		// Only when climbing up
		if ( forwardIntent <= 0.1f ) return false;

		// Up + away
		var viewFwd = Camera.WorldRotation.Forward.WithZ( 0 ).Normal;
		var away = isFacingLadder ? viewFwd : -viewFwd;
		var dismount = WorldPosition + Vector3.Up * CharacterController.Height + away * CharacterController.Radius;

		// Check for free space is free (capsule-ish sweep)
		var clearTr = Scene.Trace
			.Ray( WorldPosition + Vector3.Up * 16f, dismount + Vector3.Up * 16f )
			.Radius( CharacterController.Radius )
			.IgnoreGameObjectHierarchy( this.GameObject )
			.Run();

		if ( clearTr.Hit ) return false;

		// Check for ground under dismount point
		var groundTr = Scene.Trace
			.Ray( dismount + Vector3.Up * 8f, dismount + Vector3.Down * 72f )
			.Radius( CharacterController.Radius * 0.9f )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !groundTr.Hit ) return false;

		PlayFootLandSound( groundTr.Surface, Vector3.Zero );
		OnLadderExit( activeLadder );
		CharacterController.MoveTo( groundTr.HitPosition, useStep: true );

		// Realistic walk off velocity
		CharacterController.Velocity = away * WalkSpeed * 0.5f;

		return true;
	}

	void SnapToLadderFarFrontFace( float gap = 5f )
	{
		if ( activeLadder is null ) return;

		var lb = activeLadder.GetWorldBounds();
		var pe = CharacterController.BoundingBox.Size * 0.5f;
		var pos = WorldPosition;

		// Climable side
		var snapX = lb.Size.x < lb.Size.y;

		if ( snapX )
		{
			// Rel pos to ladder
			var midX = (lb.Mins.x + lb.Maxs.x) * 0.5f;
			if ( pos.x < midX )
				pos.x = lb.Mins.x + gap + pe.x;   // left
			else
				pos.x = lb.Maxs.x - gap - pe.x;   // right
		}
		else
		{
			// Rel pos to ladder
			var midY = (lb.Mins.y + lb.Maxs.y) * 0.5f;
			if ( pos.y < midY )
				pos.y = lb.Mins.y + gap + pe.y;   // bottom
			else
				pos.y = lb.Maxs.y - gap - pe.y;   // top
		}

		CharacterController.MoveTo( pos, useStep: false );
		CharacterController.Velocity = Vector3.Zero;
	}

	bool IsFacingLadder()
	{
		var viewFwd = Camera.WorldRotation.Forward.WithZ( 0 ).Normal;
		var ladderPos = activeLadder.WorldPosition;
		var toLadder = (ladderPos - WorldPosition).WithZ( 0 );

		if ( toLadder.IsNearZeroLength )
			toLadder = viewFwd;
		else
			toLadder = toLadder.Normal;

		var lookTowardThreshold = 0.25f; // 75 degrees
		var isFacingLadder = viewFwd.Dot( toLadder ) > lookTowardThreshold;

		return isFacingLadder;
	}

	bool IsAtTopOfLadder()
	{
		return (activeLadderTopZ - WorldPosition.z) < 20;
	}

	bool IsAtBottomOfLadder()
	{
		return (WorldPosition.z - activeLadderBottomZ) < 20;
	}
}