swb_editor/OffsetEditor.cs

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.

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