ExplodeSystem.cs

ExplodeSystem: a static game subsystem that lets the player tape M80s to fish and detonate them. It tracks taped fish, spawns particle effects for explosions and fire, updates particle physics over time, manages mode state and inventory checks, and applies game effects (economy, karma, achievements).

Obfuscated Code
namespace NoChillquarium;

public sealed class BoomParticle
{
	public float X { get; set; }
	public float Y { get; set; }
	public float Vx { get; set; }
	public float Vy { get; set; }
	public float Life { get; set; }
	public string Color { get; set; }
}

/// <summary>
/// M80 tape-and-explode: click fish to tape a visible M80 on them, then detonate.
/// </summary>
public static class ExplodeSystem
{
	static readonly Dictionary<string, float> _taped = new(); // instanceId -> time since taped
	static readonly List<BoomParticle> _particles = new();
	static readonly Random _rng = new();
	static int _version;
	static bool _mode;

	public static bool ModeActive
	{
		get => _mode;
		set
		{
			_mode = value;
			if ( !_mode )
				_taped.Clear();
			_version++;
		}
	}

	public static int SelectedCount => _taped.Count;
	public static int Version => _version;
	public static IReadOnlyList<BoomParticle> Particles => _particles;

	public static string StatusText =>
		!_mode
			? (Shop.M80Bags > 0 ? $"M80 ×{Shop.M80Bags}" : "NO M80 — BUY IN SHADY")
			: SelectedCount == 0
				? $"CLICK FISH TO TAPE M80 ({Shop.M80Bags} left)"
				: $"M80 TAPED ×{SelectedCount}/{Shop.M80Bags} · HIT DETONATE";

	public static void Clear()
	{
		_mode = false;
		_taped.Clear();
		_particles.Clear();
		_version++;
	}

	/// <summary>Enter/leave arm mode. Requires at least one bought M80 to arm.</summary>
	public static bool TrySetMode( bool active )
	{
		if ( active && !Shop.CanArmM80() )
		{
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "No M80s. Buy them on the Shady tab." );
			return false;
		}

