UI/RecoilTuner.razor

A Razor UI component for a recoil tuning panel. It exposes sliders bound to GlobalHandling static fields, shows computed readouts (per-shot, 10-shot climb, persistent stick), allows copying a C# snippet to clipboard, toggles several GlobalHandling booleans, and creates a host GameObject/screen when the console command opens the tuner.

File AccessExternal Download
@using Sandbox;
@using Sandbox.UI;
@using System;
@using NZombies;
@inherits PanelComponent

@*
    RECOIL TUNER — `nz_recoil`. Sliders for the three named parts of recoil.

    ⛔ IT EXISTS BECAUSE THE AUTHORED NUMBER IS NOT THE DELIVERED ONE. A base of 0.4 arrives as
    0.8 degrees hipfire and 0.32 aiming, after RecoilScale and the aim damp — and reading 0.4 as
    "0.4 degrees" has misled every recoil conversation this project has had. The readout does that
    arithmetic on every frame so the value being judged is the value being felt.

    ⚠️ SAME SHAPE AS SoulBoxTuner AND TradeTableTuner, deliberately: a command that ensures its own
    host, a static Visible, a copy button, and a BuildHash carrying every displayed number. It is a
    dev tool and reads as one.

    ⚠️ THE THREE PARTS ARE NAMED AND SEPARATED ON PURPOSE. Stability moves only the MODEL; vertical
    and horizontal move where you LOOK. Three fixes in a row aimed at the wrong one of those before
    they had names.
*@

<root class="tuner @(Visible ? "" : "hidden")">
    <div class="panel">

        <div class="head">
            <div class="title">RECOIL</div>
            <div class="close" onclick=@(() => Hide())>×</div>
        </div>

        @if ( !BaseOn )
        {
            <div class="warn">
                Base mode is OFF — each weapon uses its own authored numbers and these sliders do
                nothing. Press "Use base" to tune the shared base.
            </div>
        }

        <div class="group">VERTICAL — pushes your view up</div>

        <div class="row">
            <div class="label">Amount</div>
            <SliderControl class="s" Value:bind=@VerticalBase Min="@(0f)" Max="@(2f)" Step="@(0.01f)"></SliderControl>
            <div class="val">@VerticalBase.ToString( "0.00" )</div>
        </div>

        <div class="row">
            <div class="label">Variation</div>
            <SliderControl class="s" Value:bind=@VerticalJitter Min="@(0f)" Max="@(1f)" Step="@(0.01f)"></SliderControl>
            <div class="val">@((VerticalJitter * 100f).ToString( "0" ))%</div>
        </div>

        <div class="group">HORIZONTAL — pushes your view left or right, at random</div>

        <div class="row">
            <div class="label">Amount</div>
            <SliderControl class="s" Value:bind=@HorizontalBase Min="@(0f)" Max="@(2f)" Step="@(0.01f)"></SliderControl>
            <div class="val">@HorizontalBase.ToString( "0.00" )</div>
        </div>

        <div class="row">
            <div class="label">Variation</div>
            <SliderControl class="s" Value:bind=@HorizontalJitter Min="@(0f)" Max="@(1f)" Step="@(0.01f)"></SliderControl>
            <div class="val">@((HorizontalJitter * 100f).ToString( "0" ))%</div>
        </div>

        <div class="group">STABILITY — the gun model only, never your aim</div>

        <div class="row">
            <div class="label">Settle</div>
            <SliderControl class="s" Value:bind=@Settle Min="@(0.1f)" Max="@(1f)" Step="@(0.01f)"></SliderControl>
            <div class="val">@((Settle * 100f).ToString( "0" ))%</div>
        </div>

        <div class="group">RECOVERY — what comes back down after the climb</div>

        <div class="row">
            <div class="label">Recovered</div>
            <SliderControl class="s" Value:bind=@Recovered Min="@(0f)" Max="@(1f)" Step="@(0.05f)"></SliderControl>
            <div class="val">@((Recovered * 100f).ToString( "0" ))%</div>
        </div>

        <div class="row">
            <div class="label">Walk-back</div>
            <SliderControl class="s" Value:bind=@RecoverSpeed Min="@(0f)" Max="@(150f)" Step="@(5f)"></SliderControl>
            <div class="val">@(RecoverSpeed > 0f ? RecoverSpeed.ToString( "0" ) + "°/s" : "instant")</div>
        </div>

        <div class="group">EVERYTHING — one multiplier over all of the above</div>

        <div class="row">
            <div class="label">Global</div>
            <SliderControl class="s" Value:bind=@Scale Min="@(0f)" Max="@(5f)" Step="@(0.05f)"></SliderControl>
            <div class="val">@Scale.ToString( "0.00" )x</div>
        </div>

        @* ⚠️ THE DELIVERED FIGURE, NOT THE SLIDER'S. This is the whole reason the panel is worth
           having over four console commands. *@
        <div class="out">@Delivered</div>
        <div class="out climb">@Climb</div>
        <div class="out climb">@Sticks</div>

        <div class="buttons">
            <div class="btn copy" onclick=@(() => Copy())>@CopyLabel</div>
            <div class="btn @(BaseOn ? "on" : "")" onclick=@(() => ToggleBase())>
                @(BaseOn ? "Using base" : "Use base")
            </div>
            <div class="btn @(Stability ? "on" : "")" onclick=@(() => ToggleStability())>
                @(Stability ? "Stability on" : "Stability off")
            </div>
            <div class="btn @(ClimbHold ? "on" : "")" onclick=@(() => ToggleHold())>
                @(ClimbHold ? "Climbs while firing" : "Plateaus while firing")
            </div>
            <div class="btn @(Credit ? "on" : "")" onclick=@(() => ToggleCredit())>
                @(Credit ? "Pull-down counts" : "Pull-down ignored")
            </div>
            <div class="btn" onclick=@(() => ResetValues())>Reset</div>
        </div>

    </div>
