ui/CrosshairComponent.cs

UI component that draws the player's crosshair and related indicators. It computes smoothed screen position, expansion on firing, reload rotation, a recoil arrow, and dash pips/label and renders these using the HUD draw API.

NetworkingFile Access
using System;
using Sandbox;

public sealed class CrosshairComponent : Component
{
	float _lastShotNum = float.MaxValue;
	float _expandT = 0f;
	float _armAngle = 0f;
	float _reloadStartAngle = 0f;
	bool _wasReloading = false;
	Vector2 _smoothedOffset;
	Vector2 _smoothedScreenPos;
	float _smoothedArrowAngle;

	int _maxDashes = 0;
	int _numAvail = 0;
	int _regularMaxDashes = 0;
	int _numTempAvail = 0;
	float _dashRecharge = 0f;
	int _prevNumAvail = -1;
	float _pulseTimer = 0f;
	int _pulseIndex = -1;
	float _shrinkTimer = 0f;
	int _shrinkIndex = -1;

	const float BaseGap = 11.25f;
	const float ArmLength = 20f;
	const float ArmThickness = 7.5f;
	const float MaxExpand = 15f;
	const float DecayRate = 5f;
	const float ArrowPadding = 20f;
	const float ArrowRotateSpeed = 14f;
	const float ArrowTriWidth = 13f;
	const float ArrowTriHeight = 15f;
	const float DashPipSize = 8f;
	const float DashPipGap = 4f;
	const float DashBelowGap = 16f;
	const float ReferenceScreenHeight = 1080f;

	public float Scale { get; set; } = 1f;

	const float PulseDuration = 0.15f;
	const float PulseMaxScale = 1.9f;
	const float ShrinkDuration = 0.15f;
	const float ShrinkMinScale = 0.3f;

	static readonly Color PipColorFull      = new Color( 0f, 1f, 68f / 255f, 1f );
	static readonly Color PipColorEmpty     = new Color( 0f, 1f, 68f / 255f, 0.03f );
	static readonly Color PipColorFullTemp  = new Color( 0.7f, 0.3f, 1f, 1f );
	static readonly Color PipColorEmptyTemp = new Color( 0.7f, 0.3f, 1f, 0.05f );
	static readonly Color LabelColorTemp    = new Color( 0.7f, 0.3f, 1f, 1f );
	static readonly Color LabelColorNormal  = new Color( 0f, 1f, 68f / 255f, 1f );

