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