WaterFx.cs

A particle effect system that simulates pixel water drops and jets for two layers (Tank and Battle). It spawns, updates physics (gravity, bounce), lifetimes, splashes and crack-driven leaks, and exposes enumerables and counters for external use.

Native Interop
namespace NoChillquarium;

public enum WaterLayer
{
	Tank,
	Battle
}

public sealed class WaterDrop
{
	public float X;
	public float Y;
	public float Vx;
	public float Vy;
	public float W;
	public float H;
	public float Life;
	public float MaxLife;
	public string Sprite = "water_drop";
	public WaterLayer Layer;
	public bool Jet;
	public float Spin;
}

/// <summary>
/// Pixel water — jets from cracked glass, splashes on hits and booms.
/// </summary>
public static class WaterFx
{
	const int Cap = 110;
	const float Grav = 520f;
	static readonly Random _rng = new();
	static readonly List<WaterDrop> _drops = new();
	static readonly (float X, float Y, float DirX)[] CrackSites =
	{
		(154f, 80f, -1f),
		(520f, 210f, 0.2f),
		(1120f, 90f, 1f),
		(140f, 500f, -0.8f),
		(720f, 70f, 0.4f),
		(640f, 300f, 0f)
	};

	static int _version;
	static float _leakTimer;

	public static int Version => _version;
	public static int Count => _drops.Count;

	public static IEnumerable<WaterDrop> TankDrops
	{
		get
		{
			foreach ( var d in _drops )
			{
				if ( d.Layer == WaterLayer.Tank )
					yield return d;
			}
		}
	}

	public static IEnumerable<WaterDrop> BattleDrops
	{
		get
		{
			foreach ( var d in _drops )
			{
				if ( d.Layer == WaterLayer.Battle )
					yield return d;
			}
		}
	}

	public static void Clear()
	{
		_drops.Clear();
		_leakTimer = 0f;
		_version++;
	}

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

		if ( MethRush.Cracks > 0 && TankSim.IsActive )
		{
			_leakTimer -= dt;
			if ( _leakTimer <= 0f )
			{
				_leakTimer = Math.Clamp( 0.42f - MethRush.Cracks * 0.05f, 0.16f, 0.45f );
				DripFromCracks();
			}
		}

		var floor = TankSim.Height - 18f;
		for ( var i = _drops.Count - 1; i >= 0; i-- )
		{
			var d = _drops[i];
			d.Vy += Grav * dt;
			d.X += d.Vx * dt;
			d.Y += d.Vy * dt;
			d.Life -= dt;
			d.Spin += dt * 8f;

			if ( d.Layer == WaterLayer.Tank )
			{
				if ( d.Y > floor && d.Vy > 0f )
				{
					d.Y = floor;
					d.Vy *= -0.28f;
					d.Vx *= 0.55f;
					if ( MathF.Abs( d.Vy ) < 28f )
						d.Life = MathF.Min( d.Life, 0.08f );
				}
			}
			else
			{
				if ( d.Y > BattleSystem.ArenaH - 16f && d.Vy > 0f )
				{
					d.Y = BattleSystem.ArenaH - 16f;
					d.Vy *= -0.22f;
					d.Vx *= 0.5f;
				}
			}

			if ( d.Life <= 0f || d.Y > 900f || d.X < -80f || d.X > 1400f )
				_drops.RemoveAt( i );
		}

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

	/// <summary>Glass just cracked — water shoots out of that wound.</summary>
	public static void CrackJet( int crackIndex )
	{
		var i = Math.Clamp( crackIndex, 0, CrackSites.Length - 1 );
		var site = CrackSites[i];
		var n = 14 + crackIndex * 3;
		Squirt( site.X, site.Y, site.DirX, -0.35f, n, 220f, WaterLayer.Tank );
		Splash( site.X, site.Y, 10, WaterLayer.Tank );
	}