	protected override void OnUpdate()
	{
		var manager = Manager.Instance;
		if ( manager == null ) return;

		var player = manager.LocalPlayer;
		var crosshairPlayer = manager.IsSpectator && manager.SelectedPlayer.IsValid()
			? manager.SelectedPlayer
			: player;

		var showCursor =
			manager.GameState == GameState.Lobby ||
			manager.IsSpectator ||
			manager.IsPaused ||
			manager.IsHoveringPerkChoicePanel ||
			manager.IsHoveringStatsTab ||
			manager.IsEscMenuOpen ||
			manager.IsOptionsMenuOpen ||
			manager.ShouldShowGameOverScreen ||
			manager.ShouldShowPerkUnlockProgressPanel ||
			manager.ShouldShowQuestProgressPanel ||
			(manager.HoveredPerkType != null && !manager.IsHoveredPerkFromWorldItem);

		var showCrosshair = crosshairPlayer.IsValid() && (manager.IsSpectator || !showCursor);
		if ( !showCrosshair ) return;

		var cam = manager.Camera;
		if ( cam == null ) return;
		var hud = cam.Hud;

		// Position (ported verbatim from Crosshair.razor Tick)
		var isPaused = manager.IsPaused || (crosshairPlayer.IsValid() && crosshairPlayer.IsChoosingLevelUpReward);
		var targetScreenPos = manager.CrosshairScreenPos;
		var isSpectatorView = manager.IsSpectator && crosshairPlayer.IsValid() && crosshairPlayer != manager.LocalPlayer;

		Vector2 finalPos;
		if ( isSpectatorView )
		{
			_smoothedOffset = Vector2.Zero;
			if ( _smoothedScreenPos.LengthSquared <= 0.0001f )
				_smoothedScreenPos = targetScreenPos;
			_smoothedScreenPos = Vector2.Lerp( _smoothedScreenPos, targetScreenPos, RealTime.Delta * 28f );
			finalPos = _smoothedScreenPos;
		}
		else if ( Input.UsingController )
		{
			_smoothedOffset = Vector2.Zero;
			_smoothedScreenPos = targetScreenPos;
			finalPos = targetScreenPos;
		}
		else
		{
			if ( isPaused )
				_smoothedOffset = Vector2.Zero;
			else
				_smoothedOffset = Vector2.Lerp( _smoothedOffset, targetScreenPos - Mouse.Position, RealTime.Delta * 10f );
			finalPos = Mouse.Position + _smoothedOffset;
			_smoothedScreenPos = finalPos;
		}
		var center = finalPos;

		// Arm expansion
		if ( crosshairPlayer.IsValid() )
		{
			var shotNum = crosshairPlayer.GetUiStat( PlayerStat.TotalShotNum );
			if ( shotNum > _lastShotNum )
				_expandT = 1f;
			_lastShotNum = shotNum;
		}
		_expandT = MathX.Lerp( _expandT, 0f, RealTime.Delta * DecayRate );
		float userScale = MathX.Clamp( Scale, 0.5f, 3f );
		float resolutionScale = Screen.Height > 0f
			? Screen.Height / ReferenceScreenHeight
			: 1f;
		float s = userScale * resolutionScale;
		float extra = _expandT * MaxExpand * s;
		float gap = BaseGap * s + extra;

		// Reload rotation
		bool isReloading = crosshairPlayer.IsValid() && crosshairPlayer.IsReloading;
		if ( isReloading && !_wasReloading )
			_reloadStartAngle = _armAngle;
		if ( isReloading )
		{
			float t = crosshairPlayer.ReloadProgress;
			float smoothT = t * t * t * (t * (t * 6f - 15f) + 10f);
			_armAngle = _reloadStartAngle + smoothT * 90f;
		}
		else if ( _wasReloading )
			_armAngle = _reloadStartAngle + 90f;
		_wasReloading = isReloading;

		// Dash state
		int regularMax = crosshairPlayer.IsValid()
			? Math.Max( (int)MathF.Round( crosshairPlayer.GetUiStat( PlayerStat.NumDashes ) ), crosshairPlayer.NumDashesAvailable )
			: 0;
		int tempAvail = crosshairPlayer.IsValid() ? crosshairPlayer.NumTempDashesAvailable : 0;
		int newMax   = regularMax + tempAvail;
		int newAvail = crosshairPlayer.IsValid() ? crosshairPlayer.NumDashesAvailable + tempAvail : 0;
		_dashRecharge = crosshairPlayer.IsValid() ? crosshairPlayer.DashRechargeProgress : 0f;

		if ( _prevNumAvail >= 0 && newAvail > _prevNumAvail && newAvail <= newMax )
		{
			_pulseIndex = newAvail - 1;
			_pulseTimer = PulseDuration;
		}
		if ( _prevNumAvail >= 0 && newAvail < _prevNumAvail )
		{
			_shrinkIndex = newAvail;
			_shrinkTimer = ShrinkDuration;
		}
		_maxDashes = newMax;
		_numAvail = newAvail;
		_regularMaxDashes = regularMax;
		_numTempAvail = tempAvail;
		_prevNumAvail = newAvail;
		if ( _pulseTimer > 0f ) _pulseTimer = MathF.Max( 0f, _pulseTimer - RealTime.Delta );
		if ( _shrinkTimer > 0f ) _shrinkTimer = MathF.Max( 0f, _shrinkTimer - RealTime.Delta );

		// Draw arms via DrawLine — rotation is handled by computing rotated direction vectors,
		// so no matrix transform is needed. Each arm is drawn as: thick black outline then white on top.
		float angleRad = _armAngle * MathF.PI / 180f;
		float cosA = MathF.Cos( angleRad );
		float sinA = MathF.Sin( angleRad );
		// CSS clockwise rotation in Y-down space: (dx,dy) -> (dx*cos - dy*sin, dx*sin + dy*cos)
		Vector2 Rot( float dx, float dy ) => new Vector2( dx * cosA - dy * sinA, dx * sinA + dy * cosA );

		var armDirs = new[]
		{
			Rot( 0f, -1f ),  // top
			Rot( 0f,  1f ),  // bottom
			Rot( -1f, 0f ),  // left
			Rot(  1f, 0f ),  // right
		};

		float armLength = ArmLength * s;
		float armThickness = ArmThickness * s;
		float borderThickness = 3f * s;
		var outlineColor = new Color( 0f, 0f, 0f, 0.85f );
		foreach ( var dir in armDirs )
		{
			var near = center + dir * gap;
			var far  = center + dir * (gap + armLength);
			// Extend border endpoints so black bleeds out on all four sides of each arm
			hud.DrawLine( near - dir * borderThickness, far + dir * borderThickness,
				armThickness + borderThickness * 2f, outlineColor );
			hud.DrawLine( near, far, armThickness, Color.White );
		}

		// Draw recoil indicator as a filled triangle (scanline fill, tip points in recoil direction)
		var showArrow = manager.ShowCrosshairRecoilArrow;
		if ( showArrow )
		{
			var targetAngle = manager.CrosshairRecoilArrowAngle;
			float delta = ((targetAngle - _smoothedArrowAngle + 540f) % 360f) - 180f;
			_smoothedArrowAngle += delta * MathF.Min( 1f, RealTime.Delta * ArrowRotateSpeed );

			float arrowRad = _smoothedArrowAngle * MathF.PI / 180f;
			var arrowDir = new Vector2( MathF.Sin( arrowRad ), -MathF.Cos( arrowRad ) );
			var perpDir  = new Vector2( MathF.Cos( arrowRad ),  MathF.Sin( arrowRad ) );
			float arrowDist = gap + armLength + ArrowPadding * s;
			float triWidth = ArrowTriWidth * s;
			float triHeight = ArrowTriHeight * s;
			float arrowBorder = 5f * s;
			var triOutlineColor = new Color( 0f, 0f, 0f, 0.65f );

			// Border pass — expanded triangle
			float borderH = triHeight + arrowBorder * 2f;
			for ( float d = 0f; d <= borderH + 1f; d += 1f )
			{
				float t = d / borderH;
				var sc = center + arrowDir * (arrowDist - arrowBorder + borderH - d);
				float hw = (triWidth * 0.5f + arrowBorder) * t;
				hud.DrawLine( sc - perpDir * hw, sc + perpDir * hw, 2f, triOutlineColor );
			}

			// Fill pass — white triangle
			for ( float d = 0f; d <= triHeight + 1f; d += 1f )
			{
				float t = d / triHeight;
				var sc = center + arrowDir * (arrowDist + triHeight - d);
				float hw = triWidth * 0.5f * t;
				if ( hw < 0.5f ) continue;
				hud.DrawLine( sc - perpDir * hw, sc + perpDir * hw, 2f, Color.White );
			}
		}

		// Draw dash container
		if ( _maxDashes <= 0 ) return;

		float dashY = center.y + gap + armLength + DashBelowGap * s;
		bool useLabel = _maxDashes > 4;

		if ( useLabel )
		{
			float labelScale = ComputeAnimScale();
			float opacity = _numAvail == 0 ? 0.15f : 1f;
			var labelColor = (_numTempAvail > 0 ? LabelColorTemp : LabelColorNormal).WithAlpha( opacity );
			float fontSize = 20f * s * labelScale;
			var labelPos = new Vector2( center.x, dashY );
			hud.DrawText( _numAvail.ToString(), fontSize, labelColor, labelPos, TextFlag.CenterTop );
		}
		else
		{
			float pipSize = DashPipSize * s;
			float pipGap  = DashPipGap  * s;
			float totalWidth = _maxDashes * pipSize + (_maxDashes - 1) * pipGap;
			float startX = center.x - totalWidth / 2f;

			for ( int i = 0; i < _maxDashes && i < 4; i++ )
			{
				float pipX = startX + i * (pipSize + pipGap);

				bool isTemp = i >= _regularMaxDashes;
				int regularAvail = _numAvail - _numTempAvail;
				bool isFull = isTemp ? (i - _regularMaxDashes) < _numTempAvail : i < regularAvail;
				bool isRecharging = !isTemp && i == regularAvail && regularAvail < _regularMaxDashes;
				bool isPulsing   = i == _pulseIndex && _pulseTimer > 0f;
				bool isShrinking = i == _shrinkIndex && _shrinkTimer > 0f;

				Color pipColor;
				if ( isPulsing )
				{
					float t = 1f - _pulseTimer / PulseDuration;
					float brightness = MathF.Sin( t * MathF.PI );
					pipColor = isTemp
						? new Color( 0.7f + brightness * 0.3f, 0.3f + brightness * 0.7f, 1f, 1f )
						: new Color( brightness, 1f, (68f + brightness * 187f) / 255f, 1f );
				}
				else
					pipColor = isFull
						? (isTemp ? PipColorFullTemp : PipColorFull)
						: (isTemp ? PipColorEmptyTemp : PipColorEmpty);

				float animScale = ComputePipScale( isPulsing, isShrinking );

				// Scale pip rect around its center
				float pipCx = pipX + pipSize / 2f;
				float pipCy = dashY + pipSize / 2f;
				float scaledSize = pipSize * animScale;
				var pipRect = new Rect( pipCx - scaledSize / 2f, pipCy - scaledSize / 2f, scaledSize, scaledSize );
				hud.DrawRect( pipRect, pipColor );

				if ( isRecharging )
				{
					float fillHeight = Utils.EasePercent( _dashRecharge, EasingType.SineIn ) * scaledSize;
					var fillRect = new Rect( pipCx - scaledSize / 2f, pipCy + scaledSize / 2f - fillHeight, scaledSize, fillHeight );
					hud.DrawRect( fillRect, new Color( 0f, 1f, 68f / 255f, 0.1f ) );
				}
			}
		}
	}

