Game/Menu/ReactorMenu.razor
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Services;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox
@*
Landing screen for Chain Reaction.
Same s&box text rule as IdleHud: an element holds either pure literal text
or a single @expression, never a mix — Razor turns non-ASCII literals into
AddMarkupContent, which the panel renderer hoists ahead of AddContent.
*@
<root>
@if ( !GameRunning )
{
<div class="rm-root">
<div class="rm-bg"></div>
<div class="rm-bg-glow"></div>
@* Slow drifting reactor cells behind everything.
LatticeCols x LatticeRows has to overfill the widest plausible design
space — the panel scales to a 1080-tall canvas, so an ultrawide window
asks for well over 2000 design pixels of width. Too few cells and the
grid simply stops partway down the screen. *@
<div class="rm-grid-bg">
@for ( int i = 0; i < LatticeCols * LatticeRows; i++ )
{
int col = i % LatticeCols;
int row = i / LatticeCols;
float dist = MathF.Sqrt( (col - 9f) * (col - 9f) + (row - 4f) * (row - 4f) );
string cls = (i * 7 % 23 == 0) ? "rm-gc-hot"
: (i * 5 % 17 == 0) ? "rm-gc-lit"
: "rm-gc";
float delay = (dist * 0.30f) % 5f;
<div class="@cls" style="animation-delay:[email protected]("F2")s;"></div>
}
</div>
@for ( int i = 0; i < 22; i++ )
{
float pLeft = (i * 4.7f) % 100f;
float pDur = 9f + (i * 0.7f) % 8f;
float pDel = (i * 1.1f) % 11f;
int pSz = (i % 4 == 0) ? 4 : 2;
string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.28)";
<div class="rm-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>
}
<div class="rm-ui">
@* ═════════════════════ LEFT — identity ═════════════════════ *@
<div class="rm-left">
<div class="rm-eyebrow">IDLE CHAIN REACTOR</div>
<div class="rm-title">CHAIN</div>
<div class="rm-title accent">REACTION</div>
<div class="rm-tagline">Drop · Detonate · Compound</div>
<div class="rm-stats">
<div class="rm-stat-row">
<span class="rm-stat-icon">⚛️</span>
<span class="rm-stat-val">@CoresLabel()</span>
<span class="rm-stat-lbl">cores banked</span>
</div>
<div class="rm-stat-row">
<span class="rm-stat-icon">📈</span>
<span class="rm-stat-val">@RateLabel()</span>
<span class="rm-stat-lbl">per second</span>
</div>
<div class="rm-stat-row">
<span class="rm-stat-icon">🔷</span>
<span class="rm-stat-val">@FluxLabel()</span>
<span class="rm-stat-lbl">flux</span>
</div>
<div class="rm-stat-row">
<span class="rm-stat-icon">💠</span>
<span class="rm-stat-val">@FragLabel()</span>
<span class="rm-stat-lbl">fragments</span>
</div>
<div class="rm-stat-row">
<span class="rm-stat-icon">⛓️</span>
<span class="rm-stat-val">@BestChainLabel()</span>
<span class="rm-stat-lbl">best chain</span>
</div>
</div>
<div class="rm-ver">Chain Reaction · Early Access</div>
</div>
@* ═════════════════════ CENTER — panels ═════════════════════ *@
<div class="rm-center">
@* ── Reactor status ── *@
@if ( _tab == "reactor" )
{
<div class="rm-card">
<div class="rm-card-hd">THE REACTOR</div>
<div class="rm-blurb">A board that plays itself. Chests drop, drones bomb them, chain reactions compound into Cores. Spend them, then melt the whole cycle down for permanent Flux.</div>
<div class="rm-tank">
<div class="rm-tank-top">
<span class="rm-tank-lbl">UNATTENDED STORAGE</span>
<span class="rm-tank-val">@TankLabel()</span>
</div>
<div class="rm-tank-track">
<div class="rm-tank-fill @TankClass()" style="width:@TankPct()%;"></div>
</div>
<div class="rm-tank-note">@TankNote()</div>
</div>
@if ( DailyReady )
{
<div class="rm-daily">@DailyLabel()</div>
}
<div class="rm-grid-stats">
<div class="rm-gs">
<span class="rm-gs-val">@MeltdownsLabel()</span>
<span class="rm-gs-lbl">meltdowns</span>
</div>
<div class="rm-gs">
<span class="rm-gs-val">@LogLabel()</span>
<span class="rm-gs-lbl">reactor log</span>
</div>
<div class="rm-gs">
<span class="rm-gs-val">@PetBonusLabel()</span>
<span class="rm-gs-lbl">pet output</span>
</div>
</div>
</div>
}
@* ── Pet ── *@
@if ( _tab == "pet" )
{
<div class="rm-card">
<div class="rm-card-hd">YOUR COMPANION</div>
<div class="rm-pet-hero">
@if ( PetImage != null )
{
<image src=@PetImage class="rm-pet-img"/>
}
@if ( PetImage == null )
{
<div class="rm-pet-emoji">@PetEmojiLabel()</div>
}
</div>
<div class="rm-pet-name">@PetNameLabel()</div>
<div class="rm-pet-level">@PetLevelLabel()</div>
<div class="rm-pet-blurb">@PetBlurbLabel()</div>
<div class="rm-pet-bonus">
<span class="rm-pet-bonus-val">@PetBonusLabel()</span>
<span class="rm-pet-bonus-lbl">reactor output, forever</span>
</div>
@if ( !PetMaxed )
{
<div class="rm-pet-next">@PetNextLabel()</div>
<div class="rm-tank-track">
<div class="rm-tank-fill" style="width:@PetProgressPct()%;"></div>
</div>
<button class="rm-pet-buy @PetBuyClass()" onclick=@OnUpgradePet>@PetBuyLabel()</button>
}
@if ( PetMaxed )
{
<div class="rm-pet-next">Fully grown. Nothing left to feed it.</div>
}
<div class="rm-pet-foot">Fragments condense out of the reactor on a daily ceiling. The pet is what they are for.</div>
</div>
}
@* ── Leaderboard ── *@
@if ( _tab == "lb" )
{
<div class="rm-card">
<div class="rm-card-hd">LEADERBOARD</div>
<div class="rm-blurb">Ranked on total Flux earned — how deep your reactor has ever gone.</div>
@if ( _loadingLb )
{
<div class="rm-empty">Loading…</div>
}
@if ( !_loadingLb && NoBoard )
{
<div class="rm-empty">No reactors online yet — be the first.</div>
}
@if ( !_loadingLb && !NoBoard )
{
@for ( int i = 0; i < _board.Entries.Length; i++ )
{
var entry = _board.Entries[i];
int rank = i + 1;
<div class="rm-lb-row @(IsMe( entry ) ? "me" : "")">
<span class="rm-lb-rank">@RankLabel( rank )</span>
<div class="rm-lb-name-col">
<span class="rm-lb-name">@GetName( entry )</span>
<span class="rm-lb-title">@ReactorTitle( (long)entry.Value )</span>
</div>
<span class="rm-lb-score">@FluxEntryLabel( entry )</span>
</div>
}
}
</div>
}
@* ── Community ── *@
@if ( _tab == "community" )
{
<div class="rm-card">
<div class="rm-card-hd">FROM THE DEV</div>
<div class="rm-comm-greeting">Hey! 👋</div>
<div class="rm-blurb">You are playing during Early Access — you are one of the first, and your feedback genuinely shapes what gets built next. Thanks for that.</div>
<div class="rm-devnote">
<span class="rm-devnote-lbl">THIS WEEK</span>
<span class="rm-devnote-txt">Chain Reaction is now a pure idle reactor. Balancing the upgrade curve and the Meltdown loop — tell me where it drags.</span>
</div>
<div class="rm-section-title">What's next</div>
@foreach ( var ev in _events )
{
var e = ev;
<div class="rm-event-row">
<span class="rm-event-badge @e.Color">@e.Badge</span>
<span class="rm-event-text">@e.Text</span>
</div>
}
<div class="rm-blurb">Got ideas, bugs, or a screenshot of an absurd chain? Come say hi — it is just me on the other side.</div>
<button class="rm-discord @(_discordCopied ? "copied" : "")" onclick=@OnDiscordCopy>@DiscordLabel()</button>
</div>
}
@* ── Credits ── *@
@if ( _tab == "credits" )
{
<div class="rm-card">
<div class="rm-card-hd">CREDITS</div>
<div class="rm-credit">
<div class="rm-credit-role">Game design, code and art</div>
<div class="rm-credit-name">Elysia</div>
</div>
<div class="rm-credit">
<div class="rm-credit-role">Sound</div>
<div class="rm-credit-name">Freesound · Pixabay</div>
</div>
<div class="rm-credit">
<div class="rm-credit-role">Special thanks</div>
<div class="rm-credit-name">Everyone who sent feedback</div>
</div>
<div class="rm-blurb">Built solo, in the open, one chain reaction at a time. Thanks for playing.</div>
</div>
}
</div>
@* ═════════════════════ RIGHT — actions ═════════════════════ *@
<div class="rm-right">
<button class="rm-play" onclick=@OnEnterReactor>
<div class="rm-play-shine"></div>
<span class="rm-play-icon">⚛️</span>
<span class="rm-play-label">ENTER THE REACTOR</span>
@if ( SomethingWaiting )
{
<span class="rm-play-dot"></span>
}
</button>
<div class="rm-tabs">
<button class="rm-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚛️ Reactor</button>
<button class="rm-tab @TabClass("pet")" onclick=@(() => SetTab("pet"))>
🐾 Companion
@if ( CanUpgradePet )
{
<span class="rm-tab-pill">!</span>
}
</button>
<button class="rm-tab @TabClass("lb")" onclick=@(() => SetTab("lb"))>🏅 Leaderboard</button>
<button class="rm-tab @TabClass("community")" onclick=@(() => SetTab("community"))>💬 Community</button>
<button class="rm-tab @TabClass("credits")" onclick=@(() => SetTab("credits"))>🎖️ Credits</button>
</div>
</div>
</div>
@* ══════════════════ EVOLUTION CINEMATIC ══════════════════ *@
@if ( _showEvo )
{
<div class="rm-evo-bg @_evoPhase">
@if ( !string.IsNullOrEmpty( _evoText ) )
{
<div class="rm-evo-text">@_evoText</div>
}
<div class="rm-evo-pet @_evoPhase">
@if ( EvoImage != null )
{
<image src=@EvoImage/>
}
</div>
@if ( _evoPhase == "evo-reveal" )
{
<div class="rm-evo-name">@PetNameLabel()</div>
<div class="rm-evo-level">@PetLevelLabel()</div>
<button class="rm-evo-btn" onclick=@OnCloseEvo>Amazing</button>
}
</div>
}
</div>
}
</root>
@code {
public static ReactorMenu Instance { get; private set; }
ChainReactionGame Game => ChainReactionGame.Instance;
PlayerSave _save;
IdleSave Idle => _save?.Idle;
bool GameRunning => Game?.GameStarted == true;
string _tab = "reactor";
// 20x12 at a 150px cell + 6px gap covers 3120x1872 design units, which is
// past even a 32:9 window on the 1080-tall canvas.
const int LatticeCols = 20;
const int LatticeRows = 12;
// Leaderboard
Leaderboards.Board2 _board;
bool _loadingLb;
const string GameIdent = "playnplay.10_xplosion";
bool NoBoard => _board?.Entries == null || _board.Entries.Length == 0;
// Community
class Ev { public string Badge; public string Text; public string Color; }
readonly List<Ev> _events = new()
{
new Ev { Badge = "NEW", Text = "The Reactor — the whole game is now one idle board", Color = "green" },
new Ev { Badge = "NEW", Text = "Meltdown prestige, Flux perks and the Reactor Log", Color = "green" },
new Ev { Badge = "SOON", Text = "More chest tiers past Reactor Cores", Color = "purple" },
new Ev { Badge = "PLANNED", Text = "Offline production while the game is closed", Color = "gray" },
};
bool _discordCopied;
// Pet animation + evolution cinematic
bool _petFrame;
float _petFrameTimer;
bool _showEvo;
string _evoPhase = "";
string _evoText = "";
int _evoFromLevel = 1;
bool _evoShowNew;
bool _wasRunning;
// ══════════════════════════════════════════════════════════════
// Lifecycle
// ══════════════════════════════════════════════════════════════
protected override void OnStart()
{
Instance = this;
Reload();
_ = LoadLeaderboard();
}
public void Reload()
{
_save = PlayerSave.Load();
StateHasChanged();
}
protected override void OnUpdate()
{
bool running = GameRunning;
if ( running != _wasRunning )
{
_wasRunning = running;
// Coming back from the reactor — pick up whatever it saved
if ( !running ) Reload();
StateHasChanged();
}
if ( running ) return;
_petFrameTimer -= Time.Delta;
if ( _petFrameTimer <= 0f )
{
_petFrameTimer = 0.4f;
_petFrame = !_petFrame;
StateHasChanged();
}
}
protected override int BuildHash()
{
if ( GameRunning ) return 0;
var hc = new HashCode();
hc.Add( _tab );
hc.Add( _petFrame );
hc.Add( _showEvo );
hc.Add( _evoPhase );
hc.Add( _save?.TotalFragments ?? 0 );
hc.Add( _save?.PetLevel ?? 0 );
hc.Add( (long)(Idle?.Cores ?? 0) );
hc.Add( _loadingLb );
hc.Add( _board?.Entries?.Length ?? 0 );
hc.Add( _discordCopied );
return hc.ToHashCode();
}
// ══════════════════════════════════════════════════════════════
// Actions
// ══════════════════════════════════════════════════════════════
void SetTab( string tab )
{
_tab = tab;
Sound.Play( "UI_Button" );
if ( tab == "lb" ) _ = LoadLeaderboard();
StateHasChanged();
}
void OnEnterReactor()
{
Sound.Play( "PlayButton" );
Game?.StartGame( GameMode.Idle, null );
}
void OnDiscordCopy()
{
_discordCopied = true;
Sound.Play( "UI_Button" );
StateHasChanged();
}
void OnUpgradePet()
{
if ( _save == null || !_save.CanUpgradePet ) { Sound.Play( "Pop_Button_1" ); return; }
_evoFromLevel = _save.PetLevel;
_save.TryUpgradePet();
_ = PlayEvolution();
}
async System.Threading.Tasks.Task PlayEvolution()
{
_showEvo = true;
_evoShowNew = false;
_evoPhase = "evo-shake";
_evoText = _evoFromLevel == 1 ? "The egg is trembling…" : "Something is changing…";
Sound.Play( "Evolution_Pet" );
StateHasChanged();
await GameTask.DelaySeconds( 2.0f );
_evoPhase = "evo-flash";
_evoText = "";
Sound.Play( "pearl_stars" );
StateHasChanged();
await GameTask.DelaySeconds( 0.7f );
_evoShowNew = true;
_evoPhase = "evo-reveal";
Sound.Play( "fanfare_100" );
StateHasChanged();
}
void OnCloseEvo()
{
_showEvo = false;
_evoPhase = "";
if ( _save != null )
{
_save.PendingEvolution = false;
_save.Save();
}
Sound.Play( "UI_Button" );
StateHasChanged();
}
async System.Threading.Tasks.Task LoadLeaderboard()
{
_loadingLb = true;
StateHasChanged();
try
{
var b = Leaderboards.GetFromStat( GameIdent, "chain_reaction_reactor" );
b.SetAggregationMax();
b.SetSortDescending();
b.MaxEntries = 100;
await b.Refresh();
_board = b;
}
catch ( Exception e )
{
Log.Warning( $"Reactor leaderboard: {e.Message}" );
}
_loadingLb = false;
StateHasChanged();
}
// ══════════════════════════════════════════════════════════════
// Derived state
// ══════════════════════════════════════════════════════════════
bool CanUpgradePet => _save?.CanUpgradePet ?? false;
bool PetMaxed => (_save?.PetLevel ?? 1) >= IdlePet.MaxLevel;
bool DailyReady => IdleReactor.DailyAvailable( Idle );
bool SomethingWaiting
=> DailyReady || IdleReactor.PreviewOfflineCores( Idle ) > 0 || CanUpgradePet;
string PetImage => PetVisuals.GetImagePath( _save?.PetLevel ?? 1, true, _petFrame );
string EvoImage => _evoShowNew
? PetVisuals.GetEvolutionImage( _save?.PetLevel ?? 1, _petFrame )
: PetVisuals.GetEvolutionImage( _evoFromLevel, _petFrame );
// ══════════════════════════════════════════════════════════════
// Labels — one composed string per element
// ══════════════════════════════════════════════════════════════
string CoresLabel() => IdleFormat.Short( Idle?.Cores ?? 0 );
string RateLabel() => IdleFormat.Short( Idle?.MeasuredCps ?? 0 );
string FluxLabel() => (Idle?.Flux ?? 0).ToString();
string FragLabel() => (_save?.TotalFragments ?? 0).ToString();
string BestChainLabel() => (Idle?.BestChain ?? 0).ToString();
string MeltdownsLabel() => (Idle?.Meltdowns ?? 0).ToString();
string LogLabel()
=> Idle == null ? "0 / 0" : $"{IdleAchievements.CompletedCount( Idle )} / {IdleAchievements.All.Count}";
string TabClass( string t ) => _tab == t ? "on" : "";
// ── Storage tank ──
string TankPct() => (IdleReactor.PreviewStorageFraction( Idle ) * 100f).ToString( "F1" );
string TankClass() => IdleReactor.PreviewStorageFraction( Idle ) >= 1f ? "full" : "";
string TankLabel()
{
if ( Idle == null ) return "0s";
float capH = IdleBalance.OfflineCapHours(
Idle.Level( IdleUpgradeId.OfflineCap ),
Idle.PerkLevel( IdlePerkId.DeepStorage ) );
return $"{IdleFormat.Duration( IdleReactor.PreviewOfflineSeconds( Idle ) )} / {capH:F0}h";
}
string TankNote()
{
double pending = IdleReactor.PreviewOfflineCores( Idle );
if ( pending <= 0 ) return "The reactor banks output while you sit on this screen.";
return $"{IdleFormat.Short( pending )} Cores waiting to be collected";
}
string DailyLabel()
{
int next = Math.Min( 7, (Idle?.DailyStreak ?? 0) + 1 );
return $"Daily bonus ready — Day {next}";
}
// ── Pet ──
string PetEmojiLabel() => IdlePet.Emoji( _save?.PetLevel ?? 1 );
string PetNameLabel() => IdlePet.Name( _save?.PetLevel ?? 1 );
string PetBlurbLabel() => IdlePet.Blurb( _save?.PetLevel ?? 1 );
string PetLevelLabel() => $"Level {_save?.PetLevel ?? 1} / {IdlePet.MaxLevel}";
string PetBonusLabel()
{
double bonus = IdlePet.OutputBonus( _save?.PetLevel ?? 1 );
if ( (Idle?.PerkLevel( IdlePerkId.PetLink ) ?? 0) > 0 ) bonus *= 2.0;
return $"+{bonus * 100.0:F0}%";
}
string PetNextLabel()
{
int lvl = _save?.PetLevel ?? 1;
return $"Next: {IdlePet.Name( lvl + 1 )} — +{IdlePet.OutputBonus( lvl + 1 ) * 100.0:F0}% output";
}
string PetProgressPct()
{
int cost = _save?.PetUpgradeCost ?? 0;
if ( cost <= 0 ) return "100";
float f = Math.Clamp( (_save?.TotalFragments ?? 0) / (float)cost, 0f, 1f );
return (f * 100f).ToString( "F1" );
}
string PetBuyClass() => CanUpgradePet ? "afford" : "poor";
string PetBuyLabel()
{
int cost = _save?.PetUpgradeCost ?? 0;
int have = _save?.TotalFragments ?? 0;
return CanUpgradePet
? $"Feed {cost} Fragments"
: $"Need {cost - have} more Fragments";
}
// ── Leaderboard ──
string RankLabel( int rank ) => rank switch
{
1 => "🥇",
2 => "🥈",
3 => "🥉",
_ => $"#{rank}",
};
string FluxEntryLabel( Leaderboards.Board2.Entry e ) => $"🔷 {(long)e.Value:N0}";
static string ReactorTitle( long flux ) => flux switch
{
>= 5000 => "Singularity",
>= 2000 => "Core Architect",
>= 750 => "Reactor Chief",
>= 250 => "Fluxbound",
>= 60 => "Engineer",
>= 10 => "Technician",
_ => "Intern",
};
string GetName( Leaderboards.Board2.Entry e )
{
if ( !string.IsNullOrWhiteSpace( e.DisplayName ) ) return e.DisplayName;
try
{
var friend = new Sandbox.Friend( e.SteamId );
if ( !string.IsNullOrWhiteSpace( friend.Name ) ) return friend.Name;
}
catch { }
return "Anonymous";
}
bool IsMe( Leaderboards.Board2.Entry e ) => e.SteamId == Sandbox.Connection.Local?.SteamId;
string DiscordLabel() => _discordCopied ? "Link copied" : "💬 Join Discord";
}