PlayerCombatFeedback.cs
using Sandbox;
using System;

public enum OMRDamageIndicatorDirection
{
	Front,
	Left,
	Right,
	Above,
	Back
}

/// <summary>
/// Owner-local combat feedback fed by host-authoritative damage resolution.
///
/// The host decides that damage actually happened. Presentation is then sent
/// only to the connection that owns the relevant player. Lifetimes use absolute
/// RealTime timestamps so HUD rendering frequency cannot leave stale feedback.
/// </summary>
public sealed class PlayerCombatFeedback : Component
{
	private OMRCombatTuning Tuning =>
		OMRCombatResources.DefaultTuning;

	private float HitmarkerDuration =>
		MathF.Max( 0.01f, Tuning?.HitmarkerDuration ?? 0.16f );

	private float KillHitmarkerDuration =>
		MathF.Max( 0.01f, Tuning?.KillHitmarkerDuration ?? 0.26f );

	private float HitmarkerMaxOpacity =>
		MathX.Clamp( Tuning?.HitmarkerMaxOpacity ?? 1f, 0f, 1f );

	private float DamageIndicatorDuration =>
		MathF.Max( 0.01f, Tuning?.DamageIndicatorDuration ?? 0.42f );

	private float MinimumDamageIndicatorStrength =>
		MathX.Clamp( Tuning?.MinimumDamageIndicatorStrength ?? 0.65f, 0f, 1f );

	private float DamageAmountInfluence =>
		MathX.Clamp( Tuning?.DamageIndicatorDamageInfluence ?? 0.35f, 0f, 1f );

	private float DamageIndicatorHoldFraction =>
		MathX.Clamp( Tuning?.DamageIndicatorHoldFraction ?? 0.24f, 0f, 0.75f );

	public bool HitmarkerVisible =>
		(Tuning?.ShowHitmarker ?? true) &&
		RealTime.Now < _hitmarkerExpiresAt;

	public bool DamageIndicatorVisible =>
		(Tuning?.ShowDamageIndicator ?? true) &&
		RealTime.Now < _damageIndicatorExpiresAt;

	public bool LastHitWasKill { get; private set; }
	public bool LastHitWasHeadshot { get; private set; }
	public float LastConfirmedDamage { get; private set; }

	/// <summary>
	/// Kept as a coarse semantic/debug value for compatibility. Revision 11 HUD
	/// presentation uses the continuous DamageAngleDegrees value instead.
	/// </summary>
	public OMRDamageIndicatorDirection LastDamageDirection { get; private set; } =
		OMRDamageIndicatorDirection.Front;

	public bool HasDirectionalDamageSource =>
		_hasDirectionalDamageSource;

	/// <summary>
	/// Camera-relative horizontal angle to the most recent damage source.
	/// 0 = front/top of HUD, +90 = right, +/-180 = behind/bottom, -90 = left.
	/// Re-evaluated from the current eye rotation while the indicator is alive,
	/// so turning the camera keeps the cue pointing toward the source in world space.
	/// </summary>
	public float DamageAngleDegrees =>
		_hasDirectionalDamageSource
			? ResolveDamageAngleDegrees( _lastDamageSourcePosition )
			: 0f;

	public float HitmarkerOpacity
	{
		get
		{
			if ( !HitmarkerVisible )
				return 0f;

			float duration =
				MathF.Max( _activeHitmarkerDuration, 0.01f );

			float life = MathX.Clamp(
				(_hitmarkerExpiresAt - RealTime.Now) / duration,
				0f,
				1f
			);

			return HitmarkerMaxOpacity * life;
		}
	}

	public float HitmarkerScale
	{
		get
		{
			if ( LastHitWasKill )
				return MathF.Max( 0.1f, Tuning?.KillHitmarkerScale ?? 1.18f );

			if ( LastHitWasHeadshot )
				return MathF.Max( 0.1f, Tuning?.HeadshotHitmarkerScale ?? 1.08f );

			return 1f;
		}
	}

	/// <summary>
	/// 0..1 lifetime envelope. Damage feedback holds at full strength briefly,
	/// then uses a smooth fade. This avoids the Revision 10 behavior where a
	/// linear fade began immediately and made already-low opacity values vanish.
	/// </summary>
	public float DamageIndicatorFade
	{
		get
		{
			if ( !DamageIndicatorVisible )
				return 0f;

			float duration =
				MathF.Max( _activeDamageIndicatorDuration, 0.01f );

			float remaining = MathX.Clamp(
				(_damageIndicatorExpiresAt - RealTime.Now) / duration,
				0f,
				1f
			);

			float elapsed =
				1f - remaining;

			float hold =
				DamageIndicatorHoldFraction;

			if ( elapsed <= hold )
				return 1f;

			float fadeT = MathX.Clamp(
				(elapsed - hold) / MathF.Max( 1f - hold, 0.001f ),
				0f,
				1f
			);

			// Smoothstep from 1 -> 0.
			float smooth =
				fadeT * fadeT * (3f - 2f * fadeT);

			return 1f - smooth;
		}
	}

