ExplodeSystem.cs
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 );
AchievementSystem.Unlock( "tape" );
}
_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 );
var scrap = 0.0;
var failures = 0;
var keepers = 0;
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 );
// Failures = insurance scrap. Good aboms you blow up = waste.
if ( fish.IsAbominationFailure )
{
failures++;
// Quality control: scrap a chunk of what they'd flip for.
scrap += Math.Max( 8.0, TankSim.SellPrice( fish ) * 0.55 );
}
else if ( fish.IsMonster )
{
keepers++;
scrap += Math.Max( 3.0, TankSim.SellPrice( fish ) * 0.12 );
}
else
{
scrap += 2.5;
}
TankSim.RemoveFish( fish.InstanceId );
}
var n = victims.Count;
_taped.Clear();
// Screen smash + shock rings at each fish + death metal sting.
BoomFeel.Trigger( n, origins );
foreach ( var o in origins )
WaterFx.BoomSplash( o.X, o.Y );
Chaos.Add( 6f + n * 5f );
// Grandma respects scrapping failures; hates wasting champions.
if ( failures > 0 )
Karma.Add( 2.5f * failures );
if ( keepers > 0 )
Karma.Add( -5f * keepers );
if ( failures == 0 && keepers == 0 )
Karma.Add( -3f - n * 2f );
scrap = Math.Round( scrap, 1 );
if ( scrap > 0 )
Economy.Add( scrap );
if ( failures > 0 && keepers == 0 )
TankSim.ShowBanner( $"Scrapped {failures} failure{(failures == 1 ? "" : "s")}. Grandma: \"Quality control. Family business.\" +Ð{Economy.FormatDoge( scrap )}" );
else if ( keepers > 0 )
TankSim.ShowBanner( $"Boom. Grandma: \"That one had potential, idiot. Love you. Sit.\" +Ð{Economy.FormatDoge( scrap )}" );
else
TankSim.ShowBanner( $"Boom ×{n}. +Ð{Economy.FormatDoge( scrap )}" );
// Always return to default dock mode after the boom (clear via property).
ModeActive = false;
StorySystem.NotifyBoom();
JanitorPeep.On( "boom" );
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++;
}
/// <summary>Gold coin confetti for sells / big Ð moments (reuses boom bits pipeline).</summary>
public static void SpawnCoinBurst( float x, float y, int count = 14 )
{
count = Math.Clamp( count, 6, 28 );
for ( var i = 0; i < count; i++ )
{
var ang = (float)(_rng.NextDouble() * Math.PI * 2);
var spd = 50f + (float)_rng.NextDouble() * 140f;
_particles.Add( new BoomParticle
{
X = x + (float)(_rng.NextDouble() * 10.0 - 5.0),
Y = y + (float)(_rng.NextDouble() * 8.0 - 4.0),
Vx = MathF.Cos( ang ) * spd,
Vy = MathF.Sin( ang ) * spd - 40f - (float)_rng.NextDouble() * 40f,
Life = 0.45f + (float)_rng.NextDouble() * 0.55f,
Color = (i % 3) switch
{
0 => "#ffd24a",
1 => "#ffe8a0",
_ => "#c8a020"
}
} );
}
_version++;
}
/// <summary>Soft green feed sparkles when pellets land.</summary>
public static void SpawnFeedSpark( float x, float y )
{
for ( var i = 0; i < 6; i++ )
{
var ang = (float)(_rng.NextDouble() * Math.PI * 2);
var spd = 25f + (float)_rng.NextDouble() * 55f;
_particles.Add( new BoomParticle
{
X = x,
Y = y,
Vx = MathF.Cos( ang ) * spd,
Vy = MathF.Sin( ang ) * spd - 20f,
Life = 0.25f + (float)_rng.NextDouble() * 0.3f,
Color = i % 2 == 0 ? "#c8ff90" : "#e8c060"
} );
}
_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
} );
}
}
}