UI/ModelTuner.razor

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.

File Access
@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 ) );
}