UI/Player/DamageIndicator.razor
@using Sandbox.UI
@using System.Collections.Generic
@inherits Panel
<style>
DamageIndicator {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.hit {
position: absolute;
left: 50%;
top: 50%;
width: 120px;
height: 28px;
margin-left: -60px;
margin-top: -220px;
transform-origin: 60px 220px;
border-radius: 2px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.90) 0%,
rgba(255, 50, 0, 0.00) 100%
);
filter: drop-shadow(0px 0px 3px rgba(255, 80, 0, 0.9));
opacity: 0;
}
}
</style>
<root>
@foreach ( var hit in _hits )
{
<div class="hit"
style="transform: rotate(@(hit.Angle.ToString("0.0"))deg); opacity: @(hit.Alpha.ToString("0.00"));" />
}
</root>
@code
{
public static DamageIndicator Current { get; private set; }
private class HitEntry
{
public Vector3 AttackerPos;
public RealTimeSince TimeSince;
public float Angle;
public float Alpha; // driven by TimeSince for smooth fade
public bool Visible;
}
private readonly List<HitEntry> _hits = new();
private const float FadeDuration = 1.5f;
private const float HoldTime = 0.25f; // fully opaque for this long before fading
public DamageIndicator() { Current = this; }
/// <summary>Register a new hit from the given world-space attacker position.</summary>
public void AddHit( Vector3 attackerWorldPos )
{
_hits.Add( new HitEntry { AttackerPos = attackerWorldPos, TimeSince = 0f } );
}
public override void Tick()
{
base.Tick();
var pawn = LocalPlayer.Pawn;
if ( pawn == null ) return;
_hits.RemoveAll( h => h.TimeSince > FadeDuration );
foreach ( var hit in _hits )
{
hit.Angle = ComputeScreenAngle( pawn, hit.AttackerPos );
var t = ((float)hit.TimeSince - HoldTime) / (FadeDuration - HoldTime);
hit.Alpha = (1f - t.Clamp( 0f, 1f ));
hit.Visible = hit.Alpha > 0.01f;
}
}
/// <summary>
/// Returns the clockwise screen angle (0 = top = in front) to the attacker,
/// relative to the local player's current facing direction.
/// </summary>
private static float ComputeScreenAngle( PlayerPawn pawn, Vector3 attackerPos )
{
var toAttacker = (attackerPos - pawn.WorldPosition).WithZ( 0 ).Normal;
if ( toAttacker.IsNearZeroLength ) return 0f;
var forward = pawn.WorldRotation.Forward.WithZ( 0 ).Normal;
var right = pawn.WorldRotation.Right.WithZ( 0 ).Normal;
var dotF = Vector3.Dot( toAttacker, forward );
var dotR = Vector3.Dot( toAttacker, right );
// atan2 gives angle in radians; convert to degrees. 0° = forward (top of screen).
return MathF.Atan2( dotR, dotF ) * (180f / MathF.PI);
}
protected override int BuildHash()
{
var h = 0;
foreach ( var hit in _hits )
h = HashCode.Combine( h, hit.Angle, hit.Visible );
return h;
}
}