	/// <summary>
	/// Severity contribution after the configurable minimum floor. A normal hit
	/// remains clearly readable; damage amount adds emphasis instead of deciding
	/// whether the indicator is visible at all.
	/// </summary>
	public float DamageIndicatorVisualStrength
	{
		get
		{
			float severity =
				MathX.Clamp( _damageIndicatorStrength, 0f, 1f );

			return MathX.Clamp(
				1f + (severity - 1f) * DamageAmountInfluence,
				0f,
				1f
			);
		}
	}

	/// <summary>
	/// Compatibility/helper value used by HUD invalidation.
	/// Presentation layers apply their own peak opacity directly and do not nest
	/// additional opacity beneath this value.
	/// </summary>
	public float DamageIndicatorOpacity =>
		DamageIndicatorFade * DamageIndicatorVisualStrength;

	private float _hitmarkerExpiresAt;
	private float _activeHitmarkerDuration;

	private float _damageIndicatorExpiresAt;
	private float _activeDamageIndicatorDuration;
	private float _damageIndicatorStrength;
	private Vector3 _lastDamageSourcePosition;
	private bool _hasDirectionalDamageSource;


	/// <summary>
	/// Host-side entry point after damage has been authoritatively accepted.
	///
	/// We intentionally use a filtered Broadcast RPC instead of Rpc.Owner here.
	/// It makes the target connection explicit and avoids coupling hit feedback
	/// to owner-RPC resolution on whatever GameObject DamageInfo.Attacker happened
	/// to reference. Only the attacker player's owning connection receives it.
	/// </summary>
	public void NotifyHitConfirmed(
		float damage,
		bool killed,
		bool headshot
	)
	{
		if ( !Networking.IsHost )
			return;

		Connection owner =
			GameObject.Network.OwnerConnection;

		if ( Tuning?.LogCombatFeedbackRpc == true )
		{
			Log.Info(
				$"[OMR FEEDBACK] Dispatch hit confirmation | Player {GameObject.Name} | Owner {(owner is null ? "<host/unowned>" : owner.DisplayName)} | Damage {damage:0.##} | Kill {killed} | Head {headshot}"
			);
		}

		if ( owner is null || owner.IsHost )
		{
			ApplyHitConfirmed( damage, killed, headshot );
			return;
		}

		using ( Rpc.FilterInclude( connection => connection == owner ) )
		{
			ReceiveHitConfirmed( damage, killed, headshot );
		}
	}

	[Rpc.Broadcast]
	private void ReceiveHitConfirmed(
		float damage,
		bool killed,
		bool headshot
	)
	{
		ApplyHitConfirmed( damage, killed, headshot );

		if ( Tuning?.LogCombatFeedbackRpc == true )
		{
			Log.Info(
				$"[OMR FEEDBACK] Received hit confirmation | Player {GameObject.Name} | Damage {damage:0.##} | Kill {killed} | Head {headshot}"
			);
		}
	}

	private void ApplyHitConfirmed(
		float damage,
		bool killed,
		bool headshot
	)
	{
		LastConfirmedDamage =
			MathF.Max( 0f, damage );

		LastHitWasKill =
			killed;

		LastHitWasHeadshot =
			headshot;

		_activeHitmarkerDuration =
			killed
				? KillHitmarkerDuration
				: HitmarkerDuration;

		_hitmarkerExpiresAt =
			RealTime.Now + _activeHitmarkerDuration;
	}