		ModeActive = active;
		return true;
	}

	public static bool IsSelected( string instanceId ) =>
		!string.IsNullOrEmpty( instanceId ) && _taped.ContainsKey( instanceId );

	/// <summary>Remove tape from one fish (e.g. moved to another tank).</summary>
	public static void Untape( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) )
			return;
		if ( _taped.Remove( instanceId ) )
			_version++;
	}

	/// <summary>0–1, hot for a moment after tape so UI can pop the prop in.</summary>
	public static float TapeFreshness( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) || !_taped.TryGetValue( instanceId, out var age ) )
			return 0f;
		// Fresh for ~0.45s
		return Math.Clamp( 1f - (age / 0.45f), 0f, 1f );
	}

	public static bool IsFreshlyTaped( string instanceId ) => TapeFreshness( instanceId ) > 0.05f;

	public static void ToggleSelect( string instanceId )
	{
		if ( !_mode || string.IsNullOrEmpty( instanceId ) )
			return;

		// Must still exist in the tank.
		if ( TankSim.Fish.All( f => f.InstanceId != instanceId ) )
			return;

		if ( _taped.ContainsKey( instanceId ) )
		{
			_taped.Remove( instanceId );
			GameAudio.PlayUi( GameAudio.Close );
		}
		else
		{
			// One stick per fish — can't tape more than you bought.
			if ( Shop.M80Bags <= _taped.Count )
			{
				GameAudio.PlayUi( GameAudio.Error );
				TankSim.ShowBanner( Shop.M80Bags <= 0
					? "Out of M80s. Buy more in Shady."
					: $"Only {Shop.M80Bags} M80{(Shop.M80Bags == 1 ? "" : "s")} left." );
				return;
			}

			_taped[instanceId] = 0f;
			// The moment of comedy: tape slap onto the fish.
			GameAudio.PlayUi( GameAudio.Tape );
			Achievements.OnTape();
		}

		_version++;
	}

	public static bool TryDetonate()
	{
		if ( !_mode )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _taped.Count == 0 )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var victims = TankSim.Fish.Where( f => _taped.ContainsKey( f.InstanceId ) ).ToList();
		if ( victims.Count == 0 )
		{
			_taped.Clear();
			_version++;
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Spend one M80 per taped fish.
		if ( !Shop.TryConsumeM80( victims.Count ) )
		{
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "Not enough M80s in stock." );
			return false;
		}

		// Fuse already taped — this is the boom.
		GameAudio.PlayUi( GameAudio.M80 );

		var origins = new List<(float X, float Y)>( victims.Count );
		foreach ( var fish in victims )
		{
			var ox = fish.X;
			var oy = fish.DrawY;
			origins.Add( (ox, oy) );
			SpawnBurst( ox, oy, fish.Species.Color, fish.Species.FinColor );
			// Extra red stick bits for the M80 itself.
			SpawnM80Bits( ox, oy );
			// Fire / ash particles for comedy violence.
			SpawnFireRing( ox, oy );
			TankSim.RemoveFish( fish.InstanceId );
		}

		var n = victims.Count;
		_taped.Clear();

		// Screen smash + shock rings at each fish + death metal sting.
		BoomFeel.Trigger( n, origins );
		Achievements.OnDetonate( n );

		Chaos.Add( 8f + n * 6f );
		Karma.Add( -4f - n * 3f );
		var payout = n * 2.5;
		Economy.Add( payout );

		// Always return to default dock mode after the boom (clear via property).
		ModeActive = false;

		SaveGame.TrySave();
		return true;
	}

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

		if ( dt > 0.05f )
			dt = 0.05f;

		if ( _taped.Count > 0 )
		{
			// Age tape timers (for "just taped" pop animation).
			var keys = _taped.Keys.ToList();
			foreach ( var key in keys )
			{
				_taped[key] += dt;
				// Drop selection if fish was removed somehow.
				if ( TankSim.Fish.All( f => f.InstanceId != key ) )
					_taped.Remove( key );
			}

			_version++;
		}

		for ( var i = _particles.Count - 1; i >= 0; i-- )
		{
			var p = _particles[i];
			p.X += p.Vx * dt;
			p.Y += p.Vy * dt;
			p.Vy += 180f * dt;
			p.Life -= dt;
			if ( p.Life <= 0f )
				_particles.RemoveAt( i );
		}

		if ( _particles.Count > 0 )
			_version++;
	}

	/// <summary>Quiet little death poof for salinity (not M80 comedy).</summary>
	public static void SpawnDeathPoof( float x, float y )
	{
		for ( var i = 0; i < 10; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 20f + (float)_rng.NextDouble() * 50f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 10f,
				Life = 0.4f + (float)_rng.NextDouble() * 0.5f,
				Color = i % 2 == 0 ? "#4a6040" : "#2a3020"
			} );
		}
		_version++;
	}

	static void SpawnBurst( float x, float y, string colorA, string colorB )
	{
		// Bigger paste cloud.
		for ( var i = 0; i < 28; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 80f + (float)_rng.NextDouble() * 220f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 50f,
				Life = 0.4f + (float)_rng.NextDouble() * 0.7f,
				Color = (i % 2 == 0) ? colorA : colorB
			} );
		}

		// Hot white core.
		for ( var i = 0; i < 14; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 60f + (float)_rng.NextDouble() * 160f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd,
				Life = 0.2f + (float)_rng.NextDouble() * 0.35f,
				Color = i % 3 == 0 ? "#ffffff" : "#ffe080"
			} );
		}
	}

	static void SpawnM80Bits( float x, float y )
	{
		for ( var i = 0; i < 10; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 70f + (float)_rng.NextDouble() * 130f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 30f,
				Life = 0.45f + (float)_rng.NextDouble() * 0.4f,
				Color = i % 2 == 0 ? "#c41e1e" : "#2a2a2a"
			} );
		}
	}

	static void SpawnFireRing( float x, float y )
	{
		const int n = 16;
		for ( var i = 0; i < n; i++ )
		{
			var ang = (i / (float)n) * MathF.PI * 2f;
			var spd = 140f + (float)_rng.NextDouble() * 40f;
			var color = (i % 3) switch
			{
				0 => "#ff4020",
				1 => "#ff9020",
				_ => "#ffd040"
			};
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd,
				Life = 0.35f + (float)_rng.NextDouble() * 0.25f,
				Color = color
			} );
		}
	}
}