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