Game/2D/BombHandSystem.cs

Component that manages the players bomb hand and the detonation/simulation logic for a Chain Reaction style game. It handles drawing the hand, placement, swapping, selection, simulating chain reactions, executing detonations (with sounds, events, scoring and special events), and some random event handling (jam/ghost/nuke).

File AccessNetworkingNative InteropProcess ExecutionReflectionObfuscated CodeExternal DownloadHttp CallsCredential AccessSelf Modifying Code
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/* 
 * @BombHandSystem
 * This class acts as the 'brain' of the whole system.
 */
public sealed class BombHandSystem : Component
{
	public int HandSize { get; set; } = 2; // default : Only 2 bomb (unless we buy more slots)

	[Property] public ChainReactionGame Game { get; set; } // class refs 
	[Property] public GridManager Grid { get; set; } // class refs

	public List<GridManager.BombType> Hand { get; private set; } = new(); // the list of bomb types currently in the player's hand — e.g. [Cross, Sniper, Cross]

	// A dictionary mapping slot index to grid position. So if you placed slot 0 on row 2 col 3, it holds { 0: (2,3) }. 
	// It's how the system remembers "this hand slot is already on the board."
	public Dictionary<int, (int r, int c)> PlacedSlots { get; private set; } = new();

	// A list of slot indices in the order they were placed. 
	// It's used to reconstruct the sequence if needed (currently mainly maintained for potential undo logic or ordering guarantees).
	public List<int> PlacementOrder { get; private set; } = new(); // Placement Order of the bombs 

	// Whichever hand slot is currently "held" by the player — the one that will be placed next. 
	// -1 means nothing is selected. This happens after all bombs are placed.
	public int SelectedSlot { get; private set; } = -1;

	// --------------------------------------- IMPORTANT ------------------------------------------------
	// A "slot" is just the index into that array — 0, 1, or 2. 
	// A slot refers to a bomb, but it's not the bomb itself, it's the position in the hand that holds it.
	// Hand[0] = Cross   ← slot 0
	// Hand[1] = Sniper  ← slot 1  
	// Hand[2] = Cross   ← slot 2
	// --------------------------------------------------------------------------------------------------
	public bool IsDetonating { get; private set; } // is it already detonating ? If so, you can't place, remove, swap, or detonate again mid-animation.

	// The UI uses these to enable/disable the detonate button.
	public bool AnyPlaced => PlacedSlots.Count > 0; // Is there any bomb placed on the grid ? 
	public bool AllPlaced => PlacedSlots.Count >= HandSize; // Did we place all our bombs in the grid ?

	public GridManager.BombType BombAt( int slot ) =>
		slot >= 0 && slot < Hand.Count ? Hand[slot] : GridManager.BombType.None;

	// Wave 1-3: Cross only — learn the basics
	static readonly GridManager.BombType[] StarterPool = {
		GridManager.BombType.Cross, GridManager.BombType.Cross, GridManager.BombType.Cross,
	};

	// Wave 4-6: Cross + Sniper — first complexity
	static readonly GridManager.BombType[] EarlyPool = {
		GridManager.BombType.Cross, GridManager.BombType.Cross, GridManager.BombType.Sniper,
	};

	// Wave 7-11: Add Diagonal
	static readonly GridManager.BombType[] MidPool = {
		GridManager.BombType.Cross, GridManager.BombType.Sniper, GridManager.BombType.Diagonal,
	};

	// Wave 12+: Full pool
	static readonly GridManager.BombType[] FullPool = {
		GridManager.BombType.Cross, GridManager.BombType.Sniper,
		GridManager.BombType.Diagonal, GridManager.BombType.Square, GridManager.BombType.Chain,
	};

	// ────────────────────────  Challenges ───────────────────────── //

	// Store the last simulation result so ChainReactionGame can read it
	public ChainResult LastSimResult { get; private set; }

	// Track which bomb types were placed this wave
	public HashSet<GridManager.BombType> PlacedBombTypes { get; private set; } = new();

