PlayerViewModel.cs
using Sandbox;

public sealed class PlayerViewModel : Component
{
	public static PlayerViewModel Instance { get; private set; }

	[Property] private SkinnedModelRenderer Arms { get; set; }

	[Property] public CameraComponent Camera { get; set; }

	/// <summary>Degrees of viewmodel kick per normalized recoil unit.</summary>
	[Property] public float RecoilAnimScale { get; set; } = 40f;

	/// <summary>Seconds the viewmodel muzzle flash stays visible per shot.</summary>
	[Property] public float MuzzleFlashDuration { get; set; } = 0.05f;

	/// <summary>When true, draws a desktop HUD crosshair at the aim/recoil point while a weapon is held (hidden while ADS).</summary>
	[Property] public bool DrawCrosshair { get; set; }

	private ViewModel _viewModel;
	private GameObject _viewModelGO;
	private SkinnedModelRenderer _modelRenderer;
	private GameObject _muzzleFlash;
	private float _muzzleHideTime;
	private SoundPointComponent _shotSound;
	private float _ironsightsBlend;
	private Vector2 _recoilOffset;
	public bool Ironsights;

	/// <summary>Active viewmodel barrel transform (child named "muzzle"), if present.</summary>
	public GameObject Muzzle { get; private set; }

	public bool HasActiveWeapon => _modelRenderer is not null;

	/// <summary>Normalized screen point used for desktop aim, crosshair, and projectile spawn (recoil Y is up).</summary>
	public static Vector2 GetAimScreenNormal( Vector2 recoilOffset )
	{
		return new Vector2( 0.5f + recoilOffset.x, 0.5f - recoilOffset.y );
	}

	/// <summary>Camera ray through <see cref="GetAimScreenNormal"/>.</summary>
	public Ray GetAimRay( Vector2 recoilOffset )
	{
		return Camera.ScreenNormalToRay( GetAimScreenNormal( recoilOffset ) );
	}

	protected override void OnStart()
	{
		Instance = this;

		if ( _viewModelGO == null )
			Hide();
	}

	protected override void OnDestroy()
	{
		if ( Instance == this )
			Instance = null;
	}

	protected override void OnUpdate()
	{
		UpdateMuzzleFlash();
		UpdateIronsights();
		UpdateCrosshair();
	}

	private void UpdateMuzzleFlash()
	{
		if ( _muzzleFlash is null || !_muzzleFlash.Enabled )
			return;

		if ( Time.Now >= _muzzleHideTime )
			_muzzleFlash.Enabled = false;
	}

	private void UpdateCrosshair()
	{
		if ( !DrawCrosshair || Game.IsRunningInVR || Camera is null )
			return;

		var aim = GetAimScreenNormal( _recoilOffset );
		var pos = new Vector2( Screen.Width * aim.x, Screen.Height * aim.y );
		Camera.Hud.DrawCircle( pos, 5f, Color.Red );
	}

	private void UpdateIronsights()
	{
		if ( _viewModelGO is null || _viewModel is null )
			return;

		if ( Input.Pressed( "attack2" ) )
			ToggleIronsights();

		UpdateIronsightsBlend();

		if ( _viewModel.AnimationIronsights )
			UpdateAnimationIronsightsPose();
		else
			UpdateCodeIronsightsPose();
	}

	private void ToggleIronsights()
	{
		Ironsights = !Ironsights;

		if ( _viewModel.AnimationIronsights )
			_modelRenderer?.Set( "ironsights", Ironsights ? 1 : 0 );
	}

	private void UpdateAnimationIronsightsPose()
	{
		_viewModelGO.LocalPosition = Vector3.Lerp( Vector3.Zero, _viewModel.IronsightsOffset, _ironsightsBlend );
		_viewModelGO.LocalRotation = _ironsightsBlend <= 0f
			? Rotation.Identity
			: GetRecoilLookRotation( _recoilOffset * _ironsightsBlend );
	}

	private void UpdateCodeIronsightsPose()
	{
		// Keep animgraph ADS off so it doesn't fight the transform blend.
		_modelRenderer?.Set( "ironsights", 0 );

		var fullAdsRotation = Rotation.From( _viewModel.IronsightsAngle );
		var adsRotation = Rotation.Slerp( Rotation.Identity, fullAdsRotation, _ironsightsBlend );
		var adsPosition = GetCenteredIronsightsOffset( fullAdsRotation ) + _viewModel.IronsightsOffset;
		_viewModelGO.LocalPosition = Vector3.Lerp( Vector3.Zero, adsPosition, _ironsightsBlend );

		_viewModelGO.LocalRotation = _ironsightsBlend <= 0f
			? Rotation.Identity
			: adsRotation * GetRecoilLookRotation( _recoilOffset * _ironsightsBlend );
	}

	private void UpdateIronsightsBlend()
	{
		var duration = _viewModel.IronsightsDuration > 0.01f ? _viewModel.IronsightsDuration : 0.01f;
		var target = Ironsights ? 1f : 0f;
		if ( _ironsightsBlend < target )
			_ironsightsBlend = MathX.Clamp( _ironsightsBlend + Time.Delta / duration, 0f, target );
		else if ( _ironsightsBlend > target )
			_ironsightsBlend = MathX.Clamp( _ironsightsBlend - Time.Delta / duration, target, 1f );
	}