	public static void Splash( float x, float y, int count, WaterLayer layer )
	{
		count = Math.Clamp( count, 4, 28 );
		for ( var i = 0; i < count; i++ )
			Add( x, y, layer, jet: false, power: 140f + (float)_rng.NextDouble() * 180f );
		_version++;
	}

	public static void Squirt( float x, float y, float dirX, float dirY, int count, float power, WaterLayer layer )
	{
		var len = MathF.Sqrt( dirX * dirX + dirY * dirY );
		if ( len < 0.01f )
		{
			dirX = 0f;
			dirY = -1f;
			len = 1f;
		}
		dirX /= len;
		dirY /= len;
		count = Math.Clamp( count, 4, 32 );
		for ( var i = 0; i < count; i++ )
		{
			var spread = ((float)_rng.NextDouble() - 0.5f) * 0.7f;
			var px = dirX - dirY * spread;
			var py = dirY + dirX * spread;
			var spd = power * (0.55f + (float)_rng.NextDouble() * 0.7f);
			Push( new WaterDrop
			{
				X = x + (float)(_rng.NextDouble() * 10 - 5),
				Y = y + (float)(_rng.NextDouble() * 8 - 4),
				Vx = px * spd,
				Vy = py * spd - 40f,
				W = 10f + (float)_rng.NextDouble() * 10f,
				H = 14f + (float)_rng.NextDouble() * 12f,
				Life = 0.45f + (float)_rng.NextDouble() * 0.45f,
				Sprite = i % 3 == 0 ? "water_jet" : "water_drop",
				Layer = layer,
				Jet = true
			} );
		}
		_version++;
	}

	public static void BoomSplash( float x, float y )
	{
		Splash( x, y, 22, WaterLayer.Tank );
		Squirt( x, y, 0f, -1f, 10, 280f, WaterLayer.Tank );
	}

	public static void FightHit( float x, float y, bool hard )
	{
		Splash( x, y, hard ? 18 : 11, WaterLayer.Battle );
		if ( hard )
			Squirt( x, y, 0f, -1f, 8, 240f, WaterLayer.Battle );
	}

	static void DripFromCracks()
	{
		var n = Math.Clamp( MethRush.Cracks, 1, 5 );
		for ( var i = 0; i < n; i++ )
		{
			if ( _rng.NextDouble() > 0.55 )
				continue;
			var site = CrackSites[Math.Min( i, CrackSites.Length - 1 )];
			Add( site.X + (float)(_rng.NextDouble() * 24 - 12), site.Y, WaterLayer.Tank, jet: false, power: 40f );
			if ( MethRush.Cracks >= 3 && _rng.NextDouble() < 0.35 )
				Squirt( site.X, site.Y, site.DirX, 0.2f, 4, 160f, WaterLayer.Tank );
		}
	}

	static void Add( float x, float y, WaterLayer layer, bool jet, float power )
	{
		var ang = (float)(_rng.NextDouble() * Math.PI * 2);
		var spd = power * (0.45f + (float)_rng.NextDouble() * 0.7f);
		var spr = jet ? "water_jet"
			: (_rng.Next( 3 ) switch
			{
				0 => "water_blob",
				1 => "water_splash",
				_ => "water_drop"
			});
		Push( new WaterDrop
		{
			X = x,
			Y = y,
			Vx = MathF.Cos( ang ) * spd,
			Vy = MathF.Sin( ang ) * spd - 80f,
			W = jet ? 16f : 8f + (float)_rng.NextDouble() * 12f,
			H = jet ? 22f : 8f + (float)_rng.NextDouble() * 12f,
			Life = 0.35f + (float)_rng.NextDouble() * 0.5f,
			Sprite = spr,
			Layer = layer,
			Jet = jet
		} );
	}

	static void Push( WaterDrop d )
	{
		d.MaxLife = d.Life;
		if ( _drops.Count >= Cap )
			_drops.RemoveAt( 0 );
		_drops.Add( d );
	}
}