	// ─────────────────── RANDOM EVENTS ──────────────────────────── //
	int _jammedSlot = -1; // BombJam (one slot bomb is locked this way)
	public int JammedSlot => _jammedSlot;
	public bool IsSlotJammed(int slot) => slot == _jammedSlot;
	// ────────────────────────────────────────────────────────────── //

	public delegate void HandChanged();
	public delegate void DetonationFinished( int totalChests, int totalCoins );
	public event HandChanged OnHandChanged;
	public event ChainStep OnChainStep;
	public delegate void ChainStep( int idx, int total, int chestClearedSoFar, int projectedCleared,float mult );
	public event DetonationFinished OnDetonationFinished;

	// fires per chest destroyed (to spawn pop up)
	public delegate void ChestCleared( int r, int c, int coins, float mult );
	public event ChestCleared OnChestCleared;

	// fires per bomb exploded
	public delegate void BombFired( int r, int c, int depth );
	public event BombFired OnBombFired;

	public delegate void SecretChestDestroyed();
	public event SecretChestDestroyed OnSecretChestDestroyed;

	// ────────────────────────────────────────────────────────────── //

	public float LastBombFiredAt { get; private set; }

	// ────────────  MODIFIER ──────────────────────────────────────── //
	int GetRangeBonus(GridManager.BombType type) => Game?.GetRangeBonus(type) ?? 0;

	// ────────────  CONSUMABLES ──────────────────────────────────── //
	public bool NukeModeActive { get; set; } = false;

	// ────────────  Draw ──────────────────────────────────────────── //

	public void DrawHand( int wave )
	{
		Hand.Clear();
		PlacedSlots.Clear();
		PlacementOrder.Clear();
		SelectedSlot = 0;

		// challenges
		PlacedBombTypes.Clear();
		LastSimResult = null;

		var save = ChainReactionGame.Save;


		// ── Survival mode — draw only from player-selected pool ──
		if (Game?.Mode == GameMode.Survival && Game.SurvivalBombPool?.Count > 0)
		{
			var survivalPool = Game.SurvivalBombPool;
			for (int i = 0; i < HandSize; i++)
				Hand.Add(survivalPool[Game.Rng.Next(survivalPool.Count)]);

			for (int r = 0; r < Grid.Rows; r++)
				for (int c = 0; c < Grid.Cols; c++)
					Grid.Grid[r, c].Bomb = GridManager.BombType.None;

			OnHandChanged?.Invoke();
			return;
		}

		// ── Tutorial first hand — force Sniper + Cross ──────────────── //
		if (save != null && !save.TutorialCompleted && wave == 1)
		{
			HandSize = 2; // forces having 2
			Hand.Add(GridManager.BombType.Sniper);
			Hand.Add(GridManager.BombType.Cross);
			// Reset grid bombs
			for (int r = 0; r < Grid.Rows; r++)
				for (int c = 0; c < Grid.Cols; c++)
					Grid.Grid[r, c].Bomb = GridManager.BombType.None;
			OnHandChanged?.Invoke();
			return;
		}
		
    	// ────────────────────────────────────────────────────────────── //

    	// ── Build pool from owned bombs ── //
		// Cross & Diagonal are always available — starter bomb, never needs purchasing
		var pool = new List<GridManager.BombType> { GridManager.BombType.Cross, GridManager.BombType.Diagonal };
		
		if (save != null)
		{
			// Trial period — Sniper + Diagonal available for free for N runs
			if (save.IsInTrial)
			{
				pool.Add(GridManager.BombType.Sniper);
			}

			// Add each purchased bomb type to the pool (skip if already added via trial)
			foreach (var item in BombCatalog.All)
				if (save.OwnsBomb(item.Type) && !pool.Contains(item.Type))
					pool.Add(item.Type);
		}


		// Allow up to 2 retries to avoid all-identical hands.
		// With a 2-type pool (e.g. EarlyPool has only Cross+Sniper) a fully unique
		// hand of 3 is impossible, so we just reduce duplicates rather than enforce uniqueness.
		const int MaxAttempts = 5;
		for(int i = 0; i < HandSize; i++)
		{
			var pick = pool[Game.Rng.Next(pool.Count)];
			for(int attempt = 1; attempt < MaxAttempts; attempt++)
			{
				bool allSame = Hand.Count > 0 && Hand.TrueForAll(b => b == pick);
				if(!allSame) break;
				pick = pool[Game.Rng.Next(pool.Count)];
			}
			Hand.Add(pick);
    	}

		// Reset every tile in the grid to no bomb.
		for ( int r = 0; r < Grid.Rows; r++ )
			for ( int c = 0; c < Grid.Cols; c++ )
				Grid.Grid[r, c].Bomb = GridManager.BombType.None;

		
		_jammedSlot = -1; // reset
		// Random event (Bomb Jam) active? (block a bomb slot based on HandSize)
		if (ChainReactionGame.Instance?.IsBombJamActive == true && Hand.Count > 1)
		{
			int jamSlot = Game.Rng.Next(Hand.Count);
			_jammedSlot = jamSlot; // new field, -1 = none
		}

		// Triggers for UI update especially.
		OnHandChanged?.Invoke();
	}

