A Razor UI panel component for an in-game "Tilt Tuner". It exposes sliders bound to GlobalHandling static settings (visual follow gains, pivot, softness, damp, spread, ADS) and displays computed readouts; it has copy, reset, toggle follow, show/hide and host creation logic.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using NZombies;
@inherits PanelComponent
@*
TILT TUNER — `nz_tilt`. Sliders for the viewmodel lean that follows the shot.
⛔ A SECOND PANEL RATHER THAN MORE ROWS ON `nz_recoil`, BECAUSE THEY TUNE DIFFERENT SENSES.
That one moves where the bullet goes; every number here moves only what the gun LOOKS like.
Three separate fixes in this project have been aimed at the wrong one of those two, which is
exactly the confusion a shared panel would keep alive — and the existing tuner's own header
already makes that argument about its three groups.
⚠️ THE NUMBERS ARE MULTIPLIERS ON A KICK, NOT DEGREES, so the readout does the arithmetic. A
gain of 1.5 is 1.16° of lean today and something else the moment `RecoilScale` moves; reading
the slider as if it were an angle is the same mistake the recoil base kept inviting.
⚠️ SAME SHAPE AS RecoilTuner: a command that ensures its own host, a static Visible, a copy
button, and a BuildHash carrying every displayed number.
*@
<root class="tilt @(Visible ? "" : "hidden")">
<div class="panel">
@* ⛔ THE GRIP IS AN EMPTY SIBLING LAID OVER THE BAR, NOT A WRAPPER AROUND IT. Wrapping
the header in a custom Panel is what the razor generator refuses: it compiles the children
as properties of the type and fails with "'DragHandle' does not contain a definition for
'div'". No custom Panel in this project takes children in markup, and this is why.
⚠️ LAST, SO IT DRAWS ON TOP and receives the press instead of the title beneath it.
It stops short of the right edge so the close button stays clickable. *@
<div class="head">
<div class="title">WEAPON TILT</div>
<div class="close" onclick=@(() => Hide())>×</div>
<DragHandle class="grip"></DragHandle>
</div>
@if ( !Follow )
{
<div class="warn">
Follow is OFF — the model uses each weapon's authored magnitudes and a random
direction per shot. Gain and roll do nothing; pivot and softness still apply.
</div>
}
<div class="group">FOLLOW THE SHOT — how far the gun leans per degree of kick</div>
<div class="row">
<div class="label">Up</div>
<SliderControl class="s" Value:bind=@Up Min="@(0f)" Max="@(30f)" Step="@(0.05f)"></SliderControl>
<div class="val">@Up.ToString( "0.00" )</div>
</div>
<div class="row">
<div class="label">Side</div>
<SliderControl class="s" Value:bind=@Side Min="@(0f)" Max="@(30f)" Step="@(0.05f)"></SliderControl>
<div class="val">@Side.ToString( "0.00" )</div>
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@Roll Min="@(0f)" Max="@(30f)" Step="@(0.05f)"></SliderControl>
<div class="val">@Roll.ToString( "0.00" )</div>
</div>
<div class="group">AIMING — a rolled model tips the sight picture itself. Above 1 AMPLIFIES</div>
<div class="row">
<div class="label">Damp</div>
<SliderControl class="s" Value:bind=@Ads Min="@(0f)" Max="@(3f)" Step="@(0.05f)"></SliderControl>
<div class="val">@((Ads * 100f).ToString( "0" ))%</div>
</div>
<div class="group">PIVOT — 0 the grip, 1 the middle, above 1 out past the muzzle</div>
<div class="row">
<div class="label">Centre</div>
<SliderControl class="s" Value:bind=@Pivot Min="@(0f)" Max="@(3f)" Step="@(0.05f)"></SliderControl>
<div class="val">@Pivot.ToString( "0.00" )</div>
</div>
<div class="group">FLEET — how much of a weapon's recoil multiplier reaches its lean</div>
<div class="row">
<div class="label">Spread</div>
<SliderControl class="s" Value:bind=@Spread Min="@(0f)" Max="@(1f)" Step="@(0.01f)"></SliderControl>
<div class="val">@Spread.ToString( "0.00" )</div>
</div>
<div class="group">SOFTNESS — how the lean accelerates, and how it stops</div>
<div class="row">
<div class="label">Reach</div>
<SliderControl class="s" Value:bind=@Soften Min="@(0f)" Max="@(0.6f)" Step="@(0.005f)"></SliderControl>
<div class="val">@(Soften <= 0.001f ? "instant" : Soften.ToString( "0.000" ) + "s")</div>
</div>
<div class="row">
<div class="label">Stop</div>
<SliderControl class="s" Value:bind=@Damp Min="@(0.2f)" Max="@(6f)" Step="@(0.05f)"></SliderControl>
<div class="val">@Damp.ToString( "0.00" )</div>
</div>
<div class="out">@Delivered</div>
<div class="out climb">@Character</div>
<div class="out fleet">@Fleet</div>
<div class="buttons">
<div class="btn" onclick=@(() => ToggleFollow())>@(Follow ? "Following" : "Random")</div>
<div class="btn copy" onclick=@(() => Copy())>@CopyLabel</div>
<div class="btn" onclick=@(() => ResetValues())>Defaults</div>
</div>
</div>
</root>
@code {
/// <summary>Is the tuner on screen. Driven by `nz_tilt`.</summary>
public static bool Visible { get; set; }
// ⚠️ BOUND STRAIGHT TO THE STATICS, never to local copies. Every one of these also has a
// ConCmd, so a local copy would drift the moment the console wrote the same value and then
// the slider would fight it. RecoilTuner carries the same note for the same reason.
float Up
{
get => GlobalHandling.VisualFollowUp;
set => GlobalHandling.VisualFollowUp = value;
}
float Side
{
get => GlobalHandling.VisualFollowSide;
set => GlobalHandling.VisualFollowSide = value;
}
float Roll
{
get => GlobalHandling.VisualFollowRoll;
set => GlobalHandling.VisualFollowRoll = value;
}
float Ads
{
get => GlobalHandling.VisualFollowAds;
set => GlobalHandling.VisualFollowAds = value;
}
float Pivot
{
get => GlobalHandling.VisualPivot;
set => GlobalHandling.VisualPivot = value;
}
float Soften
{
get => GlobalHandling.VisualTiltSoften;
set => GlobalHandling.VisualTiltSoften = value;
}
float Damp
{
get => GlobalHandling.VisualTiltDamp;
set => GlobalHandling.VisualTiltDamp = value;
}
float Spread
{
get => GlobalHandling.VisualSpread;
set => GlobalHandling.VisualSpread = value;
}
static bool Follow => GlobalHandling.VisualFollowsRecoil;
/// <summary>
/// What a base weapon's shot actually does to the model.
///
/// ⛔ THE SLIDER IS A MULTIPLIER, NOT AN ANGLE. The lean is the KICK times the gain, and the
/// kick is `VerticalBase * RecoilScale` — so the same slider means a different number of
/// degrees the moment either of those moves. Printing only the gain is what made the earlier
/// "×6 is too big" exchange about the wrong quantity.
///
/// ⚠️ "BASE WEAPON" MEANS 800 rpm AT ×1.00, which is what the CM901 is stamped at. Every other
/// gun is this scaled by its own recoil multiplier, so this line is the fleet's anchor rather
/// than whatever happens to be in your hands.
/// </summary>
string Delivered
{
get
{
var upKick = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale;
var sideKick = GlobalHandling.HorizontalBase * GlobalHandling.RecoilScale;
return $"base weapon {upKick:0.00}° up -> {upKick * Up:0.00}° lean"
+ $" {sideKick:0.00}° across -> {sideKick * Side:0.00}°"
+ $" roll {sideKick * Side * Roll:0.00}°";
}
}
/// <summary>
/// The two things a number cannot tell you: how the stop reads, and whether the softness
/// outruns the fire rate.
///
/// ⚠️ THE SHOT GAP IS THE COMPARISON THAT MATTERS. Past ~0.075s a burst at 800 rpm never
/// reaches full tilt, which reads as the gun going limp under sustained fire — a real choice,
/// but one nobody can make from the slider alone.
/// </summary>
string Character
{
get
{
var upKick = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale;
var aiming = upKick * Up * Ads * 0.4f;
var stop = Damp < 0.9f ? "overshoots, then settles"
: Damp <= 1.1f ? "stops clean, no overshoot"
: "creeps in, never overshoots";
var reach = Soften <= 0.001f
? "arrives in one frame"
: Soften >= 0.075f
? $"{Soften:0.000}s to arrive — slower than an 800 rpm shot gap"
: $"{Soften:0.000}s to arrive";
var where = Pivot >= 0.99f ? "turns about the model's centre"
: Pivot <= 0.01f ? "turns about the grip"
: "turns between grip and centre";
return $"aiming {aiming:0.00}° {where} {reach}, {stop}";
}
}
/// <summary>
/// The same setting read as three real weapons rather than as an exponent.
///
/// ⛔ "0.45" SAYS NOTHING AND THE FLEET NUMBERS SAY EVERYTHING. At 1.00 the Kar98K leans
/// 105° — a cartwheel, not a kick — because its ×22.86 is a per-SECOND multiplier being
/// read as a per-shot one. Watching that figure move as the slider does is the only way to
/// judge this knob; the CM901 in the middle never moves, which is the point of an exponent.
/// </summary>
string Fleet
{
get
{
var anchor = GlobalHandling.VerticalBase * GlobalHandling.RecoilScale * Up;
var smg = anchor * GlobalHandling.SpreadFactor( 0.4f ) * 0.4f;
var kar = anchor * GlobalHandling.SpreadFactor( 22.86f ) * 22.86f;
return $"fleet MP40 ×0.40 {smg:0.0}° CM901 ×1.00 {anchor:0.0}°"
+ $" Kar98K ×22.86 {kar:0.0}°"
+ (kar > 25f ? " ⛔ cartwheel" : "");
}
}
string Snippet =>
$"nz_recoil_tilt {(Follow ? 1 : 0)} {Up:0.##} {Side:0.##} {Roll:0.##} {Ads:0.##} {Pivot:0.##}"
+ $" ; nz_recoil_soft {Soften:0.###} {Damp:0.##}"
+ $" ; nz_recoil_spread {Spread:0.##}";
string CopyLabel { get; set; } = "Copy";
void Copy()
{
Clipboard.SetText( Snippet );
CopyLabel = "Copied";
Log.Info( $"[nz-tilt] {Snippet}" );
}
void ToggleFollow()
{
GlobalHandling.VisualFollowsRecoil = !GlobalHandling.VisualFollowsRecoil;
Log.Info( Follow
? "[nz-tilt] following the shot — the gun leans the way the kick pushed"
: "[nz-tilt] random direction — authored magnitudes, a fresh angle per shot" );
}
/// <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 — and this panel has already watched
/// `RecoilScale` move twice in one evening.
/// </summary>
void ResetValues()
{
GlobalHandling.VisualFollowsRecoil = true;
GlobalHandling.VisualFollowUp = 5.95f;
GlobalHandling.VisualFollowSide = 3.15f;
GlobalHandling.VisualFollowRoll = 0f;
GlobalHandling.VisualFollowAds = 0.5f;
GlobalHandling.VisualPivot = 0.95f;
GlobalHandling.VisualTiltSoften = 0.275f;
GlobalHandling.VisualTiltDamp = 1.7f;
GlobalHandling.VisualSpread = 0.45f;
CopyLabel = "Copy";
Log.Info( "[nz-tilt] back to source defaults" );
}
void Hide()
{
Visible = false;
Mouse.Visibility = MouseVisibility.Hidden;
}
/// <summary>
/// `nz_tilt [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_tilt" )]
public static void TiltPanelCmd( int on = -1 )
{
EnsureHost();
Visible = on < 0 ? !Visible : on != 0;
Mouse.Visibility = Visible ? MouseVisibility.Visible : MouseVisibility.Hidden;
Log.Info( $"[nz-tilt] tuner {(Visible ? "open" : "closed")}"
+ $" — {(GlobalHandling.VisualFollowsRecoil ? "following the shot" : "random direction")},"
+ $" up ×{GlobalHandling.VisualFollowUp:0.##},"
+ $" side ×{GlobalHandling.VisualFollowSide: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.
///
/// ⚠️ ITS OWN HOST, NOT RecoilTuner'S. Sharing one would tie the two panels' lifetimes
/// together, so closing either would have to know about the other — and a ScreenPanel per dev
/// tool is what every other tuner here already does.
///
/// ⚠️ 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 = "Tilt Tuner UI";
_host.Flags |= GameObjectFlags.NotSaved;
var screen = _host.Components.Create<ScreenPanel>();
screen.ZIndex = 81;
_host.Components.Create<TiltTuner>();
Log.Info( "[nz-tilt] 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.
//
// ⚠️ `RecoilScale` AND THE BASES ARE IN IT TOO, even though no slider here touches them: the
// delivered line is computed FROM them, so a change made in the other tuner has to repaint
// this one or the two panels will disagree on screen.
protected override int BuildHash()
=> HashCode.Combine(
HashCode.Combine( Visible, Up, Side, Roll ),
HashCode.Combine( Ads, Pivot, Soften, Damp ),
HashCode.Combine( Spread ),
HashCode.Combine( Follow, CopyLabel ),
HashCode.Combine( GlobalHandling.RecoilScale, GlobalHandling.VerticalBase,
GlobalHandling.HorizontalBase ) );
}