Game/Weapon/WeaponModel/ViewModel.cs
using System.Threading;

public sealed partial class ViewModel : WeaponModel, ICameraSetup
{
	[ConVar( "sbdm.hideviewmodel", ConVarFlags.Cheat )]
	private static bool HideViewModel { get; set; } = false;

	/// <summary>
	/// A sound to play at a specific time during reload.
	/// </summary>
	public record struct ReloadSoundEntry
	{
		/// <summary>
		/// Seconds after reload starts to play this sound.
		/// </summary>
		[KeyProperty] public float Time { get; set; }

		/// <summary>
		/// The sound to play.
		/// </summary>
		[Property, KeyProperty] public SoundEvent Sound { get; set; }
	}

	/// <summary>
	/// Timed sound events to play during reload.
	/// </summary>
	[Property, Group( "Reload Sounds" )]
	public List<ReloadSoundEntry> ReloadSoundEvents { get; set; } = new();

	/// <summary>
	/// Timed sound events to play during each incremental reload cycle.
	/// </summary>
	[Property, Group( "Reload Sounds" )]
	public List<ReloadSoundEntry> IncrementalReloadSoundEvents { get; set; } = new();

	/// <summary>
	/// Timed sound events played when starting an incremental reload sequence.
	/// </summary>
	[Property, Group( "Reload Sounds" )]
	public List<ReloadSoundEntry> IncrementalReloadStartSounds { get; set; } = new();

	/// <summary>
	/// Timed sound events played when finishing an incremental reload sequence.
	/// </summary>
	[Property, Group( "Reload Sounds" )]
	public List<ReloadSoundEntry> IncrementalReloadFinishSounds { get; set; } = new();

	private CancellationTokenSource _reloadSoundCts;
	private CancellationTokenSource _reloadFinishSoundCts;

	/// <summary>
	/// Turns on incremental reloading parameters.
	/// </summary>
	[Property, Group( "Animation" )]
	public bool IsIncremental { get; set; } = false;

	/// <summary>
	/// Animation speed in general.
	/// </summary>
	[Property, Group( "Animation" )]
	public float AnimationSpeed { get; set; } = 1.0f;

	/// <summary>
	/// Animation speed for incremental reload sections.
	/// </summary>
	[Property, Group( "Animation" )]
	public float IncrementalAnimationSpeed { get; set; } = 1.0f;

	/// <summary>
	/// Use fast anims?
	/// </summary>
	[Property] 
	public bool UseFastAnimations { get; set; } = false;

	/// <summary>
	/// How much inertia should this weapon have?
	/// </summary>
	[Property, Group( "Inertia" )]
	Vector2 InertiaScale { get; set; } = new Vector2( 2, 2 );

	public bool IsAttacking { get; set; }

	TimeSince AttackDuration;

	bool _reloadFinishing;
	TimeSince _reloadFinishTimer;

	Vector2 lastInertia;
	Vector2 currentInertia;
	bool isFirstUpdate = true;

	protected override void OnStart()
	{
		foreach ( var renderer in GetComponentsInChildren<ModelRenderer>() )
		{
			// Don't render shadows for viewmodels
			renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
		}
	}

	protected override void OnUpdate()
	{
		UpdateAnimation();
	}

	void ApplyInertia()
	{
		var rot = Scene.Camera.WorldRotation.Angles();

		// Need to fetch data from the camera for the first frame
		if ( isFirstUpdate )
		{


			lastInertia = new Vector2( rot.pitch, rot.yaw );
			currentInertia = Vector2.Zero;
			isFirstUpdate = false;
		}

		var newPitch = rot.pitch;
		var newYaw = rot.yaw;

		currentInertia = new Vector2( Angles.NormalizeAngle( newPitch - lastInertia.x ), Angles.NormalizeAngle( lastInertia.y - newYaw ) );
		lastInertia = new( newPitch, newYaw );
	}

	void ICameraSetup.Setup( CameraComponent cc )
	{
		Renderer.Enabled = !HideViewModel;

		WorldPosition = cc.WorldPosition;
		WorldRotation = cc.WorldRotation;

		ApplyInertia();
		ApplyAnimationTransform( cc );
	}

	void ApplyAnimationTransform( CameraComponent cc )
	{
		if ( !Renderer.IsValid() ) return;

		if ( Renderer.TryGetBoneTransformLocal( "camera", out var bone ) )
		{
			var scale = 0.5f;
			cc.WorldPosition += cc.WorldRotation * bone.Position * scale;
			cc.WorldRotation *= bone.Rotation * scale;
		}
	}