	// Swap one slot for a fresh random bomb — once per wave
	public bool HasSwapped { get; private set; }

	// Called from a UI button (in Gamehud.razor) to swap our hands of bombs
	public bool TrySwap( int slot )
	{
		if ( HasSwapped || IsSlotPlaced( slot ) || IsDetonating ) return false;
		var pool = Game.Wave < 4 ? EarlyPool : Game.Wave < 8 ? MidPool : FullPool;
		Hand[slot] = pool[Game.Rng.Next( pool.Length )];
		HasSwapped = true;
		OnHandChanged?.Invoke();
		return true;
	}

	// ── Selection ─────────────────────────────────────────────────────────

	// We can select a slot whether from a button on the UI (for now) or from or mouse selection wheel clicking on RMB
	public void SelectSlot( int slot )
	{
		if ( IsSlotPlaced( slot ) ) return; // if that slot (bomb) wasn't already placed, pursue
		if(slot > HandSize - 1) return;
		SelectedSlot = slot;	
		OnHandChanged?.Invoke();
	}

	public bool IsSlotPlaced( int slot ) => PlacedSlots.ContainsKey( slot ); // checks if we already placed that slot (slot = a bomb from our Hand)

	// ── Placement ─────────────────────────────────────────────────────────
	public bool TryPlace( int row, int col, int rot = 0 )
	{
		if (_jammedSlot >= 0 && SelectedSlot == _jammedSlot) return false; // prevent placing blocked slot bomb this wave (random event)8
		if ( SelectedSlot < 0 || IsDetonating ) return false; // for now SelectedSlot is -1 when we placed all our bombs already
		if ( IsSlotPlaced( SelectedSlot ) ) return false; // double check just in case (but we automatically call NextUnplaced() to set to -1 

		var cell = Grid.Grid[row, col]; // Retrieve our Grid's Cell 

		// is this cell empty ?
		if ( !cell.IsEmpty ) return false;

		cell.Bomb = Hand[SelectedSlot]; // Write the bomb type from our selected hand slot, into this cell
		cell.BombRot = rot; // When you place a bomb, it resets rotation to 0 (facing up/default). This matters for Snipers specifically since they have 4 rotation states. 
		if(cell.Bomb == GridManager.BombType.Sniper) cell.CanRotate = true;
		PlacedSlots[SelectedSlot] = (row, col); // Registers that bomb (selected slot) in our PlacedSlots(bombs)

		PlacedBombTypes.Add( Hand[SelectedSlot] ); // track for challenges
		PlacementOrder.Add( SelectedSlot ); // Registers that bomb (selected slot) in our PlacementOrder list, so we keep track of bomb sequence

		Sound.Play("Place_Bomb_1");

		// Auto-advance selection
		SelectedSlot = NextUnplaced(); // if every slot (bomb) have been placed, set SelectedSlot to -1
		OnHandChanged?.Invoke(); // trigger Updates for any listener
		return true;
	}

