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