Razor UI component for the in-game HUD and game-over overlay. Renders HUD elements (wave/time, score, lives, consumables, sidebar, popups), animates score/fragments, handles input (detonate, consumables), subscribes to game events, and runs the end-of-run reveal/sequence with sound and popup spawning.
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using System;
@inherits PanelComponent
<root>
@* everything that was previously direct children of root goes here *@
@if( Game?.GameStarted == true )
{
@* Background grid *@
@if(Game?.IsRunning == true || Game?.IsGameOver == true)
{
<div class="game-bg-grid">
<div class="ov-particles">
@for(int i = 0; i < 20; i++)
{
float left = (i * 5.2f) % 100f;
float duration = 4f + (i * 0.3f) % 3f;
float delay = (i * 0.4f) % 5f;
int size = (i % 3 == 0) ? 3 : (i % 5 == 0) ? 3 : 2;
string color = (i % 7 == 0) ? "rgba(251,191,36,0.8)"
: (i % 5 == 0) ? "rgba(244,114,182,0.8)"
: (i % 3 == 0) ? "rgba(99,102,241,0.9)"
: "rgba(167,139,250,0.8)";
<div class="ov-particle"
style="left:@left.ToString("F1")%;width:@(size)px;height:@(size)px;background-color:@color;border-radius:50%;animation-duration:@duration.ToString("F1")s;animation-delay:@delay.ToString("F1")s;"></div>
}
</div>
@for(int i = 0; i < 60; i++)
{
int col = i % 10;
int row = i / 10;
<div class="game-bg-cell"
style="left:@(col * 234)px;
top:@(row * 234)px;
background-color:@(Game?.WaveGridColor ?? "#0d0820");">
</div>
}
</div>
}
@if(DEV_MODE)
{
<div class="mm-dev-music-panel">
<div class="mm-dev-music-label">🎵 MUSIC</div>
@foreach(var track in _debugTracks)
{
<button class="mm-dev-btn @(_musicHandles.ContainsKey(track.Key) ? "mm-dev-btn-active" : "")"
onclick=@(() => PlayDebugTrack(track.Key))>
@(_musicHandles.ContainsKey(track.Key) ? "▶ " : "")@track.Value
</button>
}
<button class="mm-dev-btn" onclick=@StopDebugMusic>⏹ Stop</button>
</div>
}
@* ========================================================================== *@
@* ================================= TOP BAR =================================*@
@* ========================================================================== *@
<div class="topbar">
<div class="hud-block wave">
@if (Game?.Mode == GameMode.Survival)
{
<div class="hud-lbl">TIME</div>
<div class="hud-val">@FormatSurvivalTime(Game?.SurvivalElapsed ?? 0f)</div>
}
else
{
<div class="hud-lbl">WAVE</div>
<div class="hud-val">@(Game?.Wave ?? 1)</div>
}
</div>
<div class="hud-sep"></div>
<div class="hud-block score">
<div class="hud-lbl">SCORE</div>
<div class="hud-val">@_displayScore.ToString("N0")</div>
</div>
<div class="hud-sep"></div>
<div class="hud-block best">
<div class="hud-lbl">BEST</div>
<div class="hud-val">@((Game?.BestScore??0).ToString("N0"))</div>
</div>
</div>
@* ------------------- Screen Shake overlay ------------------- *@
<div class="hud-overlay @_currentShake">
@if ( Game != null && Game.IsRunning )
{
@if (Game.Mode == GameMode.Wave)
{
float frac = Game.TurnTimeFraction.Clamp(0f,1f);
int pct = (int)(frac * 100f);
bool hurry = frac < 0.3f;
bool panic = frac < 0.15f;
bool mid = frac < 0.55f && frac >= 0.3f;
@* ----- Timer track ----- *@
<div class="timer-track">
<div class="timer-fill @(panic?"panic":hurry?"hurry":mid?"mid":"")" style="width:@pct%;"></div>
</div>
@* ----- Challenge bar ----- *@
@if ( Game?.CurrentChallenge != null && Game.IsRunning )
{
var ch = Game.CurrentChallenge;
string state = ch.Completed ? "done" : "active";
<div class="challenge-bar @state">
<span class="ch-icon">@(ch.Completed ? "✓" : "⚡")</span>
<span class="ch-label">@ch.Label</span>
<span class="ch-bonus">+@ch.BonusCoins</span>
</div>
}
}
else if (Game.Mode == GameMode.Survival)
{
<div class="survival-timer">
<span class="survival-timer-icon">⏱️</span>
<span class="survival-timer-val">@FormatSurvivalTime(Game.SurvivalElapsed)</span>
</div>
<div class="survival-spawn-track">
<div class="survival-spawn-fill" style="width:@((Game.SurvivalSpawnTimerFraction * 100f).ToString("F0"))%;"></div>
</div>
}
}
@* ----- Chain track ----- *@
@if(_chainSteps.Count > 0)
{
<div class="chain-track">
<div class="chain-track-inner">
@for(int i = 0; i < _chainSteps.Count; i++){
int idx = i;
<div class="step @(idx <= _litStep ? "lit" : "")">@_chainSteps[i]</div>
@if(idx < _chainSteps.Count-1){ <span class="arr">→</span> }
}
</div>
</div>
}
@* timer track *@
@* challenge bar *@
@* chain track *@
@* --------------- Consumables strip -------------- *@
@* When sidebar is closed, also remove right: 260px from .consumable-strip and .msg *@
@* Always show during tutorial, or when player plays *@
@if (Game?.IsRunning == true)
{
/*style="right:@(_sidebarOpen ? "260px" : "0px"*/
<div class="consumable-strip">
@foreach (var cons in ConsumableCatalog.All)
{
int count = ChainReactionGame.Save?.GetConsumableCount(cons.Id) ?? 0;
bool canUse = count > 0 && !(Hand?.IsDetonating ?? false);
<div class="consumable-slot @(canUse ? "available" : "empty")"
onclick=@(() => { if(canUse) OnUseConsumable(cons.Id); })>
<div class="consumable-icon">@cons.Icon</div>
@if (count > 0)
{
<div class="consumable-count">@count</div>
}
</div>
}
</div>
}
@* ====================== Sidebar ====================== *@
@* Sidebar toggle button *@
<button class="sidebar-toggle" onclick=@(() => { _sidebarOpen = !_sidebarOpen; StateHasChanged(); })>
@(_sidebarOpen ? "✕" : "?")
</button>
@* Sidebar — only when open *@
@if(_sidebarOpen)
{
<div class="sidebar">
<div class="sbl">Bomb Range Preview</div>
<div class="legend">
<div class="leg"><span class="dot p0"></span> Bomb 1</div>
<div class="leg"><span class="dot p1"></span> Bomb 2</div>
<div class="leg"><span class="dot p2"></span> Bomb 3</div>
<div class="leg"><span class="dot red"></span> Destroyed</div>
<div class="leg"><span class="dot chain"></span> Chain</div>
</div>
<div class="divider"></div>
<div class="sbl">Controls</div>
<div class="controls-hint">
<div>1 / 2 / 3 → Select bomb</div>
<div>LMB → Place / Remove</div>
<div>RMB → Rotate sniper</div>
<div>SPACE → Detonate</div>
</div>
</div>
}
@* ---------------------------------------------------------------*@
@* Show our Pet! (left side bottom) + LIVES (❤️❤️❤️) next to it *@
@* ---------------------------------------------------------------*@
<div class="hud-pet-wrap">
@* Pet card image *@
<div class="hud-pet-card"></div>
@* TOP — modifiers + curse *@
<div class="hud-pet-top">
@if(ChainReactionGame.Save?.ActiveModifiers?.Count > 0)
{
<div class="sbl">Active Modifiers</div>
<div class="mod-strip">
@foreach(var modIdStr in ChainReactionGame.Save.ActiveModifiers)
{
var mod = ShopCatalog.All.FirstOrDefault(m => m.Id.ToString() == modIdStr);
if(mod != null)
{
<div class="mod-strip-item" title="@mod.Name - @mod.Description">
<span class="mod-strip-icon">@mod.Icon</span>
<span class="mod-strip-name">@mod.Name</span>
</div>
}
}
</div>
}
@* Active cursed chest *@
@if(Game?.Mode == GameMode.Wave && (Game?.ActiveCursedChests ?? 0) > 0)
{
<div class="curse-warning">
💀 -@(Game.ActiveCursedChests * 2)s next wave
</div>
}
</div>
@* --- Min chest requirement --- *@
@if(Game?.IsRunning == true && Game?.Mode == GameMode.Wave)
{
<div class="hud-min-chests">
<span class="hud-min-chests-lbl">⚠️ Min chests</span>
<span class="hud-min-chests-val">@(Game?.MinChests ?? 0)</span>
</div>
}
@* BOTTOM — pet + hearts *@
<div class="hud-pet-bottom">
@{
int level = ChainReactionGame.Save?.PetLevel ?? 1;
bool unlocked = ChainReactionGame.Save?.PetUnlockShown ?? false;
string bobClass = (!unlocked || level == 1) ? "pet-bob" : "";
}
<div class="cat-anchor">
@if(_petReacting && !string.IsNullOrEmpty(_petBubbleText))
{
<div class="cat-speech-bubble">@_petBubbleText</div>
}
@if(GetPetImagePath() != null)
{
<image src=@GetPetImagePath() class="@bobClass"/>
}
else
{
<div class="@bobClass">@GetPetEmoji()</div>
}
</div>
<div class="hud-pet-lives">
@for(int i = 0; i < 3; i++)
{
bool full = i < (Game?.Lives ?? 1);
<span class="hud-pet-heart @(full ? "full" : "empty")">
@(full ? "❤️" : "🖤")
</span>
}
</div>
</div>
</div>
}
@* ---------------------------------- BOTTOM BAR ---------------------------------- *@
<div class="msg">@_msg</div>
</div>
@* ---------------------------------- GAME OVER ---------------------------------- *@
@if(Game?.IsGameOver == true)
{
<div class="ov-bg">
<div class="ov-bg-squares">
@for(int i = 0; i < 60; i++)
{
float sz = 25f + (i * 17f) % 70f;
float left = (i * 137f + (i / 5) * 311f) % 1860f;
float top = (i * 89f + (i / 4) * 197f) % 1020f;
float dur = 6f + (i * 0.7f) % 10f;
float del = (i * 0.5f) % 8f;
int rad = 3 + i % 14;
string col = (i % 6 == 0) ? "rgba(167,139,250,0.06)"
: (i % 5 == 0) ? "rgba(251,191,36,0.04)"
: (i % 4 == 0) ? "rgba(244,114,182,0.05)"
: (i % 3 == 0) ? "rgba(52,211,153,0.04)"
: (i % 2 == 0) ? "rgba(99,102,241,0.05)"
: "rgba(96,165,250,0.04)";
string brd = (i % 6 == 0) ? "rgba(167,139,250,0.18)"
: (i % 5 == 0) ? "rgba(251,191,36,0.14)"
: (i % 4 == 0) ? "rgba(244,114,182,0.16)"
: (i % 3 == 0) ? "rgba(52,211,153,0.13)"
: (i % 2 == 0) ? "rgba(99,102,241,0.16)"
: "rgba(96,165,250,0.13)";
<div class="ov-bg-sq @((i % 2 == 0) ? "sq-a" : "sq-b")"
style="width:@sz.ToString("F0")px;
height:@sz.ToString("F0")px;
left:@left.ToString("F0")px;
top:@top.ToString("F0")px;
background-color:@col;
border-color:@brd;
border-radius:@(rad)px;
animation-duration:@dur.ToString("F1")s;
animation-delay:@del.ToString("F1")s;">
</div>
}
</div>
<div class="ov-particles">
@for(int i = 0; i < 20; i++)
{
float left = (i * 5.2f) % 100f;
float duration = 4f + (i * 0.3f) % 3f;
float delay = (i * 0.4f) % 5f;
int size = (i % 3 == 0) ? 6 : (i % 5 == 0) ? 3 : 5;
string color = (i % 7 == 0) ? "rgba(251,191,36,0.8)"
: (i % 5 == 0) ? "rgba(244,114,182,0.8)"
: (i % 3 == 0) ? "rgba(99,102,241,0.9)"
: "rgba(167,139,250,0.8)";
<div class="ov-particle"
style="left:@left.ToString("F1")%;width:@(size)px;height:@(size)px;background-color:@color;border-radius:50%;animation-duration:@duration.ToString("F1")s;animation-delay:@delay.ToString("F1")s;">
</div>
}
</div>
@******************************************************************************@
@* ------------------------------- LEFT PANEL ------------------------------- *@
@******************************************************************************@
<div class="ov-side-panel ov-side-left">
@* Personal best progression bar *@
<div class="ov-side-card ov-section-enter">
<div class="ov-side-card-title">📈 YOUR BEST</div>
@{
int prevBest = Game.PrevBestScore;
int curScore = Game.Score;
bool beatBest = curScore > prevBest;
float prevPct = prevBest <= 0 ? 0f : 100f;
float curPct = prevBest <= 0 ? 100f
: MathF.Min(100f, (float)curScore / (float)MathF.Max(prevBest, curScore) * 100f);
}
<div class="ov-pb-bar-wrap">
<div class="ov-pb-bar-track">
<div class="ov-pb-bar-fill" style="width:@curPct.ToString("F0")%;"></div>
@if(prevBest > 0 && !beatBest)
{
<div class="ov-pb-marker" style="left:@prevPct.ToString("F0")%;"></div>
}
</div>
</div>
<div class="ov-pb-scores">
<div class="ov-pb-score-current">@curScore.ToString("N0")</div>
@if(prevBest > 0)
{
<div class="ov-pb-score-best">Best: @prevBest.ToString("N0")</div>
}
</div>
@if(beatBest)
{
<div class="ov-pb-newbest">🏆 New Record!</div>
}
</div>
@* Wave context *@
@if (Game.Mode == GameMode.Wave)
{
<div class="ov-side-card journey ov-section-enter">
<div class="ov-side-card-title">🌊 YOUR JOURNEY</div>
<div class="ov-journey-wave">Wave @(Game?.Wave)</div>
@if((ChainReactionGame.Save?.BestWave ?? 0) > 0)
{
<div class="ov-journey-sub">
<span>Personal best Wave : </span>
<span>@(ChainReactionGame.Save?.BestWave)</span>
</div>
}
<div class="ov-journey-sub">
Run n° :@(ChainReactionGame.Save?.TotalRuns ?? 1)
</div>
@if((ChainReactionGame.Save?.TotalRuns ?? 0) > 5)
{
float avgWave = (ChainReactionGame.Save?.BestWave ?? 1) * 0.6f;
bool aboveAvg = (Game?.Wave ?? 1) > avgWave;
<div class="ov-journey-tag @(aboveAvg ? "good" : "")">
@(aboveAvg ? "Above your average!" : "You got it my friend!")
</div>
}
</div>
}
</div>
@******************************************************************************@
@* ----------------------------- MIDDLE PANEL ------------------------------- *@
@******************************************************************************@
<div class="ov-box">
@* Ambient background orbs *@
<div class="ov-orb ov-orb-1"></div>
<div class="ov-orb ov-orb-2"></div>
<div class="ov-orb ov-orb-3"></div>
@* ── 2. Wave + reason ── *@
@* WAVE MODE *@
@if (Game.Mode == GameMode.Wave)
{
<div class="ov-wave-big ov-reveal">Wave @(Game?.Wave)</div>
}
// SURVIVAL MODE //
else
{
<div class="ov-wave-big ov-reveal">@FormatSurvivalTime(Game?.SurvivalElapsed ?? 0f)</div>
}
@if(!string.IsNullOrEmpty(Game?.GameOverReason))
{
<div class="ov-reason ov-reveal" style="animation-delay:0.2s;">
@Game.GameOverReason
</div>
}
@* ── 3. Stats row ── *@
@if(_ovShowStats)
{
<div class="ov-stats ov-section-enter">
<div class="ov-stat">
<span class="ov-stat-icon">🎁</span>
<span class="ov-stat-val">@Game.TotalChestsCleared</span>
<span class="ov-stat-lbl">chests cleared</span>
</div>
<div class="ov-stat">
<span class="ov-stat-icon">🔗</span>
<span class="ov-stat-val">@Game.BestChainLength</span>
<span class="ov-stat-lbl">best chain</span>
</div>
<div class="ov-stat">
<span class="ov-stat-icon">⚡</span>
<span class="ov-stat-val">@Game.ChallengesCompleted</span>
<span class="ov-stat-lbl">challenges</span>
</div>
</div>
}
@* ── 4. Secret chests — interactive ── *@
@if(_ovShowSecrets && (Game?.SecretChestRewards?.Count ?? 0) > 0)
{
<div class="ov-secret-section ov-section-enter">
<div class="ov-secret-eyebrow">✨ SECRET CHESTS</div>
<div class="ov-secret-hint">
@if(!_secretsDone)
{
@($"Click to open · {(Game.SecretChestRewards.Count - _revealedChests)} remaining")
}
else
{
@("All opened!")
}
</div>
<div class="ov-chest-cards">
@for(int i = 0; i < Game.SecretChestRewards.Count; i++)
{
var reward = Game.SecretChestRewards[i];
bool revealed = i < _revealedChests;
bool isNext = i == _revealedChests && !_secretsDone;
bool isNew = i == _lastRevealedIdx;
int capturedIdx = i;
@if(!revealed)
{
<div class="ov-chest-card @(isNext ? "ready" : "waiting")"
onclick=@(() => OnChestClick(capturedIdx))>
<div class="ov-chest-card-inner">
<div class="ov-chest-glow"></div>
<div class="ov-chest-emoji">🎁</div>
@if(isNext)
{
<div class="ov-chest-tap">TAP!</div>
}
else
{
<div class="ov-chest-tap locked">?</div>
}
</div>
</div>
}
else
{
<div class="ov-chest-card revealed @(reward.IsRare ? "rare" : "") @(i == _lastRevealedIdx ? "pop-in" : "")">
@* Confetti burst — locked to confetti idx, survives re-renders *@
@if(_showConfetti && i == _confettiIdx)
{
@for(int p = 0; p < 14; p++)
{
float px = -20f + (p * 11f);
float dur = 0.8f + (p * 0.07f) % 0.5f;
float del = p * 0.03f;
string pc = (p % 5 == 0) ? "rgba(251,191,36,0.95)"
: (p % 4 == 0) ? "rgba(167,139,250,0.95)"
: (p % 3 == 0) ? "rgba(244,114,182,0.95)"
: (p % 2 == 0) ? "rgba(52,211,153,0.95)"
: "rgba(255,255,255,0.90)";
<div class="ov-confetti-bit"
style="left:@px.ToString("F0")px;
background-color:@pc;
animation-duration:@dur.ToString("F1")s;
animation-delay:@del.ToString("F2")s;">
</div>
}
}
@* Chest Secret Card (revealed) *@
<div class="ov-chest-card-inner">
<div class="ov-chest-revealed-icon">@reward.Icon</div>
<div class="ov-chest-revealed-info">
<div class="ov-chest-revealed-lbl">@reward.Label</div>
@if(reward.IsRare)
{
<div class="ov-chest-rare-badge">✨ RARE · +@reward.Fragments 💠 bonus</div>
@*<div class="ov-chest-rare-badge">✨ @reward.RareDescription</div>*@ }
</div>
</div>
</div>
}
}
</div>
</div>
}
@* ── 6. Fragment breakdown ── *@
@if(_ovShowFrags)
{
<div class="ov-frag-section ov-section-enter">
<div class="ov-frag-label">💠 FRAGMENTS EARNED</div>
<div class="ov-frag-val">+@_displayFragments</div>
<div class="ov-frag-breakdown">
@if (Game.Mode == GameMode.Wave)
{
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">Wave bonus</span>
<span class="ov-frag-line-val">+@(Game.Wave / 2)</span>
</div>
}
@if((ChainReactionGame.Save?.PetLevel ?? 1) >= 1 && Game.Wave >= 20)
{
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">🐾 Pet bonus</span>
<span class="ov-frag-line-val">+@((Game.Wave / 20) * 5)</span>
</div>
}
@* SECRET CHESTS REWARDS FRAGMENTS *@
@if(Game.SecretChestRewards.Any(r => r.Fragments > 0))
{
int secretFrags = Game.SecretChestRewards.Sum(r => r.Fragments);
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">✨ Secret chests</span>
<span class="ov-frag-line-val">+@secretFrags</span>
</div>
}
@* BONUSN EVENTS FRAGMENTS *@
@if(Game.BonusEventFragments > 0)
{
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">⚡ Random events</span>
<span class="ov-frag-line-val">+@Game.BonusEventFragments</span>
</div>
}
@* PERFECT WAVE FRAGMENTS *@
@if(Game.PerfectWaveFragments > 0)
{
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">✨ Perfect waves</span>
<span class="ov-frag-line-val">+@Game.PerfectWaveFragments</span>
</div>
}
@* BOSS FRAGMENTS *@
@if(Game.BossFragments > 0)
{
<div class="ov-frag-line">
<span class="ov-frag-line-lbl">👑 Boss kills</span>
<span class="ov-frag-line-val">+@Game.BossFragments</span>
</div>
}
</div>
<div class="ov-frag-total">Total: @(ChainReactionGame.Save?.TotalFragments ?? 0) 💠</div>
<div class="ov-frag-hint">@GetFragHint()</div>
</div>
}
@* ── 7. Final score ── *@
@if(_ovShowScore)
{
<div class="ov-score-section ov-section-enter">
@if(isNewBest)
{
<div class="ov-newbest-badge">🏆 NEW BEST</div>
}
<div class="ov-score-label">FINAL SCORE</div>
<div class="ov-score @(isNewBest?"gold":"")">@_displayScore.ToString("N0")</div>
</div>
}
@* ── 8. Buttons ── *@
@if(_ovShowButtons)
{
<div class="ov-btns ov-section-enter">
<button class="ov-btn-wrap" onclick=@OnRestart>
▶ Play Again
</button>
<button class="ov-btn-menu" onclick=@OnReturnToMenu>
⌂ Menu
</button>
</div>
}
</div>
@******************************************************************************@
@* ------------------------------ RIGHT PANEL ------------------------------- *@
@******************************************************************************@
<div class="ov-side-panel ov-side-right">
@* Streak display *@
@if(_perfectWavesThisRun >= 2)
{
<div class="ov-side-card streak ov-section-enter">
<div class="ov-side-card-title">🔥 STREAK</div>
<div class="ov-streak-count">×@_perfectWavesThisRun</div>
<div class="ov-streak-label">waves cleared</div>
</div>
}
@* Next unlock teaser *@
@{
var nextUnlock = ShopCatalog.All
.Where(m => !(ChainReactionGame.Save?.OwnsModifier(m.Id) ?? false))
.OrderBy(m => m.Cost)
.FirstOrDefault();
int totalFrags = ChainReactionGame.Save?.TotalFragments ?? 0;
}
@if(nextUnlock != null)
{
int needed = nextUnlock.Cost - totalFrags;
bool canAfford = needed <= 0;
<div class="ov-side-card ov-section-enter">
<div class="ov-side-card-title">🔓 NEXT UNLOCK</div>
<div class="ov-unlock-icon">@nextUnlock.Icon</div>
<div class="ov-unlock-name">@nextUnlock.Name</div>
@if(canAfford)
{
<div class="ov-unlock-ready">Ready to unlock!</div>
}
else
{
<div class="ov-unlock-cost">@needed 💠Fragments needed : </div>
<div class="ov-unlock-bar-track">
<div class="ov-unlock-bar-fill"
style="width:@(MathF.Min(100f, (float)totalFrags / nextUnlock.Cost * 100f).ToString("F0"))%;">
</div>
</div>
}
</div>
}
</div>
}
}
</div>
}
}
@if (!string.IsNullOrEmpty(_flashClass))
{
<div class="screen-flash @_flashClass">
@if (!string.IsNullOrEmpty(_flashText))
{
<div class="flash-text">@_flashText</div>
}
</div>
}
</root>
@code {
ChainReactionGame Game => ChainReactionGame.Instance;
BombHandSystem Hand => Game?.Components.Get<BombHandSystem>();
bool DEV_MODE = false;
bool _sub;
string _msg = "Place your bombs then Detonate!";
string _multClass = "";
string _challengeMsg = "";
float _popupX = 30f; // tracks current X so popups cascade diagonally
int _litStep = -1;
int _displayScore = 0; // animated score shown on game over
Queue<(int r, int c, int coins)> _pendingChestPopups = new(); // coords of destroyed chest this wave (for spawning pop ups coins)
List<string> _chainSteps = new();
// ScreenFlash
string _flashClass = "";
string _flashText = "";
float _flashEndsAt = 0f;
// ----------------------- Game Over Screen -------------------------- //
// ------------------------------------------------------------------- //
// ------------------------------------------------------------------- //
// Use saved best score for new best detection
bool isNewBest => (Game?.Score ?? 0) > 0 && (Game?.Score ?? 0) >= (ChainReactionGame.Save?.BestScore ?? 0);
bool _ovShowStats = false;
bool _ovShowSecrets = false;
bool _ovShowFrags = false;
bool _ovShowScore = false;
bool _ovShowButtons = false;
bool _secretsDone = false;
bool _revealingChests = false;
int _revealedChests = 0;
bool _secretSectionVisible = false;
bool _chestRevealDone = false; // kept for compatibility
int _displayFragments = 0;
bool _fragsAnimating = false;
//
int _perfectWavesThisRun = 0;
int _currentWaveChestsCleared = 0;
async System.Threading.Tasks.Task StartGameOverSequence()
{
if (_revealingChests) { Log.Warning("StartGameOverSequence called twice!"); return; }
if (_revealingChests) return;
_revealingChests = true;
// Reset all
_ovShowStats = false;
_ovShowSecrets = false;
_ovShowFrags = false;
_ovShowScore = false;
_ovShowButtons = false;
_secretsDone = false;
_revealedChests = 0;
_lastRevealedIdx = -1;
_secretSectionVisible = false;
_chestRevealDone = false;
_showConfetti = false;
_confettiIdx = -1;
_displayScore = 0;
_scoreAnimating = false;
// 1. Wave + reason already visible — wait then show stats
await GameTask.DelaySeconds(1.0f);
Log.Info($"Stats shown at {Time.Now}");
_ovShowStats = true;
Sound.Play("Whoosh_Stat");
StateHasChanged();
// 2. Secrets appear AFTER stats, not before
await GameTask.DelaySeconds(0.8f);
Log.Info($"Secrets shown at {Time.Now}");
if (Game?.SecretChestRewards?.Count > 0)
{
_ovShowSecrets = true;
_secretSectionVisible = true;
Sound.Play("Whoosh_Stat");
StateHasChanged();
return; // player clicks to continue
}
await ShowFragsAndScore();
}
bool _showConfetti = false;
int _confettiIdx = -1;
int _lastRevealedIdx = -1;
// PET ANIMATION
bool _petFrameToggle = false;
float _petFrameTimer = 0f;
bool _petReacting = false;
float _petReactionTimer = 0f;
string _petReactionSprite = "";
string _petBubbleText = "";
int _reactionCount = 0;
string GetReactionLine(int total)
{
var _rng = new Random();
string[] small = { "Ooh!", "Nice!", "Yay!", "Wheee~" };
string[] mid = { "Nice chain!", "Keep going!", "You're on fire!", "So good!" };
string[] big = { "WOW!!", "INCREDIBLE!", "AMAZING!!", "UNSTOPPABLE!" };
string[] pool = total >= 8 ? big : total >= 4 ? mid : small;
return pool[_rng.Next(pool.Length)];
}
string[] _catReactionSprites = {
"ui/icons/cat/Cat_Shocked_Pose1.png",
"ui/icons/cat/Cat_Shocked_Pose2.png",
};
void OnChestClick(int idx)
{
if (idx != _revealedChests) return;
_revealedChests++;
_lastRevealedIdx = idx;
_confettiIdx = idx;
_showConfetti = true;
bool isLast = _revealedChests >= (Game?.SecretChestRewards?.Count ?? 0);
Sound.Play(isLast ? "open_last_chest" : "open_chest_1");
_ = ClearConfettiAfterDelay();
if (isLast)
{
_secretsDone = true;
_ = ShowFragsAndScore();
}
StateHasChanged();
}
async System.Threading.Tasks.Task ClearConfettiAfterDelay()
{
await GameTask.DelaySeconds(1.0f);
_showConfetti = false;
StateHasChanged();
}
async System.Threading.Tasks.Task ShowFragsAndScore()
{
await GameTask.DelaySeconds(0.3f);
_ovShowFrags = true;
_chestRevealDone = true;
_displayFragments = 0;
_fragsAnimating = true;
Sound.Play(eventName: "Whoosh_Stat");
StateHasChanged();
await GameTask.DelaySeconds(0.6f);
if (isNewBest)
{
Flash("flash-new-best", 1.0f, "🏆 NEW BEST!");
Sound.Play("gameover_bestscore");
await GameTask.DelaySeconds(0.4f);
}
_ovShowScore = true;
Sound.Play(eventName: "Whoosh_Stat");
_displayScore = 0; // reset to 0
_scoreAnimating = true; // START the animation
StateHasChanged();
await GameTask.DelaySeconds(1.4f);
_ovShowButtons = true;
Sound.Play(eventName: "Whoosh_Stat");
StateHasChanged();
}
bool _scoreAnimating = false;
async System.Threading.Tasks.Task StartScoreCount()
{
// Wait for score section to fade in (0.5s delay + 0.4s animation)
await GameTask.DelaySeconds( 0.9f );
_scoreAnimating = true;
}
void OnGameStateChanged()
{
if ( Game?.IsGameOver == true )
{
_scoreAnimating = false;
if ( !_revealingChests ) _ = StartGameOverSequence();
}
else if(Game?.IsRunning == true && Game?.Score == 0)
{
// Fresh game just started from menu — snap display to 0
_displayScore = 0;
_scoreAnimating = false;
}
StateHasChanged();
}
string GetFragHint()
{
int total = ChainReactionGame.Save?.TotalFragments ?? 0;
int toNext = 50 - (total % 50);
return toNext == 50 ? "Milestone reached!" : $"{toNext} until next unlock";
}
// -------------------------------------------------------------------- /
// Sidebar
bool _sidebarOpen = false;
bool CanDet => Game?.IsRunning == true && Hand?.AnyPlaced == true && !(Hand?.IsDetonating ?? true)
&& (TutorialOverlay.Instance?.IsActive == false || TutorialOverlay.Instance?.Step == 6 || TutorialOverlay.Instance?.Step == 9);
// PopupLayer razor component ref
PopupLayer _popupLayer;
protected override void OnTreeFirstBuilt()
{
_popupLayer = Scene.Components.GetInChildren<PopupLayer>( true );
if ( _popupLayer == null ) Log.Warning( "GameHud: PopupLayer not found in scene" );
Sub();
}
protected override void OnUpdate()
{
if (!_sub) Sub();
// Space bar detonation
if (Input.Pressed("Jump") && CanDet) OnDet();
// ------------- Pet -------------
_petFrameTimer -= Time.Delta;
if (_petFrameTimer <= 0f)
{
_petFrameTimer = 0.4f;
_petFrameToggle = !_petFrameToggle;
StateHasChanged();
}
// ------------- PET -------------
if (_petReacting)
{
_petReactionTimer -= Time.Delta;
if (_petReactionTimer <= 0f)
{
_petReacting = false;
StateHasChanged();
}
}
// Animate _displayScore toward the real score every frame (both live and game over)
if ( Game != null && _scoreAnimating )
{
int target = Game.IsGameOver ? Game.Score : Game.Score;
if ( _displayScore != target )
{
int diff = target - _displayScore;
int step = (int)MathF.Max( 1f, MathF.Abs(diff) * Time.Delta * 6f );
_displayScore += diff > 0 ? step : -step;
if ( (diff > 0 && _displayScore > target) || (diff < 0 && _displayScore < target) )
_displayScore = target;
// PLay score tick (in GameOver)
if (MathF.Abs(diff) > 50 && Time.Now - _lastScoreTick > 0.16f)
{
Sound.Play("coin_earned_single");
_lastScoreTick = Time.Now;
}
StateHasChanged();
}
}
else if ( !Game.IsGameOver )
{
int target = Game.Score;
if ( _displayScore != target )
{
int diff = target - _displayScore;
int step = (int)MathF.Max(1f, MathF.Abs(diff) * Time.Delta * 5f); // 60fps animation
// Never step past the target
if(step > MathF.Abs(diff))
{
step = (int)MathF.Abs(diff);
}
// Animate
_displayScore += diff > 0 ? step : -step;
StateHasChanged();
}
}
if (_fragsAnimating && Game != null)
{
int target = Game.FragmentsEarned;
if (_displayFragments != target)
{
int diff = target - _displayFragments;
int step = (int)MathF.Max(1f, MathF.Abs(diff) * Time.Delta * 4f);
if (step > MathF.Abs(diff)) step = (int)MathF.Abs(diff);
_displayFragments += diff > 0 ? step : -step;
StateHasChanged();
}
else
{
_fragsAnimating = false;
}
}
// Flash clear
if (!string.IsNullOrEmpty(_flashClass) && Time.Now >= _flashEndsAt)
{
_flashClass = "";
_flashText = "";
StateHasChanged();
}
// Screen Shake
var shakeClass = ScreenShaker.Instance?.ShakeClass ?? "";
if (shakeClass != _currentShake)
{
_currentShake = shakeClass;
StateHasChanged();
}
}
string _currentShake = "";
float _lastScoreTick = 0f;
void Sub()
{
var h = Hand; var g = Game;
if (h is null || g is null) return;
if (_sub) return;
// Tutorial subscriptions
h.OnHandChanged += () => { if(Hand?.AnyPlaced == true) TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.BombPlaced); };
h.OnChainStep += (i,t,c, p,m) => { if(i > 0) TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.ChainFired); };
h.OnDetonationFinished += (t,c) => TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.WaveEnd);
h.OnHandChanged += StateHasChanged;
h.OnChainStep += OnChainStep;
h.OnDetonationFinished += OnDetFinished;
g.OnStateChanged += OnGameStateChanged;
g.OnChallengeResolved += OnChallengeResolved;
h.OnChestCleared += OnChestCleared;
g.OnScreenFlashRequest += OnFlashRequest;
_sub = true;
StateHasChanged();
}
void OnDet()
{
if (!CanDet) return;
// tutorial
TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.Detonated);
_lastMultTier = 0;
_hypePopupFired = false; // reset on each detonation
Sound.Play("detonate_1");
_chainSteps = BuildSteps(); _litStep = -1;
//SpawnPopup( "TEST +100", "coins", 50f, 400f ); // debug
Game?.Detonate();
StateHasChanged();
}
void OnRestart()
{
_chainSteps.Clear(); _litStep = -1;
_displayScore = 0;
_scoreAnimating = false;
_perfectWavesThisRun = 0;
// Replay
Sound.Play(eventName: "replaybutton_ha");
// Game Over
_ovShowStats = false;
_ovShowSecrets = false;
_ovShowFrags = false;
_ovShowScore = false;
_ovShowButtons = false;
_secretsDone = false;
_revealedChests = 0;
_revealingChests = false;
_secretSectionVisible = false;
_chestRevealDone = false;
_scoreAnimating = false;
_hypePopupFired = false;
// frags
_displayFragments = 0;
_fragsAnimating = false;
_lastRevealedIdx = -1; // chest secret
_showConfetti = false;
_confettiIdx = -1;
_msg = "Place your bombs then Detonate!";
Game?.StartGame(Game.Mode, Game.SurvivalBombPool);
}
int _lastMultTier = 0;
bool _hypePopupFired = false;
void OnChainStep(int idx, int total, int clearedChestsSoFar, int projectedCleared, float mult)
{
_litStep = idx;
if (idx > 0) // only chain-triggered bombs, not the first hand-placed one
ScreenShaker.Instance?.Shake("shake-small", 0.25f);
// Spawn pop up's
if ( clearedChestsSoFar > 0 )
{
// Coin popup per chest cleared — cascades diagonally
int earned = clearedChestsSoFar <= 1 ? 100 : clearedChestsSoFar <= 2 ? 150 : clearedChestsSoFar <= 4 ? 200 : 300;
SpawnPopup( $"+{earned}", "coins", _popupX, 420f );
_popupX += 3.5f;
if ( _popupX > 55f ) _popupX = 28f; // wrap so they don't drift off screen
}
// Add hype to the hype meter
Game.AddHype(projectedCleared);
// Trigger hype max popup from HUD side
if (Game?.HypeMaxActive == true && !_hypePopupFired)
{
_hypePopupFired = true;
PopupLayer.Instance?.SpawnMultiplier("🔥 HYPE MAX!", "2× SCORE THIS WAVE", "mult-mega");
ScreenShaker.Instance?.Shake("shake-strong", 0.45f);
PopupLayer.Instance?.SpawnExplosionParticles(50f, 400f);
}
// Don't show multiplier popup when boss is alive — score is capped anyway
if (Game?.BossAlive == true)
{
// Still shake, still show coins, but no multiplier popup
StateHasChanged();
return;
}
// Spawn Popup multiplier overlay
// projectedCleared — correct tier at each explosion step
// it gives us directly (projected) the number of chests that are gonna be destroyed
// on that explosion (which returns the right modifier at this point in time)
if (projectedCleared >= 2)
{
/* Note : The key change is projectedCleared now escalates correctly with each bomb that fires —
so a 10-bomb chain that destroys 10 chests will show ×1 → ×1.5 → ×2 → ×3...
up to ×10 with matching sounds at each step,
regardless of whether the chests are destroyed early or late in the sequence */
// Only spawn if tier increased — matches sound escalation exactly
// tier going backwards means new chain group that won't beat peak — stay silent
if (projectedCleared > _lastMultTier)
{
_lastMultTier = projectedCleared;
// Old, HARD-CODED ONE : Only spawn if tier increased — matches sound escalation exactly
@* var (val, label, cls) = chests switch {
<= 2 => ("×1.5", "nice", "mult-normal"),
<= 4 => ("×2", "CHAIN!", "mult-normal"),
<= 6 => ("×3", "ON FIRE! 🔥", "mult-big"),
<= 8 => ("×4", "CHAIN REACTION! 💥","mult-mega"),
_ => ($"×{4f + (chests-8)*0.5f:F1}", "UNSTOPPABLE!! 🔥💥", "mult-insane"),
}; *@
// Use GetMultLabel for the label — matches the escalating text
string label = BombHandSystem.GetMultLabel(projectedCleared );
string val = projectedCleared switch {
<= 2 => "×1.5",
<= 3 => "×2",
<= 5 => "×3",
<= 7 => "×4",
<= 8 => "×5",
<= 9 => "×6",
<= 10 => "×7",
<= 11 => "×8",
<= 13 => "×10",
<= 14 => "×11",
_ => $"×{11f + (projectedCleared - 14):F0}"
};
Log.Warning($"Multipler : {val}");
Log.Warning($"Projected cleared : {projectedCleared}");
string cls = projectedCleared switch {
<= 4 => "mult-normal",
<= 6 => "mult-big",
<= 8 => "mult-mega",
_ => "mult-insane"
};
PopupLayer.Instance?.SpawnMultiplier(val, label, cls);
// Boss dampening txt
if (Game?.BossAlive == true)
{
var rng = new Random();
float ox = (float)(rng.NextDouble() * 20f - 10f); // x
float oy = (float)(rng.NextDouble() * 40f - 20f); // y
float rot = (float)(rng.NextDouble() * 30f - 15f); // rot
PopupLayer.Instance?.SpawnPopupRotated("⚠️ BOSS DAMPENING", "mult-cap-warning-popup",
42f + ox, 500f + oy, rot, 1.2f);
}
}
}
StateHasChanged();
}
void OnDetFinished(int total, int coins)
{
Random _rng = new Random();
// ── Cat reacts to the explosion ──
if (total > 0)
{
int level = ChainReactionGame.Save?.PetLevel ?? 1;
var reactions = PetVisuals.GetReactionSprites(level);
if (reactions.Length > 0)
{
_petReacting = true;
_petReactionTimer = 1.5f;
_petReactionSprite = reactions[_reactionCount++ % reactions.Length];
_petBubbleText = GetReactionLine(total);
Sound.Play("cat_meow"); // consider a per-level sound too later
}
}
// ── Final shake magnitude based on total chests cleared ──
string finalShake = total switch {
>= 8 => "shake-insane",
>= 6 => "shake-strong",
>= 4 => "shake-medium",
>= 2 => "shake-small",
_ => ""
};
if (!string.IsNullOrEmpty(finalShake))
ScreenShaker.Instance?.Shake(finalShake, 0.55f);
// Reset popup x drift and light up last chain step
_popupX = 30f;
_litStep = _chainSteps.Count - 1;
// ── Big chain burst — 4+ chests gets a fan of random words ──
if (total >= 4)
{
var rng = new System.Random();
// Arc positions across the screen — creates a fan/spread effect
float[] xs = { 25f, 32f, 40f, 47f, 54f };
float[] ys = { 500f, 460f, 440f, 460f, 500f };
// Word pools — randomly pick one from each category for variety
string[] openers = { "INSANE", "EPIC", "MENTAL", "NASTY", "WILD", "SICK", "BRUTAL" };
string[] middles = { "CHAIN", "COMBO", "BLAST", "BURST", "SURGE", "LINK" };
string[] closers = { "REACTION", "SEQUENCE", "DETONATION", "EXPLOSION", "CARNAGE" };
string[] words = {
openers[rng.Next(openers.Length)],
middles[rng.Next(middles.Length)],
closers[rng.Next(closers.Length)],
$"+{coins}",
"🔥💥"
};
// Stagger each word with a small delay so they cascade in
for (int i = 0; i < words.Length; i++)
SpawnPopupDelayed(words[i], "burst-popup", xs[i], ys[i], i * 0.08f);
}
// ── Small chain — 2-3 chests gets a single compact summary ──
else if (total >= 2)
{
SpawnPopup($"💥 {total} chests!", "chain-summary", 38f, 480f);
}
// ── Climax explosion sound scales with total chests ──
ClimaxExplosionSound(total);
// Only play if tier changed upward
// Crowd reaction for massive chains
if (total >= 2) // displayCleared >= 9
{
// Crowd escalating from a surprised gasp to full stadium roar
string crowd = total switch {
2 or 3 or 4 or 5 => "Small_Crowd_Applaude_1",
6 or 7 => "crowd_applause_low_impact_1",
8 or 9 => "crowd_impact_2",
10 or 11 => "crowd_applause_low_impact_1",
12 or 13 => "crowd_applause_mid_impact_1",
14 => "crowd_applause_big_impact_1",
_ => "Crowd_Applause_Pretty_Big_Impact_1" // 10-12: absolute maximum
};
// Make sound volume go up procedurally based on total destroyed
if(total >= 5)
{
var handle = Sound.Play(crowd);
handle.Volume = MathF.Min(0.6f, 0.3f + (total * 0.05f));
}
else // if < 5, then play basic level volume
{
Sound.Play(crowd);
}
}
// Spawn a big pop up and flash if we did a 3 perfect wave streak
// Check if all chests were cleared (total > 0 and grid has no chests left)
// We track consecutive perfect waves (useful for game over stats)
if (total >= (Game?.MinChests ?? 1))
{
_perfectWavesThisRun++;
// ── Perfect streak reward at 3 in a row ──
if (_perfectWavesThisRun >= 3 && _perfectWavesThisRun % 3 == 0)
{
Sound.Play("perfect_streak");
SpawnPopup("🔥 PERFECT STREAK!", "burst-popup", 35f, 380f);
SpawnPopup("×3 IN A ROW!", "mult-mega", 50f, 340f);
SpawnPopup("🏆", "popup-xl", 62f, 380f);
ScreenShaker.Instance?.Shake("shake-strong", 0.6f);
Flash("flash-new-best", 1.2f, $"🔥 PERFECT STREAK ×{_perfectWavesThisRun}!");
}
else
{
_perfectWavesThisRun = 0; // reset on failure
}
}
// ── Bottom message bar update ──
_msg = total >= 4 ? $"💥 CHAIN REACTION! {total} chests · +{coins} pts"
: total > 0 ? $"🔥 {total} chest{(total>1?"s":"")} cleared · +{coins} pts"
: "No chests hit!";
// Clear chain steps and multiplier overlay after delay
_ = Clear(2.8f);
StateHasChanged();
}
void ClimaxExplosionSound(int total)
{
// Play explosion climax sound
switch(total)
{
case 0:
break;
case 1:
break;
case 2:
Sound.Play("Climax_Explosion_Lv2");
break;
case 3:
Sound.Play("Climax_Explosion_Lv3");
break;
case 4:
Sound.Play("Climax_Explosion_Lv4");
break;
case 5:
Sound.Play("Climax_Explosion_Lv5");
break;
case 6:
Sound.Play("Climax_Explosion_Lv5");
break;
case 7:
Sound.Play("Climax_Explosion_Lv5");
break;
case 8:
Sound.Play("Climax_Explosion_Lv5");
break;
case 9:
Sound.Play("Climax_Explosion_Lv5");
break;
case 10:
Sound.Play("Climax_Explosion_Lv5");
break;
}
}
void OnChallengeResolved( bool completed, int bonus )
{
if ( completed )
{
_challengeMsg = $"⚡ +{bonus}";
SpawnPopup( $"⚡ Challenge! +{bonus}", "challenge", 35f, 300f );
}
else
{
_challengeMsg = ""; // silent on fail
}
StateHasChanged();
}
async System.Threading.Tasks.Task Clear(float s)
{
// Mult overlay clears quickly — animation is done by then
await GameTask.DelaySeconds( 1.2f );
StateHasChanged();
// Rest clears on the longer delay
float remaining = s - 1.2f;
if ( remaining > 0 ) await GameTask.DelaySeconds( remaining );
_chainSteps.Clear(); _litStep = -1;
_challengeMsg = "";
if ( Game?.IsRunning == true ) _msg = "Place your bombs then Detonate!";
StateHasChanged();
}
// Displays the steps of each bomb in the chain reaction
List<string> BuildSteps()
{
var s = new List<string>();
var sim = Hand?.Simulate();
if (sim is null) return s;
foreach (var n in sim.Sequence) s.Add(BombIcon(n.Type));
return s;
}
protected override int BuildHash()
{
int a = System.HashCode.Combine(Game?.Wave, Game?.Score, Game?.Lives,
Game?.IsRunning, Game?.IsGameOver,
Hand?.SelectedSlot, Hand?.PlacedSlots.Count, _litStep);
int b = System.HashCode.Combine(
_chainSteps.Count, _displayScore,
ChainReactionGame.Save?.ActiveModifiers?.Count ?? 0, _currentShake);
int c = System.HashCode.Combine(
_ovShowStats, _ovShowSecrets, _ovShowFrags,
_ovShowScore, _ovShowButtons, _revealedChests,
_displayFragments);
int d = System.HashCode.Combine(
_showConfetti, _confettiIdx);
int e = System.HashCode.Combine(_musicHandles.Count);
// Survival spawn indicator needs frequent re-render
int f = System.HashCode.Combine(
Game?.Mode, (int)((Game?.SurvivalSpawnTimerFraction ?? 0f) * 100f));
int g = System.HashCode.Combine(_petReacting, _petFrameToggle);
return a ^ b ^ c ^ d ^ e ^ f ^ g;
}
static string BombIcon(GridManager.BombType t) => t switch {
GridManager.BombType.Cross => "✛", GridManager.BombType.Sniper => "↑",
GridManager.BombType.Diagonal => "✕", GridManager.BombType.Square => "■",
GridManager.BombType.Chain => "⛓", _ => "?" };
// Replaces 'BombIcon' as a much more readable icon visuals for bombs
// It's there until I make proper icons for bomb types
static string Icon( GridManager.BombType t ) => t switch {
GridManager.BombType.Cross => "🎯", // targeting reticle — readable as "hits in a cross"
GridManager.BombType.Sniper => "🏹", // arrow — directional, fits the sniper identity
GridManager.BombType.Diagonal => "⚡", // lightning — diagonal energy, feels powerful
GridManager.BombType.Square => "💣", // classic bomb — area damage, universally understood
GridManager.BombType.Chain => "🔗", // chain link — exactly what it does
_ => "?"
};
static string BombName(GridManager.BombType t) => t switch {
GridManager.BombType.Cross => "Cross", GridManager.BombType.Sniper => "Sniper",
GridManager.BombType.Diagonal => "Diagonal", GridManager.BombType.Square => "Square",
GridManager.BombType.Chain => "Chain", _ => "?" };
static string BombHint(GridManager.BombType t) => t switch {
GridManager.BombType.Cross => "+ shape, 1 tile range",
GridManager.BombType.Sniper => "3 tiles in one direction",
GridManager.BombType.Diagonal => "✕ shape, 2 tile range",
GridManager.BombType.Square => "3×3 area",
GridManager.BombType.Chain => "triggers nearby bombs",
_ => "" };
// ------------------------------------------------------------------ //
// ------------------------- Floating Popup ------------------------- //
// ------------------------------------------------------------------ //
int _popupIdCounter = 0;
void SpawnPopup( string text, string cssClass, float x, float y, float lifetime = 1.4f )
{
PopupLayer.Instance?.SpawnPopup( text, cssClass, x, y, lifetime );
}
// Converts a grid cell position to approximate screen % coordinates
// Grid is centered in the area left of the 260px sidebar
// Each cell is 110px wide + 6px gap = 116px step, grid is 5 cols = 580px total
// Grid starts at roughly left: calc(50% - 130px - 290px) from viewport
@* (float x, float y) GridCellToScreen( int row, int col )
{
// Grid is centered in viewport-minus-sidebar
float cellStep = 116f; // 110px + 6px gap
float viewportW = 1920f; // assumed viewport width
// Grid center x = (viewport - sidebar) / 2 = (1920 - 260) / 2 = 830px = 43.2%
// Col 0 center = gridCenter - 2 * cellStep = 830 - 232 = 598px = 31.1%
float col0X = 925f; // px from left - tune this
float row0Y = 350f; // px from top — tune this
float xPct = (col0X + col * cellStep) / viewportW * 100f;
float yPx = row0Y + row * cellStep;
return (xPct, yPx);
} *@
(float x, float y) GridCellToScreen( int row, int col )
{
float cellStep = 116f;
float viewportW = 1920f;
float col0X = 820f; // ← tune this until col 0 lines up
float row0Y = 310f; // ← tune this until row 0 lines up
float xPct = (col0X + col * cellStep) / viewportW * 100f;
float yPx = row0Y + row * cellStep;
return (xPct, yPx);
}
// spawns the coin popup right at the chest's grid position, with size scaling based on amount
void OnChestCleared( int r, int c, int coins, float mult )
{
var (x, y) = GridCellToScreen( r, c );
// explosion particles!
PopupLayer.Instance?.SpawnExplosionParticles(x, y);
// Main coin value — bigger text for bigger amounts
string sizeClass = coins >= 400 ? "popup-xl" : coins >= 200 ? "popup-lg" : "popup-md";
SpawnPopup( $"+{coins}", $"coins {sizeClass}", x, y );
// If multiplier is active, spawn a smaller mult indicator slightly offset
if ( mult > 1f )
SpawnPopup( $"×{mult:F1}", "mult-tag", x + 8f, y - 2f );
}
void SpawnPopupDelayed( string text, string cssClass, float x, float y, float delay )
{
_ = SpawnPopupAfter( text, cssClass, x, y, delay );
}
async System.Threading.Tasks.Task SpawnPopupAfter( string text, string cssClass, float x, float y, float delay )
{
await GameTask.DelaySeconds( delay );
SpawnPopup( text, cssClass, x, y );
}
// Main Menu button
void OnReturnToMenu()
{
_displayScore = 0;
_perfectWavesThisRun = 0;
_chestRevealDone = false;
_revealedChests = 0;
_revealingChests = false;
_scoreAnimating = false;
_secretSectionVisible = false;
Game?.ReturnToMenu();
}
// Score changing (animation)
int _scoreTarget = 0;
int _scoreStepPerSec = 0; // how fast to count per second
void OnScoreChanged(int newScore)
{
int diff = newScore - _displayScore;
if(diff <= 0) { _displayScore = newScore; return; }
_scoreTarget = newScore;
// Count up over 0.4 seconds regardless of how big the jump is
_scoreStepPerSec = (int)(diff / 0.4f);
if(_scoreStepPerSec < 1) _scoreStepPerSec = 1;
}
// ---------------------------------------------------- //
// --------------------- CHEST BOSS ------------------- //
// ---------------------------------------------------- //
void OnBossKilled(int score, int frags)
{
SpawnPopup("👑 BOSS SLAIN!", "popup-mega", 50f, 360f);
SpawnPopup($"+{score} pts", "popup-chain", 50f, 400f);
SpawnPopup($"+{frags} 💠", "popup-nice", 50f, 440f);
StateHasChanged();
}
string GetPetImagePath() => PetVisuals.GetImagePath(
ChainReactionGame.Save?.PetLevel ?? 1,
ChainReactionGame.Save?.PetUnlockShown ?? false,
_petFrameToggle,
_petReacting ? _petReactionSprite : null
);
// ---------------------------------------------------- //
// -------------------- CONSUMABLES ------------------- //
// ---------------------------------------------------- //
bool HasAnyConsumable()
{
var save = ChainReactionGame.Save;
if (save == null) return false;
foreach (var cons in ConsumableCatalog.All)
if (save.GetConsumableCount(cons.Id) > 0) return true;
return false;
}
void OnUseConsumable(ConsumableId id)
{
var save = ChainReactionGame.Save;
var game = Game;
var hand = Hand;
if (save == null || game == null) return;
// Heart is handled separately — UseHeartConsumable validates and consumes
if (id == ConsumableId.Heart)
{
bool used = game.UseHeartConsumable();
if(used)
{
SpawnPopup("❤️ +1 Life!", "burst-popup", 50f, 300f);
// tutorial case
TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.HeartUsed);
}
Sound.Play("UI_Button");
StateHasChanged();
return;
}
if (!save.TryUseConsumable(id)) return;
switch (id)
{
// +8s on timer
case ConsumableId.FuseExtension:
game.AddTime(8f);
SpawnPopup("⏱️ +8s", "coins", 50f, 300f);
Sound.Play("usetimeup");
break;
// Clears most dangerous column
case ConsumableId.ColumnSweep:
game.SweepDangerColumn();
SpawnPopup("🧹 Swept!", "coins", 50f, 300f);
Sound.Play("usesweep");
break;
// Doubles multiplier this wave
case ConsumableId.Overdrive:
game.ActivateOverdrive();
SpawnPopup("💥 OVERDRIVE!", "burst-popup", 50f, 300f);
Sound.Play("useoverdrive");
break;
// Reveals next wave chests
case ConsumableId.Reveal:
_ = game.RevealNextWave();
SpawnPopup("🔮 Revealing...", "coins", 50f, 300f);
Sound.Play("usereveal");
break;
// Redraws hand
case ConsumableId.Redraw:
hand?.DrawHand(game.Wave);
SpawnPopup("🔄 New hand!", "coins", 50f, 300f);
Sound.Play("useredraw");
break;
case ConsumableId.ClusterDrop:
game.ActivateClusterDrop();
SpawnPopup("💣 Cluster Dropped!", "burst-popup", 50f, 300f);
Sound.Play("useclusterdrop");
break;
case ConsumableId.Overload:
game.ActivateOverload();
SpawnPopup("⚡ OVERLOAD!", "burst-popup", 50f, 300f);
Sound.Play("useoverload");
break;
// Chain nuke
case ConsumableId.ChainNuke:
game.ActivateChainNuke();
SpawnPopup("☢️ CHAIN NUKE!", "burst-popup", 50f, 300f);
Sound.Play("usenukebomb");
break;
}
StateHasChanged();
}
// ScreenFlash
public void Flash(string cssClass, float duration, string text = "")
{
_flashClass = cssClass;
_flashText = text;
_flashEndsAt = Time.Now + duration;
StateHasChanged();
}
void OnFlashRequest(string type, int wave)
{
switch (type)
{
case "boss": Flash("flash-boss-spawn", 2.5f); break;
case "wave": Flash("flash-wave-start", 2.5f, $"WAVE {wave}"); break;
case "newbest": Flash("flash-new-best", 1.0f, "🏆 NEW BEST!"); break;
case "gameover":Flash("flash-game-over", 0.8f); break;
case "bossdead":Flash("flash-boss-death", 0.6f); break;
case "perfect": Flash("flash-wave-start", 1.2f, $"✨ PERFECT CLEAR! +{wave * 50}"); break;
}
}
// **** PET COMPANION *** //
string GetPetEmoji() => (ChainReactionGame.Save?.PetUnlockShown == true)
? (ChainReactionGame.Save?.PetLevel ?? 1) switch
{
1 => "🥚",
2 => "🐣",
3 => "🐥",
4 => "🦅",
_ => "🐉"
}
: "📦";
/* Wave Tier for grid-like Background cs changing color*/
int GetWaveTier()
{
int wave = Game?.Wave ?? 1;
if (wave < 5) return 1;
if (wave < 10) return 2;
if (wave < 15) return 3;
if (wave < 20) return 4;
if (wave < 30) return 5;
if (wave < 40) return 6;
return 7;
}
/* MUSIC DEBUG */
// ── Music debug ──
float _loopStartTime = -1f;
float _loopDuration = 14.811f; // set this to match your actual loop length in seconds
Dictionary<string, SoundHandle> _musicHandles = new();
Dictionary<string, string> _debugTracks = new()
{
{ "music_menu", "Menu Theme" },
{ "1.bass_and_clap", "In-Game" },
{ "2.hit_hat_2", "Tension" },
{ "3.pulsed_violin", "Boss Wave" },
{ "music_gameover", "Game Over" },
};
async System.Threading.Tasks.Task PlayTrackSynced(string key, float delay)
{
await GameTask.DelaySeconds(delay);
if(!_musicHandles.ContainsKey(key)) // user may have cancelled during wait
_musicHandles[key] = Sound.Play(key);
StateHasChanged();
}
void StopDebugMusic()
{
foreach(var h in _musicHandles.Values)
if(h.IsValid()) h.Stop();
_musicHandles.Clear();
_loopStartTime = -1f;
StateHasChanged();
}
void PlayDebugTrack(string key)
{
// Toggle off — wait for next loop boundary
if(_musicHandles.TryGetValue(key, out var existing) && existing.IsValid())
{
_ = StopTrackSynced(key);
StateHasChanged();
return;
}
// First track — start immediately
if(_musicHandles.Count == 0)
{
_loopStartTime = Time.Now;
_musicHandles[key] = Sound.Play(key);
StateHasChanged();
return;
}
// Subsequent tracks — wait for next loop boundary
float elapsed = (Time.Now - _loopStartTime) % _loopDuration;
float waitTime = _loopDuration - elapsed;
_ = PlayTrackSynced(key, waitTime);
StateHasChanged();
}
async System.Threading.Tasks.Task StopTrackSynced(string key)
{
float elapsed = (Time.Now - _loopStartTime) % _loopDuration;
float waitTime = _loopDuration - elapsed;
await GameTask.DelaySeconds(waitTime);
if(_musicHandles.TryGetValue(key, out var handle) && handle.IsValid())
{
handle.Stop();
_musicHandles.Remove(key);
}
StateHasChanged();
}
// Survival Mode - helper
string FormatSurvivalTime(float seconds)
{
int m = (int)(seconds / 60f);
int s = (int)(seconds % 60f);
return $"{m:00}:{s:00}";
}
}