	// Called from a click in UI (GridPanel.razor)
	public void RemoveBomb( int row, int col )
	{
		if ( IsDetonating ) return;

		// retrieve that cell from the grid based on coords
		var cell = Grid.Grid[row, col];

		if ( !cell.HasBomb ) return;
		foreach ( var kv in PlacedSlots )
		{
			if ( kv.Value != (row, col) ) continue;
			PlacedSlots.Remove( kv.Key );
			PlacementOrder.Remove( kv.Key );
			SelectedSlot = kv.Key;
			break;
		}
		cell.Bomb = GridManager.BombType.None;
		cell.BombRot = 0;
		cell.CanRotate = false;
		OnHandChanged?.Invoke();
	}

	public void RotateSniper( int row, int col )
	{
		var cell = Grid.Grid[row, col];
		if ( cell.Bomb != GridManager.BombType.Sniper ) return;
		cell.BombRot = (cell.BombRot + 1) % 4;
		OnHandChanged?.Invoke();
	}

	int NextUnplaced()
	{
		for ( int i = 0; i < Hand.Count; i++ )
			if ( !IsSlotPlaced( i ) ) return i;
		return -1;
	}

	// ── Simulation ────────────────────────────────────────────────────────
	public ChainResult Simulate()
	{
		var result = new ChainResult(); // will be the final answer we return
		var fired = new HashSet<(int, int)>(); // a set of grid positions that have already been added to the explosion -  it's the "don't process this twice" guard
		var queue = new Queue<ChainNode>(); //  the list of bombs waiting to explode, processed one at a time 

		// Loops through every bomb placed on the grid in the order placed (kv has coords + bomb type)
		foreach ( var kv in PlacedSlots.OrderBy( x => x.Key ) )
		{
			var (r, c) = kv.Value; // fetch that slot's grid coordinates 
			fired.Add( (r, c) ); // adds it to the fired (so it doesn't get processed 2x)
			// register a ChainNode : row,col - bomb type - bomb rotation (useful for sniper) - depth - fromR/fromC (tracks coords of a triggering bomb in a chain if there's one)
			queue.Enqueue( new ChainNode { 
				R = r, 
				C = c, 
				Type = Hand[kv.Key], 
				Rot = Grid.Grid[r, c].BombRot, 
				// Nuke mode — all bombs after first (placed) one are depth 1 (chain triggered)
				Depth = NukeModeActive && kv.Key > 0 ? 1 : 0, 
				FromR = -1, 
				FromC = -1 });
		}

		// holds a dicitionary of hit chests
		var hitChests = new Dictionary<(int, int), HitInfo>();

		// Progressively adds new bombs to the queue, if the bombs originally placed (from PlacedSlots) affects new bombs on the way
		while ( queue.Count > 0 )
		{
			var node = queue.Dequeue(); // remove that original [placed] bomb from the queue (to process it)
			result.Sequence.Add( node );

			// Loops through each blast cell from that 'node' (bomb) queue that's being processed (dequeued)
			foreach ( var cell in Grid.GetBlastCells( node.R, node.C, node.Type, node.Rot, GetRangeBonus(node.Type) ) )
			{
				// if that blast cell has a bomb (and is not one of our hand-placed bombs (originally in fired list))
				if ( cell.HasBomb && !fired.Contains( (cell.Row, cell.Col) ) )
				{
					fired.Add( (cell.Row, cell.Col) ); // mark this bomb as claimed so nothing else can also trigger it (i.e: another adjacent bomb)
					
					// add that 'affected/blast' bomb to the queue - Depth lives on each ChainNode and tracks how many chain links away from you that explosion is.
					queue.Enqueue( new ChainNode { R = cell.Row, C = cell.Col, Type = cell.Bomb, Rot = cell.BombRot, Depth = node.Depth + 1, FromR = node.R, FromC = node.C } );
				}

				// if that cell has a chest
				if ( cell.HasChest )
				{
					// chest cell's coords
					var key = (cell.Row, cell.Col);

					// 1. creates a new hitChest info : (<coords> ; <chest type, hits left>)
					var info = hitChests.GetValueOrDefault( key, new HitInfo { ChestType = cell.Chest, HitsNeeded = cell.HitsLeft } );

					// 2. mark it as being hit by the range of the bomb
					info.Hits++; 

					// 3. registers that key and assign it the info in hitChests dictionary
					hitChests[key] = info;

					// if that chest is a Bombchest & can explode (enough hits) & is not contained in our fired bombs 
					// (meaning, it has not been processed yet, to check if it's affecting other stuff)
					if ( cell.Chest == GridManager.ChestType.BombChest && info.Hits >= info.HitsNeeded && !fired.Contains( key ) )
					{
						fired.Add( key );
						queue.Enqueue( new ChainNode { R = cell.Row, C = cell.Col, Type = GridManager.BombType.Cross, Rot = 0, Depth = node.Depth + 1, FromR = node.R, FromC = node.C } );
					}
				}
			}
		}
		result.HitChests = hitChests;
		result.FiredBombs = fired;
		return result;

		// notes about depth :

		// Depth lives on each ChainNode and tracks how many chain links away from you that explosion is.
		//
		// Bombs you placed by hand = depth 0. You directly caused them.
		// A bomb triggered by one of your bombs = depth 1. One step removed.
		// A bomb triggered by that bomb = depth 2. Two steps removed.
	}