	float ComputeAnimScale()
	{
		if ( _pulseTimer > 0f )
		{
			float t = 1f - _pulseTimer / PulseDuration;
			const float riseEnd = 0.25f;
			return t < riseEnd
				? 1f + (PulseMaxScale - 1f) * (t / riseEnd)
				: PulseMaxScale - (PulseMaxScale - 1f) * ((t - riseEnd) / (1f - riseEnd));
		}
		if ( _shrinkTimer > 0f )
		{
			float t = 1f - _shrinkTimer / ShrinkDuration;
			const float dropEnd = 0.25f;
			return t < dropEnd
				? 1f - (1f - ShrinkMinScale) * (t / dropEnd)
				: ShrinkMinScale + (1f - ShrinkMinScale) * ((t - dropEnd) / (1f - dropEnd));
		}
		return 1f;
	}

	float ComputePipScale( bool isPulsing, bool isShrinking )
	{
		if ( isPulsing )
		{
			float t = 1f - _pulseTimer / PulseDuration;
			const float riseEnd = 0.25f;
			return t < riseEnd
				? 1f + (PulseMaxScale - 1f) * (t / riseEnd)
				: PulseMaxScale - (PulseMaxScale - 1f) * ((t - riseEnd) / (1f - riseEnd));
		}
		if ( isShrinking )
		{
			float t = 1f - _shrinkTimer / ShrinkDuration;
			const float dropEnd = 0.25f;
			return t < dropEnd
				? 1f - (1f - ShrinkMinScale) * (t / dropEnd)
				: ShrinkMinScale + (1f - ShrinkMinScale) * ((t - dropEnd) / (1f - dropEnd));
		}
		return 1f;
	}
}