</root>

@code {

    /// <summary>Is the tuner on screen. Driven by `nz_recoil`.</summary>
    public static bool Visible { get; set; }

    // ⚠️ BOUND STRAIGHT TO THE STATICS, never to local copies. A copy drifts the moment a console
    // command writes the same value, and then the slider fights it — SoulBoxTuner's note, and it
    // matters more here because every one of these also has a ConCmd.

    float VerticalBase
    {
        get => GlobalHandling.VerticalBase;
        set { GlobalHandling.VerticalBase = value; GlobalHandling.UseRecoilBase = true; }
    }

    float HorizontalBase
    {
        get => GlobalHandling.HorizontalBase;
        set { GlobalHandling.HorizontalBase = value; GlobalHandling.UseRecoilBase = true; }
    }

    float VerticalJitter
    {
        get => GlobalHandling.VerticalJitter;
        set => GlobalHandling.VerticalJitter = value;
    }

    float HorizontalJitter
    {
        get => GlobalHandling.HorizontalJitter;
        set => GlobalHandling.HorizontalJitter = value;
    }

    float Settle
    {
        get => GlobalHandling.StabilitySettle;
        set => GlobalHandling.StabilitySettle = value;
    }

    float Scale
    {
        get => GlobalHandling.RecoilScale;
        set => GlobalHandling.RecoilScale = value;
    }

    // ⚠️ THESE TWO WORK WITH BASE MODE OFF, unlike every slider above them. The recovery is
    // the same code for an authored weapon and a based one — only the size of the kick differs —
    // so the warning at the top of the panel does not apply to this group.

    float Recovered
    {
        get => GlobalHandling.RecoilRecoverFraction;
        set => GlobalHandling.RecoilRecoverFraction = value;
    }

    float RecoverSpeed
    {
        get => GlobalHandling.RecoilRecoverSpeed;
        set => GlobalHandling.RecoilRecoverSpeed = value;
    }

    static bool BaseOn => GlobalHandling.UseRecoilBase;
    static bool Stability => GlobalHandling.RecoilStability;
    static bool ClimbHold => GlobalHandling.RecoilClimbHold;
    static bool Credit => GlobalHandling.RecoilCredit;

    /// <summary>
    /// What a shot actually does to the view.
    ///
    /// ⛔ THE SLIDER VALUE IS NOT THIS. `FinishRecoil` multiplies by RecoilScale and aiming damps
    /// to 0.4x, before perks, tech, Double Tap and Overpressure get their turn at equip/fire time.
    /// </summary>
    string Delivered
    {
        get
        {
            var up = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale;
            var side = GlobalHandling.HorizontalBase * GlobalHandling.RecoilScale;
            return $"per shot: up {up:0.00}° hip / {up * 0.4f:0.00}° ads"
                + $"   ·   side ±{side:0.00}° hip / ±{side * 0.4f:0.00}° ads";
        }
    }

    /// <summary>
    /// ⚠️ A TEN-SHOT BURST, because a per-shot figure in hundredths of a degree is not something
    /// anyone can picture. The climb is what the player fights.
    /// </summary>
    string Climb
    {
        get
        {
            var up = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale * 10f;
            return $"10-shot burst climbs {up:0.0}° hip / {up * 0.4f:0.0}° ads"
                + (ClimbHold ? ", and keeps climbing while held" : ", then plateaus — hold is OFF");
        }
    }

    /// <summary>
    /// Where the sight ends up once the burst is over.
    ///
    /// ⛔ THE PERCENTAGE ON THE SLIDER DOES NOT ANSWER THIS. "20% sticks" is a rate; what the
    /// player judges is whether a magazine leaves them looking at a torso or at a ceiling, and
    /// that is the rate times the magazine. Same argument as Delivered's — the authored number
    /// has never been the felt one.
    ///
    /// ⚠️ A 30-ROUND MAGAZINE AND THE FLEET'S 0.5 AUTO-CONTROL, which 461 of 496 prefabs
    /// author. A figure computed without it would be double what any real weapon does.
    /// </summary>
    string Sticks
    {
        get
        {
            var sticks = 1f - GlobalHandling.RecoilRecoverFraction;
            if ( sticks <= 0.001f )
                return "nothing sticks — every burst returns to the exact pixel it started on";

            var perShot = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale * 0.5f;
            var mag = 30f * perShot * sticks;
            return $"{sticks * 100f:0}% of each shot is permanent"
                + $"   ·   30 rounds leaves you {mag:0.0}° high / {mag * 0.4f:0.0}° ads";
        }
    }

    /// <summary>Real C#, so it goes back into GlobalHandling.cs without retyping.</summary>
    string Snippet =>
        $"VerticalBase {GlobalHandling.VerticalBase:0.###}f · HorizontalBase {GlobalHandling.HorizontalBase:0.###}f"
        + $" · jitter {GlobalHandling.VerticalJitter:0.##}f/{GlobalHandling.HorizontalJitter:0.##}f"
        + $" · settle {GlobalHandling.StabilitySettle:0.##}f · scale {GlobalHandling.RecoilScale:0.##}f"
        + $" · recovered {GlobalHandling.RecoilRecoverFraction:0.##}f"
        + $" · walk-back {GlobalHandling.RecoilRecoverSpeed:0.#}f";

    string CopyLabel { get; set; } = "Copy";

    void Copy()
    {
        Clipboard.SetText( Snippet );
        CopyLabel = "Copied";
        Log.Info( $"[nz-recoil] {Snippet}" );
    }

    void ToggleBase()
    {
        GlobalHandling.UseRecoilBase = !GlobalHandling.UseRecoilBase;
        Log.Info( $"[nz-recoil] base mode {(BaseOn ? "ON" : "off — authored per weapon")}" );
    }

    void ToggleStability()
    {
        GlobalHandling.RecoilStability = !GlobalHandling.RecoilStability;
        Log.Info( $"[nz-recoil] stability {(Stability ? "ON" : "off — old accumulating sweep")}" );
    }

    void ToggleHold()
    {
        GlobalHandling.RecoilClimbHold = !GlobalHandling.RecoilClimbHold;
        Log.Info( ClimbHold
            ? "[nz-recoil] climb hold ON — the view rises until firing stops"
            : "[nz-recoil] climb hold off — recovery runs during fire, so the climb plateaus" );
    }

    void ToggleCredit()
    {
        GlobalHandling.RecoilCredit = !GlobalHandling.RecoilCredit;
        Log.Info( Credit
            ? "[nz-recoil] your pull-down counts — the gun returns only what you did not"
            : "[nz-recoil] pull-down ignored — fighting the climb down then releasing will take"
                + " you the same distance below where you started" );
    }

    /// <summary>
    /// Back to what the source ships.
    ///
    /// ⛔ NOT `Reset` — that hides `Component.Reset()`, a collision this project has been bitten by
    /// twice.
    ///
    /// ⚠️ THESE MUST TRACK THE DEFAULTS IN GlobalHandling. A reset that restores values the source
    /// no longer ships is worse than no reset at all.
    /// </summary>
    void ResetValues()
    {
        GlobalHandling.VerticalBase = 0.35f;
        GlobalHandling.HorizontalBase = 0.36f;
        GlobalHandling.VerticalJitter = 0.25f;
        GlobalHandling.HorizontalJitter = 0.49f;
        GlobalHandling.StabilitySettle = 1f;
        GlobalHandling.RecoilScale = 2.2f;
        GlobalHandling.UseRecoilBase = true;
        GlobalHandling.RecoilRecoverFraction = 0.8f;
        GlobalHandling.RecoilRecoverSpeed = 35f;
        GlobalHandling.RecoilClimbHold = true;
        GlobalHandling.RecoilCredit = true;
        CopyLabel = "Copy";
        Log.Info( "[nz-recoil] back to source defaults" );
    }

    void Hide()
    {
        Visible = false;
        Mouse.Visibility = MouseVisibility.Hidden;
    }

    /// <summary>
    /// `nz_recoil [0/1]` — open the sliders.
    ///
    /// ⚠️ IT TAKES THE CURSOR, like the other tuners — a slider cannot be dragged without one, and
    /// Noclip keys off Mouse.Visibility, so leaving it hidden would have V toggling noclip under
    /// the player mid-drag.
    /// </summary>
    [ConCmd( "nz_recoil" )]
    public static void RecoilCmd( int on = -1 )
    {
        EnsureHost();

        Visible = on < 0 ? !Visible : on != 0;
        Mouse.Visibility = Visible ? MouseVisibility.Visible : MouseVisibility.Hidden;

        Log.Info( $"[nz-recoil] tuner {(Visible ? "open" : "closed")}"
            + $" — base {(GlobalHandling.UseRecoilBase ? "ON" : "off")},"
            + $" vertical {GlobalHandling.VerticalBase:0.##},"
            + $" horizontal {GlobalHandling.HorizontalBase:0.##}" );
    }

    static GameObject _host;

    /// <summary>
    /// Make sure something is drawing this panel.
    ///
    /// ⛔ THE PANEL HAS NO HOME IN THE SCENE. Without this the razor compiles and never renders:
    /// the command reports "open" and nothing appears.
    ///
    /// ⚠️ REBUILT WHENEVER THE OBJECT IS GONE, not once — a GameObject created from code does not
    /// survive a hotload.
    /// </summary>
    static void EnsureHost()
    {
        if ( _host.IsValid() ) return;

        var scene = Game.ActiveScene;
        if ( !scene.IsValid() ) return;

        _host = scene.CreateObject();
        _host.Name = "Recoil Tuner UI";
        _host.Flags |= GameObjectFlags.NotSaved;

        var screen = _host.Components.Create<ScreenPanel>();
        screen.ZIndex = 80;

        _host.Components.Create<RecoilTuner>();

        Log.Info( "[nz-recoil] created the tuner's screen panel" );
    }

    // ⚠️ EVERY DISPLAYED NUMBER IS IN THE HASH. Leave one out and it freezes on screen while the
    // value behind it moves, which reads as the panel being broken rather than stale.
    protected override int BuildHash()
        => HashCode.Combine(
            HashCode.Combine( Visible, VerticalBase, HorizontalBase, VerticalJitter ),
            HashCode.Combine( HorizontalJitter, Settle, Scale ),
            HashCode.Combine( BaseOn, Stability, CopyLabel ),
            HashCode.Combine( Recovered, RecoverSpeed, ClimbHold, Credit ) );
}