OMRCombatTuning.cs
using Sandbox;
using System;

// Kept for backward compatibility with Revision 10 resources. The new damage
// feedback presentation no longer uses coarse Gradient/Solid edge panels.
public enum OMRDamageIndicatorStyle
{
	Gradient,
	Solid
}

/// <summary>
/// Live-edit friendly gameplay/presentation tuning for combat.
///
/// Keep values that designers are expected to iterate on here instead of on
/// the spawned network player prefab. Editing the network prefab itself while
/// a multiplayer session is active can rebuild/refresh its hierarchy and break
/// ownership; editing this resource does not change the network object shape.
/// </summary>
[AssetType(
	Name = "OMR Combat Tuning",
	Extension = "omrcbt",
	Category = "One More Round/Gameplay"
)]
public sealed partial class OMRCombatTuning : GameResource
{
	// =========================================================
	// HEALTH / DAMAGE
	// =========================================================

	[Group( "Health / Damage" ), Range( 1f, 500f ), Title( "Maximum Health" )]
	public float MaxHealth { get; set; } = 180f;

	[Group( "Health / Damage" ), Range( 1f, 3f ), Title( "Headshot Damage Multiplier" )]
	public float HeadshotDamageMultiplier { get; set; } = 1.5f;

	[Group( "Health / Damage" ), Title( "Enable Headshot Height Fallback" )]
	public bool EnableHeadshotHeightFallback { get; set; } = true;

	[Group( "Health / Damage" ), Range( 0.60f, 0.95f ), Title( "Headshot Height Fraction" )]
	public float HeadshotHeightFraction { get; set; } = 0.78f;

	// =========================================================
	// HIT CONFIRMATION
	// =========================================================

	[Group( "Hit Confirmation" ), Title( "Show Hitmarker" )]
	public bool ShowHitmarker { get; set; } = true;

	[Group( "Hit Confirmation" ), Range( 0.03f, 0.5f ), Title( "Hitmarker Duration" )]
	public float HitmarkerDuration { get; set; } = 0.16f;

	[Group( "Hit Confirmation" ), Range( 0.03f, 0.75f ), Title( "Kill Hitmarker Duration" )]
	public float KillHitmarkerDuration { get; set; } = 0.26f;

	[Group( "Hit Confirmation" ), Range( 0f, 1f ), Title( "Maximum Opacity" )]
	public float HitmarkerMaxOpacity { get; set; } = 1f;

	[Group( "Hit Confirmation" ), Range( 20f, 80f ), Title( "Overall Size" )]
	public float HitmarkerSize { get; set; } = 38f;

	[Group( "Hit Confirmation" ), Range( 4f, 24f ), Title( "Line Length" )]
	public float HitmarkerLineLength { get; set; } = 11f;

	[Group( "Hit Confirmation" ), Range( 1f, 6f ), Title( "Line Thickness" )]
	public float HitmarkerLineThickness { get; set; } = 2f;

	[Group( "Hit Confirmation" ), Range( 0f, 14f ), Title( "Center Gap" )]
	public float HitmarkerCenterGap { get; set; } = 4f;

	[Group( "Hit Confirmation" ), Range( 0.75f, 1.75f ), Title( "Headshot Scale" )]
	public float HeadshotHitmarkerScale { get; set; } = 1.08f;

	[Group( "Hit Confirmation" ), Range( 0.75f, 2f ), Title( "Kill Scale" )]
	public float KillHitmarkerScale { get; set; } = 1.18f;

	[Group( "Hit Confirmation" ), Title( "Override Theme Colors" )]
	public bool OverrideHitmarkerColors { get; set; } = false;

	[Group( "Hit Confirmation" ), ShowIf( nameof( OverrideHitmarkerColors ), true ), Title( "Normal Hit Color" )]
	[ColorUsage( true, false )]
	public Color HitmarkerColor { get; set; } = Color.White;

	[Group( "Hit Confirmation" ), ShowIf( nameof( OverrideHitmarkerColors ), true ), Title( "Headshot Color" )]
	[ColorUsage( true, false )]
	public Color HeadshotHitmarkerColor { get; set; } = new Color( 0.68f, 0.78f, 1f, 1f );

