Weapons/WeaponReticle.cs

A component that creates and positions a flat quad model as a holographic weapon reticle attached to a weapon viewmodel. It loads a material, sizes and orients the quad to an attachment named "reticle", optionally only shows while aiming, and provides console commands to tune, nudge, bake and set tilt.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// The holographic dot a TFA weapon hangs on its sight. `nz_reticle` to tune, `nz_reticle_bake` to
/// print what to write back.
///
/// ⛔ THIS IS NOT PART OF THE WEAPON MODEL. TFA weapons build their reticle out of a VElement -- a
/// flat `plate1x1` pinned to a `reticle` bone, wearing a reticle material -- and the material is
/// named ONLY in the weapon's Lua, never by the model. The porter's material step never sees it and
/// the mesh does not exist, so a straight port has a sight with nothing in it. 73 VElements across
/// the Destiny pack are exactly this shape, so it is worth a component rather than 73 hand edits.
///
/// ⚠️ RIDES THE ATTACHMENT, NOT A BONE INDEX. The model declares `$attachment "reticle"`, which the
/// porter now emits, so s&box computes the transform and the dot follows recoil, sway and every
/// animation without this component knowing anything about the skeleton.
/// </summary>
public sealed class WeaponReticle : Component
{
	/// <summary>Material path, e.g. materials/reticle/destiny2_reddot.vmat</summary>
	[Property] public string MaterialPath { get; set; } = "materials/reticle/destiny2_reddot.vmat";

	/// <summary>How wide the dot is, in WORLD UNITS across.</summary>
	[Property] public float Size { get; set; } = 0.35f;

	/// <summary>Offset from the attachment, in its own space.</summary>
	///
	/// ⚠️ STARTS AT ZERO, i.e. exactly on the attachment. The VElement states (0,-6,-0.01), but that
	/// is in GMod's bone space with GMod's axis convention, and the attachment here is not
	/// guaranteed to be oriented the same way -- carrying the number across put the dot a long way
	/// off the gun. Sitting on the attachment is close enough to nudge from.
	[Property] public Vector3 Offset { get; set; } = Vector3.Zero;

	/// <summary>Rotation relative to the attachment.</summary>
	[Property] public Angles Tilt { get; set; } = new( 90f, 0f, 90f );

	/// <summary>Only show while aiming, the way a holographic sight reads in game.</summary>
	[Property] public bool AimOnly { get; set; } = false;

	SWB.Base.Weapon weapon;
	ModelRenderer quad;
	GameObject quadObject;

