Main game component for the Chain Reaction game mode. Manages game state, waves, spawning chests (including secrets and bosses), turn timer, survival mode, random events, consumables, challenges, scoring, persistence hooks and UI/events.
using Sandbox;
using System;
using Sandbox.Services;
using System.Collections;
public sealed class ChainReactionGame : Component
{
[Property] public GridManager Grid { get; set; }
[Property] public BombHandSystem Hand { get; set; }
// ── State ─────────────────────────────────────────────────────────────
public int Wave { get; private set; } = 1;
public int Score { get; private set; }
public int BestScore { get; private set; } = 0;
public int Lives { get; private set; } = 1;
public int WaveTarget { get; private set; }
public int WaveScore { get; private set; }
public int MinChests { get; private set; } = 1;
public float TurnTime { get; private set; } // seconds left this turn
public bool TimerActive { get; private set; }
public bool IsRunning { get; private set; }
public bool IsGameOver { get; private set; }
public Random Rng { get; } = new Random();
public static ChainReactionGame Instance { get; private set; }
// ── Events ────────────────────────────────────────────────────────────
public delegate void StateChanged();
public delegate void ChestDestroyed( int r, int c, int coins, float mult );
public event StateChanged OnStateChanged;
public event ChestDestroyed OnChestDestroyed2;
public delegate void ChallengeResolved( bool completed, int bonus );
public event ChallengeResolved OnChallengeResolved;
public delegate void BossKilled(int scoreBonus, int fragBonus);
public event BossKilled OnBossKilled;
public delegate void ScreenFlashRequest(string type, int wave);
public event ScreenFlashRequest OnScreenFlashRequest;
// For music & potential other things
public delegate void GameStartedEvent();
public delegate void GameReturnedEvent();
public event GameStartedEvent OnGameStarted;
public event GameReturnedEvent OnGameReturned;
// ── Config ────────────────────────────────────────────────────────────
public const int StartLives = 1;
public const int MaxLives = 3;
const int ChainBonus = 120;
const float BaseTurnTime = 20f; // seconds per turn wave 1
const float MinTurnTime = 4f; // floor at high waves
// ── Challenges ─────────────────────────────────────────────────────────
public WaveChallenge CurrentChallenge { get; private set; }
int _secretChestsSpawnedThisRun = 0;
int _chestsAtWaveStart = 0;
// ── GameOver Stats ─────────────────────────────────────────────────────
public int TotalChestsCleared { get; private set; }
public int BestChainLength { get; private set; }
public int ChallengesCompleted { get; private set; }
public int FragmentsEarned { get; private set; }
public List<SecretChestReward> SecretChestRewards { get; private set; } = new();
public int PrevBestScore { get; private set; } = 0;
// ── Lifecycle ─────────────────────────────────────────────────────────
public bool GameStarted { get; set; } = false;
// ── Game over ─────────────────────────────────────────────────────────
public string GameOverReason { get; private set; } = "";
// ── Modifiers ─────────────────────────────────────────────────────────
bool _firstChainUsedThisWave = false;
float _timerPenaltyNextWave = 0f; // cursed chest's timer curse
// ── Events / Frags Count total ─────────────────────────────────────────
public int BonusEventFragments { get; private set; } = 0; // lucky event..
bool _luckyStreakFiredThisRun {get; set;}
public int PerfectWaveFragments { get; private set; } = 0;
public int BossFragments { get; private set; } = 0;
protected override void OnStart()
{
Instance = this;
// Load Save data once on boot, persist for the entire session
Save = PlayerSave.Load();
Log.Info( $"Save loaded — fragments: {Save.TotalFragments}, best wave: {Save.BestWave}" );
Initialize();
// Wires up system once on boot, persist for the entire session
Hand.OnDetonationFinished += HandleDetonationFinished;
Hand.OnSecretChestDestroyed += RollSecretChestReward;
// Wait for components then show the menu
_ = WaitThenShowMenu();
}
private void Initialize()
{
BestScore = Save?.BestScore ?? 0;
}
// ── Save System ────────────────────────────────────────────────────────
public static PlayerSave Save { get; private set; }
public bool HasMod(ModifierId id) => Save?.ModifierActive(id) ?? false;
// ── GameMode ────────────────────────────────────────────────────────
public GameMode Mode { get; private set; } = GameMode.Wave;
// ── Survival mode state ──────────────────────────────────────────────
float _survivalSpawnTimer = 0f;
float _survivalSpawnInterval = 2.2f;
float _survivalElapsed = 0f;
bool _survivalSpawnFrozen = false; // pauses spawning while milestone overlay shows
public float SurvivalSpawnTimerFraction => _survivalSpawnInterval > 0f
? 1f - (_survivalSpawnTimer / _survivalSpawnInterval) : 0f;
public float SurvivalElapsed => _survivalElapsed;
public int SurvivalSpawnCount { get; private set; } = 0;
int _survivalFakeWave = 1;
public int SurvivalFakeWave => _survivalFakeWave;
public List<GridManager.BombType> SurvivalBombPool { get; private set; } = null;
const float SurvivalMinInterval = 0.6f; // fastest spawn rate — tune to taste
// ── Survival milestones ──────────────────────────────────────────────
int _survivalLastMilestoneMinute = 0;
public int SurvivalBonusBombSlots { get; private set; } = 0;
const int MaxSurvivalBonusSlots = 2; // caps how far hand size can grow beyond base
// ── Survival: continuous difficulty curve ───────────────────────
const float SurvivalIntervalStart = 4.2f; // was 2.2f — much gentler opening
const float SurvivalIntervalFloor = 1.0f; // was 0.9f
const float SurvivalRampMinutes = 9f; // was 8f
const float SurvivalEmptyGraceSeconds = 0.8f; // caps true dead-air time on an empty board
float _survivalEmptySince = -1f; // -1 = board isn't currently empty
const int SurvivalBatchMaxStart = 5; // unchanged
const int SurvivalBatchMaxFloor = 4; // was 3 — kept slightly higher so late-game doesn't go flat
const int SurvivalBatchMinStart = 3; // unchanged
const int SurvivalBatchMinFloor = 3; // was 2 — same reasoning
// ── Survival: per-minute flavor modifiers (reset each minute) ──
float _survivalPhaseIntervalMult = 1f;
int _survivalPhaseBatchBonus = 0;
bool _survivalGoldRushMinute = false;
// ── Survival: score surge flavor ────────────────────────────────
float _survivalScoreBoostUntil = 0f;
const float SurvivalScoreBoostMult = 2f;
// ── Leaderboard ────────────────────────────────────────────────────────
float _sessionStartTime = 0f;
async void SubmitLeaderboard( int score, int wave, int previousBestChainLength)
{
try
{
// s&box uses Stats for submission, Leaderboards for reading
if ( score >= Save.BestScore )
Stats.Increment( "chain_reaction_score", score );
if ( wave >= Save.BestWave )
Stats.Increment( "chain_reaction_wave", wave );
if ( BestChainLength >= previousBestChainLength)
Stats.Increment("chain_reaction_best_chain", BestChainLength);
// Submit top Pets total xp (including level)
int petScore = (Save.PetLevel * 1_000_000) + Save.PetXp;
Stats.Increment("chain_reaction_pet", petScore);
// Top sequence chain reaction + scores (in case multiple have same sequence)
int chainScore = (BestChainLength * 10_000_000) + Score;
Stats.Increment("chain_reaction_chain", chainScore);
// medal tier (top 100 force contributor)
int medalTier = Save?.ForceMedalTier ?? 0;
if(medalTier > 0)
Stats.Increment("chain_reaction_medal", medalTier);
Log.Info( $"Stats submitted — score: {score}, wave: {wave}" );
}
catch ( System.Exception e )
{
Log.Warning( $"Leaderboard submit failed: {e.Message}" );
}
}
// ── Gameplay ────────────────────────────────────────────────────────
// Delay spawning chest & stuff (until tiles are created)
async System.Threading.Tasks.Task WaitThenShowMenu()
{
// Wait for all components to initialize before firing first UI update
await GameTask.DelaySeconds( 0.15f );
OnStateChanged?.Invoke();
}
protected override void OnUpdate()
{
// Pause timer during tutorial
if (TutorialOverlay.Instance?.IsActive == true) return;
if (!IsRunning || Hand.IsDetonating) return;
// Survival has its own independent tick — doesn't use TimerActive at all
if (Mode == GameMode.Survival)
{
TickSurvival();
return;
}
// Handle timer freeze event
if (_timerFrozenFor > 0f)
{
_timerFrozenFor -= Time.Delta;
OnStateChanged?.Invoke();
return; // don't tick timer while frozen
}
TurnTime -= Time.Delta;
OnStateChanged?.Invoke(); // tick every frame for the timer bar
// Hype meter passive drain — always drains slowly, faster during hype max
if (_hypeMeter > 0f && IsRunning && MilestoneOverlay.Instance?.IsActive != true)
{
float drainRate = _hypeMaxActive ? 0.08f : 0.012f; // normal: ~80s to drain, max: ~12s
_hypeMeter = MathF.Max(0f, _hypeMeter - drainRate * Time.Delta);
if (_hypeMaxActive && _hypeMeter <= 0f)
{
_hypeMaxActive = false;
_hypeTriggeredThisWave = false;
}
OnStateChanged?.Invoke();
}
// Trigger Next Wave from Time Out :
// 1 - (Detonate (if any bomb - can still lose if conditions are not met)
// 2 - OR lose a live right of the bat and go next wave)
if ( TurnTime <= 0f )
{
TurnTime = 0f;
TimerActive = false;
// Time's up — force detonate whatever is placed, or lose a life
if ( Hand.AnyPlaced )
Hand.ExecuteDetonate();
else
HandleTimeUp();
}
}
// If no bomb was on any tile when time was up.. automatic result - Next Wave trigger
void HandleTimeUp()
{
Lives--;
// Check game over
if ( Lives <= 0 ) {
TriggerGameOver("⏱️ Time ran out and lives reached zero! Be faster next time my friend :)");
return;
}
// Inform player why he lost a life
MilestoneOverlay.Instance?.ShowMessage("⏱️ Time's up — life lost!", Wave);
Sound.Play("TimeIsUp");
Wave++;
WaveScore = 0;
WaveTarget = ComputeWaveTarget( Wave );
MinChests = MinChestsForWave( Wave );
SlideChestsDown();
// Same check here — time-up also slides chests down
if ( Grid.AnyChestInLastRow() ) { TriggerGameOver("📦 A chest reached the bottom row! Too bad.. hehe"); return; }
SpawnChests( ChestsForWave( Wave ) );
Hand.DrawHand( Wave );
StartTurnTimer();
OnStateChanged?.Invoke();
}
// ── Public API ────────────────────────────────────────────────────────
public void StartGame(GameMode mode, List<GridManager.BombType> survivalBombPool)
{
Mode = mode;
SurvivalBombPool = survivalBombPool;
Save = PlayerSave.Load(); // reload fresh before each run
// Safety — BombSlot2 is a permanent welcome gift, always ensure it's granted
if(!Save.OwnsModifier(ModifierId.BombSlot2))
{
Save.OwnedModifiers.Add("BombSlot2");
Save.OwnedBombs.Add(GridManager.BombType.Diagonal.ToString());
Save.Save();
Log.Info("StartGame — BombSlot2 was missing, re-granted");
}
GameStarted = true;
OnGameStarted?.Invoke();
// Reset random event state
ClearRandomEvent();
_lastEventWave = 0;
_consecutiveWavesNoLifeLoss = 0;
_consecutiveFailedWaves = 0;
_livesAtWaveStart = StartLives;
_chainMasterFiredThisRun = false;
BonusEventFragments = 0;
_luckyStreakFiredThisRun = false;
PerfectWaveFragments = 0;
BossFragments = 0;
// Base first wave data
Wave = 1;
CurrentChallenge = RollChallenge( Wave ); // new challenge
Score = 0;
// Level 2 - Bonus Pet + Level 6 - Extra bonus life
Lives = StartLives
+ (Save?.PetLevel >= 2 ? 1 : 0)
+ (Save?.PetLevel >= 6 ? 1 : 0);
WaveScore = 0;
IsRunning = true;
IsGameOver = false;
_bossAlive = false;
_secretChestsSpawnedThisRun = 0; // secret chest
WaveTarget = ComputeWaveTarget( Wave );
MinChests = MinChestsForWave( Wave );
MilestoneOverlay.Instance?._queue.Clear(); // clear Milestone queue
// cursed chest
_activeCursedChests = 0;
_timerPenaltyNextWave = 0f;
// mod
_firstChainUsedThisWave = false;
_sessionStartTime = Time.Now; // leaderboard
// Hype bar
_hypeMeter = 0f;
_hypeMaxActive = false;
_hypeTriggeredThisWave = false;
// Game Over Stats
TotalChestsCleared = 0;
BestChainLength = 0;
ChallengesCompleted = 0;
FragmentsEarned = 0;
SecretChestRewards.Clear();
Grid.InitGrid(); // 1. creates grid
if (Mode == GameMode.Survival)
Grid.ResizeGrid(7, 5); // taller grid — more buffer before bottom row
else
Grid.ResizeGrid(5, 5); // Wave Mode
SpawnChests( ChestsForWave( Wave ) ); // 2. spawn chests for wave -x
TrySpawnSecretChest(); // 8% chance
TrySpawnBossChest(); // added for consistency
_chestsAtWaveStart = Grid.AllChestCells().Count; // retrieve all chests (num) for challenge checks
int handSize = 1;
if(HasMod(ModifierId.BombSlot2)) handSize = 2;
if(HasMod(ModifierId.BombSlot3)) handSize = 3;
if(HasMod(ModifierId.BombSlot4)) handSize = 4;
Hand.HandSize = handSize;
Hand.DrawHand( Wave );
// if Survival Mode
if (Mode == GameMode.Survival)
{
_survivalSpawnTimer = 2.2f;
_survivalSpawnInterval = 2.2f;
_survivalElapsed = 0f;
SurvivalSpawnCount = 0;
_survivalLastMilestoneMinute = 0;
_survivalPhase = -1;
_survivalPhaseInitialized = false;
_survivalEmptySince = -1f; // add this
SurvivalBonusBombSlots = 0;
_survivalSpawnFrozen = false;
TimerActive = false;
if (!Save.SurvivalTutorialCompleted)
{
_survivalSpawnFrozen = true; // freeze while tutorial shows
SurvivalTutorial.Instance?.Activate();
Save.SurvivalTutorialCompleted = true;
Save.Save();
}
// survival starts with one extra slot over wave mode's base
Hand.HandSize = Math.Max(handSize + 1, 3);
Hand.DrawHand(1); // redraw with the new hand size
}
else // Wave Mode
{
StartTurnTimer();
}
OnStateChanged?.Invoke(); // fires delegate for listening classes (esp. UI updates)
Log.Info($"StartGame — OwnedModifiers: {string.Join(", ", Save.OwnedModifiers)}, HandSize: {handSize}");
}
public void UnfreezeSurvivalSpawn()
{
_survivalSpawnFrozen = false;
_survivalSpawnTimer = _survivalSpawnInterval;
}
// Calls Detonate (from Gamehud.razor button OnClickEvent)
public void Detonate()
{
if ( !IsRunning || Hand.IsDetonating || !Hand.AnyPlaced ) return;
TimerActive = false;
Hand.ExecuteDetonate();
}
// Mainly call for UI updates
public void OnChestDestroyed( int r, int c, int earned, float mult )
=> OnChestDestroyed2?.Invoke( r, c, earned, mult );
// ── Turn timer ────────────────────────────────────────────────────────
// calculates how long a turn should last, and makes later waves faster.
float _maxTurnTime = 20f; // track the actual max for this wave
void StartTurnTimer()
{
float t;
if (Wave <= 5)
t = 24f - Wave * 0.8f; // wave 1=23.2s, 5=20s — very relaxed early
else if (Wave <= 15)
t = 20f - (Wave - 5) * 0.9f; // wave 6=19.1s, 15=11s
else if (Wave <= 30)
t = 11f - (Wave - 15) * 0.35f; // wave 16=10.6s, 30=5.75s
else if (Wave <= 50)
t = MathF.Max(7f, 5.75f - (Wave - 30) * 0.05f); // wave 30-50: 7s floor
else
t = MathF.Max(6f, 7f - (Wave - 50) * 0.02f); // wave 50+: 6s floor
if(Wave == 1 && HasMod(ModifierId.TimerBonus))
t += 5f;
// Cursed chest penalty — -2s per active cursed chest on grid
float cursePenalty = MathF.Min(_activeCursedChests * 2f, 6f); // max -6s from curses
// Apply any queued penalties from previous wave
t = MathF.Max(3f, t - _timerPenaltyNextWave - cursePenalty); // cap to 3s
_timerPenaltyNextWave = 0f;
/*
This gives players a proper learning curve — wave 1-5 are relaxed,
6-15 introduce pressure, 15-30 get genuinely hard,
30+ is consistently intense without the arbitrary 4s cliff.
*/
_maxTurnTime = t;
TurnTime = t;
TimerActive = true;
// If milestone overlay is showing, immediately re-pause
if (MilestoneOverlay.Instance?.IsActive == true)
{
TimerActive = false;
Log.Info("StartTurnTimer: milestone active — re-pausing immediately");
}
}
// Returns the current timer as a percentage/fraction of the original allowed turn time (for UI progress bar)
public float TurnTimeFraction => TurnTime / _maxTurnTime;
// ── Wave logic ────────────────────────────────────────────────────────
async void HandleDetonationFinished( int chestsCleared, int coinsEarned )
{
// ------------------------ Handles the End of a Wave ------------------------- //
// If GameMode == Survival
if (Mode == GameMode.Survival)
{
await HandleSurvivalDetonationFinished(chestsCleared, coinsEarned);
return;
}
// Tracks how player behaves in game (how much lives, for how many waves in a row)
_livesAtWaveStart = Lives;
TimerActive = false;
TickHypeMeter(); // Hype meter
int bonus = chestsCleared > 2 ? (chestsCleared - 2) * ChainBonus : 0; // Bonus points if we cleared more than 2 chests.
int total = coinsEarned + bonus; // Total coins earned
// ── Challenge check ──────────────────────────────────────────────
// Must happen BEFORE Score += total so the bonus gets included in this wave
if ( CurrentChallenge != null && !CurrentChallenge.Completed )
{
bool success = CurrentChallenge.Type switch
{
ChallengeType.ClearAtLeast =>
chestsCleared >= CurrentChallenge.Target,
ChallengeType.ChainLength =>
Hand.LastSimResult != null &&
Hand.LastSimResult.Sequence.Any(n => n.Depth > 0),
ChallengeType.ClearGem =>
Hand.LastSimResult != null &&
Hand.LastSimResult.HitChests.Any(kv =>
kv.Value.WillDestroy &&
kv.Value.ChestType == GridManager.ChestType.Gem),
ChallengeType.ClearBombChest =>
Hand.LastSimResult != null &&
Hand.LastSimResult.HitChests.Any(kv =>
kv.Value.WillDestroy &&
kv.Value.ChestType == GridManager.ChestType.BombChest),
ChallengeType.ClearInOneBlast =>
Hand.LastSimResult != null &&
Hand.LastSimResult.Sequence.Count(n => n.Depth == 0) == 1 &&
chestsCleared > 0,
ChallengeType.NoBombType =>
!Hand.PlacedBombTypes.Contains(CurrentChallenge.ForbiddenBomb) &&
chestsCleared >= MinChests,
// ── New types ──
ChallengeType.ClearAll =>
Hand.LastSimResult != null &&
chestsCleared >= CurrentChallenge.Target, // Target set to chest count at wave start
ChallengeType.ChainDepth3 =>
Hand.LastSimResult != null &&
Hand.LastSimResult.Sequence.Any(n => n.Depth >= 3),
ChallengeType.NoDamage =>
chestsCleared >= MinChests && Lives == StartLives, // didn't lose a life
_ => false
};
if (success)
{
CurrentChallenge.Completed = true;
total += CurrentChallenge.BonusCoins;
// check if has FirstChainDouble modifier (wave score x2)
if(HasMod(ModifierId.FirstChainDouble) && !_firstChainUsedThisWave && chestsCleared >= 2)
{
total = (int)(total * 2f);
_firstChainUsedThisWave = true;
}
OnChallengeResolved?.Invoke( true, CurrentChallenge.BonusCoins );
}
else
{
OnChallengeResolved?.Invoke(false, 0);
}
}
// ─────────────────────────────────────────────────────────────────
// Game over stats (track)
TotalChestsCleared += chestsCleared;
int chainLen = Hand.LastSimResult?.Sequence.Count ?? 0;
if ( chainLen > BestChainLength ) BestChainLength = chainLen;
if ( CurrentChallenge?.Completed == true ) ChallengesCompleted++;
// If we didn't clear the minimum required chest, we lose a life.
if ( chestsCleared < MinChests )
{
_consecutiveFailedWaves++; // track player behavior (how he performs)
// I - Bonus Wave Action (random event)
if (_bonusWaveActive)
{
// No life loss during bonus wave
}
// Overload CONSUMABLE — ignore min chest requirement
else if (OverloadActive)
{
OverloadActive = false; // consume it
// show popup
// continue normally
}
else
{
Lives--;
if(Lives <= 0)
{
TriggerGameOver($"You cleared {chestsCleared}/{MinChests} chests..");
return;
}
// Still alive — show which source is keeping them alive
bool hasPetLife = Save?.PetLevel >= 2;
bool hasHeartCons = (Save?.GetConsumableCount(ConsumableId.Heart) ?? 0) > 0;
string lifeMsg = hasPetLife
? "💔 Your pet shields you from defeat!"
: hasHeartCons
? "💔 Heart consumable absorbed the hit!"
: "💔 You lost a life!";
MilestoneOverlay.Instance?.ShowMessage(lifeMsg, Wave);
Sound.Play("Heart_beat");
}
}
else // success
{
_consecutiveFailedWaves = 0;
if (Lives == _livesAtWaveStart)
_consecutiveWavesNoLifeLoss++;
else
_consecutiveWavesNoLifeLoss = 0;
// ── Perfect Clear — cleared every chest on the board ──
bool isPerfect = chestsCleared >= _chestsAtWaveStart && _chestsAtWaveStart > 0;
if (isPerfect)
{
int perfectBonus = Wave * 50; // scales with wave — wave 1=50, wave 10=500
total += perfectBonus;
FragmentsEarned += 1; // +1 frag per perfect wave — small but meaningful
PerfectWaveFragments += 1;
OnScreenFlashRequest?.Invoke("perfect", Wave);
Sound.Play("crowd_applause_big_impact_1"); // reuse a satisfying sound for now
}
}
// II - Bonus Wave Action (random event) : multiply total score
if (_bonusWaveActive)
total = (int)(total * 3f);
Score += total; // updates score
WaveScore += total; // updates wave score
if ( Score > BestScore ) BestScore = Score; // updates NEW! best score
// Reset overload regardless
OverloadActive = false;
// This waits exactly as long as the animations take — no more, no less.
// Buffer before next wave so UI animations complete (+coins msg etc..)
// Wait until cell flash animations finish after last bomb
float delay = Hand.GetPostDetonationWait();
await GameTask.DelaySeconds( delay );
// Move chests that weren't exploded, down.
SlideChestsDown();
// Move speed based on Wave num
int bossMoveCooldown = Wave switch {
>= 200 => 1, // wave 200+: moves every wave
>= 150 => 1, // wave 150+: moves every wave
>= 100 => 2, // wave 100+: moves every 2 waves
>= 50 => 3, // wave 50+: moves every 3 waves
_ => 3 // default: moves every 3 waves
};
// Boss advances toward bottom (after every 'bossMoveCooldown' (i.e: 3) num of wave)
if(_bossAlive && Wave - _bossSpawnedWave >= bossMoveCooldown)
{
MoveBossDown();
_bossSpawnedWave = Wave; // reset counter
}
// After chests have been moved down, we check if that column is full (of chests) if so : Game Over
if ( Grid.AnyChestInLastRow()) { TriggerGameOver("Mehh, why didn't you clear that chest before it reaches the last row!? 💔"); return; }
// -------------------------------- Next Wave ----------------------------- //
// Everything spawns in one frame together
ClearRandomEvent(); // random events cleared
int prevMinChests = MinChests;
Wave++;
WaveScore = 0;
WaveTarget = ComputeWaveTarget( Wave );
MinChests = MinChestsForWave( Wave );
if (MinChests > prevMinChests)
{
MilestoneOverlay.Instance?.ShowMessage($"⚠️ Min chests raised to {MinChests}!", Wave);
Sound.Play("Alert_Danger_MinChest");
}
// ------ Show milestone overlay if this wave is a milestone ------ //
MilestoneOverlay.Instance?.ShowIfMilestone(Wave);
// and wait for milestone overlay to be dismissed before starting timer
if (MilestoneCatalog.GetForWave(Wave) != null)
{
// Wait until overlay is no longer active (give player some time to read)
while (MilestoneOverlay.Instance?.IsActive == true)
await GameTask.DelaySeconds(0.1f);
// Apply grid expansion at milestones
UpdateGridSize(Wave);
}
// ------- If no milestone to show, try showing random event overlay (based on rules inside TryTriggerRandomEvent()) ------ //
// 1. Then check random event (only if no milestone this wave)
if (MilestoneCatalog.GetForWave(Wave) == null)
TryTriggerRandomEvent(Wave);
// 2. Wait for either overlay to dismiss (give player some time to read)
while (MilestoneOverlay.Instance?.IsActive == true)
await GameTask.DelaySeconds(0.1f);
// 3. Apply random event effects AFTER player sees the announcement
ApplyRandomEvent();
// ------------------------------------------------------------------------ //
SpawnChests( ChestsForWave( Wave ) );
TickHypeMeter(); // Hyper meter
RecountCursedChests(); // cursed chest (malus time -2s)
ConsumeOverdrive(); // reset consumable (if used)
// Milestone flash if reached wave 100
if(Wave == 100)
{
MilestoneOverlay.Instance?.ShowEvent(RandomEventType.Wave100, Wave);
Sound.Play("fanfare_100");
await Task.Delay(3000);
Sound.Play("applause_1");
await Task.Delay(2000);
Sound.Play("crowd_4_a");
}
// Milestone flash if reached wave 200
else if(Wave == 200 )
{
MilestoneOverlay.Instance?.ShowEvent(RandomEventType.Wave100, Wave);
Sound.Play("fanfare_200");
await Task.Delay(3000);
Sound.Play("applause_1");
await Task.Delay(2000);
Sound.Play("crowd_4_a");
}
// Milestone flash every 5 waves
else if (Wave % 5 == 0)
{
OnScreenFlashRequest?.Invoke("wave", Wave);
Sound.Play("WaveMilestone");
}
TrySpawnSecretChest(); // secret chest
_chestsAtWaveStart = Grid.AllChestCells().Count; // retrieve all chests (num) for challenge checks
CurrentChallenge = RollChallenge(Wave); // challenge for this wave
_firstChainUsedThisWave = false; // reset mod
Hand.DrawHand( Wave );
TrySpawnBossChest(); // boss chest
StartTurnTimer();
OnStateChanged?.Invoke();
}
// Survival Mode func
async System.Threading.Tasks.Task HandleSurvivalDetonationFinished(int chestsCleared, int coinsEarned)
{
float mult = _survivalElapsed <= _survivalScoreBoostUntil ? SurvivalScoreBoostMult : 1f;
int earned = (int)MathF.Round(coinsEarned * mult);
Score += earned;
if (Score > BestScore) BestScore = Score;
TotalChestsCleared += chestsCleared;
Hand.DrawHand(1);
OnStateChanged?.Invoke();
if (chestsCleared > 0)
{
float delay = Hand.GetPostDetonationWait();
await GameTask.DelaySeconds(delay);
if (!IsRunning) return; // guard in case the run ended while waiting
var (bmin, bmax) = GetSurvivalBaseBatch();
int count = Rng.Next(bmin, bmax + 1);
if (!SurvivalAdvanceAndSpawn(count))
return; // game over already triggered inside
_survivalSpawnTimer = _survivalSpawnInterval; // reset the independent timer so it doesn't double-fire
OnStateChanged?.Invoke();
}
}
void SlideChestsDown()
{
// i.e : 5x5 grid - 0 == top row and Grid.Rows - 1 == 4 (last row - index start at 0)
// so, we want to go from the bottom up, while the 2nd (inner) loop checks every column left -> right.
//
// Why bottom -> top?
// Because we're moving chests downward.
// If we iterated top -> bottom, a chest could move into a row that was already processed this frame.
//
// Then, why -2 ? => because we don't want to move a chest from the LAST row (it'd be out of bounds from the grid)
for ( int r = Grid.Rows - 2; r >= 0; r-- )
for ( int c = 0; c < Grid.Cols; c++ )
{
var cell = Grid.Grid[r, c];
if ( !cell.HasChest ) continue; // ignore cells that don't have any chest (even those exploded)
if ( cell.Chest == GridManager.ChestType.Secret ) continue; // secret chest doesn't slide
if (cell.Chest == GridManager.ChestType.Boss) continue; // boss chest doesn't slide
if (cell.Chest == GridManager.ChestType.Void) continue; // void doesn't slide
int nr = r + 1; // increment row (to go down)
var below = Grid.Grid[nr, c]; // fetch the grid cell a those new coords
// if that cell is empty, then update both cells (the new empty one (above), and the new filled one (below))
if ( below.IsEmpty )
{
below.Chest = cell.Chest;
below.HitsLeft = cell.HitsLeft;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
}
else if ( below.Chest == GridManager.ChestType.Secret )
{
// Normal chest passes through secret chest — swap positions
// Secret chest floats up, normal chest sinks down
below.Chest = cell.Chest;
below.HitsLeft = cell.HitsLeft;
cell.Chest = GridManager.ChestType.Secret;
cell.HitsLeft = 1;
}
}
}
// Game Over (+ update UI OnStateChanged)
void TriggerGameOver(string reason)
{
PrevBestScore = Save?.BestScore ?? 0; // capture BEFORE RecordRun overwrites it (for ov screen)
GameOverReason = reason;
IsRunning = false;
// In RecordRun, before calling Save.RecordRun:
float minutesThisRun = (Time.Now - _sessionStartTime) / 60f;
// base wave + any secret chest bonuses
FragmentsEarned += Wave / 2; // wave 10 = 5 frags, wave 69 = 34 frags — not wave itself
// Level 4 pet bonus — double frags on every milestone wave (10, 20, 30...)
if (Save?.PetLevel >= 4 && Wave % 10 == 0)
FragmentsEarned += Wave / 2; // doubles the wave/2 bonus earned this run
// Best chain length (chain reaction)
int previousChainLength = Save.BestChainLength;
int newBestChainLength = 0;
if (BestChainLength > previousChainLength) newBestChainLength = BestChainLength;
Log.Info($"TriggerGameOver — FragmentsEarned: {FragmentsEarned}, Wave: {Wave}");
// Record this run to persistent save
Save.RecordRun(
score: Score,
wave: Wave,
chestsCleared: TotalChestsCleared,
challengesCompleted: ChallengesCompleted,
fragmentsEarned: FragmentsEarned,
minutesPlayed: minutesThisRun,
bestChainLength: newBestChainLength
);
// Leaderboard results submission
SubmitLeaderboard( Score, Wave, previousChainLength );
TimerActive = false;
OnScreenFlashRequest?.Invoke("gameover", 0);; // red flash screen
_ = ShowGameOverDelayed(); // delay game over
Sound.Play("GameOver_1"); // game over trigger sound
}
async System.Threading.Tasks.Task ShowGameOverDelayed()
{
await GameTask.DelaySeconds(0.8f); // let flash play first
IsGameOver = true;
Sound.Play("GameOver_2");
OnStateChanged?.Invoke();
}
// ── Chest spawning ────────────────────────────────────────────────────
int ChestsForWave(int wave)
{
int _base = wave < 3 ? 3
: wave < 6 ? 4
: wave < 10 ? 5
: wave < 15 ? 6
: wave < 20 ? 7
: wave < 50 ? 8
: wave < 100 ? 9 // grid expands
: wave < 150 ? 10 // grid expands
: wave < 300 ? 11
: 12; // 300+ — near-full 7×7 grid
return Math.Min(_base + (GetDifficultyModifier() / 2), 14);
}
int MinChestsForWave(int wave)
{
int _base = wave < 15 ? 1
: wave < 30 ? 2
: wave < 75 ? 3
: wave < 150 ? 4
: wave < 300 ? 5
: 6; // 300+: must clear 6 minimum
return Math.Min(_base + (GetDifficultyModifier() / 3), 7);
}
// Difficulty increases based on player in-game progression (modifiers)
int GetDifficultyModifier()
{
// 0 = vanilla, 1 = slightly harder, 2 = harder
return Save?.ActiveModifiers?.Count ?? 0;
}
int ComputeWaveTarget( int wave ) => 200 + wave * 150; // Score target for that wave
// Spawn a specific number of chests on grid cells (rule : it spawns chest ONLY in the toppest rows (the first 2 ones))
void SpawnChests( int count )
{
// ── Use pre-rolled positions from Reveal CONSUMABLE, if available ── //
if (_prerolledChests != null)
{
foreach (var (r, c, type) in _prerolledChests)
{
var cell = Grid.Grid[r, c];
if (!cell.IsEmpty) continue; // safety check
cell.Chest = type;
if (type == GridManager.ChestType.Gem && HasMod(ModifierId.GemsOneHit))
cell.HitsLeft = 1;
else
cell.HitsLeft = type == GridManager.ChestType.Gem ? 2 : 1;
}
_prerolledChests = null; // consume and clear
Sound.Play("next_wave_spawn");
return;
}
// ── Tutorial wave overrides ── //
bool isTutorial = TutorialOverlay.Instance?.IsActive == true
|| (Save?.TutorialCompleted == false && Wave == 1);
if (isTutorial && Wave == 1)
{
// Force clear rows 0-1 completely before placing
for (int r = 0; r < 2; r++)
for (int c = 0; c < Grid.Cols; c++)
{
Grid.Grid[r, c].Chest = GridManager.ChestType.None;
Grid.Grid[r, c].HitsLeft = 0;
}
// Guaranteed gem at center top
Grid.Grid[0, 2].Chest = GridManager.ChestType.Gem;
Grid.Grid[0, 2].HitsLeft = 2;
// Fill rest with normals
var emptyW1 = new List<GridManager.Cell>();
for (int r = 0; r < 2; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty) emptyW1.Add(Grid.Grid[r, c]);
Shuffle(emptyW1);
int placed = 0;
foreach (var cell in emptyW1)
{
if (placed >= count - 1) break;
cell.Chest = GridManager.ChestType.Normal;
cell.HitsLeft = 1;
placed++;
}
Sound.Play("next_wave_spawn");
return;
}
if (isTutorial && Wave == 2)
{
// Force a cross of bomb-chests so any bomb placement triggers a 5+ chain
// Pattern on a 5x5 grid:
// row 0: cols 1, 3
// row 1: cols 0, 2, 4
// Leaves cols (0,0),(0,2),(0,4),(1,1),(1,3) as empty for bomb placement
// Any placement adjacent to this pattern chains through all 5 bomb-chests
var pattern = new (int r, int c)[]
{
(0, 1), (0, 3),
(1, 0), (1, 2), (1, 4)
};
foreach (var (r, c) in pattern)
{
var cell = Grid.Grid[r, c];
if (!cell.IsEmpty) continue;
cell.Chest = GridManager.ChestType.BombChest;
cell.HitsLeft = 1;
}
// Fill a few normal chests in row 2 so the grid doesn't look empty
Grid.Grid[2, 1].Chest = GridManager.ChestType.Normal;
Grid.Grid[2, 1].HitsLeft = 1;
Grid.Grid[2, 3].Chest = GridManager.ChestType.Normal;
Grid.Grid[2, 3].HitsLeft = 1;
Sound.Play("next_wave_spawn");
return;
}
// --------------------- Bomb chest cap — scales with grid size and wave ------------ //
int gridCells = Grid.Rows * Grid.Cols;
int existingBombChests = CountBombChestsOnGrid();
// Base cap: roughly 20-30% of grid cells, scales up with grid size
// 5x5=25 cells → cap ~5-6, 6x6=36 → cap ~8-9, 7x7=49 → cap ~12
float capRatio = Wave switch {
< 10 => 0.12f, // early: very few bomb chests allowed
< 20 => 0.16f,
< 35 => 0.20f,
< 60 => 0.24f,
< 100 => 0.28f,
< 200 => 0.32f,
_ => 0.36f // 300+: still capped, just more room on bigger grid
};
int bombChestCap = (int)MathF.Max(2f, gridCells * capRatio);
// ----------------------------------------------------------------------------------- //
// normal spawn
var empty = new List<GridManager.Cell>(); // create a new List of Cells (from GridManager's definition);
// ===================================================================================== //
// =========================== CHEST SPAWN ROW RULES ================================== //
// ===================================================================================== //
// Spawn rows scale with wave — higher waves spawn chests deeper into the grid (new system)
int spawnRows = Wave switch {
>= 200 => 4, // rows 0-3
>= 100 => 3, // rows 0-2
_ => 2 // rows 0-1 (default)
};
for (int r = 0; r < spawnRows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);
// 1. Collect all Cells in the first two top-row of GridManager's (Row 0 & Row 1 only) (old system)
// for ( int r = 0; r < 2; r++ ) // nested loop explanation : a. it scans a first row (r=0)
// for ( int c = 0; c < Grid.Cols; c++ ) // b. in that row, we scan every cols (c < Grid.Cols). Once it's done, it goes up to increment r++ & scan second row (r=1).
// if ( Grid.Grid[r, c].IsEmpty ) empty.Add( Grid.Grid[r, c] ); // 2. adds ONLY grid cells (coords) that are EMPTY
// ===================================================================================== //
// 3. shuffle that, so chest placement are randomized (not picked in order according to the grid's empty cells collection)
Shuffle( empty );
// ── Progressive placement bias ──
// Early waves: cluster chests together so accidental chains happen
// Late waves: spread chests apart so you need to plan
if (Wave >= 8)
{
empty.Sort((a, b) =>
{
float scoreA = GetPlacementScore(a.Row, a.Col);
float scoreB = GetPlacementScore(b.Row, b.Col);
return scoreB.CompareTo(scoreA);
});
}
// ── Modifier: bomb chests near normal chests ──
if (HasMod(ModifierId.BombChestsNearChest))
{
empty.Sort((a, b) => {
int scoreA = AdjacentNormalChestCount(a.Row, a.Col);
int scoreB = AdjacentNormalChestCount(b.Row, b.Col);
return scoreB.CompareTo(scoreA);
});
}
// Modifier: bomb chests always spawn near a normal chest
if(HasMod(ModifierId.BombChestsNearChest))
{
empty.Sort((a, b) => {
int scoreA = AdjacentNormalChestCount(a.Row, a.Col);
int scoreB = AdjacentNormalChestCount(b.Row, b.Col);
return scoreB.CompareTo(scoreA); // descending
});
}
// 4. Min(x,y) is added so that no matter what, it never loops more than the actual free grid Cells available (bc empty)
for(int i = 0; i < MathF.Min(count, empty.Count); i++)
{
var type = RollChestType(Wave);
// Enforce bomb chest cap — demote to normal if over cap
if (type == GridManager.ChestType.BombChest &&
existingBombChests >= bombChestCap)
{
type = GridManager.ChestType.Normal;
}
// NERF bomb chest specifically to avoid too big clusters at higher waves
if (type == GridManager.ChestType.BombChest)
{
if (Wave >= 25 && TooCloseToExistingBombChest(empty[i].Row, empty[i].Col))
type = GridManager.ChestType.Normal; // demote if too close
else
existingBombChests++; // track as we place
}
empty[i].Chest = type;
// 1. Base hits for chest type
int baseHits = type switch
{
GridManager.ChestType.Gem => 2, // 2 hits
GridManager.ChestType.Locked => 3, // 3 hits
_ => 1
};
// Level 3 pet bonus — multi-hit chests spawn pre-damaged
if (Save?.PetLevel >= 3 && baseHits > 1)
baseHits--;
// 2. Modifier — GemsOneHit reduces gem hits to 1 (always wins)
if (type == GridManager.ChestType.Gem && HasMod(ModifierId.GemsOneHit))
baseHits = 1;
// 3. Cursed wave event — adds extra hits on top of final base
// Applied AFTER modifier so GemsOneHit + CursedWave = 2 hits (not 3)
empty[i].HitsLeft = baseHits + ExtraHitsRequired;
}
// Spawn chest sfx
Sound.Play("next_wave_spawn");
}
// Returns a chest type to add, based on Wave number (rules defined below)
GridManager.ChestType RollChestType(int wave)
{
// Gold Rush event — only gems this wave
if (_goldRushActive == true)
return GridManager.ChestType.Gem;
int roll = Rng.Next(100);
if(wave < 4)
// gems appear, no bomb chests yet
return GridManager.ChestType.Normal; // waves 1-3: ALL normal, no gems, no bombs
// return roll < 70 ? GridManager.ChestType.Normal : GridManager.ChestType.Gem;
if(wave < 7)
// first bomb chests — rare, exciting when they appear
return roll < 55 ? GridManager.ChestType.Normal
: roll < 80 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest; // ~20% chance
if(wave < 10)
// bomb chests getting more common
return roll < 45 ? GridManager.ChestType.Normal
: roll < 70 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest; // ~30% chance
if(wave < 20)
// bomb chests common but not dominant
return roll < 45 ? GridManager.ChestType.Normal
: roll < 70 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
if(wave < 35)
// balanced chaos
return roll < 35 ? GridManager.ChestType.Normal
: roll < 60 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
// Wave 75+ — Locked chests appear
if (wave >= 75 && wave < 100)
return roll < 20 ? GridManager.ChestType.Locked
: roll < 45 ? GridManager.ChestType.Normal
: roll < 65 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
// Wave 100+ — Cursed chests appear
if (wave >= 100 && wave < 200)
return roll < 15 ? GridManager.ChestType.Locked
: roll < 25 ? GridManager.ChestType.Cursed
: roll < 45 ? GridManager.ChestType.Normal
: roll < 65 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
// Wave 200+ — Void chests appear
if (wave >= 200)
return roll < 10 ? GridManager.ChestType.Void
: roll < 20 ? GridManager.ChestType.Cursed
: roll < 30 ? GridManager.ChestType.Locked
: roll < 50 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
// late game — bomb chests significant but gems still matter
return roll < 25 ? GridManager.ChestType.Normal
: roll < 50 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest;
}
// Helper for Modifier: bomb chests
// Answer question : "how many normal chests are directly adjacent to this cell?"
int AdjacentNormalChestCount(int r, int c)
{
int count = 0;
int[] dr = {-1, 1, 0, 0}; // row offsets: up, down, stay, stay
int[] dc = {0, 0, -1, 1}; // column offsets: stay, stay, left, right
/* directions (when looping i < 4):
d=0: dr=-1, dc=0 → one row up, same column → up
d=1: dr=1, dc=0 → one row down, same column → down
d=2: dr=0, dc=-1 → same row, one column left → left
d=3: dr=0, dc=1 → same row, one column right → right
*/
for(int d = 0; d < 4; d++)
{
int nr = r + dr[d]; // new row = current row + direction offset
int nc = c + dc[d]; // new col = current col + direction offset
if(Grid.InBounds(nr, nc) && // neighbour exists on the grid
Grid.Grid[nr, nc].Chest == GridManager.ChestType.Normal) // neighbour has a normal chest
count++;
}
return count; // how many of the 4 neighbours are normal chests -> determines a score
}
// ===== RANGE BONUS MODIFIER ==== //
public int GetRangeBonus(GridManager.BombType type)
{
// Sniper bomb +1 range anytime it's equipped
if(type == GridManager.BombType.Sniper && Instance?.HasMod(ModifierId.SniperRange) == true)
return 1;
// Wave 5 and more => we have +1 range on cross bomb
if(type == GridManager.BombType.Cross && Instance?.HasMod(ModifierId.CrossRangeAfterWave5) == true && (Instance?.Wave ?? 0) >= 5)
return 1;
return 0;
}
// --------------------------------------------------------------- //
// ------------------------- SECRET CHEST ------------------------ //
// --------------------------------------------------------------- //
bool HasSecretChestOnGrid()
{
for ( int r = 0; r < Grid.Rows; r++ )
for ( int c = 0; c < Grid.Cols; c++ )
if ( Grid.Grid[r, c].Chest == GridManager.ChestType.Secret ) return true;
return false;
}
void TrySpawnSecretChest()
{
if ( _secretChestsSpawnedThisRun >= 3 ) return; // max 3 per run
if ( HasSecretChestOnGrid() ) return; // max 1 on grid at once
if ( Rng.Next( 100 ) >= 5 ) return; // 8% chance
// Only spawn in rows 2-3 (middle of grid)
// avoids row 0-1 (normal chest spawn zone) and row 4 (bottom / danger zone)
var candidates = new List<GridManager.Cell>();
for ( int r = 2; r < Grid.Rows - 1; r++ )
for ( int c = 0; c < Grid.Cols; c++ )
if ( Grid.Grid[r, c].IsEmpty ) candidates.Add( Grid.Grid[r, c] );
if ( candidates.Count == 0 ) return;
Shuffle( candidates );
var cell = candidates[0];
cell.Chest = GridManager.ChestType.Secret;
cell.HitsLeft = 1;
_secretChestsSpawnedThisRun++;
MilestoneOverlay.Instance?.ShowMessage("✨ A Secret Chest appeared!", Wave);
Sound.Play("secret_chest_alert");
}
void Shuffle<T>( List<T> list )
{
for ( int i = list.Count - 1; i > 0; i-- )
{ int j = Rng.Next( i + 1 ); (list[i], list[j]) = (list[j], list[i]); }
}
// --------------------------------------------------------------- //
// -------------------------- BOSS CHEST ------------------------- //
// --------------------------------------------------------------- //
public bool BossAlive => _bossAlive;
bool _bossAlive = false;
int _bossMaxHp = 5; // set when boss spawns
public int BossMaxHp { get; private set; } = 5;
int _bossSpawnedWave = -1;
void TrySpawnBossChest(int baseBossHp = 1)
{
Log.Info($"TrySpawnBossChest — wave={Wave}, bossAlive={_bossAlive}, wave%10={Wave%10}");
// Random Event - Boss Rush (Active ?)
if (_bossRushActive)
{
// Spawn 3 mini-bosses with 2 HP each in different positions
int spawned = 0;
for (int r = 0; r < Grid.Rows && spawned < 3; r++)
for (int c = 0; c < Grid.Cols && spawned < 3; c++)
if (Grid.Grid[r, c].IsEmpty)
{
Grid.Grid[r, c].Chest = GridManager.ChestType.Boss;
Grid.Grid[r, c].HitsLeft = 2; // mini version
_bossAlive = true;
spawned++;
}
_bossRushActive = false;
OnScreenFlashRequest?.Invoke("boss", Wave);
return;
}
if(Wave % 10 != 0) return; // only on wave 10, 20, 30...
if(_bossAlive) return; // boss already on grid (one)
_bossSpawnedWave = Wave;
// Hp's increments based on wave (difficulty up!)
int bossHp = Wave switch
{
<= 10 => 3, // wave 10 - easy
<= 20 => 5, // wave 20 - less easy
<= 30 => 7, // wave 30 - difficult
<= 40 => 9, // wave 40 - more difficult..
_ => 10 + (Wave / 10) - 4 // i.e : wave 50=11 (incremental)
};
// Compute move cooldown based on current wave for the announcement
int moveCooldown = Wave switch {
>= 200 => 1,
>= 150 => 1,
>= 100 => 2,
>= 50 => 3,
_ => 3
};
// Spawn in center of grid (row 2, col 2 on a 5x5)
var center = Grid.Grid[2, 2];
if (!center.IsEmpty)
{
for (int r = 1; r <= 3; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty)
{
Grid.Grid[r, c].Chest = GridManager.ChestType.Boss;
Grid.Grid[r, c].HitsLeft = bossHp; // ← use correct HP
_bossMaxHp = bossHp;
BossMaxHp = bossHp;
_bossAlive = true;
MilestoneOverlay.Instance?.ShowBoss(Wave, bossHp, moveCooldown); // ← show milestone
OnScreenFlashRequest?.Invoke("boss", Wave);
Sound.Play("boss_alert");
return;
}
return;
}
// event
MilestoneOverlay.Instance?.ShowBoss(Wave, bossHp, moveCooldown);
// Initialize Boss Chest Cell -
center.Chest = GridManager.ChestType.Boss;
center.HitsLeft = bossHp;
_bossMaxHp = bossHp;
BossMaxHp = bossHp;
_bossAlive = true;
OnScreenFlashRequest?.Invoke("boss", Wave); // Screen flash
Sound.Play("boss_alert"); // Spawn chest sfx
}
public void NotifyBossKilled()
{
_bossAlive = false;
int scoreBonus = 500 * (Wave / 10); // wave 10=500, 20=1000, 30=1500...
int fragBonus = 10 + (Wave / 10) * 5; // wave 10=15, 20=20, 30=25...
FragmentsEarned += 15;
BossFragments += 15;
OnBossKilled?.Invoke(scoreBonus, fragBonus);
OnStateChanged?.Invoke();
// Spawn chest sfx
Sound.Play("destroy_boss");
}
void MoveBossDown()
{
// Find boss position first
int bossR = -1, bossC = -1;
for(int r = 0; r < Grid.Rows; r++)
for(int c = 0; c < Grid.Cols; c++)
if(Grid.Grid[r, c].Chest == GridManager.ChestType.Boss)
{ bossR = r; bossC = c; break; }
if(bossR < 0) return; // no boss found
// Boss in last row already — game over
if(bossR >= Grid.Rows - 1)
{
TriggerGameOver("👑 Boss reached the bottom row. Are you gonna give up now ?😏");
return;
}
// Try to move down
var below = Grid.Grid[bossR + 1, bossC];
if(below.IsEmpty)
{
below.Chest = GridManager.ChestType.Boss;
below.HitsLeft = Grid.Grid[bossR, bossC].HitsLeft;
Grid.Grid[bossR, bossC].Chest = GridManager.ChestType.None;
Grid.Grid[bossR, bossC].HitsLeft = 0;
OnStateChanged?.Invoke();
}
else
{
// Cell below occupied — boss pushes chest off the grid (boss is heavy)
// Or alternative: do nothing this wave, try again next time
// yeah for now "do nothing" since pushing chests off is weird
}
}
// ------------------------------------------------------------- //
// ------------------------- CHALLENGES ------------------------ //
// ------------------------------------------------------------- //
WaveChallenge RollChallenge(int wave)
{
var types = new List<ChallengeType>();
// Always available
types.Add(ChallengeType.ClearAtLeast);
types.Add(ChallengeType.ChainLength);
// Unlocks progressively
if(wave >= 3) types.Add(ChallengeType.ClearGem);
if(wave >= 5) types.Add(ChallengeType.NoBombType);
if(wave >= 4 && Grid.AllChestCells().Count >= 3) types.Add(ChallengeType.ClearInOneBlast);
if(wave >= 6) types.Add(ChallengeType.ClearBombChest);
// High wave exclusive challenges
if(wave >= 10) types.Add(ChallengeType.ClearAll); // clear every chest on board
if(wave >= 15) types.Add(ChallengeType.ChainDepth3); // trigger a 3-depth chain
if(wave >= 20) types.Add(ChallengeType.NoDamage); // clear MinChests without losing life
var type = types[Rng.Next(types.Count)];
// Bonus scales with wave AND difficulty tier
int basebonus = 200 + wave * 80;
int bonus = type switch
{
ChallengeType.ClearAll => basebonus * 2,
ChallengeType.ChainDepth3 => (int)(basebonus * 1.5f),
ChallengeType.NoDamage => (int)(basebonus * 1.8f),
ChallengeType.NoBombType => (int)(basebonus * 1.3f),
ChallengeType.ClearInOneBlast => (int)(basebonus * 1.4f),
_ => basebonus
};
// ClearAtLeast target scales with wave
int clearTarget = wave < 5 ? 2 : wave < 10 ? 3 : wave < 20 ? 4 : 5;
return type switch
{
ChallengeType.ClearAtLeast => new WaveChallenge {
Type = type, Target = clearTarget,
Label = $"Clear {clearTarget}+ chests", BonusCoins = bonus },
ChallengeType.ChainLength => new WaveChallenge {
Type = type, Target = 2,
Label = "Trigger a chain reaction", BonusCoins = bonus },
ChallengeType.ClearGem => new WaveChallenge {
Type = type, Target = 1,
Label = "Destroy a Gem chest 💎", BonusCoins = bonus + 100 },
ChallengeType.NoBombType => new WaveChallenge {
Type = type,
ForbiddenBomb = Hand.BombAt(Rng.Next(Hand.HandSize)),
Label = $"Clear without using {BombLabel(Hand.BombAt(0))}", BonusCoins = bonus + 150 },
ChallengeType.ClearInOneBlast => new WaveChallenge {
Type = type, Target = 1,
Label = "Clear chests with 1 bomb only", BonusCoins = bonus + 200 },
ChallengeType.ClearBombChest => new WaveChallenge {
Type = type,
Label = "Detonate a Bomb Chest 💣", BonusCoins = bonus + 100 },
ChallengeType.ClearAll => new WaveChallenge {
Type = type, Target = _chestsAtWaveStart,
Label = "Clear ALL chests this wave", BonusCoins = bonus },
ChallengeType.ChainDepth3 => new WaveChallenge {
Type = type,
Label = "Trigger a 3-chain reaction", BonusCoins = bonus },
ChallengeType.NoDamage => new WaveChallenge {
Type = type,
Label = "Clear without losing a life", BonusCoins = bonus },
_ => new WaveChallenge {
Type = ChallengeType.ClearAtLeast, Target = 2,
Label = "Clear 2+ chests", BonusCoins = basebonus }
};
}
static string BombLabel( 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 ⛓",
_ => "?"
};
// Changes grid color every x waves and then to add a sense of progression
public string WaveGridColor => (Wave / 5) switch
{
0 => "#0d0820", // waves 1-4: deep purple
1 => "#08101f", // waves 5-9: deep blue
2 => "#081a12", // waves 10-14: deep teal
3 => "#1a0808", // waves 15-19: deep red
4 => "#1a1208", // waves 20-24: deep amber
_ => "#0d0820" // cycle back
};
public string WaveParticleColor
{
get
{
int tier = (Wave / 5) % 6;
return tier switch {
0 => "rgba(167,139,250,",
1 => "rgba(52,211,153,",
2 => "rgba(251,191,36,",
3 => "rgba(244,114,182,",
4 => "rgba(96,165,250,",
_ => "rgba(251,146,60,"
};
}
}
// Happens to open Secret chests on GameOver screen
void RollSecretChestReward()
{
int roll = Rng.Next( 100 );
SecretChestReward reward;
if ( roll < 70 ) // Common — coins
{
int coins = Rng.Next( 50, 151 );
reward = new SecretChestReward {
Icon = "🪙", Label = $"+{coins} coins",
Coins = coins, Fragments = 0, IsRare = false
};
Score += coins;
}
else if ( roll < 95 ) // Uncommon — fragments
{
int frags = Rng.Next( 3, 9 );
reward = new SecretChestReward {
Icon = "💠", Label = $"+{frags} fragments",
Coins = 0, Fragments = frags, IsRare = false
};
FragmentsEarned += frags;
Log.Info($"Secret reward rolled: +{frags} frags, FragmentsEarned now: {FragmentsEarned}");
}
else // Rare — run modifier
{
reward = new SecretChestReward {
Icon = "🌟", Label = "RARE DROP!",
Coins = 0, Fragments = 15, IsRare = true
};
FragmentsEarned += 15;
}
SecretChestRewards.Add( reward );
}
// -------------------------------------------------------- //
// ----------------- Return to Main Menu ------------------ //
// -------------------------------------------------------- //
public void ReturnToMenu()
{
OnGameReturned?.Invoke();
GameStarted = false;
IsGameOver = false;
IsRunning = false;
OnStateChanged?.Invoke();
}
// -------------------------------------------------------- //
// ----------------- TUTORIAL FOR 1st GAME ---------------- //
// -------------------------------------------------------- //
public void PauseTimer()
{
TimerActive = false;
Log.Info($"PauseTimer called — TimerActive now false");
}
public void ResumeTimer() => TimerActive = true;
// ────────────────────────────────────────────────────────────────
// ────────────────────── Consumable effects ──────────────────────
// ────────────────────────────────────────────────────────────────
// FuseExtension — adds time to current wave
public void AddTime(float seconds)
{
TurnTime = MathF.Min(TurnTime + seconds, _maxTurnTime); // cap at max, don't extend max (so it visually change the bar)
OnStateChanged?.Invoke();
}
// ColumnSweep — destroys all chests in the most dangerous column
public void SweepDangerColumn()
{
// Find column with most chests
int bestCol = -1, bestCount = 0;
for (int c = 0; c < Grid.Cols; c++)
{
int count = 0;
for (int r = 0; r < Grid.Rows; r++)
if (Grid.Grid[r, c].HasChest) count++;
if (count > bestCount) { bestCount = count; bestCol = c; }
}
if (bestCol < 0) return;
for (int r = 0; r < Grid.Rows; r++)
{
var cell = Grid.Grid[r, bestCol];
if (!cell.HasChest) continue;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
TotalChestsCleared++;
}
OnStateChanged?.Invoke();
}
// Heart consumable
public bool UseHeartConsumable()
{
if (Lives >= MaxLives) return false;
if (Save?.GetConsumableCount(ConsumableId.Heart) <= 0) return false;
Save.ConsumeConsumable(ConsumableId.Heart);
Lives++;
OnStateChanged?.Invoke();
return true;
}
// ───────── Overdrive ───────────────────────────────────────────
// — next detonation multiplier is doubled
public bool OverdriveActive { get; private set; } = false;
public void ActivateOverdrive()
{
OverdriveActive = true;
OnStateChanged?.Invoke();
}
public void ConsumeOverdrive() // called in HandleDetonationFinished
{
OverdriveActive = false;
}
// ───────── ChainNuke (removed for now) ──────────────────────────
// ChainNuke — force-links all placed bombs to a chain reaction
public void ActivateChainNuke()
{
if (!Hand.AnyPlaced) return;
TimerActive = false;
Hand.ExecuteDetonateWithNuke();
}
// ── Reveal consumable ─────────────────────────────────────────
// Reveal — shows next wave chest positions for 3 seconds
public bool RevealActive { get; private set; } = false;
public List<(int r, int c, GridManager.ChestType type)> RevealedPositions { get; private set; } = new();
// Stored pre-rolled chest positions from Reveal consumable
// null = no reveal active, use normal spawn
List<(int r, int c, GridManager.ChestType type)> _prerolledChests = null;
public async System.Threading.Tasks.Task RevealNextWave()
{
RevealedPositions.Clear();
_prerolledChests = new();
var empty = new List<GridManager.Cell>();
for (int r = 0; r < 2; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);
Shuffle(empty);
int count = ChestsForWave(Wave + 1);
for (int i = 0; i < MathF.Min(count, empty.Count); i++)
{
var type = RollChestType(Wave + 1);
var cell = empty[i];
_prerolledChests.Add((cell.Row, cell.Col, type));
RevealedPositions.Add((cell.Row, cell.Col, type));
}
RevealActive = true;
OnStateChanged?.Invoke();
await GameTask.DelaySeconds(3f);
RevealActive = false;
// Keep _prerolledChests — it gets consumed in SpawnChests next wave
OnStateChanged?.Invoke();
}
// ── ClusterDrop Consumable ─────────────────────────────────────────
// ClusterDrop — spawns 3 bomb chests in optimal positions
public void ActivateClusterDrop()
{
// Find empty cells in rows 0-3, score by adjacency to existing chests
var candidates = new List<(int r, int c, int score)>();
for (int r = 0; r < Grid.Rows - 1; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty)
{
int score = AdjacentNormalChestCount(r, c);
candidates.Add((r, c, score));
}
// Sort by adjacency score descending — place near existing chests
candidates.Sort((a, b) => b.score.CompareTo(a.score));
int placed = 0;
foreach (var (r, c, _) in candidates)
{
if (placed >= 3) break;
var cell = Grid.Grid[r, c];
if (!cell.IsEmpty) continue;
cell.Chest = GridManager.ChestType.BombChest;
cell.HitsLeft = 1;
placed++;
}
Sound.Play("next_wave_spawn");
OnStateChanged?.Invoke();
}
// ── Overload Consumable ─────────────────────────────────────────
// Overload — ignore min chest requirement this wave
public bool OverloadActive { get; private set; } = false;
public void ActivateOverload()
{
OverloadActive = true;
OnStateChanged?.Invoke();
}
// ── Random Events ──────────────────────────────────────────── //
public RandomEventType? CurrentRandomEvent { get; private set; } = null;
public bool RandomEventActive => CurrentRandomEvent.HasValue;
float _timerFrozenFor = 0;
bool _doubleDropActive = false;
bool _goldRushActive = false;
bool _bonusWaveActive = false;
bool _ghostBombsActive = false;
bool _mysteryWaveActive = false;
bool _bombJamActive = false;
bool _bossRushActive = false;
int _extraHitsRequired = 0;
bool _chainMasterFiredThisRun = false;
public bool IsDoubleDropActive => _doubleDropActive;
public bool IsGoldRushActive => _goldRushActive;
public bool IsBonusWaveActive => _bonusWaveActive;
public bool IsGhostBombsActive => _ghostBombsActive;
public bool IsMysteryWaveActive => _mysteryWaveActive;
public bool IsBombJamActive => _bombJamActive;
public int ExtraHitsRequired => _extraHitsRequired;
// ── Player Behavior tracking ─────────────────────────────────────────
int _consecutiveWavesNoLifeLoss = 0;
int _livesAtWaveStart = 0;
int _consecutiveFailedWaves = 0;
// Wave ranges that guarantee a random event fires
static readonly (int min, int max)[] EventWindows = {
(8, 12), (18, 23), (30, 40), (50, 65),
(80, 100), (120,145), (170,200), (240,280),
(320,370), (420,480)
};
int _lastEventWave = 0;
// Mirror random event type
bool _mirrorWaveActive = false;
public bool IsMirrorWaveActive => _mirrorWaveActive;
void TryTriggerRandomEvent(int wave)
{
// Check if we're in an event window and haven't fired one recently
foreach (var (min, max) in EventWindows)
{
if (wave < min || wave > max) continue;
if (_lastEventWave >= min) continue; // already fired in this window
// Roll — 60% chance per wave within the window
// Guaranteed on last wave of window
bool guaranteed = wave == max;
if (!guaranteed && Rng.Next(100) >= 60) continue;
FireRandomEvent(wave);
_lastEventWave = wave;
return;
}
// Player behavior triggers
TryTriggerBehaviorEvent(wave);
}
void FireRandomEvent(int wave)
{
// Weight positive vs negative based on wave (and player's lives)
var pool = BuildEventPool(wave);
var type = pool[Rng.Next(pool.Count)];
CurrentRandomEvent = type;
// Show milestone overlay with event description
MilestoneOverlay.Instance?.ShowEvent(type, wave);
}
List<RandomEventType> BuildEventPool(int wave)
{
var pool = new List<RandomEventType>();
// Always available positives
pool.Add(RandomEventType.BonusWave);
pool.Add(RandomEventType.FragmentRain);
pool.Add(RandomEventType.TimerFreeze);
pool.Add(RandomEventType.DoubleDrop);
pool.Add(RandomEventType.ChestBonanza);
// Unlocks as waves progress
if (wave >= 15) pool.Add(RandomEventType.GoldRush);
if (wave >= 25) pool.Add(RandomEventType.MysteryWave);
if (wave >= 30) pool.Add(RandomEventType.GhostBombs);
if (wave >= 45) pool.Add(RandomEventType.MirrorWave);
// Negatives unlock later — player needs to be ready
if (wave >= 20) pool.Add(RandomEventType.CursedWave);
if (wave >= 30) pool.Add(RandomEventType.SpeedRound);
//if (wave >= 40) pool.Add(RandomEventType.FogOfWar);
if (wave >= 50) pool.Add(RandomEventType.BombJam);
// Interesting ones
if (wave >= 35) pool.Add(RandomEventType.BossRush);
if (wave >= 45) pool.Add(RandomEventType.MirrorWave);
// If player is struggling (lost life this wave), bias toward positive
if (Lives < StartLives)
{
pool.Add(RandomEventType.BonusWave);
pool.Add(RandomEventType.TimerFreeze);
pool.Add(RandomEventType.FragmentRain);
}
return pool;
}
void TryTriggerBehaviorEvent(int wave)
{
// Chain Master — player just did a massive chain
// (checked after detonation in HandleDetonationFinished)
// Lucky Streak — 5 waves no life loss
// etc — these fire instantly, no window needed
// ── Lucky Streak — 5 consecutive waves without losing a life ── //
// Only fires once per streak, not every wave after 5
if (_consecutiveWavesNoLifeLoss == 5)
{
_consecutiveWavesNoLifeLoss = 0; // reset counter
if(!_luckyStreakFiredThisRun)
{
//MilestoneOverlay.Instance?.ShowEvent(RandomEventType.FragmentRain, wave);
_luckyStreakFiredThisRun = true;
_lastEventWave = wave;
CurrentRandomEvent = RandomEventType.FragmentRain;
ApplyRandomEvent(); // instant effect
MilestoneOverlay.Instance?.ShowMessage("🍀 Lucky Streak! +5 💠", Wave);
return;
}
}
// ── Chain Master — cleared 8+ chests last detonation ── //
// -> Only fires once, then resets
if ((Hand?.LastSimResult?.ClearedCount ?? 0) >= 8 && !_chainMasterFiredThisRun)
{
_chainMasterFiredThisRun = true;
MilestoneOverlay.Instance?.ShowEvent(RandomEventType.DoubleDrop, wave);
// Don't apply yet — applied after overlay dismissed
_lastEventWave = wave;
CurrentRandomEvent = RandomEventType.DoubleDrop;
Sound.Play("DoubleDrop_Coins");
return;
}
// ── Mercy Wave — failed MinChests 2 waves in a row ── //
// (tracked separately — see below)
// => struggling player gets a bonus ── //
if (_consecutiveFailedWaves >= 2)
{
_consecutiveFailedWaves = 0;
MilestoneOverlay.Instance?.ShowEvent(RandomEventType.BonusWave, wave);
Sound.Play("BonusWave");
// Don't apply yet — try apply on next wave
_lastEventWave = wave;
_bonusWaveActive = true;
CurrentRandomEvent = RandomEventType.BonusWave;
}
}
// Call at start of each wave in HandleDetonationFinished
void ApplyRandomEvent()
{
if (!CurrentRandomEvent.HasValue) return;
switch (CurrentRandomEvent.Value)
{
case RandomEventType.FragmentRain:
FragmentsEarned += 5;
BonusEventFragments += 5;
break;
case RandomEventType.MysteryWave:
_mysteryWaveActive = true;
break;
case RandomEventType.TimerFreeze:
_timerFrozenFor = 8f; // count down in OnUpdate
break;
case RandomEventType.DoubleDrop:
_doubleDropActive = true;
break;
case RandomEventType.CursedWave:
_extraHitsRequired = 1; // all chests need +1 hit
break;
case RandomEventType.SpeedRound:
TurnTime *= 0.5f;
_maxTurnTime *= 0.5f;
break;
case RandomEventType.ChestBonanza:
SpawnChests(3); // 3 extra chests on top of normal
break;
case RandomEventType.GoldRush:
_goldRushActive = true; // flag checked in SpawnChests
break;
case RandomEventType.BombJam:
_bombJamActive = true;
break;
case RandomEventType.BossRush:
_bossRushActive = true; // flag checked in TrySpawnBossChest
break;
// BonusWave event — all chests worth 3×
// in ExecuteDetonate() -> float bonusMult = ChainReactionGame.Instance?.IsBonusWaveActive == true ? 3f : 1f;
case RandomEventType.BonusWave: // if player failed chests requirements 2 times in a row
_bonusWaveActive = true;
break;
case RandomEventType.MirrorWave:
_mirrorWaveActive = true;
ApplyMirrorWave();
break;
}
}
// Reset event at end of wave
void ClearRandomEvent()
{
CurrentRandomEvent = null;
_doubleDropActive = false;
_goldRushActive = false;
_bonusWaveActive = false;
_ghostBombsActive = false;
_mysteryWaveActive = false;
_bombJamActive = false;
_bossRushActive = false;
_mirrorWaveActive = false;
_extraHitsRequired = 0;
_timerFrozenFor = 0f;
// note : here we don't reset _consecutiveWavesNoLifeLoss or _consecutiveFailedWaves
// — those track across waves intentionally
}
// Mirror event
void ApplyMirrorWave()
{
// Mirror all existing chest positions left ↔ right
// Step 1 — SNAPSHOT: photograph all positions before touching anything
// i.e : snapshot = [ (1, 0, Normal, 1), (1, 4, Gem, 2) ]
var snapshot = new List<(int r, int c, GridManager.ChestType type, int hits)>();
// Snpashot : only retrieve cells that have chest
for (int r = 0; r < Grid.Rows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].HasChest)
snapshot.Add((r, c, Grid.Grid[r, c].Chest, Grid.Grid[r, c].HitsLeft));
// Step 2 — CLEAR: wipe the grid clean
// i.e Grid[1,0].Chest = None
// Grid[1,4].Chest = None
// Grid is now empty at those positions.
foreach (var (r, c, _, _) in snapshot)
{
Grid.Grid[r, c].Chest = GridManager.ChestType.None;
Grid.Grid[r, c].HitsLeft = 0;
}
// Step 3 — PLACE MIRRORED: put each chest at its mirrored column
// (0,1) → mirroredC = (4 - 1) - 1 = 3 → place at (0,3)
// (0,3) → mirroredC = (4 - 1) - 3 = 1 → place at (0,1)
// Result: chests swapped left ↔ right
foreach (var (r, c, type, hits) in snapshot)
{
int GridSize = Grid.Cols - 1;
int mirroredC = GridSize - c; // i.e : (5-1) - 0 = 4
var target = Grid.Grid[r, mirroredC]; // ← was just cleared, so IsEmpty = true
// The target.IsEmpty guard handles collision (it's from the grid cell) —
// if two chests mirror to the same cell (e.g. both at col 2 center),
// the second one just skips.
if (target.IsEmpty) // i.e : Grid[1, 4].Chest = Normal ✅ -> it's a ref to the grid cell btw
{
target.Chest = type;
target.HitsLeft = hits;
}
}
// What mirroring does :
/* col 0 → 4 (leftmost ↔ rightmost)
col 1 → 3
col 2 → 2 (center stays center)
col 3 → 1
col 4 → 0
*/
OnStateChanged?.Invoke();
}
void UpdateGridSize(int wave)
{
int newRows = wave switch {
>= 100 => 7,
>= 50 => 6,
>= 20 => 6, // ← was 150, now 20
_ => 5
};
int newCols = wave switch {
>= 100 => 7,
>= 50 => 6,
>= 20 => 5, // ← 6 rows but still 5 cols — feels wider not just taller
_ => 5
};
if (newRows == Grid.Rows && newCols == Grid.Cols) return;
Grid.ResizeGrid(newRows, newCols);
}
/// <summary>
/// Void chest (new chest)
/// </summary>
/// <param name="row"></param>
/// <param name="col"></param>
public void TriggerVoidExplosion(int row, int col)
{
// Remove all chests in 2×2 area around the void chest
for (int r = row - 1; r <= row + 1; r++)
for (int c = col - 1; c <= col + 1; c++)
{
if (!Grid.InBounds(r, c)) continue;
var cell = Grid.Grid[r, c];
if (!cell.HasChest) continue;
if (cell.Chest == GridManager.ChestType.Void) continue; // skip self
// Flat 50 score — no multiplier, no bonus
// Void clears are strategic, not skill-based
Score += 50;
OnChestDestroyed(r, c, 50, 1f);
TotalChestsCleared++;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
}
//ScreenFlash.Instance?.WaveStart(Wave); // reuse flash for effect
Sound.Play("chest_break");
OnStateChanged?.Invoke();
}
// used for cursed chest so far
public void QueueTimerPenalty(float seconds)
{
_timerPenaltyNextWave += seconds;
}
int _activeCursedChests = 0;
public int ActiveCursedChests => _activeCursedChests;
void RecountCursedChests()
{
_activeCursedChests = 0;
for (int r = 0; r < Grid.Rows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].Chest == GridManager.ChestType.Cursed)
_activeCursedChests++;
}
// * -------------------------------------------------------------------- //
// * ------------------------- Hype meter state ------------------------- //
// * -------------------------------------------------------------------- //
float _hypeMeter = 0f; // 0.0 to 1.0
bool _hypeMaxActive = false; // temp multiplier active
bool _hypeTriggeredThisWave = false; // prevent double trigger
public float HypeMeter => _hypeMeter;
public bool HypeMaxActive => _hypeMaxActive;
public void AddHype(int projectedCleared)
{
if (_hypeMaxActive) return; // draining, not filling
float gain = projectedCleared switch {
>= 8 => 0.40f,
>= 6 => 0.30f,
>= 4 => 0.20f,
>= 2 => 0.10f,
_ => 0f
};
_hypeMeter = MathF.Min(1f, _hypeMeter + gain);
if (_hypeMeter >= 1f && !_hypeTriggeredThisWave)
{
_hypeTriggeredThisWave = true;
TriggerHypeMax();
}
OnStateChanged?.Invoke();
}
void TriggerHypeMax()
{
_hypeMaxActive = true;
// PopupLayer fires from GameHud — just set state here
OnStateChanged?.Invoke();
}
// Called at start of each wave in HandleDetonationFinished
public void TickHypeMeter()
{
// Don't drain here — OnUpdate handles continuous drain
// Just reset the trigger flag so hype max can fire again next time
if (!_hypeMaxActive)
_hypeTriggeredThisWave = false;
}
// * -------------------------------------------------------------------- //
// * ------------------ Smart Chest Placement Difficulty ---------------- //
// * -------------------------------------------------------------------- //
// ── How desirable is this cell for placement ──
float GetPlacementScore(int r, int c)
{
float score = 0f;
int centerC = Grid.Cols / 2;
float distFromCenter = MathF.Abs(c - centerC);
int adjacentCount = AdjacentNormalChestCount(r, c);
if (Wave < 8)
{
// Very easy — cluster everything in center
score += (Grid.Cols - distFromCenter) * 3f;
score += adjacentCount * 4f;
}
else if (Wave < 20)
{
// Easy — slight center bias, some clustering
score += (Grid.Cols - distFromCenter) * 1.5f;
score += adjacentCount * 2f;
score += Rng.Next(4); // noise keeps it feeling natural
}
else if (Wave < 50)
{
// Was too weak — double the penalties
score += distFromCenter * 1.5f;
score -= adjacentCount * 2f; // was 0.5f — this is what breaks clusters
score += Rng.Next(3);
}
else if (Wave < 100)
{
// Hard — prefer spread, penalize clustering
score += distFromCenter * 2f;
score -= adjacentCount * 1.5f;
score += Rng.Next(2);
}
else if (Wave < 200)
{
// Very hard — edges preferred, clustering punished
bool isEdge = c == 0 || c == Grid.Cols - 1;
score += distFromCenter * 3f;
score -= adjacentCount * 2.5f;
if (isEdge) score += 2f;
score += Rng.Next(2);
}
else
{
// Wave 200+ — maximum spread, corners and edges strongly preferred
bool isEdge = c == 0 || c == Grid.Cols - 1;
bool isCorner = isEdge && (r == 0 || r == Grid.Rows - 1);
score += distFromCenter * 4f;
score -= adjacentCount * 3f;
if (isEdge) score += 3f;
if (isCorner) score += 2f;
// No noise at 200+ — placement is deliberately punishing
}
return score;
}
bool TooCloseToExistingBombChest(int r, int c, int minDist = 2)
{
for (int row = 0; row < Grid.Rows; row++)
for (int col = 0; col < Grid.Cols; col++)
{
if (Grid.Grid[row, col].Chest != GridManager.ChestType.BombChest) continue;
if (MathF.Abs(row - r) <= minDist && MathF.Abs(col - c) <= minDist)
return true;
}
return false;
}
// ── Count existing bomb chests across the entire grid ──
int CountBombChestsOnGrid()
{
int count = 0;
for (int r = 0; r < Grid.Rows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].Chest == GridManager.ChestType.BombChest)
count++;
return count;
}
// * -------------------------------------------------------------------- //
// * ------------------------- GAME MODE SURVIVAL ----------------------- //
// * -------------------------------------------------------------------- //
float _survivalSlideTimer = 0f;
float _survivalSlideInterval = 3.0f; // starts slower than spawn
int _survivalPhase = 0;
bool _survivalBombChestBoost = false;
bool _survivalPhaseInitialized = false;
void TickSurvival()
{
if (_survivalSpawnFrozen || MilestoneOverlay.Instance?.IsActive == true)
return;
_survivalElapsed += Time.Delta;
int minute = (int)(_survivalElapsed / 60f);
if (minute != _survivalPhase || !_survivalPhaseInitialized)
{
_survivalPhase = minute;
_survivalPhaseInitialized = true;
OnSurvivalPhaseChange(minute);
}
// ── Empty-board watchdog — caps dead-air time without touching pacing while board is occupied ──
bool boardEmpty = Grid.AllChestCells().Count == 0;
if (boardEmpty)
{
if (_survivalEmptySince < 0f)
_survivalEmptySince = _survivalElapsed;
else if (_survivalElapsed - _survivalEmptySince >= SurvivalEmptyGraceSeconds)
_survivalSpawnTimer = 0f; // force the next spawn now
}
else
{
_survivalEmptySince = -1f;
}
_survivalSpawnTimer -= Time.Delta;
if (_survivalSpawnTimer <= 0f)
{
_survivalSpawnTimer = _survivalSpawnInterval;
SurvivalSpawnWave();
}
int currentMinute = (int)(_survivalElapsed / 60f);
if (currentMinute > _survivalLastMilestoneMinute)
{
_survivalLastMilestoneMinute = currentMinute;
TriggerSurvivalMilestone(currentMinute);
}
}
void OnSurvivalPhaseChange(int minute)
{
int phaseInCycle = minute % 6;
_survivalPhaseIntervalMult = 1f;
_survivalPhaseBatchBonus = 0;
_survivalBombChestBoost = false; // add this — was never reset before
switch (phaseInCycle)
{
case 0:
break;
case 1:
break;
case 2:
_survivalPhaseIntervalMult = 0.94f;
_survivalBombChestBoost = true;
MilestoneOverlay.Instance?.ShowMessage("💣 More bomb chests incoming!", 0, 3f);
break;
case 3:
SpawnSurvivalMiniBoss();
break;
case 4:
_survivalGoldRushMinute = true;
MilestoneOverlay.Instance?.ShowMessage("💎 Gold Rush! Gems incoming!", 0, 3f);
break;
case 5:
_survivalScoreBoostUntil = _survivalElapsed + 10f;
MilestoneOverlay.Instance?.ShowMessage("⭐ 2x SCORE for 10 seconds!", 0, 3f);
break;
}
}
void SpawnSurvivalMiniBoss()
{
// find an empty top-row cell
var empty = new List<GridManager.Cell>();
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[0, c].IsEmpty) empty.Add(Grid.Grid[0, c]);
if (empty.Count == 0) return;
var cell = empty[Rng.Next(empty.Count)];
cell.Chest = GridManager.ChestType.Boss;
cell.HitsLeft = 4; // tune HP
MilestoneOverlay.Instance?.ShowMessage("👑 A boss chest appeared! Clear it before it reaches the bottom!", 0, 4f);
Sound.Play("boss_alert");
}
void SurvivalSpawnWave()
{
_survivalFakeWave = 1 + (SurvivalSpawnCount / 12);
var (baseMin, baseMax) = GetSurvivalBaseBatch();
int min = Math.Max(2, baseMin + _survivalPhaseBatchBonus);
int max = Math.Max(min, baseMax + _survivalPhaseBatchBonus);
int count = Rng.Next(min, max + 1);
if (!SurvivalAdvanceAndSpawn(count))
return;
_survivalSpawnInterval = MathF.Max(
SurvivalIntervalFloor * 0.8f,
GetSurvivalBaseInterval() * _survivalPhaseIntervalMult
);
OnStateChanged?.Invoke();
}
// Survival-only: slide chests down, and if a chest lands on a bomb cell, trigger it
List<(int r, int c)> _survivalTriggeredBombs = new();
void SurvivalSlideAndCheckBombTriggers()
{
_survivalTriggeredBombs.Clear();
for (int r = Grid.Rows - 2; r >= 0; r--)
for (int c = 0; c < Grid.Cols; c++)
{
var cell = Grid.Grid[r, c];
if (!cell.HasChest) continue;
if (cell.Chest == GridManager.ChestType.Secret) continue;
if (cell.Chest == GridManager.ChestType.Boss) continue;
if (cell.Chest == GridManager.ChestType.Void) continue;
int nr = r + 1;
var below = Grid.Grid[nr, c];
// Chest slides into a cell with a placed bomb — mark for trigger
if (below.HasBomb && !below.HasChest)
{
below.Chest = cell.Chest;
below.HitsLeft = cell.HitsLeft;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
_survivalTriggeredBombs.Add((nr, c));
}
else if (below.IsEmpty)
{
below.Chest = cell.Chest;
below.HitsLeft = cell.HitsLeft;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
}
}
}
// Thin wrappers so we never touch the real Wave-driven side effects (grid resize, boss spawn, etc.)
int SurvivalChestsForWave(int fakeWave) => ChestsForWave(fakeWave); // reuse existing curve directly — no side effects in that method
void SurvivalSpawnChests(int count)
{
var empty = new List<GridManager.Cell>();
int spawnRows = _survivalFakeWave switch { >= 200 => 4, >= 100 => 3, _ => 2 };
for (int r = 0; r < spawnRows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);
Shuffle(empty);
for (int i = 0; i < MathF.Min(count, empty.Count); i++)
{
var type = RollSurvivalChestType();
empty[i].Chest = type;
int baseHits = type switch {
GridManager.ChestType.Gem => 2,
GridManager.ChestType.Locked => 3,
_ => 1
};
empty[i].HitsLeft = baseHits;
}
_survivalGoldRushMinute = false; // one-shot, consumed after this batch
Sound.Play("next_wave_spawn");
}
// Survival-specific chest roll — small bomb-chest chance from the very start,
// scales up gently as _survivalFakeWave climbs
GridManager.ChestType RollSurvivalChestType()
{
if (_survivalGoldRushMinute)
return GridManager.ChestType.Gem;
int roll = Rng.Next(100);
int bombBoost = _survivalBombChestBoost ? 15 : 0; // shifts 15% of roll odds toward bomb chests
if (_survivalFakeWave <= 2)
return roll < (75 - bombBoost) ? GridManager.ChestType.Normal
: roll < (90 - bombBoost) ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest; // 10% → 25% when boosted
if (_survivalFakeWave <= 6)
return roll < (55 - bombBoost) ? GridManager.ChestType.Normal
: roll < (78 - bombBoost) ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest; // 22% → 37% when boosted
// Late game — shared wave-mode curve already scales bomb chest odds with wave;
// boost doesn't stack here to avoid runaway density at high fakeWave
return RollChestType(_survivalFakeWave);
}
async void TriggerSurvivalMilestone(int minute)
{
// Cycle through reward types so runs feel varied minute to minute
string title;
string desc;
switch (minute % 4)
{
case 1: // extra bomb slot, capped
if (SurvivalBonusBombSlots < MaxSurvivalBonusSlots)
{
SurvivalBonusBombSlots++;
Hand.HandSize++;
title = "💣 EXTRA BOMB SLOT!";
desc = $"Your hand grows to {Hand.HandSize} bombs.";
}
else
{
// fallback once capped — small score burst instead
Score += 500;
title = "⚡ +500 BONUS!";
desc = "Max bomb slots reached — score burst instead.";
}
break;
case 2: // brief breather — spawn pauses for 3s
_survivalSpawnTimer += 3f;
title = "🌬️ BREATHER!";
desc = "Spawns pause briefly. Catch your breath.";
break;
case 3: // fragment bonus, scales with minute
int frags = 3 + minute;
FragmentsEarned += frags;
title = $"💠 +{frags} FRAGMENTS!";
desc = "Bonus fragments for surviving this long.";
break;
default: // score multiplier moment — clear a free row
SurvivalClearRandomRow();
title = "🧹 ROW CLEARED!";
desc = "A random row was wiped clean for you.";
break;
}
MilestoneOverlay.Instance?.ShowMessage($"⏱️ {minute} min survived! {title}", 0, 5f);
Sound.Play("WaveMilestone");
OnStateChanged?.Invoke();
// Wait for overlay to be dismissed (same pattern wave mode uses)
while (MilestoneOverlay.Instance?.IsActive == true)
await GameTask.DelaySeconds(0.1f);
// Small grace buffer after dismiss so the player has a beat before spawns resume
await GameTask.DelaySeconds(0.4f);
_survivalSpawnFrozen = false;
_survivalSpawnTimer = _survivalSpawnInterval; // don't let a stale short timer fire instantly
}
void SurvivalClearRandomRow()
{
var rowsWithChests = new List<int>();
for (int r = 0; r < Grid.Rows; r++)
for (int c = 0; c < Grid.Cols; c++)
if (Grid.Grid[r, c].HasChest) { rowsWithChests.Add(r); break; }
if (rowsWithChests.Count == 0) return;
int row = rowsWithChests[Rng.Next(rowsWithChests.Count)];
for (int c = 0; c < Grid.Cols; c++)
{
var cell = Grid.Grid[row, c];
if (!cell.HasChest) continue;
Score += 50;
TotalChestsCleared++;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
}
}
GridManager.ChestType RollSurvivalChestTypeEarly()
{
int roll = Rng.Next(100);
return roll < 75 ? GridManager.ChestType.Normal
: roll < 90 ? GridManager.ChestType.Gem
: GridManager.ChestType.BombChest; // ~10% from the very start
}
static float Lerp(float a, float b, float t) => a + (b - a) * t;
float GetSurvivalBaseInterval()
{
float t = MathF.Max(0f, MathF.Min(1f, _survivalElapsed / 60f / SurvivalRampMinutes));
return Lerp(SurvivalIntervalStart, SurvivalIntervalFloor, t);
}
(int min, int max) GetSurvivalBaseBatch()
{
float t = MathF.Max(0f, MathF.Min(1f, _survivalElapsed / 60f / SurvivalRampMinutes));
int max = (int)MathF.Round(Lerp(SurvivalBatchMaxStart, SurvivalBatchMaxFloor, t));
int min = (int)MathF.Round(Lerp(SurvivalBatchMinStart, SurvivalBatchMinFloor, t));
min = Math.Max(min, 2);
max = Math.Max(max, min);
return (min, max);
}
// Shared by both the periodic timer and detonation-triggered top-ups —
// keeps slide/game-over/spawn logic in exactly one place.
bool SurvivalAdvanceAndSpawn(int count)
{
SlideChestsDown();
if (Grid.AnyChestInLastRow())
{
TriggerGameOver("💀 A chest reached the bottom! Survival run over.");
return false; // game over already triggered — caller should stop
}
SurvivalSpawnChests(count);
SurvivalSpawnCount++;
return true;
}
}