	private Rotation GetRecoilLookRotation( Vector2 recoilOffset )
	{
		var localDirection = Camera.WorldRotation.Inverse * GetAimRay( recoilOffset ).Forward;
		return Rotation.LookAt( localDirection );
	}

	private Vector3 GetCenteredIronsightsOffset( Rotation adsRotation )
	{
		if ( Muzzle is null || Camera is null || _viewModelGO is null )
			return Vector3.Zero;

		// Muzzle in viewmodel-local space, ignoring the root's current ADS transform.
		var muzzleInCamera = Camera.WorldTransform.PointToLocal( Muzzle.WorldPosition );
		var muzzleLocal = _viewModelGO.LocalRotation.Inverse * (muzzleInCamera - _viewModelGO.LocalPosition);
		var rotated = adsRotation * muzzleLocal;
		return new Vector3( 0f, -rotated.y, -rotated.z );
	}

	private void ResetIronsightsState()
	{
		Ironsights = false;
		_ironsightsBlend = 0f;
		_recoilOffset = Vector2.Zero;
	}

	public void Show( ViewModel viewModel )
	{
		ResetIronsightsState();
		_viewModel = viewModel;
		_viewModelGO = viewModel.ModelPrefab.Clone( transform: global::Transform.Zero, parent: Camera.GameObject );
		_modelRenderer = _viewModelGO.GetComponent<SkinnedModelRenderer>();
		_modelRenderer.RenderType = ModelRenderer.ShadowRenderType.Off;
		Arms.BoneMergeTarget = _modelRenderer;
		Arms.GameObject.Enabled = true;

		Muzzle = _viewModelGO.Children.Find( go => go.Name == "muzzle" );
		_muzzleFlash = Muzzle?.Children.Find( go => go.Name == "flash" );
		_shotSound = Muzzle?.Children.Find( go => go.Name == "sound" )?.GetComponent<SoundPointComponent>();
	}

	public void Hide()
	{
		Arms.GameObject.Enabled = false;
		_modelRenderer = null;
		_viewModelGO?.Destroy();
		_viewModelGO = null;
		_viewModel = null;
		ResetIronsightsState();
		Muzzle = null;
		_muzzleFlash = null;
		_shotSound = null;
	}

	public void PlayAttack()
	{
		_modelRenderer?.Set( "b_attack", true );
	}

	public void PlayReload()
	{
		_modelRenderer?.Set( "b_reload", true );
	}

	public void PlayReloadBolt()
	{
		_modelRenderer?.Set( "b_reload_bolt", true );
	}

	/// <summary>Opens the shell reload session on the viewmodel (b_reload + b_reloading).</summary>
	public void BeginShellReload()
	{
		if ( _modelRenderer is null )
			return;

		_modelRenderer.Set( "b_reloading", true );
	}

	/// <summary>Pulses a single shell insert on the viewmodel animgraph.</summary>
	public void InsertShell()
	{
		_modelRenderer?.Set( "b_reloading_shell", true );
	}

	/// <summary>Closes the shell reload session on the viewmodel.</summary>
	public void EndShellReload()
	{
		if ( _modelRenderer is null )
			return;

		_modelRenderer.Set( "b_reloading", false );
	}

	public void PlayCharge()
	{
		_modelRenderer?.Set( "b_charge", true );
	}

	/// <summary>
	/// Drives first-person weapon animgraph params. Call every frame while the weapon is held.
	/// b_attack is pulsed separately on each shot; attack_hold tracks sustained fire.
	/// </summary>
	public void UpdateWeaponAnimation( float attackHold, Vector2 recoilOffset )
	{
		if ( _modelRenderer is null || Camera is null )
			return;

		var rot = Camera.WorldRotation.Angles();
		var animRecoil = Ironsights ? Vector2.Zero : recoilOffset;

		_modelRenderer.Set( "aim_pitch", rot.pitch );
		_modelRenderer.Set( "aim_yaw", rot.yaw );
		_modelRenderer.Set( "aim_pitch_inertia", animRecoil.y * RecoilAnimScale );
		_modelRenderer.Set( "aim_yaw_inertia", animRecoil.x * RecoilAnimScale );
		_modelRenderer.Set( "attack_hold", attackHold );

		_recoilOffset = recoilOffset;
	}

	public void ShowMuzzleFlash()
	{
		if ( _muzzleFlash is null )
			return;

		// Toggle off first so particle emitters restart on each shot.
		_muzzleFlash.Enabled = false;
		_muzzleFlash.Enabled = true;
		_muzzleHideTime = Time.Now + MuzzleFlashDuration;
	}

	public void HideMuzzleFlash()
	{
		if ( _muzzleFlash is null )
			return;

		_muzzleFlash.Enabled = false;
	}

	public void PlayShotSound()
	{
		_shotSound?.StartSound();
	}

	public void SetLoadedState(bool loaded)
	{
		_modelRenderer?.Set( "b_loaded", loaded );
	}
}