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).
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
}
}