Player/Player.Controller.cs

Player controller component for movement and interactions. It handles fixed-step simulation, capsule sweeping, momentum, collisions (stun/trip/slip), ground detection, jump, skid effects and sound, loot dropping, and body rotation.

NetworkingFile AccessNative Interop
using System;
using System.Linq;
using Sandbox;

namespace BrickJam;

public sealed partial class Player
{
	private static readonly string[] ignoreTags = { "player", "npc", "nocollide", "loot" };

	protected override void OnFixedUpdate()
	{
		if ( Scene.IsEditor || IsProxy )
			return;

		SimulateController();
	}

	/// <summary>Capsule sweep used by the <see cref="MoveHelper"/> (collides with world geometry only).</summary>
	private SceneTraceResult Sweep( Vector3 from, Vector3 to )
		=> Scene.Trace.Capsule( CollisionCapsule, from, to )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( ignoreTags )
			.Run();

	private void SimulateController()
	{
		ApplyControllerShape();

		var velocity = Velocity;

		if ( !MovementLocked )
			velocity = ApplyMomentum( velocity );

		if ( Blocked )
			velocity = Vector3.Zero.WithZ( velocity.z );

		// Soft separation from other pushables (players/NPCs) - the legacy "toPush" knockback.
		velocity += Pushables.ComputeSeparation( this, WorldPosition, CollisionRadius, PushForce ) * Time.Delta;

		Velocity = velocity;

		// Collide-and-slide with step (the legacy MoveHelper behaviour).
		var helper = new MoveHelper( WorldPosition, Velocity, Sweep ) { MaxStandableAngle = WalkAngle };
		helper.TryMoveWithStep( Time.Delta, StepSize );
		helper.TryUnstuck();

		WorldPosition = helper.Position;
		Velocity = helper.Velocity;

		// Momentum mechanics: crashing into a wall stuns, running over loot trips, wet floors slip.
		if ( IsAboveWalkingSpeed )
		{
			CalculateStun();
			CalculateTrip();
		}

		if ( !IsCrouching )
			CalculateSlip();

		// Ground + gravity.
		var down = Sweep( WorldPosition, WorldPosition + Vector3.Down * 2f );
		if ( down.Hit && Vector3.GetAngle( Vector3.Up, down.Normal ) <= WalkAngle )
		{
			IsOnGround = true;
			GroundSurface = down.Surface;
			WorldPosition = down.EndPosition;
			Velocity = Velocity.WithZ( 0 );
		}
		else
		{
			IsOnGround = false;
			GroundSurface = null;
			Velocity += Scene.PhysicsWorld.Gravity * Time.Delta;
		}

		// Jump.
		if ( !CommandsLocked && IsOnGround && Input.Pressed( JumpButton ) )
		{
			IsOnGround = false;
			Velocity += Vector3.Up * JumpHeight;
		}

		UpdateSkid();
		UpdateBodyRotation();
	}

	private SoundHandle skidSound;

	/// <summary>Replicated so every client can play the drift loop locally (see <see cref="UpdateSkidSound"/>).</summary>
	[Sync] public bool IsSkidding { get; set; }

	/// <summary>
	/// Owner-side: detect a sharp high-speed direction change (tyre-screech "drift") and replicate it via
	/// <see cref="IsSkidding"/> so every client plays the loop locally. The dust puff fires on the rising
	/// edge through the already-broadcast effect system.
	/// </summary>
	private void UpdateSkid()
	{
		var horizontal = Velocity.WithZ( 0 );
		var wish = WishVelocity.WithZ( 0 );

		var skidding = IsOnGround && !MovementLocked
			&& horizontal.Length > WalkSpeed && wish.Length > 1f
			&& Vector3.GetAngle( horizontal.Normal, wish.Normal ) > 65f;

		if ( skidding && !IsSkidding )
			MansionGame.Instance?.PlayEffect( "prefabs/particles/dust.prefab", WorldPosition, Rotation.Identity );

		IsSkidding = skidding;
	}

	/// <summary>
	/// Runs on EVERY client (see <c>Player.OnUpdate</c>): play/stop the looping drift sound locally off the
	/// replicated <see cref="IsSkidding"/> flag, positioned at the (synced) player position.
	/// </summary>
	private void UpdateSkidSound()
	{
		if ( IsSkidding )
		{
			if ( skidSound is null || skidSound.IsStopped )
				skidSound = Sound.Play( "sounds/drift.sound", WorldPosition );

			if ( skidSound is not null )
				skidSound.Position = WorldPosition;
		}
		else
		{
			StopSkidSound();
		}
	}

	private void StopSkidSound()
	{
		skidSound?.Stop();
		skidSound = null;
	}

	private Vector3 ApplyMomentum( Vector3 velocity )
	{
		var horizontal = velocity.WithZ( 0 );
		var wish = WishVelocity.WithZ( 0 );
		var frictionMultiplier = HasFrictionUpgrade ? 3f : 1f;

		if ( wish.Length >= horizontal.Length )
		{
			// Accelerating - slower you move, the more starting "push" you get.
			var momentum = horizontal.Length / WalkSpeed * 50f;
			horizontal += wish.Normal * (Acceleration - momentum) * frictionMultiplier * Time.Delta;
			horizontal = horizontal.ClampLength( WishSpeed );
		}
		else
		{
			// Decelerating - faster you move, the more momentum carries you.
			var momentum = Math.Max( horizontal.Length / WalkSpeed * 1.2f, 0.0001f );
			var target = Math.Max( horizontal.Length - (Deceleration * frictionMultiplier) / momentum * Time.Delta, 0f );
			horizontal = horizontal.ClampLength( target );
		}

		return horizontal.WithZ( velocity.z );
	}

