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.
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;
}
}