UI/DamageOverlay.razor

A UI component (Razor) that renders a blood damage overlay vignette. It computes an intensity from the local player's health, applies a threshold and pulsing sine factor, and writes opacity to three child panels each frame. It also exposes console commands to toggle and tune threshold and pulse rate.

Reflection
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    DAMAGE OVERLAY — the blood vignette that closes in as you lose health.

    Ported from `DrawDamageOverlay()` in the GMod addon
    (gamemode/revive_system/cl_view.lua), the `nz_bloodoverlay` block. Its
    arithmetic, exactly:

        threshold = MaxHealth * 0.8            -- 20% grace before anything shows
        if health < threshold:
            fade  = 1 - clamp(health / threshold, 0, 1)
            draw  highlights          @ fade
            draw  blood               @ fade
            draw  blood AGAIN         @ fade * abs(sin(t * 4))   -- the throb
        downed -> fade = 1

    ⚠️ THE THIRD LAYER IS THE SAME TEXTURE AS THE SECOND, drawn twice. That is
    not a mistake in the original — the pulse ADDS to a solid base rather than
    modulating it, so the vignette never disappears between beats. Replacing the
    two with one pulsing layer would make it strobe instead of throb.

    ⚠️ The 20% grace is why chip damage shows nothing at all. Below 80% health it
    ramps from nothing to full — it is NOT a linear map of the whole bar.

    ⛔ BuildHash IS CONSTANT. Opacity is written to Style every frame; a hash that
    tracked health would rebuild the tree on every point of damage and restart
    the pulse from zero each time.
*@

<root class="blood">
    <div @ref="Highlights" class="layer highlights"></div>
    <div @ref="Base" class="layer damage"></div>
    <div @ref="Pulse" class="layer damage"></div>
</root>

@code
{
    /// <summary>⛔ CONSTANT ON PURPOSE — see the header.</summary>
    protected override int BuildHash() => 0;

    Panel Highlights;
    Panel Base;
    Panel Pulse;

    /// <summary>
    /// Health fraction at which the overlay first appears. The original derives
    /// it as `max - max*0.2`, i.e. 80%.
    /// </summary>
    public static float Threshold { get; set; } = 0.8f;

    /// <summary>Throb rate in radians/sec. The original uses `sin(CurTime()*4)`.</summary>
    public static float PulseRate { get; set; } = 4f;

    /// <summary>
    /// Master switch — the original's `nz_bloodoverlay` cvar.
    ///
    /// ⚠️ NOT called `Enabled`. PanelComponent inherits Component.Enabled, and a
    /// static of that name SHADOWS it: the compiler warned, and the two would
    /// then be separate switches with the same name — `nz_blood 0` setting one
    /// while the component system reads the other.
    /// </summary>
    public static bool ShowOverlay { get; set; } = true;

    /// <summary>Last computed intensity, for `nz_blood_status`.</summary>
    public static float Fade { get; private set; }

    protected override void OnUpdate()
    {
        if ( Highlights is null || Base is null || Pulse is null ) return;

        Fade = Intensity();

        Highlights.Style.Opacity = Fade;
        Base.Style.Opacity = Fade;

        // ⚠️ MathF.Abs(sin), not (sin+1)/2 — the original uses abs, which gives a
        // double-rate throb that touches zero rather than a slow sine. That
        // difference is most of the feel of it.
        Pulse.Style.Opacity = Fade > 0f
            ? Fade * MathF.Abs( MathF.Sin( Time.Now * PulseRate ) )
            : 0f;
    }

    /// <summary>0 = healthy and nothing drawn, 1 = downed or empty.</summary>
    static float Intensity()
    {
        if ( !ShowOverlay ) return 0f;

        var player = NZPlayer.Local;
        if ( !player.IsValid() ) return 0f;

        // ⚠️ NOTHING ONCE BLED OUT (2026-10-05): the body is gone and the screen shows another player (`SpectateOthers`), so a
        // full-strength blood overlay would only paint their view red.
        if ( player.IsOutOfRound ) return 0f;

        // Downed pins it at full, whatever the health number says.
        if ( player.IsDown ) return 1f;

        var hp = player.Hp;
        if ( !hp.IsValid() || hp.Max <= 0f ) return 0f;

        var threshold = hp.Max * Threshold;
        if ( hp.Current >= threshold ) return 0f;

        return 1f - (hp.Current / threshold).Clamp( 0f, 1f );
    }

    // ── console ─────────────────────────────────────────────────────────────

    /// <summary>`nz_blood` toggles the overlay.</summary>
    [ConCmd( "nz_blood" )]
    public static void CmdEnable( int state = -1 )
    {
        ShowOverlay = state < 0 ? !ShowOverlay : state > 0;
        Log.Info( $"[blood] overlay {(ShowOverlay ? "on" : "off")}" );
    }

    /// <summary>`nz_blood_threshold 0.8` — health fraction it starts at.</summary>
    [ConCmd( "nz_blood_threshold" )]
    public static void CmdThreshold( float fraction )
    {
        Threshold = fraction.Clamp( 0.05f, 1f );
        Log.Info( $"[blood] shows below {Threshold * 100:0}% health" );
    }

    /// <summary>`nz_blood_pulse 4` — throb rate.</summary>
    [ConCmd( "nz_blood_pulse" )]
    public static void CmdPulse( float rate )
    {
        PulseRate = rate.Clamp( 0f, 30f );
        Log.Info( $"[blood] pulse {PulseRate:0.#}" );
    }

    /// <summary>
    /// `nz_blood_status` — what it is drawing and why, without needing to get hurt.
    /// </summary>
    [ConCmd( "nz_blood_status" )]
    public static void CmdStatus()
    {
        var player = NZPlayer.Local;
        if ( !player.IsValid() ) { Log.Info( "[blood] no player" ); return; }

        var hp = player.Hp;
        Log.Info( $"[blood] enabled={ShowOverlay} down={player.IsDown} "
            + $"hp={(hp.IsValid() ? $"{hp.Current:0}/{hp.Max:0}" : "—")} "
            + $"threshold={Threshold * 100:0}% -> fade {Fade:0.00}" );
    }
}