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