OneMoreRoundWeapon.ReloadAudio.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// Timed reload-audio presentation for OneMoreRoundWeapon.
///
/// The base weapon already synchronizes reload state and calls
/// OnReloadStarted / OnReloadCancelled / OnReloadFinished on every peer.
/// OMR only schedules local presentation sounds from those hooks.
///
/// Each cue is measured in seconds from the start of the reload.
/// Pending cues are cancelled if the reload finishes or is interrupted.
/// </summary>
public partial class OneMoreRoundWeapon
{

	/// <summary>
	/// Timed sounds for a normal full-magazine reload.
	///
	/// Example for the current MP5:
	/// 0.55s -> magazine out
	/// 1.25s -> magazine in
	/// </summary>
	[Property, Group( "Reload Audio" )]
	public List<OMRReloadSoundCue> ReloadSoundCues { get; set; } = new();

	/// <summary>
	/// Optional sound played each time BaseCombatWeapon inserts one round
	/// during IncrementalReloading.
	///
	/// This is intentionally generic: shotgun shells, individual revolver
	/// chambers, or any future one-at-a-time reload can use the same hook
	/// without a weapon-specific subclass.
	/// </summary>
	[Property, Group( "Reload Audio" )]
	public SoundEvent IncrementalReloadInsertSound { get; set; }

	/// <summary>
	/// Diagnostic logging for reload-audio scheduling and cancellation.
	/// Leave disabled during normal play.
	/// </summary>
	[Property, Group( "Reload Audio" )]
	public bool LogReloadAudio { get; set; } = false;

	private CancellationTokenSource _reloadAudioCts;

	private bool _reloadAudioEventsBound;

	/// <summary>
	/// Reload audio is now a consumer of the generic weapon presentation event
	/// stream. The mechanical/network hooks live in PresentationEvents.cs.
	/// Timed cues remain a presentation detail until weapon-model/AnimGraph
	/// events replace the remaining authored offsets.
	/// </summary>
	private void BindReloadAudioPresentationEvents()
	{
		if ( _reloadAudioEventsBound )
			return;

		PresentationEvent += HandleReloadAudioPresentationEvent;
		_reloadAudioEventsBound = true;
	}

	private void HandleReloadAudioPresentationEvent(
		WeaponPresentationEvent presentationEvent
	)
	{
		switch ( presentationEvent.Kind )
		{
			case WeaponPresentationEventKind.ReloadStarted:
				StartReloadAudio();
				break;

			case WeaponPresentationEventKind.ReloadInserted:
				PlayIncrementalReloadInsertSound();
				break;

			case WeaponPresentationEventKind.ReloadCancelled:
			case WeaponPresentationEventKind.ReloadFinished:
			case WeaponPresentationEventKind.Holstered:
				CancelReloadAudio();
				break;
		}
	}

	private void PlayIncrementalReloadInsertSound()
	{
		if ( IncrementalReloadInsertSound is null )
			return;

		GameObject emitter = GetReloadAudioEmitter();

		if ( emitter is null || !emitter.IsValid )
			return;

		emitter.PlaySound( IncrementalReloadInsertSound );
	}

	private void StartReloadAudio()
	{
		CancelReloadAudio();

		if ( ReloadSoundCues is null || ReloadSoundCues.Count == 0 )
			return;

		var validCues = ReloadSoundCues
			.Where( cue => cue.Sound is not null )
			.OrderBy( cue => cue.Time )
			.ToList();

		if ( validCues.Count == 0 )
			return;

		_reloadAudioCts = new CancellationTokenSource();

		if ( LogReloadAudio )
		{
			Log.Info(
				$"[OMR RELOAD AUDIO] Start | Weapon:{GameObject.Name} | " +
				$"Cues:{validCues.Count} | ReloadTime:{ReloadTime:0.###}s"
			);
		}

		_ = PlayReloadAudioAsync(
			validCues,
			_reloadAudioCts.Token
		);
	}

	private void CancelReloadAudio()
	{
		if ( _reloadAudioCts is null )
			return;

		if ( !_reloadAudioCts.IsCancellationRequested )
			_reloadAudioCts.Cancel();

		_reloadAudioCts.Dispose();
		_reloadAudioCts = null;

		if ( LogReloadAudio )
		{
			Log.Info(
				$"[OMR RELOAD AUDIO] Cancel/Finish | Weapon:{GameObject.Name}"
			);
		}
	}

	private async Task PlayReloadAudioAsync(
		List<OMRReloadSoundCue> cues,
		CancellationToken cancellationToken
	)
	{
		float elapsed = 0f;

		try
		{
			foreach ( OMRReloadSoundCue cue in cues )
			{
				if ( cancellationToken.IsCancellationRequested )
					return;

				float cueTime = MathF.Max( cue.Time, 0f );
				float delay = cueTime - elapsed;

				if ( delay > 0f )
				{
					await Task.DelaySeconds(
						delay,
						cancellationToken
					);
				}

				if ( cancellationToken.IsCancellationRequested )
					return;

				GameObject emitter = GetReloadAudioEmitter();

				if ( emitter is null || !emitter.IsValid )
					return;

				emitter.PlaySound(
					cue.Sound
				);

				if ( LogReloadAudio )
				{
					Log.Info(
						$"[OMR RELOAD AUDIO] Cue | Weapon:{GameObject.Name} | " +
						$"Time:{cueTime:0.###}s | Sound:{cue.Sound.ResourceName}"
					);
				}

				elapsed = cueTime;
			}
		}
		catch ( TaskCanceledException )
		{
			// Expected when reload presentation is cancelled.
		}
		catch ( OperationCanceledException )
		{
			// Expected when reload presentation is cancelled.
		}
	}

	/// <summary>
	/// Use whichever weapon model this peer is currently presenting.
	/// For the owner this is normally the first-person viewmodel; for other
	/// peers it is normally the third-person world model. Falling back to the
	/// weapon object keeps the audio functional if no model is currently shown.
	/// </summary>
	private GameObject GetReloadAudioEmitter()
	{
		BaseWeaponModel model = WeaponModel;

		if (
			model is not null &&
			model.GameObject is not null &&
			model.GameObject.IsValid
		)
		{
			return model.GameObject;
		}

		return GameObject;
	}
}