	[Group( "Hit Confirmation" ), ShowIf( nameof( OverrideHitmarkerColors ), true ), Title( "Kill Color" )]
	[ColorUsage( true, false )]
	public Color KillHitmarkerColor { get; set; } = new Color( 1f, 0.49f, 0.45f, 1f );

	// =========================================================
	// DAMAGE INDICATOR - TIMING / INTENSITY
	// =========================================================

	[Group( "Damage Indicator - General" ), Title( "Show Damage Indicator" )]
	public bool ShowDamageIndicator { get; set; } = true;

	[Group( "Damage Indicator - General" ), Range( 0.08f, 1.25f ), Title( "Duration" )]
	public float DamageIndicatorDuration { get; set; } = 0.42f;

	[Group( "Damage Indicator - General" ), Range( 0f, 0.75f ), Title( "Hold Before Fade (%)" )]
	public float DamageIndicatorHoldFraction { get; set; } = 0.24f;

	[Group( "Damage Indicator - General" ), Range( 0f, 1f ), Title( "Minimum Visual Strength" )]
	public float MinimumDamageIndicatorStrength { get; set; } = 0.65f;

	[Group( "Damage Indicator - General" ), Range( 0f, 1f ), Title( "Damage Amount Influence" )]
	public float DamageIndicatorDamageInfluence { get; set; } = 0.35f;

	[Group( "Damage Indicator - General" ), Title( "Override Theme Danger Color" )]
	public bool OverrideDamageIndicatorColor { get; set; } = false;

	[Group( "Damage Indicator - General" ), ShowIf( nameof( OverrideDamageIndicatorColor ), true ), Title( "Damage Indicator Color" )]
	[ColorUsage( true, false )]
	public Color DamageIndicatorColor { get; set; } = new Color( 1f, 0.20f, 0.16f, 1f );

	// =========================================================
	// DAMAGE INDICATOR - DIRECTIONAL ARC
	// =========================================================
	//
	// Revision 16 deliberately does NOT depend on a raw PNG for the default
	// damage arc. Raw images are not automatically shipped to connected game
	// instances unless they are listed under Project Settings -> Other ->
	// Resource Files. The default arc is therefore generated once from SVG
	// source on each client and cached as a Texture. A custom compiled Texture
	// can still be supplied when a designer wants a bespoke shape.

