FeedSystem and FoodPellet types for an aquarium game. FoodPellet stores position, life, color and source food id. FeedSystem manages unlocked foods, selected food, spawning pellets, boost timers, feeding logic, pellet movement and fish eating interactions, plus save/load and UI/feedback triggers.
namespace NoChillquarium;
public sealed class FoodPellet
{
public float X { get; set; }
public float Y { get; set; }
public float Life { get; set; }
public string Color { get; set; } = "#e8c060";
/// <summary>Food type that spawned this pellet (for eat effects).</summary>
public string FoodId { get; set; }
}
/// <summary>
/// Food ladder: selected feed type, pellets, boost multipliers, chaos.
/// </summary>
public static class FeedSystem
{
const float Cooldown = 0.32f;
const float PelletLife = 4.5f;
static readonly List<FoodPellet> _pellets = new();
static readonly HashSet<string> _unlocked = new();
static readonly Random _rng = new();
static float _cooldown;
static float _boostLeft;
static float _boostMul = 1f;
static float _bobMul = 1f;
static float _speedMul = 1f;
static int _version;
static FoodDef _selected;
public static IReadOnlyList<FoodPellet> Pellets => _pellets;
public static int Version => _version;
public static bool IsBoosted => _boostLeft > 0f;
public static float IncomeMultiplier => IsBoosted ? _boostMul : 1f;
public static float FishBobMul => IsBoosted ? _bobMul : 1f;
public static float FishSpeedMul => IsBoosted ? _speedMul : 1f;
/// <summary>Currently selected food, or null if nothing owned yet.</summary>
public static FoodDef Selected => _selected;
public static bool HasAnyFood => _unlocked.Count > 0;
public static string BoostText =>
IsBoosted && _selected is not null ? $"{_selected.Name} ×{_boostMul:0.##}" : "Hungry";
public static bool IsUnlocked( string id ) =>
!string.IsNullOrEmpty( id ) && _unlocked.Contains( id );
public static bool IsUnlocked( FoodDef def ) =>
def is not null && _unlocked.Contains( def.Id );
public static void Clear()
{
_pellets.Clear();
_cooldown = 0f;
_boostLeft = 0f;
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
_selected = null;
_unlocked.Clear();
_version++;
}
public static void Load( List<string> unlockedIds, string selectedId )
{
// Drop mid-session pellets / boost; restore owned food from save.
_pellets.Clear();
_cooldown = 0f;
_boostLeft = 0f;
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
_unlocked.Clear();
if ( unlockedIds is not null )
{
foreach ( var id in unlockedIds )
{
if ( !string.IsNullOrWhiteSpace( id ) && FoodDef.FindExact( id ) is not null )
_unlocked.Add( id );
}
}
_selected = null;
if ( !string.IsNullOrWhiteSpace( selectedId ) && IsUnlocked( selectedId ) )
_selected = FoodDef.FindExact( selectedId );
if ( _selected is null )
_selected = FoodDef.Catalog.FirstOrDefault( IsUnlocked );
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.UnlockedFoods = _unlocked.ToList();
data.SelectedFoodId = _selected?.Id ?? "";
}
public static bool CanUnlock( FoodDef def )
{
if ( def is null || IsUnlocked( def ) )
return false;
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
return false;
if ( def.UnlockCost > 0 && Economy.Balance < def.UnlockCost )
return false;
return true;
}
public static string UnlockBlockedReason( FoodDef def )
{
if ( def is null )
return "Invalid";
if ( IsUnlocked( def ) )
return "Owned";
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
{
var left = def.UnlockPlaytime - Economy.PlaytimeSeconds;
var totalMin = (int)(left / 60f);
if ( totalMin >= 60 )
{
var h = totalMin / 60;
var m = totalMin % 60;
return m > 0 ? $"Wait {h}h {m}m" : $"Wait {h}h";
}
return totalMin > 0 ? $"Wait {totalMin}m" : $"Wait {(int)left}s";
}
if ( def.UnlockCost > 0 && Economy.Balance < def.UnlockCost )
return $"Need Ð{Economy.FormatDoge( def.UnlockCost )}";
return "";
}
/// <summary>
/// Free unlock (tank gift, story). No spend, no playtime gate.
/// </summary>
public static bool Grant( FoodDef def, bool announce = false )
{
if ( def is null || IsUnlocked( def ) )
return false;
_unlocked.Add( def.Id );
_selected ??= def;
if ( announce )
{
GameAudio.PlayUi( GameAudio.Upgrade );
TankSim.ShowBanner( $"{def.Name} ready. Tools → Feed." );
}
_version++;
StorySystem.Evaluate();
return true;
}
public static bool TryUnlock( FoodDef def )
{
if ( def is null || IsUnlocked( def ) )
return false;
if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
{
TankSim.ShowBanner( $"{def.Name} unlocks after more playtime." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( def.UnlockCost > 0 && !Economy.TrySpend( def.UnlockCost ) )
{
TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( def.UnlockCost )} for {def.Name}." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_unlocked.Add( def.Id );
_selected = def;
GameAudio.PlayUi( GameAudio.Upgrade );
TankSim.ShowBanner( $"Bought {def.Name}." );
StorySystem.Evaluate();
SaveGame.TrySave();
_version++;
return true;
}
public static void Select( FoodDef def )
{
if ( def is null || !IsUnlocked( def ) )
{
GameAudio.PlayUi( GameAudio.Error );
return;
}
_selected = def;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
/// <summary>Cycle to next unlocked food (dock shortcut).</summary>
public static void CycleSelected()
{
var list = FoodDef.Catalog.Where( IsUnlocked ).ToList();
if ( list.Count == 0 )
{
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "No food yet — Shop → Food." );
return;
}
if ( list.Count == 1 )
return;
var i = list.FindIndex( f => f.Id == _selected?.Id );
i = (i + 1) % list.Count;
_selected = list[i];
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static bool TryFeed()
{
if ( !TankSim.IsActive )
return false;
if ( !HasAnyFood || _selected is null || !IsUnlocked( _selected ) )
{
GameAudio.PlayUi( GameAudio.Error );
TankSim.ShowBanner( "No food — Shop → Food." );
return false;
}
if ( _cooldown > 0f )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var food = _selected;
_cooldown = Cooldown;
_boostLeft = food.BoostDuration;
_boostMul = food.BoostMultiplier;
_bobMul = food.BobMulWhileBoost;
_speedMul = food.SpeedMulWhileBoost;
if ( food.ChaosOnFeed > 0f )
Chaos.Add( food.ChaosOnFeed );
// Proper diet builds karma; horrors spend it.
if ( food.ChaosOnFeed <= 0.5f )
Karma.Add( 1.2f );
else if ( food.ChaosOnFeed < 8f )
Karma.Add( -1.5f );
else
Karma.Add( -4f );
for ( var i = 0; i < food.PelletCount; i++ )
{
_pellets.Add( new FoodPellet
{
X = 40f + (float)_rng.NextDouble() * (TankSim.Width - 80f),
Y = 18f + (float)_rng.NextDouble() * 50f,
Life = PelletLife * (0.7f + (float)_rng.NextDouble() * 0.5f),
Color = food.PelletColor,
FoodId = food.Id
} );
}
// Special joke feeds
if ( food.Id == FoodDef.HomelessMan.Id )
GameAudio.PlayUi( GameAudio.Notice );
else if ( food.Id == FoodDef.WeedGummies.Id )
GameAudio.PlayUi( GameAudio.Notice );
else
GameAudio.PlayUi( GameAudio.Feed );
TankSim.ShowBanner( food.Id == FoodDef.WeedGummies.Id
? "Weed gummies in the water. Somebody's about to see God."
: $"Fed {food.Name}." );
// Tiny punch so feed clicks feel tactile (not a full boom).
if ( !StorySystem.HasFedOnce )
BoomFeel.SoftPunch( 0.25f );
StorySystem.NotifyFed();
_version++;
return true;
}
public static void Tick( float dt )
{
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
if ( _cooldown > 0f )
_cooldown = MathF.Max( 0f, _cooldown - dt );
if ( _boostLeft > 0f )
{
_boostLeft = MathF.Max( 0f, _boostLeft - dt );
if ( _boostLeft <= 0f )
{
_boostMul = 1f;
_bobMul = 1f;
_speedMul = 1f;
}
}
for ( var i = _pellets.Count - 1; i >= 0; i-- )
{
var p = _pellets[i];
p.Y += 22f * dt;
p.Life -= dt;
if ( p.Y > TankSim.Height - 20f )
p.Y = TankSim.Height - 20f;
foreach ( var fish in TankSim.Fish )
{
// Fatter fish have a bigger mouth hitbox (and get fatter still).
var reach = 18f + fish.FatNorm * 10f;
var dx = fish.X - p.X;
var dy = fish.DrawY - p.Y;
if ( dx * dx + dy * dy < reach * reach )
{
var food = FoodDef.FindExact( p.FoodId ) ?? _selected ?? FoodDef.Flakes;
fish.EatPellet( food.ChaosOnFeed, food.Id );
p.Life = 0f;
break;
}
}
if ( p.Life <= 0f )
_pellets.RemoveAt( i );
}
if ( _pellets.Count > 0 || IsBoosted )
_version++;
}
public static bool TryGetNearestPellet( float x, float y, out float px, out float py )
{
px = 0;
py = 0;
if ( _pellets.Count == 0 )
return false;
FoodPellet best = null;
var bestD = float.MaxValue;
foreach ( var p in _pellets )
{
var dx = p.X - x;
var dy = p.Y - y;
var d = dx * dx + dy * dy;
if ( d < bestD )
{
bestD = d;
best = p;
}
}
if ( best is null )
return false;
px = best.X;
py = best.Y;
return true;
}
}