OMRRecoilPattern.cs
using Sandbox;
using System;

/// <summary>
/// Reusable authored recoil trajectory.
///
/// The curves describe cumulative aim displacement across the whole spray:
/// - Vertical Rise is positive-up, in degrees.
/// - Horizontal Drift is signed yaw displacement, in degrees.
///
/// Runtime recoil is derived from the difference between consecutive samples,
/// so designers can draw the desired overall spray path instead of entering a
/// pitch/yaw delta for every individual shot.
/// </summary>
[AssetType(
	Name = "OMR Recoil Pattern",
	Extension = "omrcoil",
	Category = "One More Round/Weapons"
)]
public sealed partial class OMRRecoilPattern : GameResource
{
	[Group( "Pattern" ), Range( 1, 200 ), Description( "Number of shots represented by the complete 0..1 curve range." )]
	public int ShotCount { get; set; } = 30;

	[Group( "Trajectory" ), Title( "Vertical Rise" )]
	[Description( "Cumulative upward aim displacement in degrees. Keep the curve near 0 at time 0; larger values mean the spray has climbed farther upward." )]
	[TimeRange( 0f, 1f )]
	[ValueRange( 0f, 16f )]
	public Curve VerticalRise { get; set; } = new Curve(
		new Curve.Frame( 0f, 0f ),
		new Curve.Frame( 1f, 9f )
	);

	[Group( "Trajectory" ), Title( "Horizontal Drift" )]
	[Description( "Cumulative horizontal yaw displacement in degrees. Positive and negative values move the trajectory to opposite sides." )]
	[TimeRange( 0f, 1f )]
	[ValueRange( -2f, 2f )]
	public Curve HorizontalDrift { get; set; } = new Curve(
		new Curve.Frame( 0f, 0f ),
		new Curve.Frame( 1f, 0f )
	);

	/// <summary>
	/// Returns the gameplay pitch/yaw kick for a zero-based shot index.
	/// Negative pitch moves aim upward in the current OMR convention.
	/// </summary>
	public Vector2 GetKickForShot( int shotIndex, bool loop )
	{
		int count = Math.Max( ShotCount, 1 );
		int index;

		if ( loop )
		{
			index = PositiveModulo( shotIndex, count );
		}
		else
		{
			// Preserve the previous list-pattern behavior: after the authored
			// pattern is exhausted, keep repeating the final authored kick.
			index = Math.Min( Math.Max( shotIndex, 0 ), count - 1 );
		}

		float previousProgress = index / (float)count;
		float currentProgress = (index + 1) / (float)count;

		float previousRise = VerticalRise.Evaluate( previousProgress );
		float currentRise = VerticalRise.Evaluate( currentProgress );
		float previousDrift = HorizontalDrift.Evaluate( previousProgress );
		float currentDrift = HorizontalDrift.Evaluate( currentProgress );

		float riseDelta = currentRise - previousRise;
		float yawDelta = currentDrift - previousDrift;

		return new Vector2(
			-riseDelta,
			yawDelta
		);
	}

	/// <summary>
	/// Cumulative trajectory sample, useful for diagnostics and future editor
	/// previews. X = upward rise in degrees, Y = horizontal drift in degrees.
	/// </summary>
	public Vector2 EvaluateTrajectory( float progress )
	{
		float t = MathX.Clamp( progress, 0f, 1f );

		return new Vector2(
			VerticalRise.Evaluate( t ),
			HorizontalDrift.Evaluate( t )
		);
	}

	private static int PositiveModulo( int value, int modulus )
	{
		if ( modulus <= 0 )
			return 0;

		int result = value % modulus;
		return result < 0 ? result + modulus : result;
	}
}