	private void CalculateStun()
	{
		if ( IsStunned || Velocity.WithZ( 0 ).Length <= WalkSpeed )
			return;

		// A capsule that starts above step height, so only "real" walls (not ledges) count.
		var capsule = new Capsule( Vector3.Up * (CollisionRadius + StepSize), Vector3.Up * (CollisionHeight - CollisionRadius), CollisionRadius );
		var tr = Scene.Trace.Capsule( capsule, WorldPosition, WorldPosition + Velocity.WithZ( 0 ) * Time.Delta )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "nocollide", "loot", "doob" )
			.Run();

		if ( !tr.Hit )
			return;

		var dot = Math.Abs( Vector3.Dot( Velocity.WithZ( 0 ).Normal, tr.Normal ) );
		var wallVelocity = Velocity.WithZ( 0 ) * dot;
		if ( wallVelocity.Length <= WalkSpeed )
			return;

		var difference = MathX.Remap( wallVelocity.Length, WalkSpeed, RunSpeed );

		if ( tr.GameObject?.Components.Get<Player>() is { } other )
		{
			difference /= 2f;
			if ( MansionGame.Random.NextSingle() < DropChance )
				other.ThrowRandomLoot();

			other.Stun( difference );
			other.Velocity -= tr.Normal * (CollisionRadius + StunBounceVelocity - Velocity.WithZ( 0 ).Length);
			other.WorldRotation = Rotation.LookAt( tr.Normal, Vector3.Up );
		}

		Stun( difference );

		// Crash impact burst on the wall we hit.
		var impactPoint = WorldPosition + WorldRotation.Forward * CollisionRadius + Vector3.Up * CollisionHeight * 0.5f;
		MansionGame.Instance?.PlayEffect( "prefabs/particles/smoke_impact.prefab", impactPoint, Rotation.Identity );
		MansionGame.Instance?.PlayEffect( "prefabs/particles/impact.prefab", impactPoint, Rotation.LookAt( tr.Normal ) );

		Velocity += tr.Normal * (CollisionRadius + StunBounceVelocity);
		WorldRotation = Rotation.LookAt( -tr.Normal, Vector3.Up );

		if ( MansionGame.Random.NextSingle() < DropChance )
			ThrowRandomLoot();
	}

	private void CalculateTrip()
	{
		if ( IsTripping || !IsAboveWalkingSpeed )
			return;

		var capsule = new Capsule( Vector3.Up * CollisionRadius, Vector3.Up * (StepSize - CollisionRadius), CollisionRadius );
		var tr = Scene.Trace.Capsule( capsule, WorldPosition, WorldPosition + Velocity.WithZ( 0 ) * Time.Delta )
			.WithTag( "loot" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( tr.Hit )
			Trip();
	}

	private void CalculateSlip()
	{
		if ( IsSlipping || Velocity.WithZ( 0 ).Length <= CrouchSpeed )
			return;

		var capsule = new Capsule( Vector3.Up * CollisionRadius, Vector3.Up * (StepSize - CollisionRadius), CollisionRadius );
		var tr = Scene.Trace.Capsule( capsule, WorldPosition, WorldPosition + Velocity.WithZ( 0 ) * Time.Delta )
			.WithTag( "slip" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( tr.Hit )
			Slip();
	}

	/// <summary>Drop a random inventory item into the world (on crash/slip). Host-authoritative.</summary>
	public void ThrowRandomLoot()
	{
		if ( Inventory is null )
			return;

		var entries = Inventory.Entries.ToList();
		if ( entries.Count == 0 )
			return;

		var pick = entries[MansionGame.Random.Next( entries.Count )].entry;
		if ( !Inventory.Remove( pick ) )
			return;

		var loot = Loot.CreateFromEntry( pick, WorldPosition + Vector3.Up * CollisionHeight * 0.5f, Rotation.FromYaw( WorldRotation.Yaw() ) );
		if ( loot is null )
			return;

		loot.LastPlayer = this;
		if ( loot.Components.TryGet<Rigidbody>( out var body ) )
		{
			body.MotionEnabled = true;
			body.ApplyImpulse( (Vector3.Random.WithZ( 0 ).Normal + Vector3.Up) * 300f );
		}

		MansionGame.Instance?.ShowEventlog( $"Whoops... You slipped and dropped <gray>1x {pick.Name}." );
	}

	private void UpdateBodyRotation()
	{
		if ( CameraTarget.IsValid() )
		{
			// Caught: turn our body to face the monster that grabbed us (legacy CameraTarget body rotation).
			var look = CameraTarget.WorldPosition - WorldPosition;
			if ( look.Length > 0.01f )
				GameObject.WorldRotation = Rotation.Lerp( GameObject.WorldRotation, Rotation.LookAt( look, Vector3.Up ), Time.Delta * 5f );

			return;
		}

		if ( MovementLocked )
		{
			var horizontalVelocity = Velocity.WithZ( 0 );
			if ( horizontalVelocity.Length > 0.01f )
				GameObject.WorldRotation = Rotation.LookAt( horizontalVelocity, Vector3.Up );

			return;
		}

		if ( !IsStunned )
			GameObject.WorldRotation = Rotation.FromYaw( InputAngles.yaw );
	}
}