Game/2D/ChainReactionGame.cs
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
// Idempotent subscription — protects against HandleDetonationFinished firing twice
// (e.g. SlideChestsDown running twice in one wave transition) if OnStart ever runs more than once.
Hand.OnDetonationFinished -= HandleDetonationFinished;
Hand.OnDetonationFinished += HandleDetonationFinished;
Hand.OnSecretChestDestroyed -= RollSecretChestReward;
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 = 2.4f; // was 4.2f
const float SurvivalIntervalFloor = 0.7f; // was 1.0f
const float SurvivalRampMinutes = 10f; // 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 = 4; // unchanged
const int SurvivalBatchMaxFloor = 6; // was 3 — kept slightly higher so late-game doesn't go flat
const int SurvivalBatchMinStart = 3; // unchanged
const int SurvivalBatchMinFloor = 4; // was 2 — same reasoning
// ── Survival: per-minute flavor modifiers (reset each minute) ──
float _survivalPhaseIntervalMult = 1f;
int _survivalPhaseBatchBonus = 0;
bool _survivalGoldRushMinute = false;
// Add near the other Survival constants
const float SurvivalMinuteSpeedStep = 0.94f; // each minute makes spawns ~6% faster
const float SurvivalHardFloor = 0.5f; // absolute fastest, never below
// ── 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()
{
if (IsPaused) return;
// Reactor mode runs its own loop inside IdleReactor — nothing to tick here
if (Mode == GameMode.Idle) return;
// 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)
{
TickHypeDrain();
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
TickHypeDrain();
// 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();
// ── Reactor (Idle) mode — completely separate loop, no waves involved ──
if (mode == GameMode.Idle)
{
StartIdleMode();
return;
}
// 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;
SurvivalBonusBombSlots = 0;
SurvivalGoalLabel = "";
SurvivalGoalProgress = 0;
SurvivalGoalTimeLeft = 0f;
_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 || IsPaused) 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;
TickSurvivalGoalProgress(chestsCleared);
Hand.DrawHand(1);
OnStateChanged?.Invoke();
if (chestsCleared > 0)
{
_survivalAdvancePending = true; // block the ambient timer from firing until this resolves
float delay = Hand.GetPostDetonationWait();
await GameTask.DelaySeconds(delay);
if (!IsRunning) { _survivalAdvancePending = false; return; }
var (bmin, bmax) = GetSurvivalBaseBatch();
int count = Rng.Next(bmin, bmax + 1);
if (!SurvivalAdvanceAndSpawn(count)) // slide + game-over check + spawn, same path the timer uses
{
_survivalAdvancePending = false;
return; // game over already triggered inside
}
_survivalSpawnTimer = _survivalSpawnInterval; // fresh countdown starts now — no leftover near-zero value from before you detonated
_survivalAdvancePending = false;
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 );
}
// -------------------------------------------------------- //
// ------------------- REACTOR (IDLE) --------------------- //
// -------------------------------------------------------- //
/// <summary>
/// Hands control to IdleReactor. Wave state is deliberately left inert —
/// IsRunning stays false so the wave HUD, bomb wheel and hype meter all
/// hide themselves without needing mode checks scattered everywhere.
/// </summary>
void StartIdleMode()
{
IsRunning = false;
IsGameOver = false;
TimerActive = false;
IsPaused = false;
Score = 0;
Wave = 1;
GameOverReason = "";
// The shared grid is not used by the reactor — wipe it so nothing stale
// can flash on screen during the transition.
Grid?.ClearAllChests();
IdleReactor.Instance?.Begin();
OnStateChanged?.Invoke();
}
// -------------------------------------------------------- //
// ----------------- Return to Main Menu ------------------ //
// -------------------------------------------------------- //
public void ReturnToMenu()
{
// Reactor writes its LastSeen stamp here so offline earnings start ticking
if (Mode == GameMode.Idle)
IdleReactor.Instance?.End();
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;
// true from the moment a detonation clears chests until its own board advance resolves
// — prevents the ambient timer from sneaking in a second slide during that gap
bool _survivalAdvancePending = false;
void TickSurvival()
{
if (_survivalSpawnFrozen || MilestoneOverlay.Instance?.IsActive == true || _survivalAdvancePending)
return;
_survivalElapsed += Time.Delta;
int minute = (int)(_survivalElapsed / 60f);
if (minute != _survivalPhase || !_survivalPhaseInitialized)
{
_survivalPhase = minute;
_survivalPhaseInitialized = true;
OnSurvivalMinuteChange(minute);
}
// ── Micro-goal countdown ──
if (string.IsNullOrEmpty(SurvivalGoalLabel))
{
RollSurvivalGoal();
}
else
{
SurvivalGoalTimeLeft -= Time.Delta;
if (SurvivalGoalTimeLeft <= 0f)
RollSurvivalGoal(); // expired, no penalty — just a fresh target
}
bool boardEmpty = Grid.AllChestCells().Count == 0;
if (boardEmpty)
{
if (_survivalEmptySince < 0f)
_survivalEmptySince = _survivalElapsed;
else if (_survivalElapsed - _survivalEmptySince >= SurvivalEmptyGraceSeconds)
_survivalSpawnTimer = 0f;
}
else
{
_survivalEmptySince = -1f;
}
_survivalSpawnTimer -= Time.Delta;
if (_survivalSpawnTimer <= 0f)
{
_survivalSpawnTimer = _survivalSpawnInterval;
SurvivalSpawnWave();
}
}
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);
}
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));
float baseInterval = Lerp(SurvivalIntervalStart, SurvivalIntervalFloor, t);
// Compounding per-minute pressure — keeps escalating past the ramp window,
// so long runs never plateau into "safe" territory.
int minute = (int)(_survivalElapsed / 60f);
float minuteMult = MathF.Pow(SurvivalMinuteSpeedStep, minute);
return MathF.Max(SurvivalHardFloor, baseInterval * minuteMult);
}
(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())
{
Lives--;
if (Lives <= 0)
{
TriggerGameOver("💀 The board broke through. Survival run over.");
return false;
}
// Breach — survivable. Wipe the bottom two rows and keep going.
SurvivalEmergencyClear(2);
_hypeMeter = 0f; // combo dies on a breach — real cost
_hypeMaxActive = false;
_hypeTriggeredThisWave = false;
MilestoneOverlay.Instance?.ShowMessage(
$"💔 BREACH! Bottom cleared · {Lives} {(Lives == 1 ? "life" : "lives")} left", 0, 2f);
OnScreenFlashRequest?.Invoke("gameover", 0);
Sound.Play("Heart_beat");
}
SurvivalSpawnChests(count);
SurvivalSpawnCount++;
return true;
}
// Added
public bool IsPaused { get; private set; } = false;
public void PauseForMenu()
{
IsPaused = true;
OnStateChanged?.Invoke();
}
public void ResumeFromMenu()
{
IsPaused = false;
OnStateChanged?.Invoke();
}
// survival a
async void OnSurvivalMinuteChange(int minute)
{
int phaseInCycle = minute % 6;
_survivalPhaseIntervalMult = 1f;
_survivalPhaseBatchBonus = 0;
_survivalBombChestBoost = false;
string title = "";
bool showBanner = minute > 0; // nothing to announce at t=0, run just started
const float bannerDuration = 2f;
switch (phaseInCycle)
{
case 0:
if (minute > 0) showBanner = false; // still a "quiet" baseline minute
break;
case 1:
if (SurvivalBonusBombSlots < MaxSurvivalBonusSlots)
{
SurvivalBonusBombSlots++;
Hand.HandSize++;
title = $"💣 Extra bomb slot! ({Hand.HandSize} total)";
}
else
{
Score += 500;
title = "⚡ +500 bonus!";
}
break;
case 2:
_survivalPhaseIntervalMult = 0.94f;
_survivalBombChestBoost = true;
title = "💣 More bomb chests incoming!";
break;
case 3:
SpawnSurvivalMiniBoss();
showBanner = false;
break;
case 4:
_survivalGoldRushMinute = true;
title = "💎 Gold Rush! Gems incoming!";
break;
case 5:
_survivalScoreBoostUntil = _survivalElapsed + 10f;
title = "⭐ 2x SCORE for 10 seconds!";
break;
}
// ── Frags scale with minutes survived — minute 1 gives just a few, grows from there ──
if (minute > 0)
{
int frags = 2 + minute; // minute 1 = 3 frags, minute 5 = 7, minute 9 = 11
FragmentsEarned += frags;
if (showBanner)
MilestoneOverlay.Instance?.ShowMessage($"⏱️ {minute} min · {title} (+{frags} 💠)", 0, bannerDuration);
}
if (showBanner)
{
Sound.Play("WaveMilestone");
_survivalSpawnFrozen = true;
OnStateChanged?.Invoke();
while (MilestoneOverlay.Instance?.IsActive == true)
await GameTask.DelaySeconds(0.1f);
_survivalSpawnFrozen = false;
_survivalSpawnTimer = _survivalSpawnInterval;
}
OnStateChanged?.Invoke();
}
void TickHypeDrain() // survival mode hype bar drain
{
if (_hypeMeter <= 0f || !IsRunning) return;
if (MilestoneOverlay.Instance?.IsActive == true) return;
// Survival drains much faster — hype is a short, hot window you fight to keep alive.
// Wave mode keeps its slower original pacing.
float drainRate = Mode == GameMode.Survival
? (_hypeMaxActive ? 0.14f : 0.045f) // max: ~7s of 2x, idle: ~22s to empty
: (_hypeMaxActive ? 0.08f : 0.012f);
_hypeMeter = MathF.Max(0f, _hypeMeter - drainRate * Time.Delta);
if (_hypeMaxActive && _hypeMeter <= 0f)
{
_hypeMaxActive = false;
_hypeTriggeredThisWave = false;
}
OnStateChanged?.Invoke();
}
void SurvivalEmergencyClear(int rowCount)
{
for (int r = Grid.Rows - rowCount; r < Grid.Rows; r++)
{
if (r < 0) continue;
for (int c = 0; c < Grid.Cols; c++)
{
var cell = Grid.Grid[r, c];
if (!cell.HasChest) continue;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
}
}
}
// ── Survival micro-goals ──
public enum SurvivalGoalType { ClearChests, BigDetonation, ClearGems, ChainLength }
SurvivalGoalType _survivalGoalType;
public string SurvivalGoalLabel { get; private set; } = "";
public int SurvivalGoalProgress { get; private set; }
public int SurvivalGoalTarget { get; private set; }
public float SurvivalGoalTimeLeft { get; private set; }
const float SurvivalGoalDuration = 25f;
void RollSurvivalGoal()
{
int minute = (int)(_survivalElapsed / 60f);
int tier = Math.Clamp(minute, 0, 5);
_survivalGoalType = (SurvivalGoalType)Rng.Next(4);
switch (_survivalGoalType)
{
case SurvivalGoalType.ClearChests:
SurvivalGoalTarget = 8 + tier * 3;
SurvivalGoalLabel = $"Clear {SurvivalGoalTarget} chests";
break;
case SurvivalGoalType.BigDetonation:
SurvivalGoalTarget = 4 + tier;
SurvivalGoalLabel = $"Clear {SurvivalGoalTarget} in one blast";
break;
case SurvivalGoalType.ClearGems:
SurvivalGoalTarget = 2 + tier;
SurvivalGoalLabel = $"Destroy {SurvivalGoalTarget} gems 💎";
break;
case SurvivalGoalType.ChainLength:
SurvivalGoalTarget = 3 + tier;
SurvivalGoalLabel = $"Chain {SurvivalGoalTarget} bombs ⛓";
break;
}
SurvivalGoalProgress = 0;
SurvivalGoalTimeLeft = SurvivalGoalDuration;
OnStateChanged?.Invoke();
}
void CompleteSurvivalGoal()
{
int minute = (int)(_survivalElapsed / 60f);
int scoreReward = 400 + minute * 200;
int fragReward = 1 + minute / 2;
Score += scoreReward;
FragmentsEarned += fragReward;
_hypeMeter = MathF.Min(1f, _hypeMeter + 0.25f); // goals feed the combo
MilestoneOverlay.Instance?.ShowMessage($"✅ {SurvivalGoalLabel} · +{scoreReward} +{fragReward}💠", 0, 1.5f);
Sound.Play("perfect_streak");
RollSurvivalGoal();
}
void TickSurvivalGoalProgress(int chestsCleared)
{
if (string.IsNullOrEmpty(SurvivalGoalLabel)) return;
switch (_survivalGoalType)
{
case SurvivalGoalType.ClearChests:
SurvivalGoalProgress += chestsCleared;
break;
case SurvivalGoalType.BigDetonation:
if (chestsCleared >= SurvivalGoalTarget)
SurvivalGoalProgress = SurvivalGoalTarget;
break;
case SurvivalGoalType.ClearGems:
int gems = Hand.LastSimResult?.HitChests
.Count(kv => kv.Value.WillDestroy && kv.Value.ChestType == GridManager.ChestType.Gem) ?? 0;
SurvivalGoalProgress += gems;
break;
case SurvivalGoalType.ChainLength:
int chain = Hand.LastSimResult?.Sequence.Count ?? 0;
if (chain >= SurvivalGoalTarget)
SurvivalGoalProgress = SurvivalGoalTarget;
break;
}
if (SurvivalGoalProgress >= SurvivalGoalTarget)
CompleteSurvivalGoal();
}
}