OneMoreRoundWeapon.Recoil.cs
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// Gameplay recoil selection plus cosmetic camera recoil.
///
/// Existing weapons remain on LegacyRandom by default. Pattern recoil uses an
/// authored sequence plus optional noise. Optional gameplay-recoil recovery is
/// authored independently so pattern shape, lasting displacement and visual
/// camera kick remain separate tuning channels.
/// </summary>
public partial class OneMoreRoundWeapon
{
// =========================================================
// AUTHORED GAMEPLAY RECOIL
// =========================================================
[Property, Group( "Recoil" )]
public OMRGameplayRecoilModel RecoilModel { get; set; } =
OMRGameplayRecoilModel.LegacyRandom;
/// <summary>
/// Legacy vertical aim recoil range in degrees.
/// Negative pitch moves the player's aim upward in s&box.
/// </summary>
[Property, Group( "Recoil" )]
public Vector2 RecoilPitch { get; set; } =
new( -0.45f, -0.25f );
/// <summary>
/// Legacy horizontal aim recoil range in degrees.
/// </summary>
[Property, Group( "Recoil" )]
public Vector2 RecoilYaw { get; set; } =
new( -0.12f, 0.12f );
/// <summary>
/// Preferred visual-authoring source for Pattern mode. The resource stores
/// cumulative vertical/horizontal curves and converts them to per-shot kicks.
/// When assigned, it takes priority over the legacy Vector2 step list.
/// </summary>
[Property, Group( "Recoil" )]
public OMRRecoilPattern RecoilPatternAsset { get; set; }
/// <summary>
/// Per-weapon strength multiplier for the authored pattern source.
/// X scales pitch/vertical kick, Y scales yaw/horizontal kick.
/// </summary>
[Property, Group( "Recoil" )]
public Vector2 RecoilPatternScale { get; set; } = Vector2.One;
/// <summary>
/// Legacy compatibility sequence for Pattern mode.
/// X = pitch delta, Y = yaw delta, both in degrees. New weapons should
/// normally use RecoilPatternAsset instead.
/// </summary>
[Property, Group( "Recoil" )]
public List<Vector2> RecoilPattern { get; set; } = new();
/// <summary>
/// Symmetric random noise added to each authored pattern step.
/// X = maximum pitch noise, Y = maximum yaw noise.
/// </summary>
[Property, Group( "Recoil" )]
public Vector2 RecoilPatternNoise { get; set; } = Vector2.Zero;
[Property, Group( "Recoil" )]
[Range( 0f, 5f )]
public float RecoilPatternResetDelay { get; set; } = 0.35f;
/// <summary>
/// When false, a long spray stays on the final pattern step plus noise once
/// the authored sequence is exhausted. When true, the sequence loops.
/// </summary>
[Property, Group( "Recoil" )]
public bool RecoilPatternLoop { get; set; } = false;
// =========================================================
// AUTHORED GAMEPLAY RECOIL RECOVERY
// =========================================================
[Property, Group( "Recoil Recovery" )]
public bool RecoilRecoveryEnabled { get; set; } = false;
/// <summary>
/// Fraction of each gameplay recoil kick that is eligible to return
/// automatically. Keeping this below 1 preserves some lasting displacement
/// for player-controlled recoil management.
/// </summary>
[Property, Group( "Recoil Recovery" )]
[Range( 0f, 1f )]
public float RecoilRecoveryFraction { get; set; } = 0.65f;
/// <summary>
/// Time after the latest shot before automatic gameplay recoil return begins.
/// Set this above an automatic weapon's fire interval if recovery should only
/// occur after a burst/spray actually stops.
/// </summary>
[Property, Group( "Recoil Recovery" )]
[Range( 0f, 2f )]
public float RecoilRecoveryDelay { get; set; } = 0.18f;
[Property, Group( "Recoil Recovery" )]
[Range( 0f, 30f )]
public float RecoilRecoveryPitchSpeed { get; set; } = 3.25f;
[Property, Group( "Recoil Recovery" )]
[Range( 0f, 30f )]
public float RecoilRecoveryYawSpeed { get; set; } = 4.5f;
// =========================================================
// AUTHORED CAMERA RECOIL
// =========================================================
/// <summary>
/// Small transient roll wobble applied to the rendered camera after firing.
/// This does NOT move the player's actual aim.
/// </summary>
[Property, Group( "Recoil" )]
public float CameraRecoilRoll { get; set; } = 0.45f;
[Property, Group( "Recoil" )]
public float CameraRecoilFrequency { get; set; } = 1.0f;
[Property, Group( "Recoil" )]
public float CameraRecoilDuration { get; set; } = 0.30f;
/// <summary>
/// ADS keeps the same gameplay recoil but reduces cosmetic camera wobble.
/// </summary>
[Property, Group( "Recoil" )]
[Range( 0f, 1f )]
public float AdsCameraRecoilScale { get; set; } = 0.65f;
// =========================================================
// RUNTIME RECOIL STATE
// =========================================================
private int _recoilPatternIndex;
private float _lastRecoilShotAt = -1f;
private Vector2 _recoverableGameplayRecoil = Vector2.Zero;
public int CurrentRecoilPatternIndex => _recoilPatternIndex;
public Vector2 LastGameplayRecoilKick { get; private set; } = Vector2.Zero;
public Vector2 RemainingGameplayRecoilRecovery => _recoverableGameplayRecoil;
public bool IsRecoveringGameplayRecoil =>
RecoilRecoveryEnabled &&
_lastRecoilShotAt >= 0f &&
RealTime.Now - _lastRecoilShotAt >= MathF.Max( RecoilRecoveryDelay, 0f ) &&
(MathF.Abs( _recoverableGameplayRecoil.x ) > 0.0001f ||
MathF.Abs( _recoverableGameplayRecoil.y ) > 0.0001f);
private void UpdateRecoilRuntimeState()
{
UpdateGameplayRecoilRecovery();
if (
RecoilModel != OMRGameplayRecoilModel.Pattern ||
_recoilPatternIndex <= 0 ||
_lastRecoilShotAt < 0f
)
{
return;
}
float resetDelay = MathF.Max( RecoilPatternResetDelay, 0f );
if ( resetDelay <= 0f || RealTime.Now - _lastRecoilShotAt >= resetDelay )
{
_recoilPatternIndex = 0;
}
}
private void UpdateGameplayRecoilRecovery()
{
if (
!RecoilRecoveryEnabled ||
IsProxy ||
_lastRecoilShotAt < 0f
)
{
return;
}
PlayerController owner = Owner;
if ( owner is null )
return;
float recoveryDelay = MathF.Max( RecoilRecoveryDelay, 0f );
if ( RealTime.Now - _lastRecoilShotAt < recoveryDelay )
return;
if (
MathF.Abs( _recoverableGameplayRecoil.x ) <= 0.0001f &&
MathF.Abs( _recoverableGameplayRecoil.y ) <= 0.0001f
)
{
_recoverableGameplayRecoil = Vector2.Zero;
return;
}
float delta = MathF.Max( RealTime.Delta, 0f );
if ( delta <= 0f )
return;
Vector2 before = _recoverableGameplayRecoil;
Vector2 after = new(
MoveTowardZero(
before.x,
MathF.Max( RecoilRecoveryPitchSpeed, 0f ) * delta
),
MoveTowardZero(
before.y,
MathF.Max( RecoilRecoveryYawSpeed, 0f ) * delta
)
);
Vector2 returned = before - after;
_recoverableGameplayRecoil = after;
// Recoil kicks are added to EyeAngles. Returning a portion therefore
// subtracts the amount removed from the tracked recovery debt.
owner.EyeAngles += new Angles(
-returned.x,
-returned.y,
0f
);
}
private static float MoveTowardZero( float value, float maximumDelta )
{
if ( maximumDelta <= 0f )
return value;
if ( value > 0f )
return MathF.Max( value - maximumDelta, 0f );
if ( value < 0f )
return MathF.Min( value + maximumDelta, 0f );
return 0f;
}
private void ResetRecoilState()
{
_recoilPatternIndex = 0;
_lastRecoilShotAt = -1f;
_recoverableGameplayRecoil = Vector2.Zero;
LastGameplayRecoilKick = Vector2.Zero;
}
/// <summary>
/// Applies gameplay recoil after BaseCombatWeapon has already fired the
/// current shot, then adds a separate cosmetic camera response.
/// </summary>
protected virtual void ApplyRecoil()
{
PlayerController owner = Owner;
if ( owner is null )
return;
Vector2 kick = ResolveGameplayRecoilKick();
LastGameplayRecoilKick = kick;
// REAL AIM RECOIL. X is pitch and Y is yaw.
owner.EyeAngles += new Angles(
kick.x,
kick.y,
0f
);
if ( RecoilRecoveryEnabled )
{
float fraction = MathX.Clamp( RecoilRecoveryFraction, 0f, 1f );
_recoverableGameplayRecoil += kick * fraction;
}
ApplyCameraRecoil();
}
private Vector2 ResolveGameplayRecoilKick()
{
if ( RecoilModel == OMRGameplayRecoilModel.Pattern && HasAuthoredRecoilPattern() )
{
float now = RealTime.Now;
float resetDelay = MathF.Max( RecoilPatternResetDelay, 0f );
if (
_lastRecoilShotAt < 0f ||
resetDelay <= 0f ||
now - _lastRecoilShotAt >= resetDelay
)
{
_recoilPatternIndex = 0;
}
Vector2 authored = ResolveAuthoredPatternKick( _recoilPatternIndex );
Vector2 scaled = new(
authored.x * RecoilPatternScale.x,
authored.y * RecoilPatternScale.y
);
Vector2 noise = new(
Random.Shared.Float(
-MathF.Abs( RecoilPatternNoise.x ),
MathF.Abs( RecoilPatternNoise.x )
),
Random.Shared.Float(
-MathF.Abs( RecoilPatternNoise.y ),
MathF.Abs( RecoilPatternNoise.y )
)
);
_recoilPatternIndex++;
_lastRecoilShotAt = now;
return scaled + noise;
}
_lastRecoilShotAt = RealTime.Now;
return new Vector2(
Random.Shared.Float(
MathF.Min( RecoilPitch.x, RecoilPitch.y ),
MathF.Max( RecoilPitch.x, RecoilPitch.y )
),
Random.Shared.Float(
MathF.Min( RecoilYaw.x, RecoilYaw.y ),
MathF.Max( RecoilYaw.x, RecoilYaw.y )
)
);
}
private bool HasAuthoredRecoilPattern()
{
if ( RecoilPatternAsset is not null && RecoilPatternAsset.IsValid )
return true;
return RecoilPattern is not null && RecoilPattern.Count > 0;
}
private Vector2 ResolveAuthoredPatternKick( int shotIndex )
{
if ( RecoilPatternAsset is not null && RecoilPatternAsset.IsValid )
{
return RecoilPatternAsset.GetKickForShot( shotIndex, RecoilPatternLoop );
}
int count = RecoilPattern?.Count ?? 0;
if ( count <= 0 )
return Vector2.Zero;
int stepIndex = RecoilPatternLoop
? PositiveModulo( shotIndex, count )
: Math.Min( Math.Max( shotIndex, 0 ), count - 1 );
return RecoilPattern[stepIndex];
}
private static int PositiveModulo( int value, int modulus )
{
if ( modulus <= 0 )
return 0;
int result = value % modulus;
return result < 0 ? result + modulus : result;
}
private void ApplyCameraRecoil()
{
// Gameplay recoil still affects the owning controller (including bots),
// but cosmetic camera punch is strictly local-human presentation. A
// host-owned bot shares the host process and must never punch Scene.Camera.
PlayerState ownerState =
Owner?.Components.Get<PlayerState>();
if ( ownerState?.IsLocalHuman != true )
return;
CameraComponent camera = Scene.Camera;
if ( camera is null )
return;
float visualScale =
IsAiming
? AdsCameraRecoilScale
: 1f;
float roll = CameraRecoilRoll * visualScale;
if ( roll <= 0f )
return;
float signedRoll =
Random.Shared.Float( 0f, 1f ) < 0.5f
? -roll
: roll;
camera.AddPunch(
new Angles( 0f, 0f, signedRoll ),
CameraRecoilFrequency * 2f,
CameraRecoilDuration
);
}
}