GameMode/AudioComponent.cs

Singleton component that manages in-game sound playback. It stores active SoundHandles keyed by FSound, plays sounds globally or for a team via RPC, stops sounds, updates positions each tick, and cleans up finished/stopped sounds. Also defines the FSound struct used as a sound identity and payload.

Networking
using System.Threading.Tasks;

namespace KOTH;

/// <summary>
/// Manages sound playback within the game, including playing sounds globally or for specific teams,
/// updating sound states, and handling active sound instances.
/// </summary>
/// <remarks>
/// This component is responsible for managing the lifecycle of sounds,
/// including their initiation, positional updates, and cleanup. It provides
/// methods for playing sounds either globally or targeted to specific teams
/// and maintains references to active sound handles.
/// </remarks>
public sealed class AudioComponent : SingletonComponent<AudioComponent>
{
	/// <summary>
	/// A collection of sound events that have been stopped during the audio handling process.
	/// This list is managed internally and is primarily used to track and clean up finished or stopped sounds,
	/// ensuring efficient audio management within the system.
	/// </summary>
	[Property]
	private List<FSound> StoppedSounds { get; set; }

	/// <summary>
	/// A mapping between sound events and their corresponding sound handles used during audio processing.
	/// This dictionary is utilized to associate each active sound event with its control and state information,
	/// enabling management of sound playback, updates, and cleanup within the system.
	/// </summary>
	[Property]
	private Dictionary<FSound, SoundHandle> SoundHandles { get; set; }

	protected override void OnAwake()
	{
		base.OnAwake();
		SoundHandles = new Dictionary<FSound, SoundHandle>();
		StoppedSounds = new List<FSound>();
	}

	/// <summary>
	/// Plays a specified sound for all players on the given team.
	/// </summary>
	/// <param name="soundEvent">The sound event to be played.</param>
	/// <param name="team">The team for which the sound will be played.</param>
	public void PlaySoundForTeam(FSound soundEvent, Team team)
	{
		using (Rpc.FilterInclude(GameUtils.GetPlayers(team).Select(x => x.Connection)))
		{
			PlaySound(soundEvent);
		}
	}

	/// <summary>
	/// Plays a specified sound globally for all players in the game.
	/// </summary>
	/// <param name="soundEvent">The sound event to be played.</param>
	[Rpc.Broadcast]
	public void PlaySound(FSound soundEvent)
	{
		var alreadyPlaying = Instance.SoundHandles.Any(x => x.Key.Owner.Id == soundEvent.Owner.Id || x.Key.SoundId == soundEvent.SoundId);
		if (alreadyPlaying) return;
		Log.Info("Playing sound " + soundEvent.SoundId);
		var handle = Sound.Play(soundEvent.SoundEvent, soundEvent.Position);
		Instance.SoundHandles.Add(soundEvent, handle);
	}


	/// <summary>
	/// Stops the playback of a specified sound in the game.
	/// </summary>
	/// <param name="soundEvent">The sound event to be stopped.</param>
	[Rpc.Broadcast]
	public void StopSound(FSound soundEvent)
	{
		var soundHandle = Instance.SoundHandles.FirstOrDefault(x => x.Key.SoundId == soundEvent.SoundId);
		var alreadyRemoving = Instance.StoppedSounds.Any(x => x.SoundId == soundEvent.SoundId);
		if(soundHandle.Value == null || alreadyRemoving) return;
		soundHandle.Value.Stop();
		Instance.StoppedSounds.Add(soundHandle.Key);
	}

	/// <summary>
	/// Determines whether the specified sound event is currently playing.
	/// </summary>
	/// <param name="soundEvent">The sound event to check.</param>
	/// <returns>True if the sound event is currently playing; otherwise, false.</returns>
	public Boolean IsPlayingSound(FSound soundEvent)
	{
		return Instance.SoundHandles.Any(x => x.Key.SoundId == soundEvent.SoundId);
	}

	/// <summary>
	/// Updates the positions of active sounds and removes references to completed or invalid ones.
	/// </summary>
	/// <remarks>
	/// This method iterates through all active sound handles. If a sound should update its position,
	/// its position is recalculated. Stopped, finished, or invalid sound handles are marked for cleanup
	/// and later removed.
	/// </remarks>
	public void SoundTick()
	{
		foreach (var soundHandle in Instance.SoundHandles)
		{
			if (!soundHandle.Key.Owner.IsValid())
			{
				soundHandle.Value.Stop();
				Instance.StoppedSounds.Add(soundHandle.Key);
				continue;
			}
			if (soundHandle is { Value: not null, Value.Finished: false, Key.UpdatePosition: true, }
				&& soundHandle.Value.Position != soundHandle.Key.Owner.WorldPosition
				)
			{
				soundHandle.Value.Position = soundHandle.Key.Owner.WorldPosition;
			}

			if (!soundHandle.Value.IsValid() || soundHandle.Value.Finished || soundHandle.Value.IsStopped)
			{
				Instance.StoppedSounds.Add(soundHandle.Key);
			}
		}

		CleanUpStoppedSounds();
	}

	/// <summary>
	/// Cleans up the references to sounds that have been stopped, finished, or are no longer valid.
	/// </summary>
	/// <remarks>
	/// This method iterates through the list of stopped sounds, attempting to remove their corresponding entries
	/// from the active sound handles dictionary. Logs errors or successful removals as appropriate, then clears
	/// the list of stopped sounds.
	/// </remarks>
	private void CleanUpStoppedSounds()
	{
		foreach (var soundHandle in Instance.StoppedSounds)
		{
			Instance.SoundHandles.Remove(soundHandle);
		}

		Instance.StoppedSounds.Clear();
	}
}

/// <summary>
/// Represents a sound event within the game.
/// This struct is used to encapsulate data necessary to play a sound, including its location,
/// the associated sound event, and the owning component triggering the sound.
/// </summary>
public struct FSound : IEquatable<FSound>
{
	/// <summary>
	/// Represents the underlying data or identity of a playable sound within the audio system.
	/// </summary>
	public SoundEvent SoundEvent { get; init; }

	/// <summary>
	/// Represents the 3D position in the game world where the sound event should occur.
	/// </summary>
	public Vector3 Position { get; init; }

	/// <summary>
	/// A unique identifier for a specific sound event within the game.
	/// This identifier is automatically generated upon the creation of a sound event and is used
	/// to distinguish and manage individual sound instances effectively.
	/// </summary>
	public Guid SoundId { get; init; }

	/// <summary>
	/// The component responsible for triggering the sound event in the game.
	/// </summary>
	public Component Owner { get; init; }

	/// <summary>
	/// Indicates whether the position of the sound should be updated dynamically.
	/// </summary>
	/// <remarks>
	/// If set to true, the sound's position is recalculated during the sound management process
	/// to match the world position of its owning component. This is particularly useful for
	/// sounds tied to moving objects or entities within the game world. If false, the sound
	/// remains static at its initial position.
	/// </remarks>
	public bool UpdatePosition { get; init; } = false;

	public FSound(SoundEvent soundEvent, Vector3 position, Component owner, bool updatePosition = false)
	{
		SoundEvent = soundEvent;
		Position = position;
		Owner = owner;
		UpdatePosition = updatePosition;
		SoundId = Guid.NewGuid();
	}

	public bool Equals(FSound other)
	{
		return other.SoundId == SoundId;
	}

	public override bool Equals(object obj)
	{
		return obj is FSound other && Equals(other);
	}

	public override int GetHashCode()
	{
		return HashCode.Combine(SoundEvent, UpdatePosition, SoundId);
	}
}