	[Group( "Damage Indicator - Directional Arc" ), Title( "Show Directional Arc" )]
	public bool ShowDirectionalDamageArc { get; set; } = true;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Title( "Use Custom Arc Texture" )]
	public bool UseCustomDamageIndicatorArcTexture { get; set; } = false;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( UseCustomDamageIndicatorArcTexture ), true ), Title( "Custom Arc Texture" )]
	public Texture CustomDamageIndicatorArcTexture { get; set; }

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( UseCustomDamageIndicatorArcTexture ), false ), Range( 45f, 170f ), Title( "Procedural Arc Span" )]
	public float ProceduralDamageArcSpanDegrees { get; set; } = 100f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( UseCustomDamageIndicatorArcTexture ), false ), Range( 20f, 46f ), Title( "Procedural Arc Curve Radius (%)" )]
	public float ProceduralDamageArcRadiusPercent { get; set; } = 36f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( UseCustomDamageIndicatorArcTexture ), false ), Range( 1.5f, 12f ), Title( "Procedural Arc Thickness (%)" )]
	public float ProceduralDamageArcThicknessPercent { get; set; } = 5.5f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( UseCustomDamageIndicatorArcTexture ), false ), Range( 0f, 1f ), Title( "Procedural Edge Softness" )]
	public float ProceduralDamageArcEdgeSoftness { get; set; } = 0.70f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Range( 220f, 900f ), Title( "Arc Diameter" )]
	public float DamageIndicatorArcDiameter { get; set; } = 520f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Range( -180f, 320f ), Title( "Arc Radial Offset" )]
	public float DamageIndicatorArcRadialOffset { get; set; } = 0f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Range( 0f, 1f ), Title( "Arc Peak Opacity" )]
	public float DamageIndicatorArcOpacity { get; set; } = 0.90f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Range( 1f, 1.35f ), Title( "Impact Scale" )]
	public float DamageIndicatorArcImpactScale { get; set; } = 1.06f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Range( -180f, 180f ), Title( "Rotation Offset" )]
	public float DamageIndicatorArcRotationOffset { get; set; } = 0f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDirectionalDamageArc ), true ), Title( "Show Arc Glow" )]
	public bool ShowDamageIndicatorArcGlow { get; set; } = true;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDamageIndicatorArcGlow ), true ), Range( 0f, 1f ), Title( "Glow Opacity" )]
	public float DamageIndicatorArcGlowOpacity { get; set; } = 0.34f;

	[Group( "Damage Indicator - Directional Arc" ), ShowIf( nameof( ShowDamageIndicatorArcGlow ), true ), Range( 0f, 20f ), Title( "Glow Blur" )]
	public float DamageIndicatorArcGlowBlur { get; set; } = 5f;

	// Revision 11-15 compatibility only. Raw source-image paths are intentionally
	// ignored by the runtime now; keeping the fields allows older .omrcbt files
	// to deserialize without errors.
	[Hide]
	public string DamageIndicatorArcImagePath { get; set; } = "";

	[Hide]
	public Texture DamageIndicatorArcTexture { get; set; }

	// =========================================================
	// DAMAGE INDICATOR - IMPACT VIGNETTE
	// =========================================================

	[Group( "Damage Indicator - Impact Vignette" ), Title( "Show Impact Vignette" )]
	public bool ShowDamageImpactVignette { get; set; } = true;

	[Group( "Damage Indicator - Impact Vignette" ), ShowIf( nameof( ShowDamageImpactVignette ), true ), Range( 0f, 0.7f ), Title( "Vignette Peak Opacity" )]
	public float DamageImpactVignetteOpacity { get; set; } = 0.13f;

	// =========================================================
	// DAMAGE INDICATOR - LEGACY REVISION 10 DATA
	// =========================================================
	// Keep these serialized so an older default.omrcbt can still be loaded and
	// hot-replaced without asset parse failures. Revision 11 no longer consumes
	// them; the new arc/vignette presentation above owns the look.

	[Hide]
	public OMRDamageIndicatorStyle DamageIndicatorStyle { get; set; } = OMRDamageIndicatorStyle.Gradient;

	[Hide]
	public float DamageIndicatorMaxOpacity { get; set; } = 0.22f;

	[Hide]
	public float DamageIndicatorCoveragePercent { get; set; } = 34f;

	[Hide]
	public float FrontDamageOpacityScale { get; set; } = 0.68f;

	[Hide]
	public float SideDamageOpacityScale { get; set; } = 1f;

	[Hide]
	public float VerticalDamageOpacityScale { get; set; } = 0.92f;

	[Hide]
	public bool ShowDamageIndicatorRim { get; set; } = true;

	[Hide]
	public float DamageIndicatorRimThickness { get; set; } = 3f;

	[Hide]
	public float DamageIndicatorRimOpacity { get; set; } = 0.55f;

	[Hide]
	public float AboveDirectionThreshold { get; set; } = 0.42f;

	[Hide]
	public float FrontDirectionThreshold { get; set; } = 0.58f;

	[Hide]
	public float BackDirectionThreshold { get; set; } = -0.48f;

	// =========================================================
	// DIAGNOSTICS
	// =========================================================

	[Group( "Diagnostics" ), Title( "Log Damage Resolution" )]
	public bool LogDamageResolution { get; set; } = false;

	[Group( "Diagnostics" ), Title( "Log Combat Feedback Network" )]
	public bool LogCombatFeedbackRpc { get; set; } = false;

	private const int ProceduralArcTextureResolution = 512;

	private Texture _cachedProceduralArcTexture;
	private int _cachedProceduralArcHash;

	/// <summary>
	/// Returns the texture used by the directional damage cue.
	///
	/// The default texture is generated locally from vector/SVG source and is
	/// therefore available to host, client instances and published builds
	/// without requiring a loose PNG to be distributed. Custom textures are
	/// optional; an invalid/missing custom texture automatically falls back to
	/// the generated arc instead of making the cue disappear.
	/// </summary>
	public Texture GetDamageIndicatorArcTexture()
	{
		if (
			UseCustomDamageIndicatorArcTexture &&
			CustomDamageIndicatorArcTexture is not null &&
			CustomDamageIndicatorArcTexture.IsValid &&
			!CustomDamageIndicatorArcTexture.IsError
		)
		{
			return CustomDamageIndicatorArcTexture;
		}

		return GetProceduralDamageIndicatorArcTexture();
	}

	private Texture GetProceduralDamageIndicatorArcTexture()
	{
		int shapeHash =
			HashCode.Combine(
				MathF.Round( ProceduralDamageArcSpanDegrees, 2 ),
				MathF.Round( ProceduralDamageArcRadiusPercent, 2 ),
				MathF.Round( ProceduralDamageArcThicknessPercent, 2 ),
				MathF.Round( ProceduralDamageArcEdgeSoftness, 3 )
			);

		if (
			_cachedProceduralArcTexture is not null &&
			_cachedProceduralArcTexture.IsValid &&
			!_cachedProceduralArcTexture.IsError &&
			_cachedProceduralArcHash == shapeHash
		)
		{
			return _cachedProceduralArcTexture;
		}

		string svg =
			BuildProceduralDamageArcSvg();

		Texture generated =
			Texture.CreateFromSvgSource(
				svg,
				ProceduralArcTextureResolution,
				ProceduralArcTextureResolution,
				Color.White
			);

		if (
			generated is null ||
			!generated.IsValid ||
			generated.IsError
		)
		{
			// Keep the failure quiet and deterministic. The HUD can continue
			// rendering all other feedback, and the caller never starts a loop
			// of Texture.Load() attempts against a missing loose file.
			return Texture.Transparent;
		}

		_cachedProceduralArcTexture =
			generated;

		_cachedProceduralArcHash =
			shapeHash;

		return _cachedProceduralArcTexture;
	}

	private string BuildProceduralDamageArcSvg()
	{
		const float size =
			ProceduralArcTextureResolution;

		const float center =
			size * 0.5f;

		float radius =
			size *
			MathX.Clamp(
				ProceduralDamageArcRadiusPercent / 100f,
				0.20f,
				0.46f
			);

		float span =
			MathX.Clamp(
				ProceduralDamageArcSpanDegrees,
				45f,
				170f
			);

		float halfSpanRadians =
			(span * 0.5f) *
			(MathF.PI / 180f);

		float startX =
			center - MathF.Sin( halfSpanRadians ) * radius;

		float startY =
			center - MathF.Cos( halfSpanRadians ) * radius;

		float endX =
			center + MathF.Sin( halfSpanRadians ) * radius;

		float endY =
			startY;

		float coreWidth =
			size *
			MathX.Clamp(
				ProceduralDamageArcThicknessPercent / 100f,
				0.015f,
				0.12f
			);

		float softness =
			MathX.Clamp(
				ProceduralDamageArcEdgeSoftness,
				0f,
				1f
			);

		// Feathering is built from two wider, progressively fainter vector
		// strokes around the solid core. This reproduces the soft PNG look while
		// staying resolution-independent and fully local to each game instance.
		float softWidth =
			coreWidth * (1f + 1.10f * softness);

		float outerWidth =
			coreWidth * (1f + 2.25f * softness);

		float softOpacity =
			0.24f * softness;

		float outerOpacity =
			0.10f * softness;

		string path =
			$"M {SvgNumber( startX )} {SvgNumber( startY )} " +
			$"A {SvgNumber( radius )} {SvgNumber( radius )} 0 0 1 " +
			$"{SvgNumber( endX )} {SvgNumber( endY )}";

		return
			$"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
			$"width=\"{ProceduralArcTextureResolution}\" " +
			$"height=\"{ProceduralArcTextureResolution}\" " +
			$"viewBox=\"0 0 {ProceduralArcTextureResolution} {ProceduralArcTextureResolution}\">" +
				$"<path d=\"{path}\" fill=\"none\" stroke=\"#ffffff\" " +
				$"stroke-width=\"{SvgNumber( outerWidth )}\" " +
				$"stroke-opacity=\"{SvgNumber( outerOpacity )}\" stroke-linecap=\"round\"/>" +
				$"<path d=\"{path}\" fill=\"none\" stroke=\"#ffffff\" " +
				$"stroke-width=\"{SvgNumber( softWidth )}\" " +
				$"stroke-opacity=\"{SvgNumber( softOpacity )}\" stroke-linecap=\"round\"/>" +
				$"<path d=\"{path}\" fill=\"none\" stroke=\"#ffffff\" " +
				$"stroke-width=\"{SvgNumber( coreWidth )}\" " +
				$"stroke-opacity=\"1\" stroke-linecap=\"round\"/>" +
			$"</svg>";
	}

	private static string SvgNumber( float value )
	{
		// SVG requires "." as the decimal separator regardless of the operating
		// system locale.
		return value.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
	}

	public int ContentHash()
	{
		HashCode hash = new();
		hash.Add( MaxHealth );
		hash.Add( HeadshotDamageMultiplier );
		hash.Add( EnableHeadshotHeightFallback );
		hash.Add( HeadshotHeightFraction );
		hash.Add( ShowHitmarker );
		hash.Add( HitmarkerDuration );
		hash.Add( KillHitmarkerDuration );
		hash.Add( HitmarkerMaxOpacity );
		hash.Add( HitmarkerSize );
		hash.Add( HitmarkerLineLength );
		hash.Add( HitmarkerLineThickness );
		hash.Add( HitmarkerCenterGap );
		hash.Add( HeadshotHitmarkerScale );
		hash.Add( KillHitmarkerScale );
		hash.Add( OverrideHitmarkerColors );
		hash.Add( HitmarkerColor );
		hash.Add( HeadshotHitmarkerColor );
		hash.Add( KillHitmarkerColor );
		hash.Add( ShowDamageIndicator );
		hash.Add( DamageIndicatorDuration );
		hash.Add( DamageIndicatorHoldFraction );
		hash.Add( MinimumDamageIndicatorStrength );
		hash.Add( DamageIndicatorDamageInfluence );
		hash.Add( OverrideDamageIndicatorColor );
		hash.Add( DamageIndicatorColor );
		hash.Add( ShowDirectionalDamageArc );
		hash.Add( UseCustomDamageIndicatorArcTexture );
		hash.Add( CustomDamageIndicatorArcTexture );
		hash.Add( ProceduralDamageArcSpanDegrees );
		hash.Add( ProceduralDamageArcRadiusPercent );
		hash.Add( ProceduralDamageArcThicknessPercent );
		hash.Add( ProceduralDamageArcEdgeSoftness );
		hash.Add( DamageIndicatorArcDiameter );
		hash.Add( DamageIndicatorArcRadialOffset );
		hash.Add( DamageIndicatorArcOpacity );
		hash.Add( DamageIndicatorArcImpactScale );
		hash.Add( DamageIndicatorArcRotationOffset );
		hash.Add( ShowDamageIndicatorArcGlow );
		hash.Add( DamageIndicatorArcGlowOpacity );
		hash.Add( DamageIndicatorArcGlowBlur );
		hash.Add( ShowDamageImpactVignette );
		hash.Add( DamageImpactVignetteOpacity );
		hash.Add( LogDamageResolution );
		hash.Add( LogCombatFeedbackRpc );
		return hash.ToHashCode();
	}
}

/// <summary>
/// Central fallback path for combat tuning.
/// </summary>
public static class OMRCombatResources
{
	public const string DefaultTuningPath =
		"gameplay/default.omrcbt";

	public static OMRCombatTuning DefaultTuning =>
		ResourceLibrary.Get<OMRCombatTuning>( DefaultTuningPath );
}