SharkSystem.cs contains a SharkActor data type and the SharkSystem manager for ocean shark hazards. It spawns, updates, hunts, bites and removes sharks, tracks kills/eaten counters, exposes UI labels and handles player actions to scare or fend sharks, interacting with TankSim, Economy, audio/visual systems and other game systems.
namespace NoChillquarium;
/// <summary>
/// Ocean hazard: cocaine sharks hunt fish in ocean habitats.
/// Scare with Ð + team power, or Fend with trained/jacked fish.
/// </summary>
public sealed class SharkActor
{
public string Id { get; init; }
public float X { get; set; }
public float Y { get; set; }
public float Vx { get; set; }
public float Vy { get; set; }
public float BiteCooldown { get; set; }
public float Life { get; set; } = 1f; // 0 = dead
public bool Alive => Life > 0.01f;
public int Facing => Vx >= 0f ? 1 : -1;
public float Width => 110f;
public float Height => 56f;
}
public static class SharkSystem
{
public const string OceanHabitatId = "ocean";
public const float SpawnIntervalMin = 16f;
public const float SpawnIntervalMax = 30f;
public const int MaxSharks = 3;
public const double ScareCost = 18;
public const double FendCost = 0; // free but needs power
public const float BiteRange = 38f;
public const float SharkSpeed = 100f;
static readonly Random _rng = new();
static readonly List<SharkActor> _sharks = new();
static float _spawnTimer = 12f;
static float _alertFlash;
static int _version;
static int _kills;
static int _eaten;
static bool _everSpawned;
public static int Version => _version;
public static IReadOnlyList<SharkActor> Sharks => _sharks;
public static int SharkCount => _sharks.Count( s => s.Alive );
public static bool HasSharks => SharkCount > 0;
public static float AlertFlash => _alertFlash;
public static int Kills => _kills;
public static int Eaten => _eaten;
public static bool EverSpawned => _everSpawned;
public static bool IsOceanActive =>
TankSim.IsActive
&& string.Equals( TankSim.HabitatId, OceanHabitatId, StringComparison.OrdinalIgnoreCase );
public static void Clear()
{
_sharks.Clear();
_spawnTimer = 12f;
_alertFlash = 0f;
_version++;
}
public static void ResetProgress()
{
_kills = 0;
_eaten = 0;
_everSpawned = false;
Clear();
}
public static void Load( int kills, int eaten, bool everSpawned )
{
_kills = Math.Max( 0, kills );
_eaten = Math.Max( 0, eaten );
_everSpawned = everSpawned;
Clear();
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.SharkKills = _kills;
data.SharkEaten = _eaten;
data.SharkEverSpawned = _everSpawned;
}
public static void Tick( float dt )
{
if ( !TankSim.IsActive || dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _alertFlash > 0f )
{
_alertFlash = MathF.Max( 0f, _alertFlash - dt );
_version++;
}
if ( !IsOceanActive )
{
// Leave the ocean → sharks swim off.
if ( _sharks.Count > 0 )
{
_sharks.Clear();
_version++;
}
return;
}
// Spawn cadence
_spawnTimer -= dt;
if ( _spawnTimer <= 0f )
{
_spawnTimer = SpawnIntervalMin + (float)_rng.NextDouble() * (SpawnIntervalMax - SpawnIntervalMin);
if ( SharkCount < MaxSharks && TankSim.FishCount > 0 )
TrySpawn();
}
for ( var i = _sharks.Count - 1; i >= 0; i-- )
{
var shark = _sharks[i];
if ( !shark.Alive )
{
_sharks.RemoveAt( i );
continue;
}
if ( shark.BiteCooldown > 0f )
shark.BiteCooldown -= dt;
Hunt( shark, dt );
Integrate( shark, dt );
if ( shark.BiteCooldown <= 0f )
TryBite( shark );
}
if ( _sharks.Count > 0 )
_version++;
}
static void TrySpawn()
{
var fromLeft = _rng.NextDouble() < 0.5;
var y = 60f + (float)_rng.NextDouble() * (TankSim.Height - 120f);
var x = fromLeft ? -40f : TankSim.Width + 40f;
var speed = SharkSpeed * (0.85f + (float)_rng.NextDouble() * 0.35f);
var vx = fromLeft ? speed : -speed;
_sharks.Add( new SharkActor
{
Id = Guid.NewGuid().ToString( "N" )[..6],
X = x,
Y = y,
Vx = vx,
Vy = ((float)_rng.NextDouble() - 0.5f) * 30f,
BiteCooldown = 1.2f,
Life = 1f
} );
_everSpawned = true;
_alertFlash = 1.6f;
Chaos.Add( 6f );
BoomFeel.SoftPunch( 0.55f );
GameAudio.PlaySharkAlert();
var n = SharkCount;
TankSim.ShowBanner( n > 1
? $"COCAINE SHARKS ×{n}. Fend or Scare — snacks are optional."
: "COCAINE SHARK in the water. Fend (train) or Scare (Ð)." );
_version++;
}
static void Hunt( SharkActor shark, float dt )
{
if ( TankSim.FishCount == 0 )
{
// Patrol
shark.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
shark.Vy += ((float)_rng.NextDouble() - 0.5f) * 30f * dt;
return;
}
// Prefer weak / slow fish
FishActor prey = null;
var best = float.MaxValue;
foreach ( var f in TankSim.Fish )
{
var dx = f.X - shark.X;
var dy = f.DrawY - shark.Y;
var dist = MathF.Sqrt( dx * dx + dy * dy );
// Jacked / trained are harder targets (deprioritize)
var score = dist + f.Agility * 8f + f.SpeedStat * 6f + (f.Jacked ? 40f : 0f);
if ( f.IsSick || f.InWithdrawal )
score -= 30f;
if ( score < best )
{
best = score;
prey = f;
}
}
if ( prey is null )
return;
var pdx = prey.X - shark.X;
var pdy = prey.DrawY - shark.Y;
var len = MathF.Sqrt( pdx * pdx + pdy * pdy );
if ( len < 1f )
return;
var want = SharkSpeed * (0.9f + (1f - Math.Clamp( prey.TrainingNorm, 0f, 1f )) * 0.35f);
shark.Vx = pdx / len * want;
shark.Vy = pdy / len * want * 0.75f;
// Panic nearby fish slightly
if ( len < 160f )
{
prey.Vx += (pdx / len) * 40f * dt;
prey.Vy += (pdy / len) * 30f * dt;
}
}
static void Integrate( SharkActor shark, float dt )
{
shark.X += shark.Vx * dt;
shark.Y += shark.Vy * dt;
var minX = -20f;
var maxX = TankSim.Width + 20f;
var minY = 30f;
var maxY = TankSim.Height - 40f;
if ( shark.Y < minY )
{
shark.Y = minY;
shark.Vy = MathF.Abs( shark.Vy );
}
else if ( shark.Y > maxY )
{
shark.Y = maxY;
shark.Vy = -MathF.Abs( shark.Vy );
}
// Bounce horizontal within extended bounds
if ( shark.X < minX )
{
shark.X = minX;
shark.Vx = MathF.Abs( shark.Vx );
}
else if ( shark.X > maxX )
{
shark.X = maxX;
shark.Vx = -MathF.Abs( shark.Vx );
}
}
static void TryBite( SharkActor shark )
{
FishActor closest = null;
var bestDist = BiteRange;
foreach ( var f in TankSim.Fish )
{
var dx = f.X - shark.X;
var dy = f.DrawY - shark.Y;
var dist = MathF.Sqrt( dx * dx + dy * dy );
if ( dist < bestDist )
{
bestDist = dist;
closest = f;
}
}
if ( closest is null )
return;
// Escape chance from agility / speed / jacked / meth
var escape = 0.18f + closest.Agility * 0.04f + closest.SpeedStat * 0.03f;
if ( closest.Jacked )
escape += 0.22f;
if ( closest.OnMeth )
escape += 0.12f;
if ( closest.IsSick || closest.InWithdrawal )
escape -= 0.2f;
escape = Math.Clamp( escape, 0.08f, 0.78f );
shark.BiteCooldown = 1.6f + (float)_rng.NextDouble() * 0.8f;
if ( _rng.NextDouble() < escape )
{
// Dash away
var ang = (float)(_rng.NextDouble() * Math.PI * 2);
closest.Vx = MathF.Cos( ang ) * closest.Species.Speed * 2.4f;
closest.Vy = MathF.Sin( ang ) * closest.Species.Speed * 1.2f;
if ( _rng.NextDouble() < 0.35 )
TankSim.ShowBanner( $"{closest.Species.Name} dodged a shark. Barely." );
return;
}
// Eaten
var name = closest.Species.Name;
var ex = closest.X;
var ey = closest.DrawY;
TankSim.RemoveFish( closest.InstanceId );
ExplodeSystem.SpawnDeathPoof( ex, ey );
WaterFx.Splash( ex, ey, 16, WaterLayer.Tank );
_eaten++;
TankSim.NotifyFishDied();
Chaos.Add( 5f );
Karma.Add( -4f );
GameAudio.PlayUi( GameAudio.M80 );
TankSim.ShowBanner( $"Shark ate {name}. Grandma: still your fault." );
_version++;
}
public static float TeamPower()
{
if ( TankSim.FishCount == 0 )
return 0f;
float sum = 0;
foreach ( var f in TankSim.Fish )
sum += BattleSystem.CombatPower( f );
return sum;
}
public static bool CanScare =>
IsOceanActive && HasSharks && Economy.Balance >= ScareCost;
public static bool CanFend =>
IsOceanActive && HasSharks && TankSim.FishCount > 0 && TeamPower() >= 6f;
/// <summary>Dock labels — always show cost / power so ocean isn't a mystery.</summary>
public static string ScareButtonLabel =>
"Scare Ð" + Economy.FormatDoge( ScareCost );
public static string FendButtonLabel
{
get
{
var pow = TeamPower().ToString( "0.#" );
return CanFend ? "Fend · pow " + pow : "Fend · need power";
}
}
/// <summary>Spend Ð — chance to drive a shark off based on team power.</summary>
public static bool TryScare()
{
if ( !HasSharks )
{
TankSim.ShowBanner( "No sharks. Ocean is... fine?" );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( ScareCost ) )
{
TankSim.ShowBanner( $"Scare costs Ð{Economy.FormatDoge( ScareCost )}." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var power = TeamPower();
var chance = Math.Clamp( 0.35f + power * 0.022f, 0.35f, 0.92f );
if ( _rng.NextDouble() < chance )
{
var shark = _sharks.FirstOrDefault( s => s.Alive );
if ( shark is not null )
{
ExplodeSystem.SpawnDeathPoof( shark.X, shark.Y );
_sharks.Remove( shark );
}
Chaos.Add( 2f );
Karma.Add( 3f );
BoomFeel.SoftPunch( 0.35f );
GameAudio.PlayLayered( GameAudio.Upgrade, GameAudio.Notice, 0.4f );
var tip = Math.Round( ScareCost * 0.35, 1 );
if ( tip > 0.5 )
Economy.AddForced( tip );
TankSim.ShowBanner( "Shark scared off. +Ð" + Economy.FormatDoge( tip ) + " hazard pay." );
}
else
{
Chaos.Add( 3f );
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "Shark unimpressed. Wallet lighter. Train or Fend." );
foreach ( var s in _sharks )
s.BiteCooldown = MathF.Min( s.BiteCooldown, 0.4f );
}
SaveGame.TrySave();
_version++;
return true;
}
/// <summary>Fend with trained fish — can kill a shark for Ð loot.</summary>
public static bool TryFend()
{
if ( !HasSharks || TankSim.FishCount == 0 )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var power = TeamPower();
var chance = Math.Clamp( 0.22f + power * 0.028f, 0.22f, 0.94f );
var jacked = TankSim.Fish.Count( f => f.Jacked );
if ( jacked > 0 )
chance = Math.Clamp( chance + jacked * 0.07f, 0f, 0.96f );
// Monsters eat sharks for breakfast.
if ( TankSim.HasAnyMonster )
chance = Math.Clamp( chance + 0.08f, 0f, 0.97f );
var shark = _sharks.FirstOrDefault( s => s.Alive );
if ( shark is null )
return false;
if ( _rng.NextDouble() < chance )
{
var sx = shark.X;
var sy = shark.Y;
ExplodeSystem.SpawnDeathPoof( sx, sy );
ExplodeSystem.SpawnCoinBurst( sx, sy, count: 16 );
_sharks.Remove( shark );
_kills++;
// Kill streak pays — ocean is a real Ð loop late game.
var streak = Math.Min( 8, _kills );
var loot = Math.Round( 55 + _rng.NextDouble() * 55 + streak * 8, 1 );
Economy.AddForced( loot );
Chaos.Add( 4f );
Karma.Add( 2f );
BoomFeel.SoftPunch( 0.7f );
GameAudio.PlayBigWin( bark: _kills <= 3 || _kills % 5 == 0 );
StatFloat.PushTag( "SHARK +" + "Ð" + Economy.FormatDoge( loot ), "rare", sx, sy - 24f, life: 2f );
var killLine = _kills == 1
? $"First shark down! +Ð{Economy.FormatDoge( loot )}."
: $"Shark kill ×{_kills}! +Ð{Economy.FormatDoge( loot )}.";
TankSim.ShowBanner( killLine + " Cocaine not recovered." );
if ( _kills == 1 )
GrandmaReact.OnFirstSharkKill();
foreach ( var f in TankSim.Fish )
{
f.Endurance = MathF.Min( 10f, f.Endurance + 0.12f );
f.Agility = MathF.Min( 10f, f.Agility + 0.14f );
f.SpeedStat = MathF.Min( 10f, f.SpeedStat + 0.08f );
}
}
else
{
Chaos.Add( 5f );
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "Fend failed. Train harder or Scare with Ð." );
shark.BiteCooldown = 0.2f;
// Retaliation less common so fending stays attractive.
if ( _rng.NextDouble() < 0.28 && TankSim.FishCount > 0 )
{
var weak = TankSim.Fish.OrderBy( f => f.Agility + f.SpeedStat ).First();
var name = weak.Species.Name;
var ex = weak.X;
var ey = weak.DrawY;
TankSim.RemoveFish( weak.InstanceId );
ExplodeSystem.SpawnDeathPoof( ex, ey );
_eaten++;
TankSim.NotifyFishDied();
Karma.Add( -4f );
TankSim.ShowBanner( $"Retaliation: shark ate {name}. Your fault." );
}
}
SaveGame.TrySave();
_version++;
return true;
}
}