	void UpdateAnimation()
	{
		var playerController = GetComponentInParent<PlayerController>();
		if ( !playerController.IsValid() ) return;

		var rot = Scene.Camera.WorldRotation.Angles();

		Renderer.Set( "b_twohanded", true );
		Renderer.Set( "deploy_type", UseFastAnimations ? 1 : 0 );
		Renderer.Set( "reload_type", UseFastAnimations ? 1 : 0 );

		Renderer.Set( "b_grounded", playerController.IsOnGround );
		Renderer.Set( "move_bob", GamePreferences.ViewBobbing ? playerController.Velocity.Length.Remap( 0, playerController.RunSpeed * 2f ) : 0 );

		Renderer.Set( "aim_pitch", rot.pitch );
		Renderer.Set( "aim_pitch_inertia", currentInertia.x * InertiaScale.x );

		Renderer.Set( "aim_yaw", rot.yaw );
		Renderer.Set( "aim_yaw_inertia", currentInertia.y * InertiaScale.y );

		Renderer.Set( "attack_hold", IsAttacking ? AttackDuration.Relative.Clamp( 0f, 1f ) : 0f );

		if ( _reloadFinishing && _reloadFinishTimer >= 0.5f )
		{
			_reloadFinishing = false;
			Renderer.Set( "speed_reload", AnimationSpeed );
			Renderer.Set( "b_reloading", false );
		}

		var velocity = playerController.Velocity;

		var dir = velocity;
		var forward = Scene.Camera.WorldRotation.Forward.Dot( dir );
		var sideward = Scene.Camera.WorldRotation.Right.Dot( dir );

		var angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();

		Renderer.Set( "move_direction", angle );
		Renderer.Set( "move_speed", velocity.Length );
		Renderer.Set( "move_groundspeed", velocity.WithZ( 0 ).Length );
		Renderer.Set( "move_y", sideward );
		Renderer.Set( "move_x", forward );
		Renderer.Set( "move_z", velocity.z );
	}

	public override void OnAttack()
	{
		Renderer?.Set( "b_attack", true );

		DoMuzzleEffect();
		DoEjectBrass();

		if ( IsThrowable )
		{
			Renderer?.Set( "b_throw", true );

			Invoke( 0.5f, () =>
			{
				Renderer?.Set( "b_deploy_new", true );
				Renderer?.Set( "b_pull", false );
			} );
		}
	}

	public override void CreateRangedEffects( BaseWeapon weapon, Vector3 hitPoint, Vector3? origin )
	{
		DoTracerEffect( hitPoint, origin );
	}

	/// <summary>
	/// Called when starting to reload a weapon.
	/// </summary>
	public void OnReloadStart()
	{
		_reloadFinishing = false; // cancel any pending incremental finish from a previous reload
		Renderer?.Set( "speed_reload", AnimationSpeed );
		Renderer?.Set( IsIncremental ? "b_reloading" : "b_reload", true );

		if ( IsIncremental )
			StartSounds( IncrementalReloadStartSounds, ref _reloadFinishSoundCts );
		else
			StartSounds( ReloadSoundEvents, ref _reloadSoundCts );
	}

	/// <summary>
	/// Called when incrementally reloading a weapon.
	/// </summary>
	public void OnIncrementalReload( bool firstShell = false )
	{
		Renderer?.Set( "speed_reload", IncrementalAnimationSpeed );

		if ( firstShell )
			Renderer?.Set( "b_reloading_first_shell", true );
		else
			Renderer?.Set( "b_reloading_shell", true );

		StartSounds( IncrementalReloadSoundEvents, ref _reloadSoundCts );
	}

	public void OnReloadFinish()
	{
		CancelSounds( ref _reloadSoundCts );

		if ( IsIncremental )
		{
			StartSounds( IncrementalReloadFinishSounds, ref _reloadFinishSoundCts );

			_reloadFinishing = true;
			_reloadFinishTimer = 0;
		}
		else
		{
			Renderer?.Set( "b_reload", false );
		}
	}

	public void OnReloadCancel()
	{
		CancelSounds( ref _reloadSoundCts );
		CancelSounds( ref _reloadFinishSoundCts );
	}

	private void StartSounds( List<ReloadSoundEntry> events, ref CancellationTokenSource cts )
	{
		CancelSounds( ref cts );

		if ( events.Count == 0 )
			return;

		cts = new CancellationTokenSource();
		_ = PlaySoundsAsync( events, cts.Token );
	}

	private void CancelSounds( ref CancellationTokenSource cts )
	{
		if ( cts is null ) return;

		cts.Cancel();
		cts.Dispose();
		cts = null;
	}

	private async Task PlaySoundsAsync( List<ReloadSoundEntry> events, CancellationToken ct )
	{
		var sorted = events.OrderBy( e => e.Time ).ToList();
		var elapsed = 0f;

		foreach ( var entry in sorted )
		{
			var delay = entry.Time - elapsed;

			if ( delay > 0f )
				await Task.DelaySeconds( delay, ct );

			if ( ct.IsCancellationRequested )
				return;

			if ( entry.Sound is not null )
				GameObject.PlaySound( entry.Sound );

			elapsed = entry.Time;
		}
	}
}