Powerups/PowerupMusic.cs

Component attached to a player that manages looping music for active powerups. It checks ActivePowerups each update, starts the appropriate looped sound when a powerup is active, re-starts if the played clip naturally ends, and stops the sound when the powerup ends or the component is destroyed/disabled.

Native Interop
using Sandbox;

namespace NZombies;

/// <summary>
/// The loop a powerup plays for as long as it is running.
///
/// ⛔ A COMPONENT, BECAUSE SOMETHING HAS TO TICK. `ActivePowerups` is a static with
/// no update of its own — it can say what is running, but nothing there can notice
/// the moment a powerup ENDS and stop a sound. This sits on the player and watches.
///
/// ⚠️ THE HANDLE IS KEPT AND STOPPED BY HAND, the same rule the powerup floor-hum and
/// the mystery box jingle both needed: a looping cue whose handle is dropped plays
/// until the map unloads, long after the thing that started it is gone.
/// </summary>
public sealed class PowerupMusic : Component
{
	/// <summary>
	/// The looping music for a powerup, or empty for silence.
	///
	/// ⚠️ FIRE SALE ONLY, for now. Insta-Kill and Double Points have `*_loop_zhd`
	/// files in the pack that belong here too — they are not wired because nobody has
	/// heard them yet and three overlapping loops is a decision to make with ears,
	/// not in advance.
	/// </summary>
	public static string LoopFor( PowerupKind kind ) => kind switch
	{
		PowerupKind.FireSale => NZSound.PowerupFireSale,
		_ => "",
	};

	PowerupKind? _playing;
	SoundHandle _handle;

	protected override void OnUpdate()
	{
		// ⛔ STOP FIRST, then consider starting. Checking "should something be
		// playing" before "should this one stop" lets a powerup that just expired
		// keep its loop for a frame while its replacement starts — briefly two.
		if ( _playing.HasValue && !ActivePowerups.IsActive( _playing.Value ) )
			Stop();

		if ( _playing.HasValue )
		{
			// ⚠️ Re-established when the clip ends, like the powerup floor-hum: these
			// are finite files, so starting once gives one play and then silence for
			// the rest of the 30 seconds.
			if ( !_handle.IsValid() )
				_handle = Sound.Play( LoopFor( _playing.Value ) );

			return;
		}

		foreach ( var (kind, _) in ActivePowerups.All() )
		{
			if ( string.IsNullOrEmpty( LoopFor( kind ) ) ) continue;

			_playing = kind;
			_handle = Sound.Play( LoopFor( kind ) );

			Log.Info( $"[nz] {kind} music started" );
			return;
		}
	}

	void Stop()
	{
		_handle?.Stop();
		_handle = null;

		if ( _playing.HasValue ) Log.Info( $"[nz] {_playing.Value} music stopped" );

		_playing = null;
	}

	/// <summary>⚠️ Both exits — the component going away must not orphan the loop.</summary>
	protected override void OnDestroy() => Stop();
	protected override void OnDisabled() => Stop();
}