BoomFeel.cs

Visual/audio effect manager for an explosion detonation. It tracks transient camera shake, red/white flash intensity, and expanding shock rings (per fish origin), exposes read-only state and updates over time, and triggers audio/banner when detonated.

File Access
namespace NoChillquarium;

public sealed class BoomShock
{
	public float X { get; set; }
	public float Y { get; set; }
	public float Life { get; set; }
}

/// <summary>
/// One-shot detonation juice: camera smash, white/red flash, brief death-metal sting.
/// Shock rings spawn at each fish blast origin.
/// Additive with salinity <see cref="DeathFeel"/>.
/// </summary>
public static class BoomFeel
{
	static float _time;
	static float _shakeLeft;
	static float _shakeAmp;
	static float _flashLeft;
	static float _flashPeak;
	static float _whiteFlashLeft;
	static int _version;
	static readonly List<BoomShock> _shocks = new();

	public const float ShockDuration = 0.45f;

	public static int Version => _version;
	public static bool IsActive =>
		_shakeLeft > 0f || _flashLeft > 0f || _whiteFlashLeft > 0f || _shocks.Count > 0;

	public static float ShakeX { get; private set; }
	public static float ShakeY { get; private set; }

	/// <summary>Red blast flash 0–1.</summary>
	public static float Flash { get; private set; }

	/// <summary>Initial white hit flash 0–1.</summary>
	public static float WhiteFlash { get; private set; }

	/// <summary>Expanding rings, one per fish origin.</summary>
	public static IReadOnlyList<BoomShock> Shocks => _shocks;

	public static void Clear()
	{
		_shakeLeft = 0f;
		_flashLeft = 0f;
		_whiteFlashLeft = 0f;
		_shakeAmp = 0f;
		_flashPeak = 0f;
		ShakeX = 0f;
		ShakeY = 0f;
		Flash = 0f;
		WhiteFlash = 0f;
		_shocks.Clear();
		_version++;
	}

	/// <param name="victimCount">How many fish just became paste.</param>
	/// <param name="origins">Tank-local centers for each boom ring (one per fish).</param>
	public static void Trigger( int victimCount, IReadOnlyList<(float X, float Y)> origins = null )
	{
		var n = Math.Max( 1, victimCount );
		var power = Math.Clamp( 0.45f + n * 0.28f, 0.45f, 1.35f );

		var soft = GameSettings.ReduceStrobe;
		_shakeAmp = (10f + power * 16f) * GameSettings.ShakeScale;
		_shakeLeft = soft ? 0.28f + power * 0.12f : 0.55f + power * 0.35f;
		_flashPeak = (0.55f + power * 0.4f) * GameSettings.FlashScale;
		_flashLeft = soft ? 0.2f + power * 0.1f : 0.45f + power * 0.25f;
		_whiteFlashLeft = soft ? 0.04f : 0.12f + power * 0.06f;
		_time = 0f;

		// One expanding ring per fish — fall back to tank center if no positions.
		if ( origins is { Count: > 0 } )
		{
			foreach ( var o in origins )
			{
				_shocks.Add( new BoomShock
				{
					X = o.X,
					Y = o.Y,
					Life = ShockDuration
				} );
			}
		}
		else
		{
			_shocks.Add( new BoomShock
			{
				X = TankSim.Width * 0.5f,
				Y = TankSim.Height * 0.5f,
				Life = ShockDuration
			} );
		}

		_version++;

		GameAudio.TriggerDetonationSting( n );
		TankSim.ShowBanner( n == 1 ? "M80. Gone." : $"M80 ×{n}." );
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f )
			return;
		if ( dt > 0.05f )
			dt = 0.05f;

		if ( !IsActive && ShakeX == 0f && Flash == 0f )
			return;

		_time += dt;

		for ( var i = _shocks.Count - 1; i >= 0; i-- )
		{
			_shocks[i].Life -= dt;
			if ( _shocks[i].Life <= 0f )
				_shocks.RemoveAt( i );
		}

		if ( _shakeLeft > 0f )
		{
			_shakeLeft -= dt;
			var t = Math.Clamp( _shakeLeft / 0.7f, 0f, 1f );
			var amp = _shakeAmp * t * t; // decay fast after peak
			var hz = GameSettings.ReduceStrobe
				? 6f + (1f - t) * 4f
				: 18f + (1f - t) * 10f;
			ShakeX = MathF.Sin( _time * hz * MathF.PI * 2f ) * amp;
			ShakeY = MathF.Cos( _time * hz * 1.35f * MathF.PI * 2f ) * amp * 0.75f;
			if ( _shakeLeft <= 0f )
			{
				ShakeX = 0f;
				ShakeY = 0f;
			}
		}
		else
		{
			ShakeX = 0f;
			ShakeY = 0f;
		}

		if ( _whiteFlashLeft > 0f )
		{
			_whiteFlashLeft -= dt;
			WhiteFlash = Math.Clamp( _whiteFlashLeft / 0.12f, 0f, 1f );
		}
		else
		{
			WhiteFlash = 0f;
		}

		if ( _flashLeft > 0f )
		{
			_flashLeft -= dt;
			var t = Math.Clamp( _flashLeft / 0.6f, 0f, 1f );
			// Double-pulse red after the white hit (steady fade if Reduce Strobe).
			if ( GameSettings.ReduceStrobe )
				Flash = _flashPeak * t * 0.7f;
			else
			{
				var pulse = 0.55f + 0.45f * MathF.Sin( _time * 22f );
				Flash = _flashPeak * t * pulse;
			}
		}
		else
		{
			Flash = 0f;
		}

		_version++;
	}
}