	protected override void OnStart()
	{
		weapon = Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelfAndAncestors );
	}

	protected override void OnDestroy()
	{
		quadObject?.Destroy();
	}

	void Ensure()
	{
		if ( quad.IsValid() ) return;

		quadObject = Scene.CreateObject();
		quadObject.Name = "weapon_reticle";
		quad = quadObject.Components.Create<ModelRenderer>();
		// ⚠️ dev/plane is the engine's own unit quad -- no asset of ours to author or ship.
		quad.Model = Model.Load( "models/dev/plane.vmdl" );
		quad.MaterialOverride = Material.Load( MaterialPath );
		// ⛔ Off, not ShadowsOnly. `Off` draws the mesh WITHOUT casting a shadow; `ShadowsOnly`
		// would make the dot invisible and leave a shadow, which is the exact inverse.
		quad.RenderType = ModelRenderer.ShadowRenderType.Off;
	}

	protected override void OnUpdate()
	{
		var vm = weapon?.ViewModelRenderer;
		if ( !vm.IsValid() || vm.Model is null )
		{
			if ( quad.IsValid() ) quad.Enabled = false;
			return;
		}

		Ensure();

		// ⚠️ TryGetAttachment, so a weapon whose model has no reticle attachment simply shows
		// nothing rather than parking a glowing quad at the world origin.
		var at = vm.GetAttachment( "reticle" );
		if ( !at.HasValue )
		{
			quad.Enabled = false;
			return;
		}

		var show = !AimOnly || weapon.IsAiming;
		quad.Enabled = show;
		if ( !show ) return;

		var t = at.Value;
		quadObject.WorldPosition = t.PointToWorld( Offset );
		quadObject.WorldRotation = t.Rotation * Tilt.ToRotation();
		// ⛔ SCALE IS DERIVED FROM THE MODEL'S OWN BOUNDS, so Size means world units across rather
		// than "some multiple of whatever dev/plane happens to be". Assuming it was a unit quad put
		// a 24-unit dot on screen -- roughly a hundred times too big -- and made every tuning number
		// meaningless.
		var span = quad.Model?.Bounds.Size.x ?? 1f;
		quadObject.WorldScale = Size / (span > 0.001f ? span : 1f);
	}

	static WeaponReticle Current =>
		Game.ActiveScene?.GetAllComponents<WeaponReticle>().FirstOrDefault( x => x.IsValid() );

	/// <summary>`nz_reticle [size] [ox] [oy] [oz]` — tune it while looking down the sight.</summary>
	[ConCmd( "nz_reticle" )]
	public static void Tune( float size = -1, float ox = float.NaN, float oy = float.NaN, float oz = float.NaN )
	{
		var r = Current;
		if ( r is null ) { Log.Info( "[reticle] no WeaponReticle on the active weapon" ); return; }

		if ( size > 0 ) r.Size = size;
		if ( !float.IsNaN( ox ) ) r.Offset = r.Offset.WithX( ox );
		if ( !float.IsNaN( oy ) ) r.Offset = r.Offset.WithY( oy );
		if ( !float.IsNaN( oz ) ) r.Offset = r.Offset.WithZ( oz );

		// ⛔ REPORT THE LIVE TRANSFORM, NOT THE SETTINGS. "I cannot see it" has four causes that
		// look identical on screen: no attachment, a renderer that is disabled, a quad scaled to
		// nothing, or a quad sitting inside the gun. Each is one line here.
		var span = r.quad?.Model?.Bounds.Size.x ?? 0f;
		var vm = r.weapon?.ViewModelRenderer;
		var at = vm.IsValid() ? vm.GetAttachment( "reticle" ) : null;

		Log.Info( $"[reticle] size {r.Size:0.###} units across  offset {r.Offset}  tilt {r.Tilt}" );
		Log.Info( $"[reticle]   plane spans {span:0.##}u -> scale {(span > 0 ? r.Size / span : 0):0.#####}" );
		Log.Info( $"[reticle]   attachment: {(at.HasValue ? at.Value.Position.ToString() : "NOT FOUND")}" );
		Log.Info( $"[reticle]   quad: {(r.quad.IsValid() ? (r.quad.Enabled ? "enabled" : "DISABLED") : "NOT CREATED")}"
			+ $"  at {(r.quadObject.IsValid() ? r.quadObject.WorldPosition.ToString() : "-")}"
			+ $"  scale {(r.quadObject.IsValid() ? r.quadObject.WorldScale.x.ToString( "0.#####" ) : "-")}" );
		var cam = Game.ActiveScene?.Camera;
		if ( cam.IsValid() && r.quadObject.IsValid() )
			Log.Info( $"[reticle]   camera at {cam.WorldPosition}  distance {cam.WorldPosition.Distance( r.quadObject.WorldPosition ):0.##}u" );
		Log.Info( $"[reticle]   material {r.MaterialPath} ({(Material.Load( r.MaterialPath ) is null ? "FAILED TO LOAD" : "loaded")})" );
	}

	/// <summary>
	/// `nz_reticle_nudge <dx> <dy> <dz>` — move it by small steps in the ATTACHMENT's own axes.
	///
	/// ⚠️ RELATIVE, because absolute offsets are unusable here. The attachment sits at the reticle
	/// BONE, which is inside the sight housing, so the dot starts occluded by the gun and has to be
	/// walked out into the pane. Retyping absolute triples to do that is how a five-second
	/// adjustment becomes a dozen round trips.
	/// </summary>
	[ConCmd( "nz_reticle_nudge" )]
	public static void Nudge( float dx = 0, float dy = 0, float dz = 0 )
	{
		var r = Current;
		if ( r is null ) { Log.Info( "[reticle] no WeaponReticle on the active weapon" ); return; }
		r.Offset += new Vector3( dx, dy, dz );
		Log.Info( $"[reticle] offset {r.Offset}   (nz_reticle_bake to print it for the prefab)" );
	}

	/// <summary>`nz_reticle_bake` — print the numbers to write back into the prefab.</summary>
	[ConCmd( "nz_reticle_bake" )]
	public static void Bake()
	{
		var r = Current;
		if ( r is null ) { Log.Info( "[reticle] no WeaponReticle on the active weapon" ); return; }
		Log.Info( $"[reticle] BAKE  Size {r.Size:0.####}  Offset {r.Offset.x:0.###},{r.Offset.y:0.###},{r.Offset.z:0.###}"
			+ $"  Tilt {r.Tilt.pitch:0.#},{r.Tilt.yaw:0.#},{r.Tilt.roll:0.#}" );
	}

	/// <summary>`nz_reticle_tilt <p> <y> <r>`</summary>
	[ConCmd( "nz_reticle_tilt" )]
	public static void SetTilt( float p, float y, float r )
	{
		var a = Current;
		if ( a is null ) { Log.Info( "[reticle] no WeaponReticle on the active weapon" ); return; }
		a.Tilt = new Angles( p, y, r );
		Log.Info( $"[reticle] tilt {a.Tilt}" );
	}
}