UI/GameHud.razor
@using System
@using System.Globalization
@using Sandbox
@using Sandbox.UI
@namespace DesertPump
@inherits PanelComponent

<div class="hud @SceneClass @( ShakeClass )">

	<div class="sky"></div>
	<div class="sun">
		<div class="sun-rays"></div>
	</div>
	<div class="stars"></div>

	<div class="clouds">
		<div class="cloud c1"></div>
		<div class="cloud c2"></div>
		<div class="cloud c3"></div>
	</div>

	<div class="floor"></div>
	<div class="dune far"></div>
	<div class="dune near"></div>
	<div class="shimmer"></div>
	<div class="scenery"></div>

	<div class="motes">
		<div class="mote m1"></div>
		<div class="mote m2"></div>
		<div class="mote m3"></div>
		<div class="mote m4"></div>
		<div class="mote m5"></div>
		<div class="mote m6"></div>
		<div class="mote m7"></div>
		<div class="mote m8"></div>
	</div>

	@if ( State is null )
	{
		<div class="missing">
			<div class="missing-title">No Desert Pump Game in the scene</div>
			<div class="missing-body">Add a "Desert Pump Game" component to any GameObject.</div>
		</div>
	}
	else
	{
		<div class="topbar">

			<div class="stat coins @( CoinPopping ? "pop" : "" )">
				<div class="coin"></div>
				<div class="value">@Numbers.Short( State.Coins )</div>
				@if ( CoinPopping )
				{
					<div class="coinflash"></div>
				}
			</div>

			<div class="stat tankstat @( State.TankFull ? "full" : "" )">
				<div class="tank">
					<div class="tank-fill" style="@FillStyle()">
						<div class="tank-wave"></div>
					</div>
					<div class="tank-shine"></div>
				</div>
				<div class="tank-text">
					<div class="value">@Numbers.Short( State.Water )<span class="unit"> / @Numbers.Short( State.TankCapacity )L</span></div>
					<div class="sub">@RateText()</div>
				</div>
			</div>

			<div class="spacer"></div>

			<div class="iconbutton shopbtn @( AnyUpgradeAffordable ? "highlight" : "" )" onclick="@OpenShop">
				<i>storefront</i>
				@if ( AnyUpgradeAffordable )
				{
					<div class="dot"></div>
				}
			</div>

			<div class="iconbutton @( State.Muted ? "muted" : "" )" onclick="@ToggleMute">
				<i>@( State.Muted ? "volume_off" : "volume_up" )</i>
			</div>

			<div class="iconbutton" onclick="@OpenSettings">
				<i>settings</i>
			</div>

		</div>

		<div class="stage">

			<div class="pumpwrap">

				<div class="pumparea @PumpAreaClass()" onclick="@DoPump">
					<div class="glow" style="@GlowStyle()"></div>
					<div class="heatring"></div>
					<PumpArt TierIndex="@State.PumpLevel" Artwork="@ArtworkFor( State.PumpLevel )"></PumpArt>
					<div class="ground"></div>
				</div>

				@if ( ShowClickRing )
				{
					<div class="clickring"></div>
				}

				@if ( ShowSurgeBurst )
				{
					<div class="surgering"></div>
				}

				<div class="fx">
					@foreach ( var p in particles )
					{
						<div class="particle @p.Kind" style="@ParticleStyle( p )"></div>
					}

					@foreach ( var f in floaters )
					{
						<div class="floater @f.Kind" style="@FloaterStyle( f )">@f.Text</div>
					}
				</div>

			</div>

			<div class="pressure s@( State.PressureStage ) @( State.Pressure > 0.02f ? "" : "cold" )">
				<div class="pressure-bar">
					<div class="pressure-fill" style="@PressureFillStyle()"></div>
				</div>
				<div class="pressure-label">
					<span class="pressure-name">PRESSURE</span>
					<span class="pressure-value">@State.PressureBonusText</span>
				</div>
			</div>

			<div class="nameplate">
				<div class="tierno">@State.CurrentPump.Position &middot; @State.Tier.Name.ToUpper()</div>
				<div class="name">@State.CurrentPump.Name</div>
				<div class="tagline">@State.Tier.Tagline</div>
				<div class="tierbar">
					<div class="tierfill" style="@TierFillStyle()"></div>
				</div>
				<div class="perclick">+@Numbers.Short( State.LitresPerClick )L per pump - @Numbers.Short( State.PricePerLitre ) @Numbers.Plural( State.PricePerLitre, "coin", "coins" ) per litre</div>
			</div>

			@if ( ShowToast )
			{
				<div class="toast">@toast</div>
			}

			@if ( ShowIdleNote )
			{
				<div class="idlenote"><i>schedule</i> The pump made @Numbers.Short( idleNotice )L while you were away</div>
			}

			<div class="treearea">

				<div class="tree @( State.TreeIsGrowing ? "growing" : "" )" onclick="@DoWaterTree"
					 style="background-image: url( '/ui/tree/tree_@(State.TreeArtStage).svg' );">
				</div>

				<div class="treeinfo">
					<div class="treeheight"><i>park</i> @State.TreeHeight @( State.TreeHeight == 1 ? "stage" : "stages" )</div>

					@if ( State.TreeIsGrowing )
					{
						<div class="treestatus growing">Growing - @State.TreeTimeLeft</div>
					}
					else
					{
						<div class="treebar">
							<div class="treefill" style="@TreeFillStyle()"></div>
						</div>
						<div class="treestatus">@Numbers.Short( State.TreeWater )/@Numbers.Short( State.TreeWaterNeeded )L</div>
					}

					<div class="treebtn @( State.CanWaterTree ? "" : "disabled" )" onclick="@DoWaterTree">
						<i>water_drop</i> WATER
					</div>

					<div class="treebtn board" onclick="@OpenTree">
						<i>leaderboard</i> BOARD
					</div>
				</div>

			</div>

			@* Input.Pressed is edge triggered, so SPACE is one pump per press - holding it
			   does nothing and telling the player otherwise reads as the game being broken. *@
			<div class="hint">Click the pump or tap SPACE - keep a rhythm going to build pressure</div>

			@* Last in the stage on purpose. It spawns at a random spot whose range
			   overlaps the tree, and drawn any earlier the tree paints over it and eats
			   the click - so the gusher silently waters the tree and then expires. It is
			   a rare, timed reward; nothing in here should ever be allowed on top of it. *@
			@if ( State.GusherActive )
			{
				<div class="gusher" style="@GusherStyle()" onclick="@ClaimGusher">
					<div class="gusher-drop"></div>
					<div class="gusher-ring"></div>
					<div class="gusher-ring two"></div>
				</div>
			}

		</div>

		@if ( State.CanClaimDaily )
		{
			<div class="daily" onclick="@ClaimDaily">
				<div class="daily-icon"><i>redeem</i></div>
				<div class="daily-text">
					<div class="daily-title">DAILY BONUS READY</div>
					<div class="daily-sub">@Numbers.Short( State.DailyReward ) coins &middot; day @State.ProjectedDailyStreak streak</div>
				</div>
			</div>
		}

		<div class="actions">

			<div class="bigbutton sell @( State.CanSell ? "" : "disabled" )" onclick="@DoSell">
				<div class="btn-icon"><i>local_drink</i></div>
				<div class="btn-text">
					<div class="btn-title">SELL WATER</div>
					<div class="btn-sub">+@Numbers.Short( State.SaleValue ) coins</div>
				</div>
				<div class="shine"></div>
			</div>

			@if ( State.IsMaxed )
			{
				<div class="bigbutton buy maxed">
					<div class="btn-icon"><i>emoji_events</i></div>
					<div class="btn-text">
						<div class="btn-title">ALL PUMPS OWNED</div>
						<div class="btn-sub">the desert is yours</div>
					</div>
				</div>
			}
			else
			{
				<div class="bigbutton buy @( State.CanUpgrade ? "ready" : "disabled" )" onclick="@DoBuy">
					<div class="btn-icon"><i>upgrade</i></div>
					<div class="btn-text">
						<div class="btn-title">BUY @State.NextPump.Name.ToUpper()</div>
						<div class="btn-sub">@Numbers.Short( State.NextPump.Cost ) coins</div>
					</div>
					<div class="shine"></div>
				</div>
			}

		</div>

		<div class="collection">
			@foreach ( var pump in VisiblePumps )
			{
				<div class="slot @SlotClass( pump )">
					<div class="slotart">
						<PumpArt TierIndex="@pump.Index"
								 Silhouette="@( pump.Index > State.PumpLevel )"
								 Artwork="@ArtworkFor( pump.Index )"></PumpArt>
						@if ( pump.Index > State.PumpLevel )
						{
							<div class="question">?</div>
						}
					</div>
					<div class="slotlabel">@SlotLabel( pump )</div>
					@if ( pump.Index <= State.PumpLevel )
					{
						<div class="tick"><i>check</i></div>
					}
				</div>
			}
		</div>

		@if ( ShowCelebration )
		{
			<div class="celebrate">
				<div class="celebrate-ring"></div>
				<div class="celebrate-ring two"></div>
				<div class="celebrate-card">
					<div class="celebrate-kicker">NEW PUMP INSTALLED</div>
					<div class="celebrate-name">@celebrateName</div>
					<div class="celebrate-sub">@celebrateSub</div>
				</div>
			</div>
		}

		@if ( ShowSurgeFlash )
		{
			<div class="surgeflash"></div>
		}

		@if ( showShop )
		{
			<ShopPanel OnClose="@CloseOverlays"></ShopPanel>
		}

		@if ( showSettings )
		{
			<SettingsPanel OnClose="@CloseOverlays"></SettingsPanel>
		}

		@if ( showTree )
		{
			<TreePanel OnClose="@CloseOverlays"></TreePanel>
		}
	}