	private void ApplyDamageTaken(
		float damage,
		bool killed,
		Vector3 sourcePosition,
		bool hasDirectionalSource
	)
	{
		PlayerHealth health =
			Components.Get<PlayerHealth>();

		float maxHealth =
			MathF.Max(
				health?.MaxHealth ?? 180f,
				1f
			);

		float normalizedDamage =
			MathX.Clamp(
				MathF.Max( 0f, damage ) / maxHealth,
				0f,
				1f
			);

		// Use a curved severity response and a high configurable floor. Damage
		// amount should add emphasis, not make small hits practically invisible.
		float curvedSeverity =
			MathF.Sqrt( normalizedDamage );

		_damageIndicatorStrength =
			MinimumDamageIndicatorStrength +
			(1f - MinimumDamageIndicatorStrength) * curvedSeverity;

		if ( killed )
		{
			_damageIndicatorStrength =
				1f;
		}

		_hasDirectionalDamageSource =
			hasDirectionalSource;

		_lastDamageSourcePosition =
			sourcePosition;

		LastDamageDirection =
			hasDirectionalSource
				? ResolveCoarseDamageDirection( sourcePosition )
				: OMRDamageIndicatorDirection.Front;

		_activeDamageIndicatorDuration =
			DamageIndicatorDuration;

		_damageIndicatorExpiresAt =
			RealTime.Now + _activeDamageIndicatorDuration;
	}

	private float ResolveDamageAngleDegrees(
		Vector3 sourcePosition
	)
	{
		GetDamageReference(
			out Vector3 referencePosition,
			out Rotation referenceRotation
		);

		Vector3 toSource =
			sourcePosition - referencePosition;

		Vector3 flatDirection =
			new(
				toSource.x,
				toSource.y,
				0f
			);

		if ( flatDirection.Length < 0.01f )
			return 0f;

		Vector3 flatForward =
			new(
				referenceRotation.Forward.x,
				referenceRotation.Forward.y,
				0f
			);

		Vector3 flatRight =
			new(
				referenceRotation.Right.x,
				referenceRotation.Right.y,
				0f
			);

		if (
			flatForward.Length < 0.01f ||
			flatRight.Length < 0.01f
		)
		{
			return 0f;
		}

		flatDirection = flatDirection.Normal;
		flatForward = flatForward.Normal;
		flatRight = flatRight.Normal;

		float forwardDot =
			Vector3.Dot( flatDirection, flatForward );

		float rightDot =
			Vector3.Dot( flatDirection, flatRight );

		return MathF.Atan2( rightDot, forwardDot ) *
			(180f / MathF.PI);
	}

	private OMRDamageIndicatorDirection ResolveCoarseDamageDirection(
		Vector3 sourcePosition
	)
	{
		GetDamageReference(
			out Vector3 referencePosition,
			out _
		);

		Vector3 toSource =
			sourcePosition - referencePosition;

		if ( toSource.Length >= 0.01f )
		{
			float upDot =
				Vector3.Dot( toSource.Normal, Vector3.Up );

			if ( upDot >= 0.60f )
				return OMRDamageIndicatorDirection.Above;
		}

		float angle =
			ResolveDamageAngleDegrees( sourcePosition );

		float absolute =
			MathF.Abs( angle );

		if ( absolute <= 45f )
			return OMRDamageIndicatorDirection.Front;

		if ( absolute >= 135f )
			return OMRDamageIndicatorDirection.Back;

		return angle >= 0f
			? OMRDamageIndicatorDirection.Right
			: OMRDamageIndicatorDirection.Left;
	}

	private void GetDamageReference(
		out Vector3 referencePosition,
		out Rotation referenceRotation
	)
	{
		PlayerController controller =
			Components.Get<PlayerController>();

		referencePosition =
			controller is not null
				? controller.EyePosition
				: GameObject.WorldPosition;

		referenceRotation =
			controller is not null
				? controller.EyeTransform.Rotation
				: GameObject.WorldRotation;
	}

	/// <summary>
	/// Local presentation bridge used by PlayerHealth's host-synchronized
	/// damage event. This never performs damage logic; it only starts the
	/// already-authorized visual feedback on the locally controlled player.
	/// </summary>
	internal void ReceiveLocalDamageFeedback(
		float damage,
		bool killed,
		Vector3 sourcePosition,
		bool hasDirectionalSource
	)
	{
		if ( IsProxy || Components.Get<PlayerState>()?.IsBot == true )
			return;

		ApplyDamageTaken(
			damage,
			killed,
			sourcePosition,
			hasDirectionalSource
		);
	}

	public void ResetTransient()
	{
		_hitmarkerExpiresAt = 0f;
		_activeHitmarkerDuration = 0f;

		_damageIndicatorExpiresAt = 0f;
		_activeDamageIndicatorDuration = 0f;
		_damageIndicatorStrength = 0f;
		_lastDamageSourcePosition = Vector3.Zero;
		_hasDirectionalDamageSource = false;

		LastHitWasKill = false;
		LastHitWasHeadshot = false;
		LastConfirmedDamage = 0f;
		LastDamageDirection = OMRDamageIndicatorDirection.Front;
	}

	protected override void OnDisabled()
	{
		ResetTransient();
		base.OnDisabled();
	}
}