DogeFloat.cs

UI helper for floating Dogecoin popup messages. Defines DogeFloatItem data and DogeFloat static manager that creates, updates, rate-limits, and formats spend/earn/meme popups with lifetimes, drift and labels.

File Access
namespace NoChillquarium;

public sealed class DogeFloatItem
{
	public string Key { get; set; }
	public string Label { get; set; }
	public bool IsSpend { get; set; }
	public bool IsFunny { get; set; }
	public float Life { get; set; }
	public float MaxLife { get; set; }
	public float OffsetY { get; set; }
	public float DriftX { get; set; }
}

/// <summary>
/// Quick Dogecoin popups near the wallet.
/// Spends: always simple. Earns: only pleasant / meme amounts (no drip spam).
/// </summary>
public static class DogeFloat
{
	const int MaxVisible = 6;
	const float SpendLife = 1.35f;
	const float EarnLife = 1.75f;
	const float FunnyLife = 2.4f;

	static readonly List<DogeFloatItem> _items = new();
	static readonly Random _rng = new();
	static int _version;
	static float _earnCooldown; // soft rate-limit pleasant earns

	/// <summary>Meme / joke whole-coin payouts worth a float + sometimes achievements.</summary>
	public static readonly int[] FunnyAmounts =
	{
		11, 22, 33, 42, 69, 88, 99, 123, 180, 360, 404, 420, 666, 777, 800, 1337, 8008, 9001
	};

	static readonly HashSet<int> FunnySet = new( FunnyAmounts );

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

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

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

		if ( _earnCooldown > 0f )
			_earnCooldown = MathF.Max( 0f, _earnCooldown - dt );

		var dirty = false;
		for ( var i = _items.Count - 1; i >= 0; i-- )
		{
			var it = _items[i];
			it.Life -= dt;
			it.OffsetY -= 28f * dt;
			if ( it.Life <= 0f )
			{
				_items.RemoveAt( i );
				dirty = true;
			}
			else
			{
				dirty = true; // animate every frame while visible
			}
		}

		if ( dirty )
			_version++;
	}

	/// <summary>Always show a simple spend pop (e.g. −Ð15).</summary>
	public static void NotifySpend( double amount )
	{
		if ( amount < 0.5 )
			return;

		var whole = Math.Max( 1, (int)Math.Round( amount ) );
		Push( new DogeFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" )[..6],
			Label = $"−Ð{Economy.FormatDoge( whole )}",
			IsSpend = true,
			IsFunny = false,
			Life = SpendLife,
			MaxLife = SpendLife,
			OffsetY = 0f,
			DriftX = (float)(_rng.NextDouble() * 24.0 - 12.0)
		} );
	}

	/// <summary>
	/// Earn pop only for pleasant / meme whole amounts — never idle drip crumbs.
	/// </summary>
	public static void NotifyEarn( double amount )
	{
		if ( amount < 0.5 )
			return;

		var whole = (int)Math.Round( amount );
		if ( whole < 1 )
			return;

		var funny = FunnySet.Contains( whole );
		if ( !funny && !IsPleasantEarn( whole ) )
			return;

		// Soft cooldown so a burst of rewards doesn't bury the HUD.
		if ( !funny && _earnCooldown > 0f )
			return;

		var life = funny ? FunnyLife : EarnLife;
		var label = funny
			? FunnyLabel( whole )
			: $"+Ð{Economy.FormatDoge( whole )}";

		Push( new DogeFloatItem
		{
			Key = Guid.NewGuid().ToString( "N" )[..6],
			Label = label,
			IsSpend = false,
			IsFunny = funny,
			Life = life,
			MaxLife = life,
			OffsetY = 0f,
			DriftX = (float)(_rng.NextDouble() * 28.0 - 14.0)
		} );

		if ( !funny )
			_earnCooldown = 0.55f;

		if ( funny )
			GameAudio.PlayDogeBark();
		else if ( whole >= 25 )
			GameAudio.PlayCoin();

		Achievements.OnPleasantDogeEarn( whole, funny );
	}

	static bool IsPleasantEarn( int whole )
	{
		// Skip tiny crumbs — idle never hits NotifyEarn, but small payouts still can.
		if ( whole < 5 )
			return false;

		// Nice round / "good even" feeling numbers.
		if ( whole % 100 == 0 )
			return true;
		if ( whole % 50 == 0 && whole >= 50 )
			return true;
		if ( whole % 25 == 0 && whole >= 25 )
			return true;
		if ( whole % 10 == 0 && whole >= 10 )
			return true;
		if ( whole is 5 or 15 or 20 )
			return true;

		return false;
	}

	static string FunnyLabel( int whole ) => whole switch
	{
		69 => "+Ð69 nice",
		420 => "+Ð420 blaze it",
		1337 => "+Ð1337 leet",
		666 => "+Ð666 oh no",
		777 => "+Ð777 jackpot",
		42 => "+Ð42 answer",
		88 => "+Ð88 lucky",
		404 => "+Ð404 not found",
		8008 => "+Ð8008 boobies",
		9001 => "+Ð9001 over 9000",
		11 => "+Ð11 pairwise",
		22 => "+Ð22 double",
		33 => "+Ð33 thrice",
		99 => "+Ð99 almost",
		123 => "+Ð123 easy",
		180 => "+Ð180 full turn",
		360 => "+Ð360 no scope",
		800 => "+Ð800 solid",
		_ => $"+Ð{whole} much wow"
	};

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

	/// <summary>0–1 remaining life for CSS opacity/rise.</summary>
	public static float LifeNorm( DogeFloatItem item )
	{
		if ( item is null || item.MaxLife <= 0f )
			return 0f;
		return Math.Clamp( item.Life / item.MaxLife, 0f, 1f );
	}
}