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