Game/Idle/UI/IdleHud.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox
@*
IMPORTANT — s&box text rendering quirk.
Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
renderer hoists every AddMarkupContent node ahead of every AddContent node,
so "@A × @B" comes out as "×AB".
The rule this file follows: an element holds EITHER pure literal text OR a
single @expression. Anything composed goes through a helper below.
*@
<root>
@if ( Visible )
{
<div class="idle-root">
@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
<div class="idle-bg"></div>
<div class="idle-bg-glow @GlowClass()"></div>
@for ( int i = 0; i < 12; i++ )
{
float pLeft = (i * 8.3f) % 100f;
float pDur = 10f + (i * 0.9f) % 8f;
float pDel = (i * 1.4f) % 12f;
int pSz = (i % 4 == 0) ? 4 : 2;
string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
<div class="idle-particle"
style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
}
@* ══════════════════════════ TOP BAR ══════════════════════════ *@
<div class="idle-top">
<button class="idle-back" onclick=@OnBack>⌂</button>
<div class="idle-brand">
<div class="idle-brand-title">THE REACTOR</div>
<div class="idle-brand-sub">chain reactions, forever</div>
</div>
<div class="idle-currencies">
<div class="idle-cur cores">
<span class="idle-cur-icon">⚛️</span>
<span class="idle-cur-val">@CoresLabel()</span>
<span class="idle-cur-lbl">Cores</span>
</div>
<div class="idle-cur rate">
<span class="idle-cur-icon">📈</span>
<span class="idle-cur-val">@RateLabel()</span>
<span class="idle-cur-lbl">per sec</span>
</div>
<div class="idle-cur flux">
<span class="idle-cur-icon">🔷</span>
<span class="idle-cur-val">@FluxLabel()</span>
<span class="idle-cur-lbl">Flux</span>
</div>
<div class="idle-cur frag">
<span class="idle-cur-icon">💠</span>
<span class="idle-cur-val">@FragLabel()</span>
<span class="idle-cur-lbl">Frags</span>
</div>
</div>
</div>
@* ═══════════════════════════ BODY ════════════════════════════ *@
@* Hidden behind a modal — the panel renderer draws emoji glyphs above
overlay panels, so the board has to actually leave the tree. *@
@if ( !ModalOpen )
{
<div class="idle-body">
@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
<div class="idle-col idle-left">
<div class="idle-card">
<div class="idle-card-hd">OUTPUT</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Cores / sec</span>
<span class="idle-stat-val hot">@RateLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Global multiplier</span>
<span class="idle-stat-val">@GlobalMultLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Chain exponent</span>
<span class="idle-stat-val">@ExponentLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Last chain</span>
<span class="idle-stat-val">@LastChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Best chain</span>
<span class="idle-stat-val">@BestChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Board</span>
<span class="idle-stat-val">@BoardLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Feed line</span>
<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
</div>
</div>
@if ( ShowBoosts )
{
<div class="idle-card">
<div class="idle-card-hd">ACTIVE</div>
@if ( R.SurgeActive )
{
<div class="idle-boost surge">
<span class="idle-boost-icon">⚡</span>
<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
</div>
}
@foreach ( var boost in R.Boosts )
{
var b = boost;
<div class="idle-boost">
<span class="idle-boost-icon">@b.Icon</span>
<span class="idle-boost-lbl">@BoostLabel( b )</span>
<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
</div>
}
</div>
}
<div class="idle-card">
<div class="idle-card-hd">CONDENSER</div>
<div class="idle-bar-track">
<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
</div>
<div class="idle-card-note">@CondenserLabel()</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Today</span>
<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
</div>
</div>
<div class="idle-card">
<div class="idle-card-hd">MANIFEST</div>
@foreach ( var t in IdleBalance.Tiers )
{
var tier = t;
bool got = S.CycleCores >= tier.Requires;
<div class="idle-tier @(got ? "on" : "")">
<span class="idle-tier-icon">@tier.Icon</span>
<div class="idle-tier-body">
<div class="idle-tier-name">@tier.Name</div>
<div class="idle-tier-blurb">@TierBlurb( tier, got )</div>
</div>
</div>
}
</div>
</div>
@* ────────────────────── CENTER COLUMN ────────────────────── *@
<div class="idle-col idle-center">
<div class="idle-board-wrap">
<div class="idle-board @SurgeClass() @BoardShake()">
@for ( int r = 0; r < R.Size; r++ )
{
<div class="idle-row">
@for ( int c = 0; c < R.Size; c++ )
{
int row = r;
int col = c;
var cell = R.Board[row, col];
bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;
<div class="idle-cell @CellClass( cell, golden )"
style="width:@(CellPx)px;height:@(CellPx)px;"
onclick=@(() => OnCellClick( row, col ))>
@if ( golden )
{
<div class="idle-golden">🌟</div>
<div class="idle-golden-timer">@GoldenTimerLabel()</div>
}
@if ( !golden && cell.HasChest )
{
<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
}
@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
{
<div class="idle-hits">@cell.HitsLeft</div>
}
@if ( cell.HasDrone )
{
<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
}
</div>
}
</div>
}
</div>
@foreach ( var floater in _floaters )
{
var f = floater;
<div class="idle-float @f.Cls"
style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
@f.Text
</div>
}
@if ( _calloutLife > 0f )
{
<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
}
</div>
<div class="idle-controls">
<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
<span class="idle-overload-label">@OverloadLabel()</span>
<span class="idle-overload-sub">@OverloadSubLabel()</span>
</button>
<div class="idle-surge-wrap">
<div class="idle-surge-track @SurgeReadyClass()">
<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
<span class="idle-surge-lbl">@SurgeLabel()</span>
</div>
@if ( R.SurgeReady )
{
<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
}
</div>
</div>
<div class="idle-meltdown-strip @MeltdownStripClass()">
<span class="idle-melt-icon">☢️</span>
<span class="idle-melt-text">@MeltdownLabel()</span>
@if ( R.CanMeltdown )
{
<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
}
@if ( !R.CanMeltdown )
{
<div class="idle-melt-bar">
<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
</div>
}
</div>
</div>
@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
<div class="idle-col idle-right">
<div class="idle-tabs">
<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
<button class="idle-tab @TabClass("chains")" onclick=@(() => SetTab("chains"))>⛓️</button>
<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
</div>
@if ( IsUpgradeTab )
{
<div class="idle-buyrow">
<span class="idle-buyrow-lbl">BUY</span>
@foreach ( var amount in BuyAmounts )
{
int amt = amount;
<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
}
</div>
}
<div class="idle-panel">
@if ( IsUpgradeTab )
{
@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
{
var up = upgrade;
bool revealed = R.IsRevealed( up );
int lvl = S.Level( up.Id );
bool maxed = lvl >= up.MaxLevel;
int count = revealed ? BuyCount( up.Id ) : 0;
double cost = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
bool afford = revealed && !maxed && count > 0 && Cores >= cost;
<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@UpgradeName( up, revealed )</span>
<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
</div>
<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
@if ( revealed )
{
<div class="idle-up-bottom">
<span class="idle-up-effect">@up.Effect( lvl )</span>
<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
</div>
}
</div>
</div>
}
}
@if ( _tab == "flux" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@FluxHeadLabel()</div>
<div class="idle-flux-sub">@FluxSubLabel()</div>
</div>
@foreach ( var perk in IdleUpgradeCatalog.Perks )
{
var p = perk;
int lvl = S.PerkLevel( p.Id );
bool maxed = lvl >= p.MaxLevel;
int cost = p.CostAt( lvl );
bool afford = !maxed && S.Flux >= cost;
<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
<div class="idle-up-icon">@p.Icon</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@p.Name</span>
<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
</div>
<div class="idle-up-desc">@p.Desc</div>
<div class="idle-up-bottom">
<span class="idle-up-effect">@p.Effect( lvl )</span>
<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
</div>
</div>
</div>
}
}
@if ( _tab == "log" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@LogHeadLabel()</div>
<div class="idle-flux-sub">Reactor Log — every entry pays out once.</div>
</div>
@foreach ( var achievement in IdleAchievements.All )
{
var a = achievement;
bool done = S.HasAchievement( a.Id );
<div class="idle-up ach @(done ? "done" : "")">
<div class="idle-up-icon">@AchIcon( a, done )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@a.Name</span>
<span class="idle-ach-reward">@RewardLabel( a )</span>
</div>
<div class="idle-up-desc">@a.Desc</div>
@if ( !done )
{
<div class="idle-ach-bar">
<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
</div>
<div class="idle-ach-prog">@ProgressLabel( a )</div>
}
</div>
</div>
}
}
</div>
</div>
</div>
}
@* ═════════════════════════ TOASTS ═════════════════════════ *@
<div class="idle-toasts">
@foreach ( var toast in _toasts )
{
var t = toast;
<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
<span class="idle-toast-icon">@t.Icon</span>
<span class="idle-toast-text">@t.Text</span>
</div>
}
</div>
@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
@if ( R.OfflinePending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal offline">
<div class="idle-modal-eyebrow">REACTOR REPORT</div>
<div class="idle-modal-title">Welcome back</div>
<div class="idle-modal-sub">@OfflineSubLabel()</div>
<div class="idle-offline-haul">
<div class="idle-offline-amount">@OfflineAmountLabel()</div>
<div class="idle-offline-lbl">CORES</div>
</div>
@if ( R.PendingOfflineFrags > 0 )
{
<div class="idle-offline-extra">@OfflineFragLabel()</div>
}
@if ( OfflineTankFull )
{
<div class="idle-offline-warn">@OfflineWarnLabel()</div>
}
<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
</div>
}
@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal daily">
<div class="idle-modal-eyebrow">DAILY CYCLE</div>
<div class="idle-modal-title">@DailyTitleLabel()</div>
<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>
<div class="idle-streak-row">
@for ( int d = 1; d <= 7; d++ )
{
int day = d;
<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
}
</div>
<div class="idle-daily-reward">
<div class="idle-daily-mult">@DailyMultLabel()</div>
<div class="idle-daily-lbl">@DailyRewardLabel()</div>
</div>
<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
</div>
}
@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal meltdown">
<div class="idle-modal-eyebrow">PRESTIGE</div>
<div class="idle-modal-title">MELTDOWN</div>
<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>
<div class="idle-melt-payout">
<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
<div class="idle-melt-payout-lbl">FLUX</div>
</div>
<div class="idle-melt-cols">
<div class="idle-melt-col keep">
<div class="idle-melt-col-hd">KEPT</div>
<div class="idle-melt-line">Flux and every perk</div>
<div class="idle-melt-line">Reactor Log entries</div>
<div class="idle-melt-line">Records and Fragments</div>
<div class="idle-melt-line">Kickstart head start</div>
</div>
<div class="idle-melt-col lose">
<div class="idle-melt-col-hd">BURNED</div>
<div class="idle-melt-line">@BurnedCoresLabel()</div>
<div class="idle-melt-line">All Core upgrades</div>
<div class="idle-melt-line">Reactor Frame size</div>
<div class="idle-melt-line">Unlocked chest tiers</div>
</div>
</div>
<div class="idle-modal-btns">
<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
</div>
</div>
}
@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
@if ( !R.OfflinePending && _showIntro )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal intro">
<div class="idle-modal-eyebrow">HOW IT WORKS</div>
<div class="idle-modal-title">THE REACTOR</div>
<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>
<div class="idle-intro-steps">
<div class="idle-intro-step">
<span class="idle-intro-num">1</span>
<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">2</span>
<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">3</span>
<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">4</span>
<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
</div>
</div>
<button class="idle-modal-btn" onclick=@OnCloseIntro>START THE REACTOR</button>
</div>
}
</div>
}
</root>
@code {
public static IdleHud Instance { get; private set; }
IdleReactor R => IdleReactor.Instance;
ChainReactionGame Game => ChainReactionGame.Instance;
IdleSave S => ChainReactionGame.Save?.Idle;
bool Visible => Game?.GameStarted == true
&& Game?.Mode == GameMode.Idle
&& R != null && S != null;
double Cores => S?.Cores ?? 0;
bool ModalOpen => R.OfflinePending || R.DailyPending || _showMeltdown || _showIntro;
bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;
// ── UI state ──────────────────────────────────────────────────
string _tab = "reactor";
int _buyAmount = 1; // -1 == MAX
bool _showMeltdown;
bool _showIntro;
bool _subscribed;
bool _wasVisible;
static readonly int[] BuyAmounts = { 1, 10, 25, -1 };
bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";
IdleUpgradeCategory CurrentCategory => _tab switch
{
"reactor" => IdleUpgradeCategory.Reactor,
"chains" => IdleUpgradeCategory.Chains,
_ => IdleUpgradeCategory.Systems,
};
// ── Floating numbers ──────────────────────────────────────────
class Floater
{
public string Text;
public string Cls;
public float X, Y;
public float Life, MaxLife;
public float Alpha => MathF.Min( 1f, Life / 0.35f );
public float Rise => -(MaxLife - Life) * 42f;
public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
}
readonly List<Floater> _floaters = new();
class ToastMsg
{
public string Icon;
public string Text;
public string Cls;
public float Life;
public float Alpha => MathF.Min( 1f, Life / 0.4f );
}
readonly List<ToastMsg> _toasts = new();
string _calloutText = "";
string _calloutCls = "";
float _calloutLife;
// Board shake is kept local rather than going through ScreenShaker so the
// class string stays stable between rebuilds — a changing class would
// restart the animation on every repaint and read as a vibration.
string _shakeCls = "";
float _shakeUntil;
int CellPx => R == null ? 92 : R.Size switch
{
5 => 96,
6 => 84,
7 => 74,
8 => 66,
_ => 59,
};
// ══════════════════════════════════════════════════════════════
// Lifecycle
// ══════════════════════════════════════════════════════════════
protected override void OnStart()
{
Instance = this;
}
protected override void OnUpdate()
{
if ( !_subscribed && R != null ) Subscribe();
bool vis = Visible;
if ( vis != _wasVisible )
{
_wasVisible = vis;
if ( vis )
{
_floaters.Clear();
_toasts.Clear();
_calloutLife = 0f;
_shakeUntil = 0f;
_showMeltdown = false;
_showIntro = S != null && !S.IntroShown;
_tab = "reactor";
}
StateHasChanged();
}
if ( !vis ) return;
// SPACE is the detonate key everywhere else in the game, so it fires the
// reactor's manual burst here too.
if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
R.Overload();
float dt = Time.Delta;
for ( int i = _floaters.Count - 1; i >= 0; i-- )
{
_floaters[i].Life -= dt;
if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
}
for ( int i = _toasts.Count - 1; i >= 0; i-- )
{
_toasts[i].Life -= dt;
if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
}
if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
}
void Subscribe()
{
var r = R;
if ( r == null || _subscribed ) return;
r.OnPop += OnPop;
r.OnToast += OnToastEvent;
r.OnDetonation += OnDetonation;
_subscribed = true;
}
/// <summary>
/// Rebuilds only when something on screen would actually differ.
///
/// The first version forced a repaint 15 times a second, which meant
/// rebuilding the board, both side columns and every upgrade row constantly
/// — that was the source of the stutter. Between detonations almost nothing
/// changes, so hashing real state instead of a clock cuts the repaint rate
/// by roughly an order of magnitude.
/// </summary>
protected override int BuildHash()
{
if ( !Visible ) return 0;
var hc = new HashCode();
hc.Add( _tab );
hc.Add( _buyAmount );
hc.Add( _showMeltdown );
hc.Add( _showIntro );
hc.Add( ModalOpen );
// Currencies — quantised to what the labels actually show
hc.Add( (long)Cores );
hc.Add( (long)R.Cps );
hc.Add( S.Flux );
hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
hc.Add( S.TodayReactorFragments );
hc.Add( (int)(R.FragmentProgressFraction * 60f) );
// Live controls
hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
hc.Add( (int)(R.Charge * 100f) );
hc.Add( R.SurgeActive );
hc.Add( (int)R.SurgeTimeLeft );
hc.Add( (int)R.GoldenTimeLeft );
hc.Add( R.Boosts.Count );
hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
hc.Add( R.FeedStarved );
// Transient effects
hc.Add( _floaters.Count );
hc.Add( (int)(_calloutLife * 20f) );
hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
hc.Add( _toasts.Count );
// Board — chest layout, damage, drones and any live flash
hc.Add( R.Size );
float now = Time.Now;
bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;
for ( int r = 0; r < R.Size; r++ )
for ( int c = 0; c < R.Size; c++ )
{
var cell = R.Board[r, c];
hc.Add( (int)cell.Chest );
hc.Add( cell.HitsLeft );
hc.Add( cell.HasDrone );
hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );
if ( cell.FlashUntil > now ) animating = true;
if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
}
hc.Add( R.GoldenRow );
hc.Add( R.GoldenCol );
hc.Add( R.LastChain );
// Anything mid-flight is drawn from inline styles rather than keyframes,
// so it needs a clock — but only while it is actually moving. The rest of
// the time this hash is stable and the panel is never rebuilt at all.
if ( animating ) hc.Add( (int)(now * 20f) );
return hc.ToHashCode();
}
// ══════════════════════════════════════════════════════════════
// Reactor events
// ══════════════════════════════════════════════════════════════
void OnPop( int row, int col, double value, bool crit )
{
if ( R == null || _floaters.Count > 10 ) return;
float step = 100f / R.Size;
_floaters.Add( new Floater {
Text = "+" + IdleFormat.Short( value ),
Cls = crit ? "crit" : "",
X = step * col + step * 0.5f,
Y = step * row + step * 0.5f,
Life = 0.9f,
MaxLife = 0.9f,
} );
}
void OnToastEvent( string icon, string text, string cls )
{
_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
}
void OnDetonation( int chain, double payout, bool crit )
{
string shake = crit || chain >= 25 ? "idle-shake-3"
: chain >= 12 ? "idle-shake-2"
: chain >= 4 ? "idle-shake-1"
: "";
if ( shake != "" )
{
_shakeCls = shake;
_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
}
if ( chain < 15 && !crit ) return;
double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );
_calloutText = crit
? $"CRIT ×{critPower:F0} · +{IdleFormat.Short( payout )}"
: $"{chain} CHAIN · +{IdleFormat.Short( payout )}";
_calloutCls = crit ? "crit" : chain >= 35 ? "mega" : "big";
_calloutLife = 1f;
}
// ══════════════════════════════════════════════════════════════
// Input
// ══════════════════════════════════════════════════════════════
void SetTab( string tab ) { _tab = tab; Sound.Play( "UI_Button" ); }
void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }
int BuyCount( IdleUpgradeId id )
=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;
void OnBuy( IdleUpgradeId id )
{
if ( R == null ) return;
if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
}
void OnBuyPerk( IdlePerkId id )
{
if ( R == null ) return;
if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
}
void OnOverload() => R?.Overload();
void OnSurge() => R?.ActivateSurge();
void OnCellClick( int r, int c ) => R?.ClickCell( r, c );
void OnClaimOffline() => R?.ClaimOffline();
void OnClaimDaily() => R?.ClaimDaily();
void OnOpenMeltdown() { _showMeltdown = true; Sound.Play( "UI_Button" ); }
void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }
void OnMeltdown()
{
_showMeltdown = false;
R?.DoMeltdown();
}
void OnCloseIntro()
{
_showIntro = false;
if ( S != null )
{
S.IntroShown = true;
ChainReactionGame.Save?.Save();
}
Sound.Play( "PlayButton" );
}
void OnBack()
{
Sound.Play( "UI_Button" );
Game?.ReturnToMenu();
}
// ══════════════════════════════════════════════════════════════
// Class helpers
// ══════════════════════════════════════════════════════════════
string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );
string GlowClass() => R.SurgeActive ? "surging" : "";
string SurgeClass() => R.SurgeActive ? "surging" : "";
string SurgeReadyClass() => R.SurgeReady ? "ready" : "";
string OverloadClass() => R.OverloadReady ? "ready" : "cooling";
string BoardShake() => Time.Now < _shakeUntil ? _shakeCls : "";
string TabClass( string t ) => _tab == t ? "on" : "";
string BuyAmtClass( int amt ) => _buyAmount == amt ? "on" : "";
string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
string CapClass() => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
string FeedClass() => R.FeedStarved ? "starved" : "";
string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";
string StreakPipClass( int day )
{
int streak = S?.DailyStreak ?? 1;
if ( day == streak ) return "on today";
return day < streak ? "on" : "";
}
// ══════════════════════════════════════════════════════════════
// Text helpers — one composed string per element (see note at top)
// ══════════════════════════════════════════════════════════════
string CoresLabel() => IdleFormat.Short( Cores );
string RateLabel() => IdleFormat.Short( R.Cps );
string FluxLabel() => (S?.Flux ?? 0).ToString();
string FragLabel() => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();
string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
string ExponentLabel() => R.ChainExponent.ToString( "F2" );
string LastChainLabel() => $"{R.LastChain} chests";
string BestChainLabel() => $"{S.BestChain} chests";
string BoardLabel() => $"{R.Size} × {R.Size}";
string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";
string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
string SecondsLabel( float s ) => $"{s:F0}s";
string CondenserLabel()
=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";
string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";
string TierBlurb( IdleBalance.ChestTier tier, bool unlocked )
=> unlocked ? tier.Blurb : $"unlocks at {IdleFormat.Short( tier.Requires )} cycle Cores";
string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );
string OverloadLabel()
=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";
string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
string OverloadFillPct() => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
string UnleashLabel() => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";
string SurgeLabel()
{
if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
if ( R.SurgeReady ) return "SURGE READY";
return $"CHARGE {R.Charge * 100f:F0}%";
}
string MeltdownLabel()
=> R.CanMeltdown
? $"Meltdown available — +{R.PendingFlux} Flux"
: $"Meltdown unlocks at {IdleFormat.Short( IdleBalance.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";
string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";
string UpgradeClass( bool revealed, bool maxed, bool afford )
{
if ( !revealed ) return "locked";
if ( maxed ) return "maxed";
return afford ? "afford" : "poor";
}
string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";
string UpgradeDesc( IdleUpgrade up, bool revealed )
=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";
string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
{
if ( !revealed ) return "—";
return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
}
string CostLabel( bool maxed, double cost, int count )
{
if ( maxed ) return "MAXED";
return count > 1
? $"{IdleFormat.Short( cost )} ×{count}"
: IdleFormat.Short( cost );
}
string PerkLevelLabel( IdlePerk p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";
string FluxHeadLabel() => $"🔷 {S.Flux}";
string FluxSubLabel()
=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";
string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";
string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";
string RewardLabel( IdleAchievement a )
{
if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷 +{a.RewardFrags}💠";
if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
return $"+{a.RewardFrags}💠";
}
string ProgressLabel( IdleAchievement a )
{
double cur = a.Progress( S );
return a.BigNumbers
? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
: $"{(long)cur} / {(long)a.Target}";
}
// ── Modal labels ──────────────────────────────────────────────
string OfflineSubLabel()
=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";
string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );
string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";
string OfflineWarnLabel()
=> $"Storage filled up after {R.OfflineCapHours:F0}h — upgrade Core Storage to bank longer trips.";
string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
string DailyMultLabel() => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";
string DailyRewardLabel()
{
var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
return $"for {IdleFormat.Duration( reward.seconds )} · +{reward.frags} Fragments";
}
string MeltPayoutLabel() => "+" + R.PendingFlux;
string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";
// ══════════════════════════════════════════════════════════════
// Cell rendering
// ══════════════════════════════════════════════════════════════
string CellClass( IdleReactor.Cell cell, bool golden )
{
if ( golden ) return "golden";
string cls = cell.HasChest ? "filled" : "empty";
if ( cell.FlashUntil > Time.Now )
{
cls += cell.FlashKind switch
{
3 => " fx-drone",
2 => " fx-kill",
_ => " fx-blast",
};
}
if ( cell.HasDrone ) cls += " armed";
if ( cell.HasChest )
{
cls += cell.Chest switch
{
GridManager.ChestType.Gem => " c-gem",
GridManager.ChestType.BombChest => " c-bomb",
GridManager.ChestType.Locked => " c-locked",
GridManager.ChestType.Cursed => " c-cursed",
GridManager.ChestType.Void => " c-void",
GridManager.ChestType.Boss => " c-boss",
_ => " c-normal",
};
}
return cls;
}
/// <summary>
/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
/// at any moment and a class-driven animation would restart mid-flight.
/// </summary>
string ChestStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.SpawnAt;
if ( age >= 0.25f || age < 0f ) return "";
float scale = 0.4f + 0.6f * (age / 0.25f);
return $"transform:scale({scale.ToString( "F2" )});";
}
/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
string DroneStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.DroneAt;
if ( age >= 0.18f || age < 0f ) return "";
float t = age / 0.18f;
float scale = 2.0f - 1.0f * t;
return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
}
string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
{
GridManager.ChestType.BombChest => "💣",
GridManager.ChestType.Locked => "🔒",
GridManager.ChestType.Cursed => "💀",
GridManager.ChestType.Void => "🌑",
GridManager.ChestType.Boss => "👑",
GridManager.ChestType.Gem => "💎",
_ => "🎁",
};
}