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