	// Preview chain is called from GridPanel.razor
	// It shows 
	public ChainResult PreviewChain() => AnyPlaced ? Simulate() : null;

	// ── Detonate ──────────────────────────────────────────────────────────
	
	// Actually Executes Detonation 
	public async void ExecuteDetonate()
	{
		if ( IsDetonating || !AnyPlaced ) return; // can't detonate if no bomb or already detonating

		IsDetonating = true;

		try
		{
			var sim = Simulate();
			LastSimResult = sim; // for challenges
			int cleared = 0;
			int coins = 0;
			var destroyed = new HashSet<(int, int)>();


			// ------- Pre-compute how many chests each sequence step will destroy ------- // 
			// This lets us show the correct escalating multiplier as each bomb fires
			var chestsPerStep = new int[sim.Sequence.Count];
			var projectedDestroyed = new HashSet<(int, int)>();

			for (int i = 0; i < sim.Sequence.Count; i++)
			{
				var node = sim.Sequence[i];
				int hitsThisStep = 0;

				foreach (var cell in Grid.GetBlastCells(node.R, node.C, node.Type, node.Rot, GetRangeBonus(node.Type)))
				{
					if (!cell.HasChest) continue;
					var key = (cell.Row, cell.Col);
					if (projectedDestroyed.Contains(key)) continue;

					// Check if this hit will destroy it (accounting for multi-hit chests)
					var hitInfo = sim.HitChests.GetValueOrDefault(key);
					if (hitInfo != null && hitInfo.WillDestroy)
					{
						projectedDestroyed.Add(key);
						hitsThisStep++;
					}
				}
				chestsPerStep[i] = hitsThisStep;
			}

			// Running projected total — increments as each bomb fires
			int projectedCleared = 0;
			// ------------------------------------------------------------------------ // 


			// ── Main detonation loop ── // 
			// Triggers a sequence of explosion (with delay for each one, to simulate a chain reaction explosion with animations)
			for ( int i = 0; i < sim.Sequence.Count; i++ )
			{
				var node = sim.Sequence[i];

				// Increment projected total BEFORE invoking so HUD sees correct value
            	projectedCleared += chestsPerStep[i];

				// invoked multiple times for each explosion (listened from Gamehud.Razor)
				OnChainStep?.Invoke(i, sim.Sequence.Count, cleared, projectedCleared, 
						GetMult(projectedCleared, Game?.BossAlive ?? false));

				// delay explosion based on depth (depth == 0 means it's a hand-placed bomb. Higher means it's a bomb triggered by another bomb ( chain reaction))
				await GameTask.DelaySeconds( node.Depth == 0 ? 0.15f : 0.22f );

				// for calculation time to wait before next wave in ChainReactionGame
				// It takes into account the time of the last fired bomb (used when OnDetonationFinished event fires)
				LastBombFiredAt = Time.Now;

				// trigger a sparkle explosion for each bomb on their cell
				OnBombFired?.Invoke( node.R, node.C, node.Depth );

				// Explosion Sound  
				if ( node.Depth == 0 )
					Sound.Play( "climax_explosion_lv1" );
				else
					Sound.Play( "climax_explosion_lv2" );
				
				// Play multiplier Sound
				ComputeAndPlayMultiplierSound( projectedCleared, destroyed, node );


				// loops through every blast cell for that bomb in the Sequence of index i
				foreach ( var cell in Grid.GetBlastCells( node.R, node.C, node.Type, node.Rot, GetRangeBonus( node.Type ) ) )
				{
					// only care for cells having a chest (could be a chest bomb btw)
					if ( !cell.HasChest ) continue;

					// Cell coords 
					var key = (cell.Row, cell.Col);

					// if that coord is a blast cell for multiple bombs, avoid processing an already destroyed chest multiple times
					if ( destroyed.Contains( key ) ) continue;

					// Hit cell's chest
					cell.HitsLeft--;

					// If that last hit destroyed the chest
					if (cell.HitsLeft <= 0)
					{
						ProcessChestDestruction(cell, ref cleared, ref coins, destroyed);
					}
				}

				// Clears the bomb that just exploded on the cell in the Sequence
				Grid.Grid[node.R, node.C].Bomb = GridManager.BombType.None;
				Grid.Grid[node.R, node.C].BombRot = 0;
			}

			// Random event (GhostBomb) check
			// GhostBombs event -- fire all bombs a SECOND TIME -- //
			if (ChainReactionGame.Instance?.IsGhostBombsActive == true)
			{
				foreach (var node in sim.Sequence)
				{
					foreach (var blastCell in Grid.GetBlastCells(node.R, node.C, node.Type, node.Rot, GetRangeBonus(node.Type)))
					{
						if (!blastCell.HasChest) continue;

						var key = (blastCell.Row, blastCell.Col);
						if (destroyed.Contains(key)) continue;

						blastCell.HitsLeft--;

						// If that last hit destroyed the chest
						if (blastCell.HitsLeft <= 0)
							ProcessChestDestruction(blastCell, ref cleared, ref coins, destroyed);
					}
				}
			}

			// Resets for next wave
			PlacedSlots.Clear(); PlacementOrder.Clear(); SelectedSlot = -1; 

			// Reset, if we had swapped on that wave 
			HasSwapped = false; 

			// Triggers OnDetonationFinished (cf. listeners : Gamehud.razor + HandleDetonationFinished() from ChainReactionGame).
			OnDetonationFinished?.Invoke( cleared, coins ); 
			_lastMultTier = 0;
			
		}
		catch ( Exception e )
		{
			Log.Error( $"[BombHandSystem] ExecuteDetonate failed: {e}" );
			// Reset to a safe state so the game isn't permanently stuck
			PlacedSlots.Clear(); PlacementOrder.Clear(); SelectedSlot = -1;
			HasSwapped = false;
			OnDetonationFinished?.Invoke( 0, 0 );
		}
		finally
		{
			// Reset IsDetonating (finished)
			IsDetonating = false;
			NukeModeActive = false; // consumable (if used)

			// Triggers OnHandChanged (cf. listeners Gamehud.razor // GridPanel.Razor // BombWheel.razor) UI updates
			OnHandChanged?.Invoke();
		}
	}

