Utils/SoundEmitter.cs

A component that plays a SoundEvent from a GameObject. It starts playback on start or when Play() is called, optionally follows the GameObject, can loop or destroy the GameObject when finished, and can modulate volume over a lifetime curve. Also provides GameObject extension methods to spawn a temporary emitter object and play a sound by event or resource path.

File Access
namespace KOTH;

/// <summary>
/// A simple component that plays a sound.
/// </summary>
public sealed class SoundEmitter : Component
{
	SoundHandle handle;

	/// <summary>
	/// How long until we destroy the GameObject.
	/// </summary>
	[Property] public SoundEvent SoundEvent { get; set; }

	/// <summary>
	/// Should we follow the current GameObject?
	/// </summary>
	[Property] public bool Follow { get; set; } = true;

	/// <summary>
	/// Should the GameObject be destroyed when the sound has finished?
	/// </summary>
	[Property] public bool DestroyOnFinish { get; set; } = true;

	[Property] public bool Loop { get; set; } = false;

	[Property, ToggleGroup("VolumeModifier", Label = "Volume Modifier")]
	public bool VolumeModifier { get; set; } = false;

	[Property, ToggleGroup("VolumeModifier")]
	public Curve VolumeOverTime { get; set; } = new(new Curve.Frame(0f, 1f), new Curve.Frame(1f, 1f));

	[Property, ToggleGroup("VolumeModifier")]
	public float LifeTime { get; set; } = 1f;

	private TimeSince TimeSincePlayed { get; set; }

	public void Play()
	{
		handle?.Stop();

		if (SoundEvent == null) return;
		TimeSincePlayed = 0f;
		handle = Sound.Play(SoundEvent, WorldPosition);
	}

	protected override void OnStart()
	{
		Play();
	}

	protected override void OnUpdate()
	{
		if (handle is null) return;

		// If we stopped playing, kill the game object (maybe)
		if (handle.IsStopped)
		{
			if (DestroyOnFinish)
				GameObject.Destroy();
			else if (Loop)
				handle = Sound.Play(SoundEvent, WorldPosition);
		}
		// Otherwise, let's keep updating the position
		else if (Follow)
		{
			handle.Position = GameObject.WorldPosition;
		}

		if (VolumeModifier)
		{
			handle.Volume = VolumeOverTime.Evaluate(TimeSincePlayed / LifeTime);
		}
	}

	protected override void OnDestroy()
	{
		handle?.Stop();
		handle = null;
	}
}

public static partial class GameObjectExtensions
{
	/// <summary>
	/// Creates a GameObject that plays a sound.
	/// </summary>
	/// <param name="self"></param>
	/// <param name="sndEvent"></param>
	/// <param name="follow"></param>
	public static void PlaySound(this GameObject self, SoundEvent sndEvent, bool follow = true)
	{
		if (!self.IsValid())
			return;

		if (sndEvent is null)
			return;

		var gameObject = self.Scene.CreateObject();
		gameObject.Name = sndEvent.ResourceName;

		if (follow)
			gameObject.Parent = self;
		else
			gameObject.Transform.World = self.Transform.World;

		var emitter = gameObject.Components.Create<SoundEmitter>();
		emitter.SoundEvent = sndEvent;
		emitter.Play();
	}

	/// <inheritdoc cref="PlaySound(GameObject, SoundEvent, bool)"/>
	public static void PlaySound(this GameObject self, string sndPath, bool follow = true)
	{
		if (ResourceLibrary.TryGet<SoundEvent>(sndPath, out var sndEvent))
		{
			self.PlaySound(sndEvent, follow);
		}
	}
}