StatFloat.cs

UI/gameplay helper that creates and manages floating stat popups over fish (single or combined gains, flavored messages). It stores active StatFloatItem entries, updates their life, position (optionally tethered to a fish), and exposes a last-line summary for gym UI.

File Access
namespace NoChillquarium;

public sealed class StatFloatItem
{
	public string Key { get; set; }
	public string Label { get; set; }
	/// <summary>Optional fish to follow (tank instance id).</summary>
	public string FishId { get; set; }
	/// <summary>Tank-local spawn / draw X.</summary>
	public float X { get; set; }
	/// <summary>Tank-local spawn / draw Y.</summary>
	public float Y { get; set; }
	public float Life { get; set; }
	public float MaxLife { get; set; }
	/// <summary>Seconds before this pop becomes visible (stagger multi-stat).</summary>
	public float Delay { get; set; }
	public float Rise { get; set; }
	public float DriftX { get; set; }
	/// <summary>end | spd | agi — color class.</summary>
	public string Kind { get; set; }
	/// <summary>True once delay elapsed (drawn this frame).</summary>
	public bool Visible => Delay <= 0f && Life > 0f;
}

/// <summary>
/// RPG-style +stat pops over fish while training / dosing.
/// Display points are tenths of the 0–10 internal stat (0.25 → "+3").
/// </summary>
public static class StatFloat
{
	const int MaxVisible = 24;
	const float DefaultLife = 1.8f;

	static readonly List<StatFloatItem> _items = new();
	static readonly Random _rng = new();
	static int _version;
	static string _lastLine = "";
	static float _lastLineTimer;

	public static int Version => _version;
	public static IReadOnlyList<StatFloatItem> Items => _items;

	/// <summary>Compact line for gym side panel, e.g. "+3 Speed  +1 Agility".</summary>
	public static string LastLine => _lastLineTimer > 0f ? _lastLine : "";

	public static void Clear()
	{
		_items.Clear();
		_lastLine = "";
		_lastLineTimer = 0f;
		_version++;
	}

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

		if ( _lastLineTimer > 0f )
		{
			_lastLineTimer = MathF.Max( 0f, _lastLineTimer - dt );
			_version++;
		}

		var dirty = false;
		for ( var i = _items.Count - 1; i >= 0; i-- )
		{
			var it = _items[i];

			// Keep locked to swimming fish so the pop doesn't spawn and vanish off-screen.
			if ( !string.IsNullOrEmpty( it.FishId ) )
			{
				var fish = TankSim.FindFish( it.FishId );
				if ( fish is not null )
				{
					it.X = fish.X + it.DriftX;
					it.Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.45f + it.Rise;
				}
			}

			if ( it.Delay > 0f )
			{
				it.Delay -= dt;
				dirty = true;
				continue;
			}

			it.Life -= dt;
			it.Rise -= 42f * dt;
			if ( it.Life <= 0f )
			{
				_items.RemoveAt( i );
				dirty = true;
			}
			else
			{
				dirty = true;
			}
		}