	// ── Shared chest destruction logic ────────────────────────────
	// Called both from normal detonation and GhostBombs second pass
	void ProcessChestDestruction(GridManager.Cell cell, ref int cleared, ref int coins, 
	HashSet<(int,int)> destroyed)
	{
		destroyed.Add((cell.Row, cell.Col));
		cleared++;

		float mult = GetMult(cleared, Game?.BossAlive ?? false);
		float lockedMult = cell.Chest == GridManager.ChestType.Locked ? 3f : 1f; // locked chest triple score 

		// ── Chest destruction sound ── //
		if (cell.Chest == GridManager.ChestType.Normal
		||  cell.Chest == GridManager.ChestType.Gem
		||  cell.Chest == GridManager.ChestType.Secret)
		{
			string chestSound = cell.Chest switch 
			{
				GridManager.ChestType.Gem    => "gems_break_3",
				GridManager.ChestType.Secret => "secret_pop",
				GridManager.ChestType.Locked => "chest_break",
				GridManager.ChestType.Cursed => "chest_break",
				GridManager.ChestType.Void   => "chest_break",
				_                            => "chest_break"
			};
			Sound.Play(chestSound);
		}

		// Level 5 pet bonus — 5+ chest chain grants +15% score	
		float petChainBonus = (ChainReactionGame.Save?.PetLevel >= 5 && cleared >= 5) ? 1.15f : 1f;

		// ── Score calculation ── //
		float gemMult   = (cell.Chest == GridManager.ChestType.Gem &&
						ChainReactionGame.Instance?.HasMod(ModifierId.GemScoreBonus) == true) ? 2f : 1f;
		float goldMult  = (cell.Chest == GridManager.ChestType.Gem &&
						ChainReactionGame.Instance?.IsGoldRushActive == true) ? 5f : 1f;
		float dropMult  = ChainReactionGame.Instance?.IsDoubleDropActive == true ? 2f : 1f;
		float bonusMult = ChainReactionGame.Instance?.IsBonusWaveActive  == true ? 3f : 1f;
		float hypeMult = ChainReactionGame.Instance?.HypeMaxActive == true ? 2f : 1f;

		int earned = (int)MathF.Round(
			(cell.Chest == GridManager.ChestType.Gem ? 300 : 100)
			* mult * gemMult * goldMult * dropMult * bonusMult  * lockedMult * hypeMult * petChainBonus
		);

		coins += earned;

		// ── Special chest tracking ── //
		if (cell.Chest == GridManager.ChestType.Secret)
			OnSecretChestDestroyed?.Invoke();

		if (cell.Chest == GridManager.ChestType.Boss)
			Game.NotifyBossKilled();

		// ── Fire events for UI (popups, coins) ── //
		Game.OnChestDestroyed(cell.Row, cell.Col, earned, mult);
		OnChestCleared?.Invoke(cell.Row, cell.Col, earned, mult);

		// ── Clear cell ── //
		cell.Chest    = GridManager.ChestType.None;
		cell.HitsLeft = 0;

		// Cursed chest — penalizes 2s from timer
		// -> RecountCursedChests(), in ChainReactionGame handles it already

		// Void chest — destroys all chests in 2×2 area
		if (cell.Chest == GridManager.ChestType.Void)
			ChainReactionGame.Instance?.TriggerVoidExplosion(cell.Row, cell.Col);		
	}
	
