A Razor UI panel component for an in-game Model Tuner. It lists spawned zombie variants, lets you pick a variant, and exposes sliders for pitch, yaw, roll and scale; it applies those values every frame to all zombies of the chosen variant and can copy a pasteable .zvar line including scaled ground speeds.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@using NZombies;
@inherits PanelComponent
@*
MODEL TUNER — `nz_model`. A rig's orientation and its size, on sliders, against the live thing.
⛔ IT TUNES A VARIANT, NOT AN OBJECT. Every zombie of the chosen variant gets the values every
frame, so a boss that dies and respawns is still turned the way you left it — and a horde of
forty walkers standing behind it is not.
⚠️ IT DOES NOT WRITE THE `.zvar`. The paste line at the bottom is the deliverable; a respawn
from a fresh asset load undoes everything here. Same contract as `nz_zscale` and `nz_model_turn`.
*@
<root class="mtune @(Visible ? "" : "hidden")">
<div class="panel">
<div class="head">
<div class="title">MODEL</div>
<div class="close" onclick=@(() => Hide())>×</div>
</div>
<div class="state @(Targets.Any() ? "" : "bad")">@State</div>
@if ( Targets.Any() )
{
@* ⚠️ ONE BUTTON PER VARIANT ACTUALLY STANDING THERE, rather than a text field. The ids
are asset names — `oberon`, `walker`, `brutus` — and typing one you cannot see is how
you spend a minute turning nothing. *@
<div class="group">TARGET</div>
<div class="buttons">
@foreach ( var v in Targets )
{
<div class="btn @(v == Target ? "on" : "")" onclick=@(() => Pick( v ))>
@v <small>×@(Count( v ))</small>
</div>
}
</div>
<div class="group">TURN — the rig's own correction, applied after the facing</div>
<div class="row">
<div class="label">Pitch</div>
<SliderControl class="s" Value:bind=@Pitch Min="@(-180f)" Max="@(180f)" Step="@(Step)"></SliderControl>
<div class="num">@Pitch.ToString( "0.##" )</div>
</div>
<div class="row">
<div class="label">Yaw</div>
<SliderControl class="s" Value:bind=@Yaw Min="@(-180f)" Max="@(180f)" Step="@(Step)"></SliderControl>
<div class="num">@Yaw.ToString( "0.##" )</div>
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@Roll Min="@(-180f)" Max="@(180f)" Step="@(Step)"></SliderControl>
<div class="num">@Roll.ToString( "0.##" )</div>
</div>
<div class="group">HEIGHT — @(Tall.ToString( "0" )) units tall at ×@(Scale.ToString( "0.###" ))</div>
<div class="row">
<div class="label">Size</div>
<SliderControl class="s" Value:bind=@Scale Min="@(0.1f)" Max="@(2f)" Step="@(0.01f)"></SliderControl>
<div class="num">@Scale.ToString( "0.###" )</div>
</div>
@* ⛔ THE SPEEDS ARE PART OF THE SIZE AND THIS IS WHERE THAT GETS SAID. `ModelScale` never
reaches `_clipGroundSpeed`, so a model resized without its ground speeds resized to
match cycles its legs at the wrong rate for the distance its feet travel — a skate.
The paste line below carries both, already scaled. *@
@if ( SpeedWarning is not null )
{
<div class="warn">@SpeedWarning</div>
}
<div class="buttons">
<div class="btn @(Fine ? "on" : "")" onclick=@(() => Fine = !Fine)>
@(Fine ? "Fine 0.1°" : "Coarse 1°")
</div>
<div class="btn copy" onclick=@(() => Copy())>@CopyLabel</div>
<div class="btn" onclick=@(() => Print())>Print</div>
<div class="btn" onclick=@(() => Zero())>Zero turn</div>
</div>
<div class="out">@Line</div>
}
</div>
</root>
@code {
/// <summary>Is the tuner on screen. Driven by `nz_model`.</summary>
public static bool Visible { get; set; }
/// <summary>Which variant the sliders drive. Empty until something is picked.</summary>
public static string Target { get; set; } = "";
static bool Fine { get; set; }
static string CopyLabel = "Copy";
static GameObject _host;
/// <summary>
/// The values, held here rather than read back from a zombie every frame.
/// </summary>
///
/// ⚠️ THE PANEL IS THE AUTHORITY WHILE IT IS OPEN. Reading them back from the first live zombie
/// would mean a boss that respawned with the asset's values silently dragged the sliders back,
/// mid-drag, to numbers you had just replaced.
static float Pitch { get; set; }
static float Yaw { get; set; }
static float Roll { get; set; }
static float Scale { get; set; } = 1f;
static float Step => Fine ? 0.1f : 1f;
static IEnumerable<ZombieAI> All
=> Game.ActiveScene?.GetAllComponents<ZombieAI>().Where( z => z.IsValid() )
?? Enumerable.Empty<ZombieAI>();
/// <summary>Every variant standing in the scene right now.</summary>
static List<string> Targets
=> All.Select( z => z.Variant?.ResourceName ?? "walker" ).Distinct().OrderBy( s => s ).ToList();
static int Count( string v ) => All.Count( z => (z.Variant?.ResourceName ?? "walker") == v );
static IEnumerable<ZombieAI> Chosen
=> All.Where( z => (z.Variant?.ResourceName ?? "walker") == Target );
/// <summary>The model's height in units at the current size, measured from the one standing there.</summary>
static float Tall
{
get
{
var z = Chosen.FirstOrDefault();
if ( !z.IsValid() ) return 0f;
// ⚠️ `BodyHeight` IS ALREADY SCALED — `ApplyModelScale` multiplies it at spawn and again
// on every change — so it is the honest number to show, not the asset's value.
return z.BodyHeight;
}
}
static string State
{
get
{
if ( !Targets.Any() ) return "nothing spawned — nz_boss_spawn oberon, or the dev menu";
if ( string.IsNullOrEmpty( Target ) ) return "pick a variant";
return $"{Target} ×{Count( Target )} · pitch {Pitch:0.##} yaw {Yaw:0.##} roll {Roll:0.##}"
+ $" · ×{Scale:0.###}";
}
}
/// <summary>
/// Said out loud whenever the size has moved away from what the asset's speeds were written for.
/// </summary>
static string SpeedWarning
{
get
{
var z = Chosen.FirstOrDefault();
if ( !z.IsValid() || z.Variant is null ) return null;
var authored = z.Variant.ModelScale <= 0f ? 1f : z.Variant.ModelScale;
if ( MathF.Abs( Scale - authored ) < 0.005f ) return null;
return $"⚠ ground speeds were written for ×{authored:0.###} — at ×{Scale:0.###} they must "
+ $"scale by {Scale / authored:0.###} or the legs cycle at the wrong rate. "
+ "The paste line has them.";
}
}
/// <summary>The line to put in the `.zvar`, including the speeds the size drags with it.</summary>
static string Line
{
get
{
var z = Chosen.FirstOrDefault();
if ( !z.IsValid() ) return "";
var authored = (z.Variant?.ModelScale ?? 1f) <= 0f ? 1f : z.Variant.ModelScale;
var k = Scale / authored;
var speeds = string.Join( ", ", (z.Variant?.SpeedTiers ?? new()).Select(
t => $"{t.Name} {t.GroundSpeed * k:0.#}" ) );
return $"\"ModelPitchOffset\": {Pitch:0.###}, \"ModelYawOffset\": {Yaw:0.###}, "
+ $"\"ModelRollOffset\": {Roll:0.###}, \"ModelScale\": {Scale:0.###}"
+ (speeds.Length > 0 ? $" · GroundSpeed → {speeds}" : "");
}
}
void Pick( string v )
{
Target = v;
// ⚠️ ADOPT WHAT THAT VARIANT IS ALREADY WEARING, so picking a target does not snap it to
// whatever the previous one was set to. Only on the pick — see the note on the fields.
var z = Chosen.FirstOrDefault();
if ( !z.IsValid() ) return;
Pitch = z.ModelPitchOffset;
Yaw = z.ModelYawOffset;
Roll = z.ModelRollOffset;
Scale = z.ModelScale <= 0f ? 1f : z.ModelScale;
}
/// <summary>
/// Push the sliders onto every zombie of the chosen variant, every frame.
/// </summary>
///
/// ⚠️ EVERY FRAME RATHER THAN ON CHANGE, so a zombie that spawns while the panel is open is
/// turned too. `ApplyModelScale` computes a factor from what it last applied and returns early
/// when that factor is 1, so repeating it costs nothing.
protected override void OnUpdate()
{
if ( !Visible || string.IsNullOrEmpty( Target ) ) return;
foreach ( var z in Chosen )
{
z.ModelPitchOffset = Pitch;
z.ModelYawOffset = Yaw;
z.ModelRollOffset = Roll;
z.ApplyModelScale( Scale );
}
}
void Zero()
{
Pitch = Yaw = Roll = 0f;
}
void Copy()
{
Clipboard.SetText( Line );
CopyLabel = "Copied";
Log.Info( "[nz-model] copied" );
}
/// <summary>⛔ One `Log.Info` per line — the console keeps a message's first line and drops the
/// rest. See INSTRUCTIONS §33.</summary>
void Print()
{
Log.Info( $"[nz-model] {Target}:" );
Log.Info( Line );
}
void Hide() => Shut();
static void Shut()
{
Visible = false;
Mouse.Visibility = MouseVisibility.Hidden;
Log.Info( "[nz-model] closed — nothing was written to the .zvar" );
}
/// <summary>
/// `nz_model [0/1]` — the model tuner: pitch, yaw, roll and size against the live thing.
/// </summary>
[ConCmd( "nz_model" )]
public static void ModelCmd( int on = -1 )
{
var fresh = EnsureHost();
// ⛔ A PANEL THAT WAS JUST BUILT OPENS, IT DOES NOT TOGGLE — `Visible` is a static and
// survives a hotload while the GameObject holding the panel does not.
Visible = fresh || (on < 0 ? !Visible : on != 0);
if ( !Visible ) { Shut(); return; }
Mouse.Visibility = MouseVisibility.Visible;
// ⚠️ OPEN ON A BOSS IF ONE IS STANDING THERE. It is the only kind of model anybody opens
// this for, and "walker" is almost never the answer.
if ( string.IsNullOrEmpty( Target ) || !Targets.Contains( Target ) )
{
var boss = All.FirstOrDefault( z => z.Variant?.IsBoss ?? false );
var pick = boss.IsValid() ? boss.Variant.ResourceName : Targets.FirstOrDefault();
if ( !string.IsNullOrEmpty( pick ) )
{
var panel = Game.ActiveScene?.GetAllComponents<ModelTuner>().FirstOrDefault();
panel?.Pick( pick );
}
}
Log.Info( $"[nz-model] open — {State}" );
}
static bool EnsureHost()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return false;
if ( _host.IsValid() ) return false;
_host = scene.CreateObject();
_host.Name = "Model Tuner UI";
_host.Flags |= GameObjectFlags.NotSaved;
var screen = _host.Components.Create<ScreenPanel>();
screen.ZIndex = 82;
_host.Components.Create<ModelTuner>();
Log.Info( "[nz-model] created the tuner panel" );
return true;
}
// ⚠️ EVERY DISPLAYED VALUE IS IN THE HASH, or it freezes on screen while the value behind it
// moves — which reads as a broken panel rather than a stale one.
protected override int BuildHash()
=> HashCode.Combine(
HashCode.Combine( Visible, Target, CopyLabel, Fine ),
HashCode.Combine( Pitch, Yaw, Roll, Scale ),
HashCode.Combine( State, Line, Tall, Targets.Count ) );
}