		if ( dirty )
			_version++;
	}

	public static float LifeNorm( StatFloatItem item )
	{
		if ( item is null || !item.Visible || item.MaxLife <= 0f )
			return 0f;
		return Math.Clamp( item.Life / item.MaxLife, 0f, 1f );
	}

	/// <summary>0 = Endurance, 1 = Speed, 2 = Agility. <paramref name="applied"/> is real 0–10 delta.</summary>
	public static void Spawn( FishActor fish, int statIndex, float applied, float delay = 0f )
	{
		if ( fish is null )
			return;

		// Always show at least +1 when any gain was attempted (cap still may zero real gain).
		var pts = applied > 0.001f
			? Math.Max( 1, (int)MathF.Round( applied * 10f ) )
			: 0;
		if ( pts <= 0 )
			return;

		var (name, kind) = StatLabel( statIndex );
		// Centered above fish — no random pile-up
		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = $"+{pts} {name}",
			FishId = fish.InstanceId,
			X = fish.X,
			Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.55f - 8f,
			Life = DefaultLife,
			MaxLife = DefaultLife,
			Delay = Math.Max( 0f, delay ),
			Rise = 0f,
			DriftX = 0f,
			Kind = kind
		} );
	}

	/// <summary>
	/// One clean combined pop for multi-stat gains (training / meth / juice).
	/// Avoids three labels stacking on the fish.
	/// </summary>
	public static void SpawnTrio( FishActor fish, float endGain, float spdGain, float agiGain )
	{
		if ( fish is null )
			return;

		var line = BuildGainLine( endGain, spdGain, agiGain, shortNames: true );
		if ( string.IsNullOrEmpty( line ) )
			return;

		_lastLine = line;
		_lastLineTimer = 2.4f;

		// Single float above the fish
		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = line,
			FishId = fish.InstanceId,
			X = fish.X,
			Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.55f - 10f,
			Life = 2.0f,
			MaxLife = 2.0f,
			Delay = 0f,
			Rise = 0f,
			DriftX = 0f,
			Kind = "combo"
		} );
		_version++;
	}

	/// <summary>End-of-set: one TOTAL float + long gym last-line.</summary>
	public static void ShowSessionSummary( FishActor fish, float endGain, float spdGain, float agiGain )
	{
		if ( fish is null )
			return;

		var line = BuildGainLine( endGain, spdGain, agiGain, shortNames: true );
		if ( string.IsNullOrEmpty( line ) )
			return;

		_lastLine = "TOTAL  " + line;
		_lastLineTimer = 4.8f;

		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = line,
			FishId = fish.InstanceId,
			X = fish.X,
			Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.6f - 12f,
			Life = 2.8f,
			MaxLife = 2.8f,
			Delay = 0.05f,
			Rise = 0f,
			DriftX = 0f,
			Kind = "combo"
		} );
		_version++;
	}

	/// <summary>Short labels: END/SPD/AGI. Largest gain first.</summary>
	public static string BuildGainLine( float end, float spd, float agi, bool shortNames = true )
	{
		var parts = new List<string>( 3 );
		void Add( int stat, float gain )
		{
			if ( gain <= 0.001f )
				return;
			var pts = Math.Max( 1, (int)MathF.Round( gain * 10f ) );
			var name = shortNames ? ShortStat( stat ) : StatLabel( stat ).Name;
			parts.Add( $"+{pts} {name}" );
		}

		var order = new[] { (0, end), (1, spd), (2, agi) };
		Array.Sort( order, ( a, b ) => b.Item2.CompareTo( a.Item2 ) );
		foreach ( var (stat, gain) in order )
			Add( stat, gain );
		return string.Join( "  ", parts );
	}

	static string ShortStat( int statIndex ) => statIndex switch
	{
		0 => "END",
		1 => "SPD",
		_ => "AGI"
	};

	static (string Name, string Kind) StatLabel( int statIndex ) => statIndex switch
	{
		0 => ("END", "end"),
		1 => ("SPD", "spd"),
		_ => ("AGI", "agi")
	};

	/// <summary>Weed gummies bite — one vibe line, not a stack of overlapping pops.</summary>
	public static void SpawnWeedHigh( FishActor fish, float endGain, float agiGain, float spdLoss )
	{
		if ( fish is null )
			return;

		var bits = new List<string> { "+Chill", "+Munchies" };
		if ( endGain > 0.05f )
			bits.Add( $"+{Math.Max( 1, (int)MathF.Round( endGain * 10f ) )} END" );
		if ( agiGain > 0.05f )
			bits.Add( $"+{Math.Max( 1, (int)MathF.Round( agiGain * 10f ) )} AGI" );
		if ( spdLoss > 0.05f )
			bits.Add( $"-{Math.Max( 1, (int)MathF.Round( spdLoss * 10f ) )} SPD" );
		bits.Add( "+Vibes" );

		var line = string.Join( "  ", bits );
		_lastLine = line;
		_lastLineTimer = 2.6f;

		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = line.Length > 28 ? "+Chill  +Munchies  +Vibes" : line,
			FishId = fish.InstanceId,
			X = fish.X,
			Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.55f - 10f,
			Life = DefaultLife + 0.4f,
			MaxLife = DefaultLife + 0.4f,
			Delay = 0f,
			Rise = 0f,
			DriftX = 0f,
			Kind = "weed"
		} );
		_version++;
	}

	public static void SpawnFlavor( FishActor fish, string label, string kind, float delay = 0f )
	{
		if ( fish is null || string.IsNullOrWhiteSpace( label ) )
			return;

		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = label,
			FishId = fish.InstanceId,
			X = fish.X,
			Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.5f - 8f,
			Life = DefaultLife + 0.25f,
			MaxLife = DefaultLife + 0.25f,
			Delay = Math.Max( 0f, delay ),
			Rise = 0f,
			DriftX = 0f,
			Kind = kind
		} );
	}

	static void Push( StatFloatItem item )
	{
		_items.Add( item );
		while ( _items.Count > MaxVisible )
			_items.RemoveAt( 0 );
		_version++;
	}
}