	int _lastMultTier = 0;
	private void ComputeAndPlayMultiplierSound( int projectedCleared, HashSet<(int, int)> destroyed, ChainNode node )
	{
		// No multiplier sounds during boss — score is capped at x1.5
		if (ChainReactionGame.Instance?.BossAlive == true) return;

		int newTier = projectedCleared switch
		{	
			>= 16 => 12,
			>= 15 => 11,
			>= 14 => 10,
			>= 13 => 9,
			>= 12 => 8,
			>= 11 => 7,
			>= 10 => 6,
			>= 9 => 5,
			>= 8 => 4,
			>= 6 => 3,
			>= 4 => 2,
			>= 2 => 1,
			_ => 0
		};

		// Only play if tier changed upward
		if ( newTier > _lastMultTier && newTier > 0 )
		{
			Sound.Play( $"multx{newTier}" );
			_lastMultTier = newTier;

			// Crowd reaction for massive chains
			// if (newTier >= 5) // displayCleared >= 9
			// {
			// 	// Crowd escalating from a surprised gasp to full stadium roar
			// 	string crowd = newTier switch {
			// 		2 => "Small_Crowd_Applaude_1",
			// 	    4 => "crowd_impact_1",
			// 		6 => "crowd_impact_2",
			// 		8 => "crowd_applause_low_impact_1",
			// 		10 => "crowd_applause_mid_impact_1",
			// 		12 => "crowd_applause_big_impact_1",
			// 		_ => "Crowd_Applause_Pretty_Big_Impact_1"  // 10-12: absolute maximum
			// 	};
			// 	Sound.Play(crowd);
			// }
		}
	}

