diff --git a/code/swb_base/Weapon.Shoot.cs b/code/swb_base/Weapon.Shoot.cs
index 94669ff..0d3ebc6 100644
--- a/code/swb_base/Weapon.Shoot.cs
+++ b/code/swb_base/Weapon.Shoot.cs
@@ -101,8 +101,18 @@ public virtual void Shoot( ShootInfo shootInfo, bool isPrimary )
// Barrel smoke
barrelHeat += 1;
- // Recoil
- Owner.ApplyEyeAnglesOffset( GetRecoilAngles( shootInfo ) );
+ // Recoil / AimPunch
+ var recoilAngles = GetRecoilAngles( shootInfo );
+ if ( shootInfo.UseAimPunch )
+ {
+ // Apply aimpunch (camera recoil with recovery)
+ Owner.ApplyAimPunch( recoilAngles, shootInfo.AimPunchRecoverySpeed );
+ }
+ else
+ {
+ // Apply instant recoil (legacy behavior)
+ Owner.ApplyEyeAnglesOffset( recoilAngles );
+ }
// Screenshake
if ( shootInfo.ScreenShake is not null )
diff --git a/code/swb_base/structures/ShootInfo.cs b/code/swb_base/structures/ShootInfo.cs
index 19b6645..704d43e 100644
--- a/code/swb_base/structures/ShootInfo.cs
+++ b/code/swb_base/structures/ShootInfo.cs
@@ -67,6 +67,12 @@ public class ShootInfo : Component
/// Weapon recoil
[Property, Group( "Bullets" )] public float Recoil { get; set; } = 0.1f;
+ /// Enable aimpunch (camera recoil with recovery)
+ [Property, Group( "Bullets" )] public bool UseAimPunch { get; set; } = false;
+
+ /// Aimpunch recovery speed (higher = faster recovery)
+ [Property, Group( "Bullets" )] public float AimPunchRecoverySpeed { get; set; } = 5f;
+
/// Rate Per Minute, firing speed (higher is faster)
[Property, Group( "Bullets" )] public int RPM { get; set; } = 200;
diff --git a/code/swb_player/Inventory.cs b/code/swb_player/Inventory.cs
index 9bba76d..0c08486 100644
--- a/code/swb_player/Inventory.cs
+++ b/code/swb_player/Inventory.cs
@@ -56,6 +56,9 @@ public void SetActive( GameObject gameObject )
oldActive.OnCarryStop();
}
+ // Reset aimpunch when switching weapons
+ player?.ResetAimPunch();
+
if ( gameObject.Components.TryGet( out var newActive, FindMode.EverythingInSelf ) )
{
newActive.OnCarryStart();
diff --git a/code/swb_player/PlayerBase.AimPunch.cs b/code/swb_player/PlayerBase.AimPunch.cs
new file mode 100644
index 0000000..a28f5a1
--- /dev/null
+++ b/code/swb_player/PlayerBase.AimPunch.cs
@@ -0,0 +1,72 @@
+namespace SWB.Player;
+
+public partial class PlayerBase
+{
+ Angles aimPunchAngle;
+ float aimPunchRecoverySpeed = 5f;
+ RealTimeSince timeSinceLastPunch;
+
+ ///
+ /// Apply aimpunch recoil to the camera.
+ /// Similar to Flinch but with configurable recovery and shooting-aware delay.
+ ///
+ /// The angular offset to apply
+ /// How fast the camera recovers (higher = faster)
+ public virtual void ApplyAimPunch( Angles punchAmount, float recoverySpeed = 5f )
+ {
+ aimPunchAngle += punchAmount;
+ aimPunchRecoverySpeed = recoverySpeed;
+ timeSinceLastPunch = 0;
+
+ ApplyEyeAnglesOffset( punchAmount );
+ }
+
+ ///
+ /// Reset aimpunch angle (called on weapon switch, death, etc.)
+ ///
+ public virtual void ResetAimPunch()
+ {
+ aimPunchAngle = Angles.Zero;
+ }
+
+ ///
+ /// Handle aimpunch recovery over time.
+ /// Recovery only starts when player stops shooting.
+ ///
+ public virtual void HandleAimPunch()
+ {
+ if ( !IsAlive )
+ {
+ aimPunchAngle = Angles.Zero;
+ return;
+ }
+
+ bool isShooting = Inventory?.ActiveItem?.IsShooting() ?? false;
+
+ if ( isShooting || timeSinceLastPunch < 0.1f )
+ return;
+
+ if ( aimPunchAngle.pitch != 0 || aimPunchAngle.yaw != 0 || aimPunchAngle.roll != 0 )
+ {
+ var decayAmount = aimPunchRecoverySpeed * Time.Delta;
+
+ var oldPitch = aimPunchAngle.pitch;
+ var oldYaw = aimPunchAngle.yaw;
+ var oldRoll = aimPunchAngle.roll;
+
+ aimPunchAngle = new Angles(
+ aimPunchAngle.pitch.Approach( 0, decayAmount ),
+ aimPunchAngle.yaw.Approach( 0, decayAmount ),
+ aimPunchAngle.roll.Approach( 0, decayAmount )
+ );
+
+ var deltaAngle = new Angles(
+ aimPunchAngle.pitch - oldPitch,
+ aimPunchAngle.yaw - oldYaw,
+ aimPunchAngle.roll - oldRoll
+ );
+
+ ApplyEyeAnglesOffset( deltaAngle );
+ }
+ }
+}
diff --git a/code/swb_player/PlayerBase.cs b/code/swb_player/PlayerBase.cs
index 88f985d..be2dd49 100644
--- a/code/swb_player/PlayerBase.cs
+++ b/code/swb_player/PlayerBase.cs
@@ -224,6 +224,7 @@ protected override void OnUpdate()
ViewModelCamera.Enabled = IsFirstPerson && IsAlive;
HandleFlinch();
HandleScreenShake();
+ HandleAimPunch();
}
if ( IsAlive )
@@ -259,7 +260,8 @@ public void TriggerAnimation( Animations animation )
public void ApplyEyeAnglesOffset( Angles offset )
{
- CameraMovement?.EyeAnglesOffset += offset;
+ if ( CameraMovement is null ) return;
+ CameraMovement.EyeAnglesOffset += offset;
}
public void ParentToBone( GameObject weaponObject, string boneName )
diff --git a/code/swb_shared/IInventory.cs b/code/swb_shared/IInventory.cs
index 5fe2fe9..e3434fc 100644
--- a/code/swb_shared/IInventory.cs
+++ b/code/swb_shared/IInventory.cs
@@ -36,4 +36,7 @@ public interface IInventoryItem : IValid
/// Can the GameObject be switched out
public bool CanCarryStop();
+
+ /// Is the item currently being used (e.g. weapon is firing)
+ public bool IsShooting();
}
diff --git a/code/swb_shared/IPlayerBase.cs b/code/swb_shared/IPlayerBase.cs
index a428e7b..8dfedc0 100644
--- a/code/swb_shared/IPlayerBase.cs
+++ b/code/swb_shared/IPlayerBase.cs
@@ -115,6 +115,14 @@ public interface IPlayerBase : IValid, Sandbox.Component.IDamageable
/// The suggested angular offset to apply
public void ApplyEyeAnglesOffset( Angles offset );
+ ///
+ /// Apply aimpunch (camera recoil with recovery) to the player's view.
+ /// Unlike ApplyEyeAnglesOffset, this will smoothly recover over time.
+ ///
+ /// The angular offset to apply
+ /// How fast the camera recovers (higher = faster)
+ public void ApplyAimPunch( Angles punchAmount, float recoverySpeed = 5f );
+
///
/// Called when the weapon object should be attached/parented to the player's body
///