StatFloat.cs

UI/gameplay utility that spawns and manages short-lived floating stat pop items over fish. It stores StatFloatItem entries, updates their position, life, delay and visibility, and provides convenience methods to spawn labeled pops for single stats, trios, session summaries and flavor/weed messages.

File AccessNetworking
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 (lane DriftX + Rise hold vertical stagger).
			if ( !string.IsNullOrEmpty( it.FishId ) )
			{
				var fish = TankSim.FindFish( it.FishId );
				if ( fish is not null )
				{
					it.X = fish.X + it.DriftX;
					// Rise starts negative for upper lanes; floats continue upward as Rise decreases.
					it.Y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.5f + 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>
	/// Fixed visual lane per stat so pops never stack:
	/// END left, SPD center, AGI right — each a bit higher.
	/// </summary>
	static void LaneFor( int statIndex, out float driftX, out float rise0 )
	{
		switch ( statIndex )
		{
			case 0: // END
				driftX = -40f;
				rise0 = 0f;
				break;
			case 1: // SPD
				driftX = 0f;
				rise0 = -16f;
				break;
			default: // AGI
				driftX = 40f;
				rise0 = -32f;
				break;
		}
	}

	/// <summary>
	/// Generic tank-local pop (growth, sell, rare, stage). Optional fish follow.
	/// Kinds: grow · stage · sell · rare · fed · end · spd · agi
	/// </summary>
	public static void PushTag( string label, string kind, float x, float y, string fishId = null, float delay = 0f, float life = 1.45f )
	{
		if ( string.IsNullOrWhiteSpace( label ) )
			return;
		kind ??= "combo";
		life = Math.Clamp( life, 0.6f, 3.2f );
		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = label.Trim(),
			FishId = fishId,
			X = x,
			Y = y,
			Life = life,
			MaxLife = life,
			Delay = Math.Max( 0f, delay ),
			Rise = -6f,
			DriftX = (float)(_rng.NextDouble() * 18.0 - 9.0),
			Kind = kind
		} );
	}

	/// <summary>Pop over a living fish (or last known pos if null id only).</summary>
	public static void PushOverFish( FishActor fish, string label, string kind, float delay = 0f, float life = 1.45f )
	{
		if ( fish is null || string.IsNullOrWhiteSpace( label ) )
			return;
		var y = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.55f - 10f;
		PushTag( label, kind, fish.X, y, fish.InstanceId, delay, life );
	}

	/// <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 );
		LaneFor( statIndex, out var driftX, out var rise0 );
		var baseY = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.55f - 8f;

		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = $"+{pts} {name}",
			FishId = fish.InstanceId,
			X = fish.X + driftX,
			Y = baseY + rise0,
			Life = DefaultLife,
			MaxLife = DefaultLife,
			Delay = Math.Max( 0f, delay ),
			Rise = rise0,
			DriftX = driftX,
			Kind = kind
		} );
	}

	/// <summary>
	/// Multi-stat gains as separate staggered pops (END / SPD / AGI lanes).
	/// Gym panel still gets one combined LastLine.
	/// </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;

		// Fixed order + timing: left→right, one after another (readable, not a pile).
		const float step = 0.18f;
		var delay = 0f;
		if ( endGain > 0.001f )
		{
			Spawn( fish, 0, endGain, delay );
			delay += step;
		}
		if ( spdGain > 0.001f )
		{
			Spawn( fish, 1, spdGain, delay );
			delay += step;
		}
		if ( agiGain > 0.001f )
			Spawn( fish, 2, agiGain, delay );

		_version++;
	}

	/// <summary>End-of-set: staggered totals + 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;

		const float step = 0.2f;
		var delay = 0.05f;
		if ( endGain > 0.001f )
		{
			SpawnLong( fish, 0, endGain, delay );
			delay += step;
		}
		if ( spdGain > 0.001f )
		{
			SpawnLong( fish, 1, spdGain, delay );
			delay += step;
		}
		if ( agiGain > 0.001f )
			SpawnLong( fish, 2, agiGain, delay );

		_version++;
	}

	/// <summary>Same lanes as Spawn, longer life for end-of-set totals.</summary>
	static void SpawnLong( FishActor fish, int statIndex, float applied, float delay )
	{
		if ( fish is null )
			return;
		var pts = applied > 0.001f
			? Math.Max( 1, (int)MathF.Round( applied * 10f ) )
			: 0;
		if ( pts <= 0 )
			return;

		var (name, kind) = StatLabel( statIndex );
		LaneFor( statIndex, out var driftX, out var rise0 );
		var baseY = fish.DrawY - (fish.Species?.Height ?? 30f) * 0.6f - 12f;
		const float life = 2.6f;

		Push( new StatFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 ),
			Label = $"+{pts} {name}",
			FishId = fish.InstanceId,
			X = fish.X + driftX,
			Y = baseY + rise0,
			Life = life,
			MaxLife = life,
			Delay = Math.Max( 0f, delay ),
			Rise = rise0,
			DriftX = driftX,
			Kind = kind
		} );
	}

	/// <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++;
	}
}