	// delay going to next wave based on the chain reaction animations to play this wave
	public float GetPostDetonationWait()
	{
		float flashDuration = 0.6f;
		float elapsed = Time.Now - LastBombFiredAt;
		return MathF.Max( 0.1f, flashDuration - elapsed );
	}

	// n = number of chest cleared - returns a multiplier 
	public static float GetMult(int n, bool bossAlive = false)
	{
		float mult = n switch
		{
			<= 1 => 1f,
			<= 2 => 1.5f,
			<= 4 => 2f,
			<= 6 => 3f,
			<= 8 => 4f,
			<= 9 => 5f,
			<= 10 => 6f,
			<= 11 => 7f,
			<= 12 => 8f,
			<= 13 => 9f,
			<= 14 => 10f,
			<= 15 => 11f,
			_    => 11f + (n - 8) * 0.5f
		};
		
		// It decreases the multipliers (might need a feed back)
		return bossAlive ? MathF.Min(mult, 1.5f) : mult;
	}

	public static string GetMultLabel( int n ) => n switch 
	{ <= 1 => "", 
	<= 2 => "×1.5", 
	<= 4 => "×2 CHAIN!", 
	<= 6 => "×3 ON FIRE! 🔥", 
	<= 8 => "x4 ULTRA CHAIN REACTION!",
	<= 10 => "x6 MONSTERRRR CHAIN REACTION! 💥",
	<= 12 => "x8 🔥 BOOMBOOOOCLAAAAAAAAAAAAAAAT! 🔥",
	<= 14 => "💥 x10 TEN THOUSAND CHAIN REACTION! 💥",
	<= 15 => "x11 🔥 MEGA GIGA ULTRA BOMBOCLAT! 🔥",
	_ => $"x{n} 💥 MASTER GOD OF DESTRUCTIOOOOOON! 💥" };

	// Registers a (potential) chain of bombs - FromR/FromC are the coords of another bomb we specify (if the current bomb was activated from the chain)
	public class ChainNode { public int R, C, Depth, FromR, FromC; public GridManager.BombType Type; public int Rot; }

	// Gives info about if a chest is hit and if it can be destroyed as a consequence too.
	public class HitInfo { public GridManager.ChestType ChestType; public int Hits, HitsNeeded; public bool WillDestroy => Hits >= HitsNeeded; }

	// Actually holds all the results of the chain
	public class ChainResult
	{
		public List<ChainNode> Sequence = new();
		public Dictionary<(int, int), HitInfo> HitChests = new();
		public HashSet<(int, int)> FiredBombs = new();
		public int ClearedCount => HitChests.Values.Count( h => h.WillDestroy );
	}

	// extension-free version using NextDouble
	float RandomPitch(float min, float max)
	{
		return min + (float)Game.Rng.NextDouble() * (max - min);
	}
	
	// ────────────────────────────────────────────────────────────── //
	// ─────────────────────── CONSUMABLES ────────────────────────── //
	// ────────────────────────────────────────────────────────────── //

	public void ExecuteDetonateWithNuke()
	{
		if (!AnyPlaced) return;
		NukeModeActive = true;
		ExecuteDetonate();
		// NukeModeActive resets to false at end of ExecuteDetonate
	}
}