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.
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}" );
}
}