</div>

@code
{
	/// <summary>Optional - left empty, the HUD finds the game itself.</summary>
	[Property] public DesertPumpGame Target { get; set; }

	/// <summary>
	/// The real pump artwork, one texture per tier in the same order as
	/// <see cref="PumpTier.All"/>. Any slot left empty falls back to placeholder art.
	/// </summary>
	[Property] public List<Texture> PumpArtwork { get; set; } = new();

	DesertPumpGame State => Target.IsValid() ? Target : DesertPumpGame.Current;

	const float FloaterLife = 1.1f;
	const float SurgeFloaterLife = 1.5f;
	const float ToastLife = 2.2f;
	const float IdleNoticeLife = 8f;
	const float PumpAnimLife = 0.16f;
	const float ClickRingLife = 0.42f;
	const float SurgeFlashLife = 0.4f;
	const float SurgeBurstLife = 0.6f;
	const float CoinPopLife = 0.36f;
	const float CelebrationLife = 2.6f;
	const float ShakeLife = 0.32f;

	/// <summary>Pixels per second squared pulling the burst particles back down.</summary>
	const float Gravity = 1150f;

	/// <summary>Must match the width of .floater in the stylesheet - see FloaterStyle.</summary>
	const float FloaterWidth = 300f;

	/// <summary>Above this the whole effects layer stops spawning - a fast clicker can outrun it.</summary>
	const int MaxParticles = 40;

	class Floater
	{
		public string Text;
		public string Kind;
		public float X;
		public float Drift;
		public float Life;
		public RealTimeSince Age;
	}

	/// <summary>
	/// One piece of the burst. Positions are integrated in C# rather than handed to a CSS
	/// keyframe because each one needs its own arc - see <see cref="ParticleStyle"/>.
	/// </summary>
	class Particle
	{
		public string Kind;
		public float X;
		public float Y;
		public float Vx;
		public float Vy;
		public float Size;
		public float Spin;
		public float Life;
		public RealTimeSince Age;
	}

	readonly List<Floater> floaters = new();
	readonly List<Particle> particles = new();

	string toast;
	RealTimeSince toastAge;

	double idleNotice;
	RealTimeSince idleAge;

	// Every one-shot effect below is gated on "has it been less than N seconds since
	// this fired". Left at the default timestamp of zero that reads as "it fired at
	// engine time zero" - fine in the editor, which has been up for hours, but in a
	// fresh build RealTime.Now starts near zero too and the first frame would come up
	// mid surge-flash. Start them all firmly in the past instead.
	const float LongAgo = 9999f;

	RealTimeSince timeSincePump = LongAgo;
	RealTimeSince clickRingAge = LongAgo;
	RealTimeSince surgeAge = LongAgo;
	RealTimeSince coinPopAge = LongAgo;
	RealTimeSince celebrateAge = LongAgo;
	RealTimeSince shakeAge = LongAgo;

	string celebrateName;
	string celebrateSub;

	/// <summary>Set when a shake is wanted, and which of the two flavours.</summary>
	string shakeKind;

	/// <summary>Bumped every frame so the particles get rebuilt while they're moving.</summary>
	int animTick;

	bool hooked;

	bool showShop;
	bool showSettings;
	bool showTree;

	/// <summary>Puts a nudge on the shop button when something in there is affordable.</summary>
	bool AnyUpgradeAffordable =>
		State is not null && UpgradeTrack.All.Any( x => State.CanBuy( x.Kind ) );

	/// <summary>
	/// The strip along the bottom shows the tier you're in, not all 108 pumps - nine
	/// slots you can actually finish is a far better carrot than a hundred you can't.
	/// </summary>
	IEnumerable<PumpModel> VisiblePumps
	{
		get
		{
			if ( State is null ) return Enumerable.Empty<PumpModel>();

			var first = State.Tier.FirstPumpIndex;
			return PumpModel.All.Skip( first ).Take( PumpTier.PumpsPerTier );
		}
	}

	string SceneClass => $"scene-{State?.SelectedBackground ?? BackgroundStyle.DefaultId}";

	// ---- one-shot effect gates ---------------------------------------------

	/// <summary>
	/// The settings toggle that governs all the per-action juice - numbers, droplets,
	/// coins, rings, flashes and the shake. The new-pump card is deliberately left out
	/// of it: that one is a milestone rather than noise, and a player who turned the
	/// effects down still wants to be told they bought something.
	/// </summary>
	static bool EffectsOn => PumpSettings.Current.ShowFloatingNumbers;

	bool ShowClickRing => EffectsOn && clickRingAge < ClickRingLife;
	bool ShowSurgeFlash => EffectsOn && surgeAge < SurgeFlashLife;
	bool ShowSurgeBurst => EffectsOn && surgeAge < SurgeBurstLife;
	bool CoinPopping => EffectsOn && coinPopAge < CoinPopLife;
	bool ShowCelebration => celebrateName is not null && celebrateAge < CelebrationLife;

	/// <summary>
	/// The shake is a class on the root rather than an inline transform, so the whole
	/// keyframe runs on the GPU and the C# side only has to say when it starts.
	/// </summary>
	string ShakeClass => shakeAge < ShakeLife ? $"shake-{shakeKind}" : "";

	void OpenShop()
	{
		showShop = !showShop;
		showSettings = false;
	}

	void OpenSettings()
	{
		showSettings = !showSettings;
		showShop = false;
	}

	void OpenTree()
	{
		showTree = !showTree;
		showShop = false;
		showSettings = false;
	}

	/// <summary>Open a panel from the console, so UI can be inspected without clicking.</summary>
	public void ShowPanel( string which )
	{
		showShop = which is "shop" or "bg" or "workers";
		showSettings = which == "settings";
		showTree = which == "tree";

		StateHasChanged();
	}

	void CloseOverlays()
	{
		showShop = false;
		showSettings = false;
		showTree = false;
	}

	string TreeFillStyle() => $"width: {N( State.TreeFraction * 100f )}%;";

	void DoWaterTree()
	{
		var poured = State?.WaterTree() ?? 0;
		if ( poured <= 0 ) return;

		Spawn( $"-{Numbers.Short( poured )}L", "water" );

		toast = State.TreeIsGrowing
			? $"Tree is growing - back in {State.TreeTimeLeft}"
			: $"{Numbers.Short( State.TreeWaterRemaining )}L to go";
		toastAge = 0;
	}

	void OnTreeGrew( int height )
	{
		toast = $"Your tree grew to {height} {( height == 1 ? "stage" : "stages" )}";
		toastAge = 0;
	}

	bool ShowToast => !string.IsNullOrEmpty( toast ) && toastAge < ToastLife;
	bool ShowIdleNote => idleNotice > 0 && idleAge < IdleNoticeLife;

	/// <summary>
	/// The panel a PanelComponent builds for itself defaults to pointer-events: none,
	/// and no stylesheet rule can reach it - it has no class and a generated type name.
	/// Left alone it makes every button underneath it dead to the mouse.
	/// </summary>
	protected override void OnTreeFirstBuilt()
	{
		base.OnTreeFirstBuilt();

		Panel.Style.PointerEvents = PointerEvents.All;
	}

	protected override void OnStart()
	{
		Hook();

		if ( State is not null && State.IdleCatchUp > 0 )
		{
			idleNotice = State.IdleCatchUp;
			idleAge = 0;
		}
	}

	protected override void OnDestroy()
	{
		Unhook();
	}

	protected override void OnUpdate()
	{
		// The game component can come alive after us, so keep trying until it's there.
		if ( !hooked ) Hook();

		floaters.RemoveAll( x => x.Age > x.Life );
		particles.RemoveAll( x => x.Age > x.Life );

		if ( floaters.Count > 0 || particles.Count > 0 || timeSincePump < PumpAnimLife )
		{
			animTick = (int)(RealTime.Now * 30f);
		}
	}

	protected override int BuildHash()
	{
		var state = State;
		if ( state is null ) return 0;

		// The counts have to be in here, not just animTick. animTick only advances while
		// something is animating, so on the frame the last floater is removed it freezes
		// - and if nothing else in the hash moved, the tree never rebuilds and that
		// floater stays on screen forever, stuck at whatever opacity it last drew at.
		// At pump 1 there is no idle income to accidentally cover for it.
		// ShowSurgeFlash belongs here for the same reason the counts do - it is a panel
		// that has to come off the screen, and nothing else in the hash is guaranteed to
		// have moved on the frame it expires. Combine takes eight, so the last two share
		// a nested one rather than being OR'd together, which would hide a swap.
		var effects = HashCode.Combine(
			floaters.Count, particles.Count, animTick, ShakeClass,
			ShowClickRing, ShowSurgeBurst, ShowSurgeFlash,
			HashCode.Combine( CoinPopping, ShowCelebration ) );

		var overlays = HashCode.Combine(
			showShop, showSettings, showTree, ShowToast, ShowIdleNote,
			AnyUpgradeAffordable, state.SelectedBackground, state.CanClaimDaily );

		var world = HashCode.Combine(
			state.GusherActive, state.TreeHeight, Numbers.Bucket( state.TreeWater ),
			(int)state.TreeSecondsLeft );

		// Numbers go through Numbers.Bucket rather than a cast. Coins reach 2.5e51 by
		// the last pump, and a double that big cast to long lands on the same value
		// every time - so the hash would stop moving and the wallet would freeze on
		// screen from tier 9 onward. See the remarks on Bucket.
		return HashCode.Combine(
			Numbers.Bucket( state.Coins ),
			Numbers.Bucket( state.Water ),
			state.PumpLevel,
			state.Muted,
			// Twenty bands rather than the raw float - the gauge still reads as smooth
			// and the tree isn't rebuilt on every frame that pressure bleeds off.
			(int)(state.Pressure * 20f),
			effects,
			overlays,
			world );
	}

	void Hook()
	{
		var state = State;
		if ( state is null || hooked ) return;

		state.Pumped += OnPumped;
		state.Surged += OnSurged;
		state.Sold += OnSold;
		state.Upgraded += OnUpgraded;
		state.Refused += OnRefused;
		state.Purchased += OnPurchased;
		state.TreeGrew += OnTreeGrew;

		hooked = true;
	}

	void Unhook()
	{
		var state = State;
		if ( state is null || !hooked ) return;

		state.Pumped -= OnPumped;
		state.Surged -= OnSurged;
		state.Sold -= OnSold;
		state.Upgraded -= OnUpgraded;
		state.Refused -= OnRefused;
		state.Purchased -= OnPurchased;
		state.TreeGrew -= OnTreeGrew;

		hooked = false;
	}

	void DoPump() => State?.Pump();
	void DoSell() => State?.SellAll();
	void DoBuy() => State?.BuyNextPump();
	void ToggleMute() => State?.ToggleMute();

	void OnPumped( double litres )
	{
		timeSincePump = 0;
		clickRingAge = 0;

		Spawn( $"+{Numbers.Short( litres )}L", "water" );

		// More water on screen the harder you're going, so the gauge isn't the only
		// thing telling you pressure is up.
		var count = 4 + (int)(State.Pressure * 5f);
		SprayDroplets( count );
	}

	void OnSurged( double litres )
	{
		timeSincePump = 0;
		clickRingAge = 0;
		surgeAge = 0;

		Shake( "hard" );
		Spawn( $"SURGE  +{Numbers.Short( litres )}L", "surge" );
		SprayDroplets( 14 );
		SpawnSparks( 12 );
	}

	void OnSold( double coins )
	{
		Spawn( $"+{Numbers.Short( coins )}", "coin" );
		SpawnCoins( 10 );

		coinPopAge = 0;
		toast = null;
	}

	void OnUpgraded( PumpModel pump )
	{
		celebrateName = pump.Name;
		celebrateSub = pump.Rank == 1
			? $"{pump.Tier.Name} - {pump.Tier.Tagline}"
			: $"{Numbers.Short( pump.PerClick )}L per pump  -  {Numbers.Short( pump.Tank )}L tank";
		celebrateAge = 0;

		Shake( "soft" );
		SpawnSparks( 18 );

		toast = null;
		idleNotice = 0;
	}

	void OnRefused( string reason )
	{
		toast = reason;
		toastAge = 0;
	}

	void OnPurchased( UpgradeTrack track )
	{
		toast = $"{track.Name} upgraded";
		toastAge = 0;
	}

	void Shake( string kind )
	{
		if ( !PumpSettings.Current.ShowFloatingNumbers )
			return;

		shakeKind = kind;
		shakeAge = 0;
	}

	void Spawn( string text, string kind )
	{
		if ( !PumpSettings.Current.ShowFloatingNumbers )
			return;

		// Keep the list short - a fast clicker can outrun the fade otherwise.
		if ( floaters.Count > 12 ) floaters.RemoveAt( 0 );

		floaters.Add( new Floater
		{
			Text = text,
			Kind = kind,
			X = kind == "surge" ? Rand( -30f, 30f ) : Rand( -70f, 70f ),
			Drift = Rand( -18f, 18f ),
			Life = kind == "surge" ? SurgeFloaterLife : FloaterLife,
			Age = 0
		} );
	}

	/// <summary>Water thrown up out of the pump head.</summary>
	void SprayDroplets( int count )
	{
		for ( var i = 0; i < count; i++ )
		{
			Add( new Particle
			{
				Kind = "drop",
				X = Rand( -26f, 26f ),
				Y = Rand( -96f, -60f ),
				Vx = Rand( -280f, 280f ),
				Vy = Rand( -440f, -230f ),
				Size = Rand( 7f, 16f ),
				Spin = 0f,
				Life = Rand( 0.55f, 0.85f ),
				Age = 0
			} );
		}
	}

	/// <summary>Coins tossed out on a sale.</summary>
	void SpawnCoins( int count )
	{
		for ( var i = 0; i < count; i++ )
		{
			Add( new Particle
			{
				Kind = "coinbit",
				X = Rand( -40f, 40f ),
				Y = Rand( -30f, 20f ),
				Vx = Rand( -240f, 240f ),
				Vy = Rand( -520f, -320f ),
				Size = Rand( 14f, 24f ),
				Spin = Rand( -420f, 420f ),
				Life = Rand( 0.75f, 1.05f ),
				Age = 0
			} );
		}
	}

	/// <summary>The flat radial burst a surge or a new pump throws off.</summary>
	void SpawnSparks( int count )
	{
		for ( var i = 0; i < count; i++ )
		{
			var angle = (i / (float)count) * MathF.PI * 2f + Rand( -0.2f, 0.2f );
			var speed = Rand( 380f, 620f );

			Add( new Particle
			{
				Kind = "spark",
				X = 0f,
				Y = -40f,
				Vx = MathF.Cos( angle ) * speed,
				Vy = MathF.Sin( angle ) * speed,
				Size = Rand( 8f, 15f ),
				Spin = Rand( -240f, 240f ),
				Life = Rand( 0.45f, 0.7f ),
				Age = 0
			} );
		}
	}

	void Add( Particle particle )
	{
		if ( !PumpSettings.Current.ShowFloatingNumbers )
			return;

		// Drop the oldest rather than refusing the new one, so a burst always reads as
		// a burst even when the screen is already busy.
		if ( particles.Count >= MaxParticles )
			particles.RemoveAt( 0 );

		particles.Add( particle );
	}

	Texture ArtworkFor( int index )
	{
		if ( PumpArtwork is null ) return null;
		if ( index < 0 || index >= PumpArtwork.Count ) return null;

		return PumpArtwork[index];
	}

	string RateText()
	{
		var perSecond = State.TotalWaterPerSecond;

		return perSecond > 0
			? $"+{Numbers.Rate( perSecond )}L/s while idle"
			: "hand powered";
	}

	string PumpAreaClass()
	{
		var classes = timeSincePump < PumpAnimLife ? "pumping" : "";
		if ( State.TankFull ) classes += " full";
		if ( State.PressureHot ) classes += " hot";

		return classes;
	}

	/// <summary>The pump's halo brightens and widens with pressure.</summary>
	string GlowStyle()
	{
		var p = State.Pressure;
		var size = 78f + p * 14f;
		var alpha = 0.14f + p * 0.22f;

		return $"width: {N( size )}%; height: {N( size )}%; " +
			$"background-color: rgba( 255, 255, 255, {N( alpha )} );";
	}

	string PressureFillStyle() => $"width: {N( State.Pressure * 100f )}%;";

	string SlotClass( PumpModel tier )
	{
		if ( tier.Index <= State.PumpLevel ) return "owned";

		return tier.Index == State.PumpLevel + 1 ? "locked next" : "locked";
	}

	string SlotLabel( PumpModel tier )
	{
		return tier.Index <= State.PumpLevel
			? tier.Name
			: $"{Numbers.Short( tier.Cost )} {Numbers.Plural( tier.Cost, "coin", "coins" )}";
	}

	string FillStyle() => $"height: {N( State.TankFraction * 100f )}%;";

	string TierFillStyle() => $"width: {N( State.TierProgress * 100f )}%;";

	string GusherStyle() =>
		$"left: {N( State.GusherX * 100f )}%; top: {N( State.GusherY * 100f )}%;";

	void ClaimGusher()
	{
		var won = State?.ClaimGusher() ?? 0;
		if ( won <= 0 ) return;

		Spawn( $"+{Numbers.Short( won )}", "coin" );
		SpawnCoins( 14 );
		coinPopAge = 0;

		toast = "Gusher! Free coins";
		toastAge = 0;
	}

	void ClaimDaily()
	{
		var won = State?.ClaimDaily() ?? 0;
		if ( won <= 0 ) return;

		Spawn( $"+{Numbers.Short( won )}", "coin" );
		SpawnCoins( 16 );
		coinPopAge = 0;

		toast = $"Day {State.DailyStreak} streak - come back tomorrow";
		toastAge = 0;
	}

	string FloaterStyle( Floater f )
	{
		var t = Math.Clamp( (float)f.Age / f.Life, 0f, 1f );
		var rise = 70f + t * 110f;
		var fade = 1f - t * t;

		// Surge numbers punch out to full size and settle, so they read as an event
		// rather than as one more of the numbers already streaming past.
		var scale = f.Kind == "surge"
			? 1f + (1f - MathF.Min( t * 5f, 1f )) * 0.5f
			: 1f;

		// Centred on the anchor by hand: the panel is a fixed 300px wide with centred
		// text, so half its width comes back off the left. Text has no width to measure.
		return $"left: {N( f.X + f.Drift * t - FloaterWidth * 0.5f )}px; top: {N( -rise )}px; " +
			$"opacity: {N( fade )}; transform: scale( {N( scale )} );";
	}

	string ParticleStyle( Particle p )
	{
		var t = (float)p.Age;
		var k = Math.Clamp( t / p.Life, 0f, 1f );

		// Sparks fly flat - they're a burst of light, not something thrown in the air.
		var gravity = p.Kind == "spark" ? 0f : Gravity;

		var x = p.X + p.Vx * t;
		var y = p.Y + p.Vy * t + 0.5f * gravity * t * t;

		var fade = 1f - k * k;
		var scale = p.Kind == "spark" ? 1f - k * 0.7f : 1f;

		return $"left: {N( x - p.Size * 0.5f )}px; top: {N( y - p.Size * 0.5f )}px; " +
			$"width: {N( p.Size )}px; height: {N( p.Size )}px; opacity: {N( fade )}; " +
			$"transform: rotate( {N( p.Spin * t )}deg ) scale( {N( scale )} );";
	}

	/// <summary>Style strings need dots for decimals, whatever the machine's locale says.</summary>
	static string N( float value ) => value.ToString( "0.##", CultureInfo.InvariantCulture );

	static float Rand( float min, float max ) => min + (float)System.Random.Shared.NextDouble() * (max - min);
}