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