Player/Player.cs

Player component for a game pawn. Manages input, movement state, camera eye position, collider shape, replicated movement/animation properties, and update loops for input, camera, animation and visibility.

NetworkingFile Access
using System;
using Sandbox;

namespace BrickJam;

[Title( "Mansion Player" )]
[Category( "Player" )]
public sealed partial class Player : Component, IPushable
{
	private CapsuleCollider bodyCollider;
	private bool isCrouching;

	[Property, Sync] public bool IsAlive { get; set; } = true;
	[Property, Sync] public bool Blocked { get; set; }

	[Property, Sync] public float DropChance { get; set; } = 0.5f;
	[Property, Sync] public float CrouchSpeed { get; set; } = 80f;
	[Property, Sync] public float WalkSpeed { get; set; } = 200f;
	[Property, Sync] public float RunSpeed { get; set; } = 350f;
	[Property, Sync] public float JumpHeight { get; set; } = 250f;
	[Property, Sync] public float Acceleration { get; set; } = 1000f;
	[Property, Sync] public float Deceleration { get; set; } = 400f;
	[Property, Sync] public float StunDuration { get; set; } = 1.5f;
	[Property, Sync] public float TripDuration { get; set; } = 1.5f;
	[Property, Sync] public float SlipDuration { get; set; } = 2f;
	[Property, Sync] public float StepSize { get; set; } = 16f;
	[Property, Sync] public float WalkAngle { get; set; } = 70f;
	[Property, Sync] public float StunBounceVelocity { get; set; } = 550f;
	[Property, Sync] public float PushForce { get; set; } = 2500f;

	/// <summary>Computed from the "Work Shoes" upgrade (legacy parity) - not a designer property,
	/// otherwise the upgrade silently does nothing.</summary>
	public bool HasFrictionUpgrade => HasUpgrade( "Work Shoes" );
	[Property, InputAction] public string RunButton { get; set; } = "run";
	[Property, InputAction] public string CrouchButton { get; set; } = "crouch";
	[Property, InputAction] public string JumpButton { get; set; } = "jump";

	[Sync] public Vector3 InputDirection { get; set; }
	[Sync] public Angles InputAngles { get; set; }
	[Sync] public new bool IsRunning { get; set; }

	[Sync]
	public bool IsCrouching
	{
		get => isCrouching;
		set
		{
			if ( isCrouching == value ) return;

			isCrouching = value;
			ApplyControllerShape();
		}
	}

	public bool CommandsLocked => IsStunned || MovementLocked || Blocked;
	public bool MovementLocked => IsTripping || IsSlipping || !IsAlive;
	public bool IsAboveWalkingSpeed => Velocity.WithZ( 0 ).Length >= MathX.Lerp( WalkSpeed, RunSpeed, 0.5f );
	public float StunSpeed => (float)(WalkSpeed + (RunSpeed - WalkSpeed) * Math.Sin( 45f.DegreeToRadian() ));
	public float WishSpeed => InputDirection.IsNearlyZero() ? 0 : (IsCrouching ? CrouchSpeed : (IsRunning ? RunSpeed : WalkSpeed));
	public Vector3 WishVelocity => (InputDirection.IsNearlyZero() || IsStunned)
		? Vector3.Zero
		: InputDirection * Rotation.FromYaw( InputAngles.yaw ) * WishSpeed;
	public float CollisionRadius => IsCrouching ? 22f : 12f;
	public float CollisionHeight => IsCrouching ? 36f : 72f;
	public Capsule CollisionCapsule => new( Vector3.Up * CollisionRadius, Vector3.Up * (CollisionHeight - CollisionRadius), CollisionRadius );
	public Rotation InputRotation => InputAngles.ToRotation();

	/// <summary>
	/// Eye / camera origin. Follows the body's animated "eyes" attachment so the first-person camera bobs
	/// with the walk cycle (the legacy head-bob, lost when this returned a fixed height). The small back/down
	/// nudge keeps the near plane out of the face; falls back to a static offset if the attachment is missing.
	/// </summary>
	public Vector3 EyePosition
	{
		get
		{
			if ( Body.IsValid() && Body.GetAttachment( "eyes" ) is { } eyes )
				return eyes.Position - InputRotation.Up * 2f - Rotation.FromYaw( InputAngles.yaw ).Forward * 3f;

			return GameObject.WorldPosition + Vector3.Up * (IsCrouching ? 28f : 64f);
		}
	}

	/// <summary>Replicated so proxies animate. Driven by the custom <see cref="MoveHelper"/> controller.</summary>
	[Sync] public Vector3 Velocity { get; set; }

	/// <summary>Whether the player is standing on the ground (set by the controller each fixed update).</summary>
	[Sync] public bool IsOnGround { get; private set; }

	/// <summary>The surface the player is standing on (for footsteps), or null when airborne.</summary>
	public Surface GroundSurface { get; private set; }

	protected override void OnAwake()
	{
		bodyCollider = GetOrAddComponent<CapsuleCollider>();

		GameObject.Tags.Add( "player" );
		ApplyControllerShape();
		ResetStatus();
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor ) return;

		// Input + camera only run for the owning client; animation runs for everyone
		// (including proxies) so remote players animate correctly.
		if ( !IsProxy )
		{
			// While the lockpicking minigame is open the mouse drives the lock, so freeze
			// movement/look/interaction (camera stays put).
			if ( UI.LockpickerBus.IsOpen )
			{
				InputDirection = Vector3.Zero;
				IsRunning = false;

				// Fail-safe: always allow escaping the minigame so input can never get stuck.
				if ( Input.EscapePressed || Input.Pressed( "attack2" ) )
					UI.LockpickerBus.IsOpen = false;
			}
			else
			{
				BuildInput();
				UpdateUse();
				UpdatePing();
			}

			UpdateCamera();
			TickSave();
		}

		UpdateAnimation();

		// Runs for ALL pawns (incl. proxies): hides our own body in FP and forces other players' bodies
		// visible so a host's locally-hidden body isn't left invisible on clients.
		UpdateBodyVisibility();

		// Drift/skid loop, driven locally on every client off the replicated IsSkidding flag.
		UpdateSkidSound();

		// Outline living players in their slot colour while the local client is spectating.
		UpdateSpectatorGlow();
	}

	private void BuildInput()
	{
		if ( !CommandsLocked )
		{
			InputDirection = Input.AnalogMove;
			InputAngles += Input.AnalogLook;
			InputAngles = InputAngles.WithPitch( Math.Clamp( InputAngles.pitch, -80f, 80f ) );
		}
		else
		{
			InputDirection = Vector3.Zero;
		}

		if ( !MovementLocked )
		{
			IsRunning = Input.Down( RunButton, false );
			IsCrouching = Input.Down( CrouchButton, false );
		}
		else
		{
			IsRunning = false;
			IsCrouching = false;
		}
	}

	private void ApplyControllerShape()
	{
		bodyCollider ??= GetComponent<CapsuleCollider>();
		if ( bodyCollider is null ) return;

		bodyCollider.Start = Vector3.Up * CollisionRadius;
		bodyCollider.End = Vector3.Up * (CollisionHeight - CollisionRadius);
		bodyCollider.Radius = CollisionRadius;
	}
}