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

/// <summary>
/// Timed bolt/pump/lever audio driven by the generic cycle presentation stream.
///
/// Cues are measured from CycleStarted, which already occurs after the cycle
/// profile's StartDelay and is generated peer-locally from the synchronized
/// ShotFired presentation event. The owner therefore hears the sound from the
/// viewmodel while remote peers hear it from the worldmodel.
/// </summary>
public partial class OneMoreRoundWeapon
{
	[Property, Group( "Cycle Audio" )]
	public List<OMRCycleSoundCue> CycleSoundCues { get; set; } = new();

	[Property, Group( "Cycle Audio" )]
	public bool LogCycleAudio { get; set; } = false;

	private CancellationTokenSource _cycleAudioCts;
	private bool _cycleAudioEventsBound;

	private void BindCycleAudioPresentationEvents()
	{
		if ( _cycleAudioEventsBound )
			return;

		PresentationEvent += HandleCycleAudioPresentationEvent;
		_cycleAudioEventsBound = true;
	}

	private void HandleCycleAudioPresentationEvent(
		WeaponPresentationEvent presentationEvent
	)
	{
		switch ( presentationEvent.Kind )
		{
			case WeaponPresentationEventKind.CycleStarted:
				StartCycleAudio();
				break;

			case WeaponPresentationEventKind.CycleCompleted:
			case WeaponPresentationEventKind.Holstered:
				CancelCycleAudio();
				break;
		}
	}

	private void StartCycleAudio()
	{
		CancelCycleAudio();

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

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

		if ( validCues.Count == 0 )
			return;

		_cycleAudioCts = new CancellationTokenSource();

		if ( LogCycleAudio )
		{
			Log.Info(
				$"[OMR CYCLE AUDIO] Start | Weapon:{GameObject.Name} | " +
				$"Cues:{validCues.Count} | Duration:{CycleProfile?.Duration ?? 0f:0.###}s"
			);
		}

		_ = PlayCycleAudioAsync( validCues, _cycleAudioCts.Token );
	}

	private void CancelCycleAudio()
	{
		if ( _cycleAudioCts is null )
			return;

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

		_cycleAudioCts.Dispose();
		_cycleAudioCts = null;

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

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

		try
		{
			foreach ( OMRCycleSoundCue 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 = GetCycleAudioEmitter();
				if ( emitter is null || !emitter.IsValid )
					return;

				emitter.PlaySound( cue.Sound );

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

				elapsed = cueTime;
			}
		}
		catch ( TaskCanceledException )
		{
			// Expected when cycle presentation ends or the weapon is holstered.
		}
		catch ( OperationCanceledException )
		{
			// Expected when cycle presentation ends or the weapon is holstered.
		}
	}

	private GameObject GetCycleAudioEmitter()
	{
		BaseWeaponModel model = WeaponModel;

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

		return GameObject;
	}
}