FeedSystem.cs

Feed system for the game. Defines a FoodPellet data class and a static FeedSystem that tracks unlocked foods, selected food, spawning pellets when feeding, boost multipliers, pellet lifetime, collision with fish, saving/loading unlocked/selected food, and simple UI/audio feedback.

File Access
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.45f;
	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 "";
	}

	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}." );
		Achievements.OnFoodUnlocked( def.Id );
		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 );

		Achievements.OnFed();
		Achievements.OnFoodFed( food.Id );
		TankSim.ShowBanner( food.Id == FoodDef.WeedGummies.Id
			? "Weed gummies in the water. Somebody's about to see God."
			: $"Fed {food.Name}." );
		_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;
	}
}