PlayerLocomotion.cs
using Sandbox;
using System;
/// <summary>
/// OMR's player-movement coordinator.
///
/// This component owns OMR-specific sprint/crouch/slide resolution and also
/// implements IWeaponLocomotionProvider so reusable weapon code can consume
/// movement facts without depending on PlayerSprint, PlayerSlide or this class.
/// </summary>
public sealed class PlayerLocomotion : Component, IWeaponLocomotionProvider
{
[Property]
public PlayerController Controller { get; set; }
[Property]
public PlayerMovementInput MovementInput { get; set; }
[Property]
public PlayerSprint Sprint { get; set; }
[Property]
public PlayerCrouch Crouch { get; set; }
[Property]
public PlayerSlide Slide { get; set; }
[Property]
public BaseInventoryComponent Inventory { get; set; }
private TimeSince _timeSinceJump;
private bool _controlsWereEnabled;
// Round-scoped movement rule. Standard rounds keep this at zero.
private int _roundAdditionalAirJumps;
private int _remainingRoundAirJumps;
private bool _roundAirJumpWasGrounded;
// Weapon-facing motion derivatives. These do not influence movement yet;
// they simply make the locomotion contract ready for later gun-feel work.
private bool _weaponMotionInitialized;
private Vector3 _previousWeaponVelocity;
private Vector3 _weaponAcceleration;
private bool _weaponWasGrounded;
private float _weaponLandingImpulse;
/// <summary>
/// Current weapon-facing locomotion snapshot. Values are built from resolved
/// physical state, not raw input, so weapon mechanics see the same truth the
/// movement system ultimately applied.
/// </summary>
public WeaponLocomotionSnapshot WeaponLocomotion =>
BuildWeaponLocomotionSnapshot();
protected override void OnStart()
{
Controller ??=
Components.Get<PlayerController>();
MovementInput ??=
Components.Get<PlayerMovementInput>();
Sprint ??=
Components.Get<PlayerSprint>();
Crouch ??=
Components.Get<PlayerCrouch>();
Slide ??=
Components.Get<PlayerSlide>();
Inventory ??=
Components.Get<BaseInventoryComponent>();
if ( Controller is null )
{
Log.Warning(
"PlayerLocomotion could not find PlayerController."
);
return;
}
if ( MovementInput is null )
{
Log.Warning(
"PlayerLocomotion could not find PlayerMovementInput."
);
return;
}
Controller.UseInputControls =
false;
_timeSinceJump =
999f;
InitializeWeaponMotionTracking();
Log.Info(
$"PlayerLocomotion active | Proxy: {IsProxy} | Owner: {Network.OwnerId}"
);
}
protected override void OnFixedUpdate()
{
if ( Controller is not null )
{
UpdateWeaponMotionTracking();
}
if ( IsProxy )
return;
if (
Controller is null ||
MovementInput is null
)
return;
Controller.UseInputControls =
false;
if ( !MovementInput.ControlsEnabled )
{
if ( _controlsWereEnabled )
{
ResetLocomotion();
}
_controlsWereEnabled =
false;
Controller.WishVelocity =
Vector3.Zero;
return;
}
_controlsWereEnabled =
true;
Vector3 moveInput =
MovementInput.MoveInput;
UpdateSprintIntent(
moveInput
);
UpdateCrouchAndSlideIntent();
ResolveStance(
moveInput
);
ResolveSprint(
moveInput
);
UpdateMovement(
moveInput
);
UpdateRoundAirJumpRecharge();
UpdateJump();
}
// =========================================================
// INTENT
// =========================================================
private void UpdateSprintIntent(
Vector3 moveInput
)
{
IWeaponHandlingState activeWeapon =
GetActiveWeaponHandling();
if (
Sprint is null ||
!Sprint.Enabled
)
{
activeWeapon?.SetSprintAimSuppressed(
false
);
return;
}
bool pressed =
MovementInput.ConsumeSprintPressed();
bool released =
MovementInput.ConsumeSprintReleased();
Sprint.UpdateIntent(
MovementInput.SprintDown,
pressed,
released,
moveInput.x
);
// Fresh sprint input beats an already active ADS state. The weapon owns
// how that temporary priority is represented internally.
if (
pressed &&
activeWeapon is not null &&
activeWeapon.IsAiming
)
{
activeWeapon.SetSprintAimSuppressed(
true
);
}
// Once sprint intent is gone, remove the temporary ADS suppression. If
// aim input is still held, the weapon can naturally return to ADS.
if ( !Sprint.WantsSprint )
{
activeWeapon?.SetSprintAimSuppressed(
false
);
}
}
private void UpdateCrouchAndSlideIntent()
{
if (
Crouch is null ||
!Crouch.Enabled
)
return;
bool crouchPressed =
MovementInput.ConsumeCrouchPressed();
// Always update crouch intent. For Toggle this matters only on press;
// for Hold it follows the button continuously.
Crouch.UpdateIntent(
MovementInput.CrouchDown,
crouchPressed
);
if (
Slide is null ||
!Slide.Enabled
)
return;
// Ground slide: crouch/slide pressed while expressing sprint intent.
// Intent survives transitions, so WantsSprint is deliberately used.
if (
crouchPressed &&
!Slide.IsSliding &&
Controller.IsOnGround &&
Sprint is not null &&
Sprint.Enabled &&
Sprint.WantsSprint &&
Crouch.WantsCrouch
)
{
Slide.TryStartSlide();
}
// Landing slide.
if (
crouchPressed &&
Controller.IsAirborne
)
{
if ( Crouch.WantsCrouch )
{
Slide.QueueLandingSlide();
}
else
{
Slide.CancelQueuedLandingSlide();
}
}
// Toggle C a second time in the air and the player has withdrawn
// crouch/landing-slide intent.
if (
Controller.IsAirborne &&
!Crouch.WantsCrouch
)
{
Slide.CancelQueuedLandingSlide();
}
}
// =========================================================
// STANCE RESOLUTION
// =========================================================
private void ResolveStance(
Vector3 moveInput
)
{
if (
Crouch is null ||
!Crouch.Enabled
)
return;
bool sliding =
Slide is not null &&
Slide.Enabled &&
Slide.IsSliding;
IWeaponHandlingState activeWeapon =
GetActiveWeaponHandling();
bool weaponBlocksSprint =
activeWeapon?.BlocksSprint == true;
bool sprintIntent =
Sprint is not null &&
Sprint.Enabled &&
Sprint.WantsSprint;
bool movingForward =
Sprint is not null
? moveInput.x >=
Sprint.ForwardInputThreshold
: moveInput.x > 0.25f;
// Slide always wins stance.
bool forceCrouch =
sliding;
// Sprint only forces standing when the held item allows physical sprint.
// ADS currently blocks that through IWeaponHandlingState, preserving the
// existing crouch + ADS behavior without knowing the concrete weapon type.
bool forceStand =
!sliding &&
!weaponBlocksSprint &&
Controller.IsOnGround &&
sprintIntent &&
movingForward;
if ( forceStand )
{
// The crouch press used for Slide should not unexpectedly return after
// sprint eventually ends.
Crouch.ClearCrouchIntent();
}
Crouch.ApplyResolvedStance(
forceCrouch,
forceStand
);
}
// =========================================================
// GAIT RESOLUTION
// =========================================================
private void ResolveSprint(
Vector3 moveInput
)
{
IWeaponHandlingState activeWeapon =
GetActiveWeaponHandling();
if (
Sprint is null ||
!Sprint.Enabled
)
{
ApplyMovementSpeed(
Controller.WalkSpeed,
activeWeapon
);
return;
}
bool sliding =
Slide is not null &&
Slide.Enabled &&
Slide.IsSliding;
bool weaponBlocksSprint =
activeWeapon?.BlocksSprint == true;
bool movingForward =
moveInput.x >=
Sprint.ForwardInputThreshold;
bool groundRequirementMet =
!Sprint.RequireGrounded ||
Controller.IsOnGround;
bool validPhysicalMode =
!Controller.IsSwimming &&
!Controller.IsClimbing;
bool physicallyAbleToSprint =
Sprint.WantsSprint &&
movingForward &&
groundRequirementMet &&
validPhysicalMode &&
!sliding &&
!weaponBlocksSprint &&
!Controller.IsDucking;
Sprint.SetPhysicalSprint(
physicallyAbleToSprint
);
ApplyMovementSpeed(
physicallyAbleToSprint
? Sprint.SprintSpeed
: Sprint.BaseMoveSpeed,
activeWeapon
);
}
private void ApplyMovementSpeed(
float speed,
IWeaponHandlingState activeWeapon
)
{
float weaponScale =
activeWeapon?.MovementSpeedScale ?? 1f;
if ( activeWeapon?.IsAiming == true )
{
weaponScale *=
activeWeapon.AimingMovementSpeedScale;
}
weaponScale =
MathF.Max(
weaponScale,
0f
);
float resolvedSpeed =
speed * weaponScale;
Controller.WalkSpeed =
resolvedSpeed;
Controller.RunSpeed =
resolvedSpeed;
}
// =========================================================
// PHYSICAL MOVEMENT
// =========================================================
private void UpdateMovement(
Vector3 moveInput
)
{
if ( Controller.Mode is null )
{
Controller.WishVelocity =
Vector3.Zero;
return;
}
Rotation eyes =
Controller.EyeAngles.ToRotation();
Controller.WishVelocity =
Controller.Mode.UpdateMove(
eyes,
moveInput
);
}
// =========================================================
// JUMP
// =========================================================
private void UpdateJump()
{
bool jumpPressed =
MovementInput.ConsumeJumpPressed();
if ( !jumpPressed )
return;
bool canGroundJump =
Controller.TimeSinceGrounded <= 0.33f &&
_timeSinceJump >= 0.5f;
bool canRoundAirJump =
!canGroundJump &&
Controller.IsAirborne &&
_remainingRoundAirJumps > 0 &&
_timeSinceJump >= 0.15f;
if ( !canGroundJump && !canRoundAirJump )
return;
float jumpSpeed =
Controller.JumpSpeed;
// Preserve OMR's reduced crouch jump only for the normal ground jump.
// A round-granted air jump should remain consistent and readable.
if (
canGroundJump &&
Crouch is not null &&
Crouch.Enabled
)
{
jumpSpeed =
Crouch.GetJumpSpeed();
}
if ( jumpSpeed <= 0f )
return;
// Slide ends physically, but sprint INTENT remains.
if (
Slide is not null &&
Slide.Enabled &&
Slide.IsSliding
)
{
Slide.EndForJump();
}
if ( canRoundAirJump )
{
_remainingRoundAirJumps =
Math.Max(
_remainingRoundAirJumps - 1,
0
);
}
_timeSinceJump =
0f;
Controller.Jump(
Vector3.Up *
jumpSpeed
);
// Once airborne we're not physically sprinting, but WantsSprint remains.
Sprint?.SetPhysicalSprint(
false
);
Controller.OnJumped();
PlayerController.IEvents.PostToGameObject(
GameObject,
x => x.OnJumped()
);
}
public void SetRoundAdditionalAirJumps(
int additionalAirJumps
)
{
_roundAdditionalAirJumps =
Math.Clamp(
additionalAirJumps,
0,
3
);
_remainingRoundAirJumps =
_roundAdditionalAirJumps;
_roundAirJumpWasGrounded =
Controller?.IsOnGround == true;
}
private void UpdateRoundAirJumpRecharge()
{
if ( Controller is null )
return;
bool grounded =
Controller.IsOnGround;
if (
grounded &&
!_roundAirJumpWasGrounded
)
{
_remainingRoundAirJumps =
_roundAdditionalAirJumps;
}
_roundAirJumpWasGrounded =
grounded;
}
// =========================================================
// WEAPON CONTRACTS
// =========================================================
private IWeaponHandlingState GetActiveWeaponHandling()
{
Inventory ??=
Components.Get<BaseInventoryComponent>();
return
Inventory?.ActiveItem
as IWeaponHandlingState;
}
private WeaponLocomotionSnapshot BuildWeaponLocomotionSnapshot()
{
if ( Controller is null )
return WeaponLocomotionSnapshot.Invalid;
Vector3 velocity =
Controller.Velocity;
float horizontalSpeed =
velocity
.WithZ( 0f )
.Length;
float referenceSpeed =
Sprint is not null &&
Sprint.Enabled
? MathF.Max( Sprint.BaseMoveSpeed, 1f )
: MathF.Max( Controller.RunSpeed, 1f );
bool sprinting =
Sprint is not null &&
Sprint.Enabled &&
(
Sprint.IsSprinting ||
(
Controller.WalkSpeed > referenceSpeed + 1f &&
horizontalSpeed > 10f
)
);
return new WeaponLocomotionSnapshot(
isValid: true,
isGrounded: Controller.IsOnGround,
isAirborne: Controller.IsAirborne,
isCrouched: Controller.IsDucking,
isSliding:
Slide is not null &&
Slide.Enabled &&
Slide.IsSliding,
wantsSprint:
Sprint is not null &&
Sprint.Enabled &&
Sprint.WantsSprint,
isSprinting: sprinting,
velocity: velocity,
acceleration: _weaponAcceleration,
referenceMoveSpeed: referenceSpeed,
landingImpulse: _weaponLandingImpulse
);
}
private void InitializeWeaponMotionTracking()
{
if ( Controller is null )
return;
_weaponMotionInitialized = true;
_previousWeaponVelocity = Controller.Velocity;
_weaponAcceleration = Vector3.Zero;
_weaponWasGrounded = Controller.IsOnGround;
_weaponLandingImpulse = 0f;
}
private void UpdateWeaponMotionTracking()
{
if ( Controller is null )
return;
if ( !_weaponMotionInitialized )
{
InitializeWeaponMotionTracking();
return;
}
Vector3 velocity =
Controller.Velocity;
float dt =
MathX.Clamp(
Time.Delta,
0.001f,
0.1f
);
_weaponAcceleration =
(velocity - _previousWeaponVelocity) /
dt;
bool grounded =
Controller.IsOnGround;
if (
!_weaponWasGrounded &&
grounded
)
{
_weaponLandingImpulse =
MathF.Max(
-_previousWeaponVelocity.z,
0f
);
}
else
{
_weaponLandingImpulse =
MathX.ExponentialDecay(
_weaponLandingImpulse,
0f,
0.08f,
dt
);
}
_previousWeaponVelocity = velocity;
_weaponWasGrounded = grounded;
}
// =========================================================
// RESET
// =========================================================
private void ResetLocomotion()
{
Sprint?.ResetSprint();
Slide?.ResetSlide();
Crouch?.ResetToStanding();
GetActiveWeaponHandling()?.SetSprintAimSuppressed(
false
);
Controller.WishVelocity =
Vector3.Zero;
_remainingRoundAirJumps =
_roundAdditionalAirJumps;
_roundAirJumpWasGrounded =
Controller.IsOnGround;
}
}