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