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