UI Panel that draws a small configurable red dot at screen centre when a weapon from a specific pack is fully aimed. It computes the dot position by projecting a sway-free and current viewmodel point through the weapon viewmodel camera, clamps and scales the resulting screen-space offset, and updates a child panel's style to show the dot.
using Sandbox;
using Sandbox.UI;
using System.Linq;
using SWB.Base;
namespace NZombies;
/// <summary>
/// A small red dot at the centre of the screen while a Destiny weapon is fully aimed, which moves
/// with the weapon as it sways and kicks.
///
/// ⛔ THE SWAY SYSTEM DECIDES HOW FAR IT MOVES, NOT A NUMBER TUNED HERE. `HandleSwayAnimation` is
/// the whole of "how much a weapon swings when you turn" -- the eye rotation lags the real one at
/// `swayspeed` (5 at the hip, 20 aiming), and that lag becomes 0.2 degrees of rotation and 0.04
/// units of shift per degree, capped at 4 degrees and 1.5 units. It now publishes what it applied,
/// and the dot reads it. One source of truth, so the dot cannot disagree with the gun it sits on.
///
/// ⛔ CENTRE IS THE ANCHOR; ONLY THE SWING IS PROJECTED. Projecting the sight's absolute position
/// puts the dot wherever the sight bone happens to be -- on the glass, off-centre, different on
/// every weapon. Projecting the SWING and adding it to the centre gives a dot whose home is the
/// centre by construction: sway is zero at rest, so the two points coincide, with nothing to
/// calibrate and nothing to place per gun.
///
/// ⛔ A PROJECTED DIFFERENCE, NOT DEGREES TIMES A CONSTANT. An earlier attempt scaled the gun's
/// angular deviation into pixels with a tuned factor. That is not what a camera does, and it ignored
/// the gun's TRANSLATION -- and sway is half translation. Both points go through the real
/// projection here, so the dot travels exactly as the sight does.
///
/// ⛔ THROUGH THE VIEWMODEL'S OWN CAMERA. `ViewModelHandler` renders the gun through a SEPARATE
/// CameraComponent whose FOV is the weapon's `ViewModelFOV` (35 while aiming, against the player's
/// 90). Projecting through the scene camera would exaggerate the motion by the ratio between them,
/// worst exactly while aiming, which is the only time the dot is shown.
///
/// ⚠️ PACK-GATED FROM THE MANIFEST, not from a flag on 73 prefabs. `Assets/weapons/manifest.json`
/// already records `"pack": "Destiny"` for each one, so this is a data lookup rather than a bulk
/// prefab edit that a re-port would undo.
///
/// ⚠️ "FULLY ADS" IS NOT `IsAiming`. That flag flips the instant the button goes down, while the
/// viewmodel is still travelling -- `HandleIronAnimation` ramps over its first 0.2s. Showing the dot
/// on the flag makes it appear while the sight is still swinging into place.
/// </summary>
public sealed class AdsCenterDot : Panel
{
/// <summary>Diameter in panel units. `nz_ads_dot` to change it live.</summary>
public static float DotSize { get; set; } = 4f;
/// <summary>Red, as asked for. Tunable so it can be read against a bright sight.</summary>
public static Color DotColor { get; set; } = Color.Red;
/// <summary>Which pack gets the dot, or `all`.</summary>
public static string ForPack { get; set; } = "Destiny";
/// <summary>
/// How long after aiming starts the dot appears.
///
/// ⚠️ Matches the 0.2s ramp in `ViewModelHandler.HandleIronAnimation` and `ScopeInDelay`, so
/// the dot lands as the sight settles rather than before or well after it.
/// </summary>
public static float SettleTime { get; set; } = 0.2f;
/// <summary>
/// How far in front of the eye the tracked point sits, when the model has no `reticle`
/// attachment to measure it from.
///
/// ⛔ DEPTH DECIDES HOW MUCH THE GUN'S TRANSLATION SHOWS. Rotation moves a near point and a far
/// point identically; a sideways SHIFT of the gun swings a near point across the screen and
/// barely touches a far one. Too near and the dot swims; far enough and it behaves like a real
/// collimated sight, which is to say it stops moving at all.
/// </summary>
public static float SightDistance { get; set; } = 30f;
/// <summary>
/// Fraction of the weapon's own swing the dot makes. 1 is one-for-one with the gun.
///
/// ⛔ SCALED AFTER THE PROJECTION, NEVER BY FAKING THE DEPTH. Pushing the tracked point further
/// away would damp translation and rotation by DIFFERENT amounts, so the dot would stop
/// following a shift of the gun while still swinging on a turn. Scaling the finished
/// screen-space displacement keeps the motion the right shape and only changes its size.
/// </summary>
public static float Travel { get; set; } = 0.20f;
/// <summary>
/// How much of its own range of movement the dot may use. 1 lets it swing the full way.
///
/// ⛔ A FRACTION OF THE SWAY'S OWN LIMITS, NOT A PIXEL COUNT. The sway clamps at 4 degrees and
/// 1.5 units, so there is a real furthest-the-gun-can-swing to measure against -- which makes
/// 30% mean the same thing at every resolution, FOV and sight depth. The absolute pixel backstop
/// this replaces was set so wide it never once took effect.
///
/// ⛔ A BACKSTOP BY DEFAULT, AND IT SHOULD STAY ONE. Set below 1 it TRUNCATES rather than scales,
/// and it does so unevenly: a look up rarely generates enough lag to reach it, while a sideways
/// sweep passes it instantly -- so the dot tracks the gun faithfully going up and then stops dead
/// going sideways, while the gun carries on. That reads as the horizontal being exaggerated when
/// it is in fact the only axis being cut short. <see cref="Travel"/> is the knob for how far the
/// dot moves, because scaling keeps the motion proportional at every size.
/// </summary>
public static float RangeLimit { get; set; } = 1f;
/// <summary>
/// Extra scale on the SIDEWAYS movement only. 1 leaves it matching the vertical.
///
/// ⛔ THE SWAY ITSELF IS ALREADY SYMMETRIC — this is not correcting a bug. `HandleSwayAnimation`
/// applies the same 0.2 degrees and 0.04 units per degree of lag to yaw as to pitch, and both
/// clamp at the same 4 degrees and 1.5 units, so a degree of turn and a degree of look move the
/// dot by the same number of pixels. What differs is the INPUT: sweeping the view left and right
/// covers far more degrees than looking up and down does, so the horizontal lag is routinely
/// several times the vertical and the dot lives near its sideways ceiling while barely troubling
/// its vertical one.
///
/// ⚠️ SO THIS IS A DELIBERATE ASYMMETRY, and 1 -- off -- is the right default. It was briefly set
/// to 0.5 to tame a sideways swing that turned out to be the RangeLimit truncating the axis, not
/// the axis being too large. Damping a symmetric system to hide an asymmetric clamp would have
/// buried the real cause and made the dot lag the gun in both directions.
/// </summary>
public static float HorizontalScale { get; set; } = 1f;
readonly Weapon weapon;
readonly Panel dot;
// ⚠️ Nullable, and resolved LAZILY. `WeaponSource` is stamped by the spawner and this panel is
// built from the weapon's own OnStart -- the order is not guaranteed, so a check done in the
// constructor reads an empty prefab and answers "not Destiny" forever.
bool? isTargetPack;
bool wasAiming;
RealTimeSince sinceAimStart;
float lastDepth;
Vector2 lastOffset;
public AdsCenterDot( Weapon weapon )
{
this.weapon = weapon;
Style.Position = PositionMode.Absolute;
Style.Left = Length.Percent( 50 );
Style.Top = Length.Percent( 50 );
dot = Add.Panel( "adsCenterDot" );
Apply();
}
/// <summary>Push the current size/colour onto the panel.</summary>
void Apply()
{
dot.Style.Position = PositionMode.Absolute;
dot.Style.Width = DotSize;
dot.Style.Height = DotSize;
// ⚠️ FOUR CORNERS. `BorderRadius` is a scss shorthand only -- PanelStyle exposes the
// individual corners, and the shorthand does not exist to be assigned from code.
dot.Style.BorderTopLeftRadius = DotSize;
dot.Style.BorderTopRightRadius = DotSize;
dot.Style.BorderBottomLeftRadius = DotSize;
dot.Style.BorderBottomRightRadius = DotSize;
dot.Style.BackgroundColor = DotColor;
// ⚠️ A dark halo, because a red dot on a red-lit sight is invisible without one.
dot.Style.BoxShadow = new ShadowList
{
new Shadow { Color = Color.Black.WithAlpha( 0.5f ), Blur = 2f, Spread = 1f }
};
Place();
}
/// <summary>Centre of the screen, plus however far the gun has wandered.</summary>
void Place()
{
// ⛔ HALF THE SIZE, NEGATIVE. `left/top: 50%` puts the dot's CORNER on the centre pixel, not
// its middle -- a half-dot bias in both axes, which is exactly the sort of "close enough"
// that stops the dot meaning "where the bullet goes".
dot.Style.MarginLeft = -DotSize / 2f + lastOffset.x;
dot.Style.MarginTop = -DotSize / 2f + lastOffset.y;
}
bool IsTargetPack()
{
if ( isTargetPack.HasValue ) return isTargetPack.Value;
if ( string.Equals( ForPack, "all", System.StringComparison.OrdinalIgnoreCase ) )
return true;
var prefab = Rarity.PrefabOf( weapon );
if ( string.IsNullOrEmpty( prefab ) ) return false; // not stamped yet — ask again next tick
isTargetPack = string.Equals( WeaponLibrary.Find( prefab )?.Pack, ForPack,
System.StringComparison.OrdinalIgnoreCase );
return isTargetPack.Value;
}
/// <summary>The camera the GUN is drawn through — not the one the world is drawn through.</summary>
CameraComponent GunCamera => weapon.ViewModelHandler?.Camera;
/// <summary>
/// How far in front of the eye to track, measured off the model where possible.
///
/// ⚠️ THE ATTACHMENT IS USED FOR ITS DISTANCE ONLY. Its ORIENTATION is what defeated the old
/// world-space reticle quad -- the axis convention did not survive the port and the dot ended up
/// nowhere near the gun. A distance cannot be mis-oriented.
/// </summary>
float Depth( CameraComponent cam )
{
var vm = weapon.ViewModelRenderer;
var at = vm.IsValid() ? vm.GetAttachment( "reticle" ) : null;
lastDepth = at.HasValue
? at.Value.Position.Distance( cam.WorldPosition )
: SightDistance;
return lastDepth;
}
/// <summary>
/// How far, in screen pixels, a fully swung gun moves the tracked point — per axis.
///
/// ⚠️ EACH AXIS AT ITS OWN WORST CASE: the horizontal range is a full yaw tilt plus a full
/// sideways shift, the vertical a full pitch tilt plus a full lift. Taking one combined number
/// would let a limit set for the wider axis leave the narrower one effectively unclamped.
/// </summary>
static Vector2 FullSwingPixels( CameraComponent cam, Vector3 camPos, Rotation camRot,
float depth, Vector2 rest )
{
var r = ViewModelHandler.SwayRotLimit;
var p = ViewModelHandler.SwayPosLimit;
var xWorld = camPos + (camRot * Rotation.From( 0, r, 0 )).Forward * depth + camRot.Right * p;
var yWorld = camPos + (camRot * Rotation.From( r, 0, 0 )).Forward * depth + camRot.Up * p;
var x = cam.PointToScreenPixels( xWorld, out _ );
var y = cam.PointToScreenPixels( yWorld, out _ );
return new Vector2( System.MathF.Abs( x.x - rest.x ), System.MathF.Abs( y.y - rest.y ) );
}
public override void Tick()
{
if ( !weapon.IsValid() || !IsTargetPack() ) { SetShown( false ); return; }
// ⚠️ The same conditions under which the AIM POSE is applied -- reloading with the aim
// button held keeps `IsAiming` true while the viewmodel is back at the hip.
var aiming = weapon.IsAiming && !weapon.IsReloading && !weapon.IsRunning
&& weapon.AimAnimData != AngPos.Zero;
if ( aiming && !wasAiming ) sinceAimStart = 0;
wasAiming = aiming;
var firstPerson = weapon.Owner?.IsFirstPerson ?? false;
var shown = aiming && firstPerson && !weapon.IsCustomizing && sinceAimStart >= SettleTime;
var cam = GunCamera;
var handler = weapon.ViewModelHandler;
if ( !cam.IsValid() || handler is null ) { SetShown( false ); return; }
if ( !shown )
{
lastOffset = 0;
Place();
SetShown( false );
return;
}
// ⛔ ONE POINT, TWO POSES — the gun as it is, and the gun as it would be without sway. Their
// projected difference IS the sight's real motion on screen, with the clamping, the damping
// and the visual recoil already baked in, because both come out of the same composition.
// Rebuilding that motion from the raw sway numbers cannot work: those are a TARGET the pose
// is slerped toward, so the gun answers a fast turn on a different curve than a slow one and
// no constant reconciles the two.
var vm = weapon.ViewModelRenderer;
if ( !vm.IsValid() || vm.GameObject is null ) { SetShown( false ); return; }
var gun = vm.GameObject.WorldTransform;
var free = handler.SwayFreeTransform;
var depth = Depth( cam );
var camPos = cam.WorldPosition;
var camRot = cam.WorldRotation;
// ⛔ THE POINT IS PINNED IN THE SWAY-FREE POSE'S SPACE, on the camera's forward axis. That
// is what makes the centre of the screen the dot's home BY CONSTRUCTION -- the resting
// projection IS the centre ray, so there is no residual to converge away and no chance of
// the dot settling a few pixels off on some weapon whose pose happens to sit differently.
var restWorld = camPos + camRot.Forward * depth;
var local = free.PointToLocal( restWorld );
var moved = cam.PointToScreenPixels( gun.PointToWorld( local ), out var behindNow );
var rest = cam.PointToScreenPixels( restWorld, out var behindRest );
if ( behindNow || behindRest ) { SetShown( false ); return; }
// ⛔ THE CEILING IS MEASURED, NOT GUESSED: the same projection run at the sway's own clamps
// says how far the gun can EVER swing on screen, so RangeLimit is a real fraction of a real
// range rather than a pixel count that means something different on every screen.
// ⚠️ NOT MULTIPLIED BY Travel. The ceiling has to sit ABOVE the motion to be a backstop, and
// the measured motion now includes the visual recoil kick, which the sway limits know nothing
// about — folding Travel in here would put the ceiling below a legitimate kick and clip it.
var limit = FullSwingPixels( cam, camPos, camRot, depth, rest ) * RangeLimit;
// ⚠️ THE DIFFERENCE IS TAKEN IN REAL PIXELS AND ONLY THEN SCALED TO PANEL UNITS.
// `PointToScreenPixels` answers in screen pixels while panel margins are in panel units;
// subtracting and clamping first keeps the two spaces from ever being mixed, which is the
// bug that makes a HUD line up at 1080p and nowhere else.
// ⚠️ THE HORIZONTAL SCALE HITS THE MOVEMENT AND ITS CEILING ALIKE. Scaling only the movement
// would leave a ceiling the dot could no longer reach, so the knob would quietly stop
// working partway down its range; scaling only the ceiling would flatten the sideways motion
// into a hard stop instead of shrinking it.
var s = ScaleFromScreen;
var hx = limit.x * HorizontalScale;
lastOffset = new Vector2(
MathX.Clamp( (moved.x - rest.x) * Travel * HorizontalScale, -hx, hx ) * s,
MathX.Clamp( (moved.y - rest.y) * Travel, -limit.y, limit.y ) * s );
Place();
SetShown( true );
}
void SetShown( bool shown )
{
var want = shown ? 1f : 0f;
if ( dot.Style.Opacity != want ) dot.Style.Opacity = want;
}
static System.Collections.Generic.List<AdsCenterDot> Live() =>
Game.ActiveScene?.GetAllComponents<Weapon>()
.Select( w => w.RootPanel?.Panel )
.Where( p => p is not null )
.SelectMany( p => p.Children.OfType<AdsCenterDot>() )
.ToList() ?? new();
/// <summary>
/// `nz_ads_dot [size] [r] [g] [b]` — tune it while aiming.
///
/// ⚠️ STANDING RULE: every visual gets a command. Nobody can drag a slider over MCP, and a dot
/// that can only be judged in play is a dot that takes a round trip per guess to size.
/// </summary>
[ConCmd( "nz_ads_dot" )]
public static void Tune( float size = -1, float r = -1, float g = -1, float b = -1 )
{
if ( size > 0 ) DotSize = size;
if ( r >= 0 && g >= 0 && b >= 0 ) DotColor = new Color( r, g, b );
var live = Live();
foreach ( var d in live ) d.Apply();
Log.Info( $"[ads-dot] size {DotSize:0.##} colour [{DotColor.r:0.##} {DotColor.g:0.##} {DotColor.b:0.##}]"
+ $" pack '{ForPack}' settle {SettleTime:0.##}s ({live.Count} panel(s) live)" );
foreach ( var d in live ) d.Report();
}
/// <summary>
/// `nz_ads_dot_travel 5` — how much of the gun's real on-screen travel the dot makes, in PERCENT.
///
/// ⚠️ PERCENT, not a fraction, because that is how the amount gets talked about. 100 is
/// one-for-one with the sight; 5 is a dot that only just breathes.
/// </summary>
[ConCmd( "nz_ads_dot_travel" )]
public static void SetTravel( float percent = -1, float rangePercent = -1, float sidePercent = -1 )
{
if ( percent >= 0 ) Travel = percent / 100f;
if ( rangePercent >= 0 ) RangeLimit = rangePercent / 100f;
if ( sidePercent >= 0 ) HorizontalScale = sidePercent / 100f;
Log.Info( $"[ads-dot] travel {Travel * 100f:0.##}% of the gun's motion,"
+ $" capped at {RangeLimit * 100f:0.##}% of its range,"
+ $" sideways at {HorizontalScale * 100f:0.##}% of vertical" );
foreach ( var d in Live() ) d.Report();
}
/// <summary>
/// `nz_ads_dot_track [sightDistance]` — the depth the swing is measured at.
///
/// ⚠️ DEPTH IS NOT AN AMOUNT KNOB. It changes the BALANCE between the gun's rotational swing and
/// its positional one, because a shift moves a near point much further than a far one. Use
/// `nz_ads_dot_travel` to change how much the dot moves.
/// </summary>
[ConCmd( "nz_ads_dot_track" )]
public static void SetTracking( float sightDistance = -1 )
{
if ( sightDistance > 0 ) SightDistance = sightDistance;
Log.Info( $"[ads-dot] fallback distance {SightDistance:0.##}" );
foreach ( var d in Live() ) d.Report();
}
void Report()
{
var vm = weapon?.ViewModelRenderer;
var hasAttachment = vm.IsValid() && vm.GetAttachment( "reticle" ).HasValue;
var h = weapon?.ViewModelHandler;
Log.Info( $"[ads-dot] {weapon?.DisplayName ?? "?"} pack-match={isTargetPack?.ToString() ?? "unresolved"}"
+ $" aiming={weapon?.IsAiming} shown={dot.Style.Opacity > 0}"
+ $" depth={lastDepth:0.##} ({(hasAttachment ? "from reticle attachment" : "FALLBACK — no attachment")})"
+ $" travel={Travel * 100f:0.##}% range={RangeLimit * 100f:0.##}%"
+ $" side={HorizontalScale * 100f:0.##}%"
+ $" offset[{lastOffset.x:0.#} {lastOffset.y:0.#}]"
+ $" vmFOV={GunCamera?.FieldOfView:0.#}" );
// ⚠️ The gun's live swing, so "the dot is not moving" can be told apart from "the gun is not
// swinging" — they look identical on screen and have completely different causes.
if ( h is not null )
Log.Info( $"[ads-dot] sway rot[p {h.SwayRotOffset.x:0.##} y {h.SwayRotOffset.y:0.##}]"
+ $" pos[r {h.SwayPosOffset.x:0.##} u {h.SwayPosOffset.z:0.##}]" );
}
/// <summary>`nz_ads_dot_pack Destiny` — which pack gets the dot, or `all`.</summary>
[ConCmd( "nz_ads_dot_pack" )]
public static void SetPack( string pack = "Destiny" )
{
ForPack = pack;
// ⚠️ Every cached answer is now wrong -- clear them or the change only takes on respawn.
foreach ( var d in Live() ) d.isTargetPack = null;
Log.Info( $"[ads-dot] pack '{ForPack}'" );
}
}