Weapons/AdsCenterDot.cs

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.

File AccessNetworking
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}'" );
	}
}