UI/RoundCarveHud.razor

A UI PanelComponent that displays a large carved round number in the center of the screen for a short animation when a new round starts, only for the Basalt HUD theme. It listens to RoundManager state and round number, computes opacity and scale easing over time, and exposes a console command to force the carve.

Http Calls
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    BASALT'S ROUND, CARVED IN — a new round's numeral struck large in the middle of the screen for a moment, glowing like the rock,
    then gone as the corner counter takes it. The user's pick (2026-09-27): *"a new round's numeral is struck big in the centre
    for about a second, glowing ember, then fades as the corner counter ticks"*. The round's own sound plays with it
    (`Gameplay.RoundStartSound`, Ancient Evil's on basalt).

    ⚠️ BASALT'S THEME ONLY (`HudTheme.Basalt`): every other map's HUD is as it was. Roman from I to IX and digits after, exactly
    as the corner counter reads (`HudTheme.RomanFor`).

    ⚠️ ITS OWN PanelComponent, mounted as the round bar is (`SurvivalHud.OnUpdate`), so no scene edit. ⚠️ EVERY MACHINE FROM THE
    ROUND IT IS TOLD — `RoundManager.State` and `Round`, which a client holds mirrored — so nothing crosses the wire.
*@

<root class="carve-hud @HudTheme.Class">
    @if ( Showing )
    {
        <div class="carve @(Roman ? "roman" : "digits")" style="opacity: @(Css( Opacity )); transform: scale( @(Css( Scale )) )">@Text</div>
    }
</root>

@code
{
    /// <summary>How long it holds the screen, all told: struck, held, gone.</summary>
    const float Seconds = 2.2f;

    int _round;             // the round being carved
    TimeSince _since;       // since it was struck
    bool _struck;
    bool _wasActive;
    bool _primed;           // ⚠️ the first look only records, so a hotload mid-round does not strike it again

    static RoundManager Rm => RoundManager.Instance;

    float T => _struck ? (float)_since : 99f;

    /// <summary>Struck in 0.18 s, held to 1.3 s, gone by 2.2 s.</summary>
    float Opacity
    {
        get
        {
            var t = T;
            if ( t < 0.18f ) return t / 0.18f;
            if ( t < 1.3f ) return 1f;
            return System.Math.Clamp( (Seconds - t) / (Seconds - 1.3f), 0f, 1f );
        }
    }

    /// <summary>1.4 at the strike, easing to 1 by 0.35 s, then settling back to 0.94 as it goes.</summary>
    float Scale
    {
        get
        {
            var t = T;
            if ( t < 0.35f )
            {
                var p = 1f - t / 0.35f;
                return 1f + 0.4f * p * p;
            }

            return 1f - 0.06f * System.Math.Clamp( (t - 0.35f) / (Seconds - 0.35f), 0f, 1f );
        }
    }

    bool Roman => HudTheme.RomanFor( _round );
    string Text => Roman ? HudTheme.ToRoman( _round ) : _round.ToString();

    bool Showing => HudTheme.Basalt && _struck && T < Seconds && _round > 0
        && NZGame.Mode == GameMode.Survival && !NZGame.IsCreative;

    /// <summary>⚠️ A POINT FOR THE DECIMAL, whatever the machine's language — this goes into a style.</summary>
    static string Css( float v ) => v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );

    protected override void OnUpdate()
    {
        var rm = Rm;
        if ( !rm.IsValid() ) return;

        // ⚠️ A ROUND BEGINS WHEN THE STATE TURNS ACTIVE — `BeginRound`, or on a client the mirror of it — or when the number
        // moves while it is (`nz_round_set`)
        var active = rm.State == RoundState.Active;
        if ( _primed && active && rm.Round > 0 && (!_wasActive || rm.Round != _round) )
        {
            _round = rm.Round;
            _since = 0f;
            _struck = true;
        }

        if ( !_primed ) _round = rm.Round;
        _primed = true;
        _wasActive = active;
    }

    /// <summary>⚠️ REBUILDS THROUGH THE STRIKE IN STEPS, then never again until the next one.</summary>
    protected override int BuildHash() => System.HashCode.Combine( Showing, _round, (int)(Opacity * 30f), (int)(Scale * 100f), HudTheme.Class );

    /// <summary>`nz_round_carve [round]` — strike a numeral now, to see it without waiting for a round (basalt's theme only).</summary>
    [ConCmd( "nz_round_carve" )]
    public static void Cmd( int round = -1 )
    {
        var hud = Game.ActiveScene?.GetAllComponents<RoundCarveHud>().FirstOrDefault();
        if ( !hud.IsValid() ) { Log.Info( "[nz-hud] no round carving on this HUD" ); return; }

        hud._round = round > 0 ? round : Rm.IsValid() && Rm.Round > 0 ? Rm.Round : 1;
        hud._since = 0f;
        hud._struck = true;

        Log.Info( $"[nz-hud] round {hud._round} carved in as '{hud.Text}'"
            + (HudTheme.Basalt ? "" : " — but only basalt's theme draws it (nz_hud_theme basalt)") );
    }
}