UI editor panel for weapon viewmodel offsets. It stores and edits position and rotation (AngPos) and FOV, supports dragging to change values, copying/saving/printing offsets, switching between pose slots and a VM correction mode, and persists values via NZombies.WeaponPlacement.
using Sandbox.UI;
using SWB.Base;
using SWB.Base.Attachments;
using System;
namespace SWB.Editor;
public enum DragMode
{
pos = 0,
angle = 1,
}
public partial class OffsetEditor
{
public float X { get; set; } = 0f;
public float Y { get; set; } = 0f;
public float Z { get; set; } = 0f;
public float Pitch { get; set; } = 0f;
public float Yaw { get; set; } = 0f;
public float Roll { get; set; } = 0f;
public float Sensitivity { get; set; } = 0f;
public float FOV { get; set; } = 75f;
public string DragModeLabel { get; set; }
DragMode dragMode = DragMode.pos;
float startX;
float startY;
/// <summary>
/// Per-axis drag direction. ⚠️ Remapping the controls for the 90 degree
/// viewmodel spin also flips the SIGN of some axes — a rotation that sends
/// X to Y sends Y to -X, so half the drags come out inverted. Which half
/// depends on the spin, so these are live-flippable rather than guessed:
/// `nz_editor_invert y | z | pitch | yaw` toggles one.
/// </summary>
// ⚠️ InvY = -1 is MEASURED, not a guess: the 90 degree spin sends X to Y and
// Y to -X, so the horizontal position drag comes out backwards. Confirmed in
// game. The other three are correct at +1.
public static float InvY = -1f, InvZ = 1f, InvPitch = 1f, InvYaw = 1f;
/// <summary>Flip one drag axis. `nz_editor_invert pitch`.</summary>
[ConCmd( "nz_editor_invert" )]
public static void InvertAxis( string axis = "" )
{
switch ( axis.ToLowerInvariant() )
{
case "y": InvY = -InvY; break;
case "z": InvZ = -InvZ; break;
case "pitch": InvPitch = -InvPitch; break;
case "yaw": InvYaw = -InvYaw; break;
default:
Log.Info( "[editor] nz_editor_invert <y|z|pitch|yaw>" );
Log.Info( $"[editor] current y {InvY:+0;-0} z {InvZ:+0;-0} " +
$"pitch {InvPitch:+0;-0} yaw {InvYaw:+0;-0}" );
return;
}
Log.Info( $"[editor] invert y {InvY:+0;-0} z {InvZ:+0;-0} " +
$"pitch {InvPitch:+0;-0} yaw {InvYaw:+0;-0}" );
}
float xOrigin;
float yOrigin;
float zOrigin;
float pitchOrigin;
float yawOrigin;
Weapon weapon;
/// <summary>
/// Which slot the loaded values came from, so SAVE writes them back to the
/// right one. Defaults to the aim pose — that is what this is used for.
/// </summary>
string slot = NZombies.WeaponPlacement.Aim;
public OffsetEditor( Weapon weapon )
{
this.weapon = weapon;
}
protected override void OnAfterTreeRender( bool firstTime )
{
if ( !firstTime ) return;
DragModeLabel = "y/z";
FOV = weapon.ViewModelFOV;
}
public virtual void OnReset()
{
X = 0;
Y = 0;
Z = 0;
Pitch = 0;
Yaw = 0;
Roll = 0;
FOV = weapon.ViewModelFOV;
}
public virtual void OnCopy()
{
Clipboard.SetText( String.Format( "new AngPos {{ Angle = new Angles({0:0.###}f, {1:0.###}f, {2:0.###}f), Pos = new Vector3({3:0.###}f, {4:0.###}f, {5:0.###}f) }}",
Pitch, Yaw, Roll, X, Y, Z ) );
}
/// <summary>
/// Persist the current values for this weapon, applied automatically on spawn.
///
/// ⛔ THE PREFAB IS READ-ONLY AT RUNTIME, so this writes to FileSystem.Data and
/// WeaponPlacement re-applies it when the weapon spawns. Aligning a sight IS
/// the fix now — no copying numbers out of the console for someone else to
/// paste into a prefab.
/// </summary>
public virtual void OnSave()
{
var value = new AngPos
{
Angle = new Angles( Pitch, Yaw, Roll ),
Pos = new Vector3( X, Y, Z ),
};
NZombies.WeaponPlacement.Save( weapon, slot, value );
// ⛔ APPLY IT NOW, not on the next spawn. Saving and seeing nothing change
// reads as "the save did not work", and the editor's own preview would keep
// overriding it anyway — so the weapon is updated here and the live editor
// offset is cleared so the saved value is what you are actually seeing.
switch ( slot )
{
case NZombies.WeaponPlacement.Aim: weapon.AimAnimData = value; break;
case NZombies.WeaponPlacement.Hip: weapon.ViewModelOffset = value; break;
case NZombies.WeaponPlacement.Run: weapon.RunAnimData = value; break;
case NZombies.WeaponPlacement.Customize: weapon.CustomizeAnimData = value; break;
}
}
public virtual void OnPrint()
{
var sightAttach = weapon.GetActiveAttachmentForCategory( AttachmentCategory.Sight );
var sightName = sightAttach is not null ? $"({sightAttach.Name})" : "";
Log.Info( $"-- {weapon.DisplayName} {sightName}" );
Log.Info( String.Format( "Angle = {0:0.###}f, {1:0.###}f, {2:0.###}f", Pitch, Yaw, Roll ) );
Log.Info( String.Format( "Pos = {0:0.###}f, {1:0.###}f, {2:0.###}f", X, Y, Z ) );
Log.Info( String.Format( "FOV = {0:0.###}f", FOV ) );
Log.Info( "--" );
}
// No dragging if not directly on base panel
public virtual bool CanDragOnPanel( Panel p )
{
return p.ElementName == "offseteditor";
}
/// <summary>⚠️ ADDED FOR THIS PROJECT — loads the HIP offset for editing, so
/// a ported model can be placed on screen at all. See Weapon.ViewModelOffset.</summary>
public void SetViewModelOffset()
{
slot = NZombies.WeaponPlacement.Hip;
vmMode = false;
SetFromAngPos( weapon.ViewModelOffset, weapon.ViewModelFOV );
}
public void SetAimAnimData()
{
slot = NZombies.WeaponPlacement.Aim;
vmMode = false;
SetFromAngPos( weapon.AimAnimData, weapon.AimInfo.ViewModelFOV );
}
public void SetRunAnimData()
{
slot = NZombies.WeaponPlacement.Run;
vmMode = false;
SetFromAngPos( weapon.RunAnimData, weapon.ViewModelFOV );
}
public void SetCustomizeAnimData()
{
// ⛔ THE MISSING LINE. Every other Use Data button sets `slot`; this one did not, so SAVE
// went on writing to the PREVIOUS slot with this pose's numbers in it. Silent, and it
// reads as "the slot I actually wanted did not save".
slot = NZombies.WeaponPlacement.Customize;
vmMode = false;
SetFromAngPos( weapon.CustomizeAnimData, weapon.ViewModelFOV );
}
private void SetFromAngPos( AngPos angPos, float fov )
{
X = angPos.Pos.x;
Y = angPos.Pos.y;
Z = angPos.Pos.z;
Pitch = angPos.Angle.pitch;
Yaw = angPos.Angle.yaw;
Roll = angPos.Angle.roll;
FOV = fov;
}
protected override void OnMouseMove( MousePanelEvent e )
{
base.OnMouseMove( e );
if ( !HasActive || !CanDragOnPanel( e.Target ) ) return;
// ⛔ REMAPPED FOR THE 90 DEGREE VIEWMODEL CORRECTION — the CONTROLS moved,
// the offset did NOT. The model is deliberately spun 90 (nz_vm_yaw), so the
// axis that reads as "horizontal on screen" is no longer the one these
// drags used to write. Only the mouse-to-field mapping changes here; the
// values stay raw model-space, exactly as the runtime applies them.
//
// ⚠️ An earlier attempt converted the OFFSET instead, which moved the gun
// as well as the controls and broke alignment. The offset must not be
// touched — this is purely which field the mouse drives.
if ( dragMode == DragMode.pos )
{
// horizontal drag -> Y (was X), vertical unchanged
Y = yOrigin - InvY * (startX - Mouse.Position.x) * 0.001f;
Z = zOrigin + InvZ * (startY - Mouse.Position.y) * 0.001f;
}
else
{
// ⚠️ ROTATION IS **NOT** SWAPPED. Position needed remapping for the 90
// degree spin, so swapping these too looked consistent — and was wrong:
// left/right came out as up/down. Horizontal stays yaw, vertical stays
// pitch, exactly as before the remap. Only POSITION moved axis.
Yaw = yawOrigin + InvYaw * (startX - Mouse.Position.x) * 0.01f;
Pitch = pitchOrigin - InvPitch * (startY - Mouse.Position.y) * 0.01f;
}
SkipTransitions();
e.StopPropagation();
}
protected override void OnRightClick( MousePanelEvent e )
{
if ( dragMode == DragMode.pos )
{
DragModeLabel = "yaw/pitch";
dragMode = DragMode.angle;
}
else
{
DragModeLabel = "y/z";
dragMode = DragMode.pos;
}
}
protected override void OnMouseDown( MousePanelEvent e )
{
base.OnMouseDown( e );
if ( !CanDragOnPanel( e.Target ) ) return;
startX = Mouse.Position.x;
startY = Mouse.Position.y;
xOrigin = X;
yOrigin = Y;
zOrigin = Z;
pitchOrigin = Pitch;
yawOrigin = Yaw;
e.StopPropagation();
}
/// <summary>
/// ⚠️ ADDED FOR THIS PROJECT. When true, dragging edits the whole-viewmodel
/// CORRECTION (ViewModelHandler.VM*) rather than a per-pose offset.
///
/// A ported model needs a one-off reorientation before any pose offset means
/// anything — and hunting that by typing `nz_vm_add` values is exactly the
/// job this panel already does well for everything else.
/// </summary>
bool vmMode;
public void SetVMCorrection()
{
vmMode = true;
X = SWB.Base.ViewModelHandler.VMX;
Y = SWB.Base.ViewModelHandler.VMY;
Z = SWB.Base.ViewModelHandler.VMZ;
Pitch = SWB.Base.ViewModelHandler.VMPitch;
Yaw = SWB.Base.ViewModelHandler.VMYaw;
Roll = SWB.Base.ViewModelHandler.VMRoll;
FOV = weapon.ViewModelFOV;
}
public override void Tick()
{
if ( weapon is null || weapon.ViewModelHandler is null ) return;
if ( vmMode )
{
// ⛔ WRITES THE CORRECTION, AND LEAVES EditorOffset ALONE. Driving both
// would apply the same drag twice — once as a pose offset and once as
// the reorientation — and the number you read off would be half of
// what you were looking at.
SWB.Base.ViewModelHandler.VMX = X;
SWB.Base.ViewModelHandler.VMY = Y;
SWB.Base.ViewModelHandler.VMZ = Z;
SWB.Base.ViewModelHandler.VMPitch = Pitch;
SWB.Base.ViewModelHandler.VMYaw = Yaw;
SWB.Base.ViewModelHandler.VMRoll = Roll;
weapon.ViewModelHandler.EditorOffset = new AngPos();
weapon.ViewModelHandler.EditorFOV = FOV;
return;
}
weapon.ViewModelHandler.EditorOffset = new AngPos
{
Angle = new Angles( Pitch, Yaw, Roll ),
Pos = new Vector3( X, Y, Z )
};
weapon.ViewModelHandler.EditorFOV = FOV;
}
protected override int BuildHash()
{
return HashCode.Combine( DateTime.Now.ToString() );
}
}