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 · @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 · 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);
}