A Razor UI component (GridPanel) for a 2D chain-reaction game. It renders the game grid, particle effects, cell icons and previews for bomb placements, handles mouse input (click, hover, right-click rotation), subscribes to game/hand events and builds a visual diff hash for efficient UI updates.
@using System.ComponentModel
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
<root>
@* Screen Shake the whole root*@
<div class="grid-shake-wrap @_currentShake">
@if( Game?.GameStarted == true )
{
<div class="outer" style="background-color:@(Game?.WaveGridColor ?? "#0d0820");">
<div class="grid-particles">
@for(int i = 0; i < 20; i++)
{
float left = (i * 8.5f) % 100f;
float duration = 5f + (i * 0.4f) % 4f;
float delay = (i * 0.6f) % 6f;
int size = (i % 3 == 0) ? 4 : 2;
float opacity = (i % 2 == 0) ? 0.5f : 0.3f;
string baseColor = Game?.WaveParticleColor ?? "rgba(167,139,250,";
string color = baseColor + opacity.ToString("F1") + ")";
string leftStr = left.ToString("F1");
string durationStr = duration.ToString("F1");
string delayStr = delay.ToString("F1");
<div class="grid-particle"
style="left:@leftStr%;width:@(size)px;height:@(size)px;background-color:@color;border-radius:50%;animation-duration:@durationStr s;animation-delay:@delayStr s;"></div>
}
</div>
<div class="grid-wrap">
@if ( Grid != null )
{
@for(int r = 0; r < Grid.Rows; r++)
{
<div class="grid-row">
@for(int c = 0; c < Grid.Cols; c++)
{
int row = r;
int col = c;
var cell = Grid.Grid[row,col];
string preview = GetPreviewCss(row,col);
<div class="cell @CellCss(cell,row,col) @GetPreviewCss(row,col)"
style="@GetSpawnDelay(row,col)"
onclick=@(()=>OnCellClick(row,col))
onmousedown=@((PanelEvent e)=>OnCellMouseDown(e,row,col))
onmouseover=@((PanelEvent e)=>OnCellHoverEvent(e,row,col))
onmouseout=@((PanelEvent e)=>OnCellLeaveEvent(e,row,col))>
@*Bomb preview icon*@
<div class="icon">
@if( row == _hoverRow && col == _hoverCol && cell.IsEmpty && Hand?.SelectedSlot >= 0 && !( Hand?.IsDetonating ?? false ) )
{
@HoverBombIcon()
}
else if( cell.HasChest )
{
@* Hide chest identity with ❓ for this wave (mystery wave) *@
@if(ChainReactionGame.Instance?.IsMysteryWaveActive == true)
{
<div class="cell-mystery-icon">❓</div>
}
else
{
@switch(cell.Chest)
{
case GridManager.ChestType.Normal:
<image src="ui/icons/chest_normal_a.png" class="cell-chest-sprite chest-normal" />
break;
@* case GridManager.ChestType.Gem:
<image src="ui/icons/chest_gem_a.png" class="cell-chest-sprite chest-gem"/>
break; *@
case GridManager.ChestType.Boss:
<image class="cell-chest-sprite chest-boss" src="/ui/icons/chest_boss_a.png" />
break;
case GridManager.ChestType.Secret:
<image class="cell-chest-sprite chest-secret" src="/ui/icons/chest_secret.png" />
break;
default:
@CellIcon(cell)
break;
}
}
}
else
{
@CellIcon(cell)
}
</div>
@if(cell.HasBomb)
{
int n = GetOrder(row,col);
@if(n > 0){<div class="badge">@n</div>}
}
@if(cell.HasChest && cell.HitsLeft > 1)
{
<div class="hits">@cell.HitsLeft ♥</div>
}
@* Boss chest healthbar *@
@if(cell.Chest == GridManager.ChestType.Boss)
{
float hpPct = Game != null ? (cell.HitsLeft / (float)Game.BossMaxHp * 100f) : 100f;
string barColor = hpPct > 60f ? "rgba(52,211,153,0.90)"
: hpPct > 30f ? "rgba(251,191,36,0.90)"
: "rgba(239,68,68,0.90)";
<div class="boss-hp-bar">
<div class="boss-hp-fill" style="width:@hpPct.ToString("F0")%;background-color:@barColor;"></div>
</div>
@* <div class="boss-suppress-icon" title="Multipliers capped while boss alive">⚠️</div> *@
}
@* Flash cells *@
@if(_flashCells.TryGetValue((row,col), out var flashCls))
{
<div class="cell-flash @flashCls"></div>
}
</div>
}
</div>
}
}
</div>
</div>
}
</div>
</root>
@code {
GridManager Grid => GridManager.Instance;
BombHandSystem Hand => ChainReactionGame.Instance?.Components.Get<BombHandSystem>();
ChainReactionGame Game => ChainReactionGame.Instance;
public static GridPanel Instance { get; private set; }
bool _subscribed;
bool _hasData;
Dictionary<(int,int),string> _previewCells = new();
Dictionary<(int,int),string> _flashCells = new();
HashSet<(int,int)> _flashedThisDetonation = new();
// Hover with mouse
int _hoverRow = -1;
int _hoverCol = -1;
int _hoverRot = 0; // i.e : sniper bomb..
protected override void OnStart()
{
Instance = this;
if (Game?.IsRunning == true && Game?.Score == 0)
{
_lastKnownWave = -1; // forces clear on wave 1
_spawnedThisWave.Clear();
}
}
protected override void OnTreeFirstBuilt()
{
TrySubscribe();
}
protected override void OnUpdate()
{
if ( !_subscribed ) TrySubscribe();
if ( !_hasData && Grid != null )
{
for ( int r = 0; r < Grid.Rows; r++ )
for ( int c = 0; c < Grid.Cols; c++ )
if ( Grid.Grid[r,c].HasChest )
{ _hasData = true; break; }
StateHasChanged();
}
// Screen Shake
var shakeClass = ScreenShaker.Instance?.ShakeClass ?? "";
if (shakeClass != _currentShake)
{
_currentShake = shakeClass;
StateHasChanged();
}
}
string _currentShake = "";
void TrySubscribe()
{
var h = Hand;
var g = Game;
if (h is null || g is null) return;
if (_subscribed) return;
h.OnHandChanged += OnHandChanged;
h.OnDetonationFinished += OnDetonationFinished;
h.OnBombFired += OnBombFiredGrid;
h.OnChestCleared += OnChestClearedGrid;
g.OnStateChanged += StateHasChanged;
g.OnStateChanged += OnStateChanged;
_subscribed = true;
StateHasChanged();
}
void OnDetonationFinished( int a, int b )
{
_hasData = false;
_previewCells.Clear();
_flashCells.Clear();
_flashedThisDetonation.Clear();
StateHasChanged();
}
bool _wasRunning = false; // clears the tiles after returning to main menu
void OnStateChanged()
{
// Detect fresh game start (new run)
bool isRunning = Game?.IsRunning ?? false;
if (isRunning && !_wasRunning)
{
// New game started — clear everything
_lastKnownWave = -1;
_spawnedThisWave.Clear();
_destroyedCells.Clear();
_firedCells.Clear();
}
_wasRunning = isRunning;
if (Game?.Mode == GameMode.Survival)
{
// Reset per survival spawn cycle instead of per-wave
if (Game.SurvivalSpawnCount != _lastKnownSurvivalTick && Game.IsRunning)
{
_lastKnownSurvivalTick = Game.SurvivalSpawnCount;
_spawnedThisWave.Clear();
_destroyedCells.Clear();
_firedCells.Clear();
}
}
// detect new wave by comparing wave number
if (Game?.Wave != _lastKnownWave && Game?.IsRunning == true)
{
// used to clear the list of spawned cells this wave (to avoid multiple spawn animations when state rebuilds)
_lastKnownWave = Game.Wave;
_spawnedThisWave.Clear();
// turns cells exploded dark gray (after flash animation interrupts)
_destroyedCells.Clear();
_firedCells.Clear();
}
StateHasChanged();
}
void OnHandChanged()
{
RebuildPreview();
StateHasChanged();
}
void RebuildPreview()
{
_previewCells.Clear();
var hand = Hand;
if (hand is null || Grid is null) return;
// ------------------------------------------------------------------------------------- //
// -------------------------- PLACE BOMB FIRST - Base Layer --------------------------- //
// ------------------------------------------------------------------------------------- //
var sim = hand.PreviewChain();
var order = hand.PlacementOrder;
// Show the preview of our placed bombs blast cells (color depends on if it's a chest or empty cell)
for (int oi = 0; oi < order.Count; oi++)
{
int slot = order[oi];
if (!hand.PlacedSlots.TryGetValue(slot, out var pos)) continue;
string col = new[]{"p0","p1","p2"}[oi % 3];
// range bonus (modifier)
var rangeBonus = 0;
if (ChainReactionGame.Instance != null)
{
rangeBonus = ChainReactionGame.Instance.GetRangeBonus(hand.BombAt(slot));
}
var blastCells = Grid.GetBlastCells(pos.r, pos.c, hand.BombAt(slot), Grid.Grid[pos.r, pos.c].BombRot,
rangeBonus);
foreach (var cell in blastCells)
{
var key = (cell.Row, cell.Col);
if (!_previewCells.ContainsKey(key))
_previewCells[key] = cell.HasChest ? $"phit {col}" : $"pblast {col}";
else if (cell.HasChest && _previewCells[key].StartsWith("pblast"))
_previewCells[key] = $"phit {col}";
}
}
// ------------------------------------------------------------------------------------ //
// ---------------------- SIMULATION - accurate hit/destroy state --------------------- //
// ------------------------------------------------------------------------------------ //
// Check simulation on all the chest currently being hit by one of our bombs (Simulate())
// If it's gonna be destroyed, turns the cell red otherwise turn it yellow
if (sim != null)
foreach (var kv in sim.HitChests)
_previewCells[kv.Key] = kv.Value.WillDestroy ? "pdestroy" : "phit-survive";
// checks if there's a bomb in our chain, that has depth > 0
// meaning, if a bomb we hand-placed, touches another bomb (from grid spawn)
// if so, give it pchain (but not used yet on that gamemode)
if (sim != null)
foreach (var node in sim.Sequence)
if (node.Depth > 0)
if (!_previewCells.ContainsKey((node.R, node.C)))
_previewCells[(node.R, node.C)] = "pchain";
// ------------------------------------------------------------------------------------ //
// --------------------------------- PREVIEW ONLY ------------------------------------- //
// ------------------------------------------------------------------------------------ //
// ------------------------------------------------------------------------------------ //
// 1 ------------------ HOVER PREVIEW — highest priority, runs last ------------------- //
// ------------------------------------------------------------------------------------ //
if ( _hoverRow >= 0 && _hoverCol >= 0 && hand.SelectedSlot >= 0 && !hand.IsDetonating )
{
// The cell we're hovering
var hoverCell = Grid.Grid[_hoverRow, _hoverCol];
if ( hoverCell.IsEmpty )
{
// Bomb type + color
var bombType = hand.BombAt( hand.SelectedSlot );
string hoverCol = hand.SelectedSlot switch { 0 => "p0", 1 => "p1", _ => "p2" };
// Loop through each of the cell's blast cells according to our hand-bomb type
foreach ( var cell in Grid.GetBlastCells( _hoverRow, _hoverCol, bombType, _hoverRot, ChainReactionGame.Instance?.GetRangeBonus(bombType) ?? 0 ))
{
var key = (cell.Row, cell.Col);
var gridCell = Grid.Grid[cell.Row, cell.Col];
// Already marked as destroy by simulation — don't touch
if ( _previewCells.TryGetValue( key, out var existing ) && existing == "pdestroy" )
continue;
string cls;
if ( gridCell.HasChest )
cls = "phit-hover"; // pulsing highlight, not red
else
cls = $"pblast {hoverCol}"; // empty cell — show blast path
_previewCells[key] = cls;
}
}
}
// ------------------------------------------------------------------------------------ //
// 2 ── BOMB CHEST CHAIN PREVIEW — show blast of bomb chests (depth > 0) hit by hover ──
// ------------------------------------------------------------------------------------ //
if ( _hoverRow >= 0 && _hoverCol >= 0 && hand.SelectedSlot >= 0 && !hand.IsDetonating )
{
string hoverCol = hand.SelectedSlot switch { 0 => "p0", 1 => "p1", _ => "p2" };
// Track visited bomb chests to prevent infinite loops (A hits B hits A etc.)
var visited = new HashSet<(int,int)>();
// We process in a queue so newly discovered bomb chests also get expanded
var queue = new Queue<(int,int)>();
// Seed the queue with bomb chests already marked phit-hover from the main hover pass
foreach ( var key in _previewCells.Keys.ToList() )
{
// only process cells marked as phit hover previously
if ( _previewCells[key] != "phit-hover" ) continue;
var gridCell = Grid.Grid[key.Item1, key.Item2];
// Only look for chests that are bomb chests
if ( gridCell.Chest != GridManager.ChestType.BombChest ) continue;
// enqueue - passed all the tests
queue.Enqueue( key );
}
// Now only process bomb chests
// (recursed way, so if their range blast another bomb, it will be added to 'queue')
while ( queue.Count > 0 )
{
var key = queue.Dequeue();
// Check if that bomb chest already has been processed — do not process it twice
if ( visited.Contains( key ) ) continue;
visited.Add( key );
// This bomb chest is in hover range — show its blast
// (so far, only one type of chest bomb — might need to modify later on)
// Loop over THAT chest bomb's own blast cells
foreach ( var blastCell in Grid.GetBlastCells( key.Item1, key.Item2,
GridManager.BombType.Cross, 0, ChainReactionGame.Instance?.GetRangeBonus(GridManager.BombType.Cross) ?? 0) )
{
var bkey = (blastCell.Row, blastCell.Col);
// Do not override cells already processed
if ( _previewCells.ContainsKey( bkey ) ) continue;
// Retrieve the Cell object from the Grid using its coords
var bc = Grid.Grid[blastCell.Row, blastCell.Col];
// If it has a chest, mark it as hover-hit
// If it's a bomb chest too, enqueue it for further expansion (recursion via queue)
// If empty, give it the hand-bomb's neutral blast color
if ( bc.HasChest )
{
_previewCells[bkey] = "phit-hover";
if ( bc.Chest == GridManager.ChestType.BombChest )
queue.Enqueue( bkey ); // this bomb chest will also be expanded
}
else
{
_previewCells[bkey] = $"pblast {hoverCol}";
}
}
}
}
}
string GetPreviewCss( int r, int c )
{
if ( Hand?.IsDetonating == true ) return "";
// Origin cell — show slot color
if ( r == _hoverRow && c == _hoverCol )
{
var cell = Grid?.Grid[r,c];
var hand = Hand;
if ( cell != null && cell.IsEmpty && hand != null && hand.SelectedSlot >= 0 )
{
string slotCol = hand.SelectedSlot switch { 0 => "p0", 1 => "p1", _ => "p2" };
return $"cell-hover-origin {slotCol}";
}
}
// All other cells — check preview dict (includes hover blast range)
return _previewCells.TryGetValue( (r,c), out var v ) ? v : "";
}
protected override int BuildHash()
{
var grid = Grid;
if (grid == null) return 0;
var hc = new System.HashCode();
for (int r = 0; r < grid.Rows; r++)
for (int c = 0; c < grid.Cols; c++)
{
var cell = grid.Grid[r,c];
hc.Add((int)cell.Chest);
hc.Add((int)cell.Bomb);
hc.Add(cell.HitsLeft);
}
hc.Add(Hand?.SelectedSlot ?? -1);
hc.Add(Hand?.PlacedSlots.Count ?? 0);
hc.Add(_previewCells.Count);
hc.Add(_hoverRow);
hc.Add(_hoverCol);
// Consumable Reveal
hc.Add(ChainReactionGame.Instance?.RevealActive ?? false);
hc.Add(ChainReactionGame.Instance?.RevealedPositions?.Count ?? 0);
return hc.ToHashCode();
}
void OnCellClick(int r, int c)
{
var hand = Hand;
var game = Game;
if (hand is null || game is null) return;
if (hand.IsDetonating || !game.IsRunning) return;
// ── Tutorial gate ──────────────────────────────────────────── //
// Block everything before the place bomb step (index 2)
var tut = TutorialOverlay.Instance;
bool blockClickDuringTutorial = (tut?.IsActive == true && tut.Step != 3 && tut.Step != 8 && tut.Step != 9);
if (tut?.IsActive == true)
{
// Before step 2 (place bomb) — block all cell clicks (step (index) 0 - 1 - 2)
if (tut.Step < 2) return;
// On step 2 — only allow placing on cells adjacent to a chest
if (tut.Step == 3)
{
var clickedCell = Grid.Grid[r, c];
// no removing during tutorial
if (clickedCell.HasBomb) return;
bool firstBombAlreadyPlaced = (Hand?.PlacedSlots.Count ?? 0) >= 1;
// Only restrict if no bombs placed yet
// Once first bomb is placed, allow second placement anywhere empty
if (!firstBombAlreadyPlaced && !HasChestNearby(r, c)) return;
}
// Steps 4, 5, 6, 7 — block (not placement steps)
else if (tut.Step != 8 && tut.Step != 9)
return;
}
// ───────────────────────────────────────────────────────────── //
var cell = Grid.Grid[r,c];
if (cell.HasBomb)
hand.RemoveBomb(r,c);
else if (!cell.HasChest)
{
if (hand.SelectedSlot < 0 || hand.IsSlotPlaced(hand.SelectedSlot)) return;
hand.TryPlace(r, c, _hoverRot); // ← pass preview rotation
}
_hoverRot = 0; // reset rotation after placing
RebuildPreview();
StateHasChanged();
}
// Useful check for tutorial checking if the range hits a chest (only allow this click)
bool HasChestNearby(int r, int c)
{
// Check all blast cells for this bomb type — if any has a chest, it's a valid placement
var hand = Hand;
if (hand == null || Grid == null) return false;
var bombType = hand.BombAt(hand.SelectedSlot);
var blastCells = Grid.GetBlastCells(r, c, bombType, _hoverRot,
ChainReactionGame.Instance?.GetRangeBonus(bombType) ?? 0);
return blastCells.Any(cell => cell.HasChest);
}
public MouseButtons MouseButton { get; set; }
void OnCellMouseDown( PanelEvent e, int r, int c )
{
// block during tuto
var tut = TutorialOverlay.Instance;
bool blockClickDuringTutorial = (tut?.IsActive == true && tut.Step != 2 && tut.Step != 3 && tut.Step != 8 && tut.Step != 9);
if(blockClickDuringTutorial) return;
var mouse = e as MousePanelEvent;
if ( mouse?.MouseButton == MouseButtons.Right )
{
// rotate sniper bomb that was put on that cell
var cell = Grid?.Grid[r,c];
if ( cell != null && cell.HasBomb && cell.CanRotate)
{
Hand?.RotateSniper( r, c );
TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.SniperRotated); // if tuto
Sound.Play("RotateBomb");
}
// Allow rotating when hovering an empty cell
else if ( cell != null && cell.IsEmpty)
{
var selectedBomb = Hand?.BombAt(Hand?.SelectedSlot ?? -1);
if (selectedBomb == GridManager.BombType.Sniper)
{
// Cycle preview rotation
_hoverRot = (_hoverRot + 1) % 4;
TutorialOverlay.Instance?.TryAdvance(TutorialOverlay.TutorialTrigger.SniperRotated); // if tuto
Sound.Play("RotateBomb");
}
}
RebuildPreview();
StateHasChanged();
}
}
string CellCss( GridManager.Cell cell, int r, int c )
{
// Reveal — show ghost of next wave chest positions
if (ChainReactionGame.Instance?.RevealActive == true)
{
var revealed = ChainReactionGame.Instance.RevealedPositions
.FirstOrDefault(p => p.r == r && p.c == c);
if (revealed != default)
{
return revealed.type switch {
GridManager.ChestType.Gem => "cell empty cell-reveal-gem",
GridManager.ChestType.BombChest => "cell empty cell-reveal-bomb",
GridManager.ChestType.Locked => "cell empty cell-reveal-locked",
GridManager.ChestType.Cursed => "cell empty cell-reveal-cursed",
GridManager.ChestType.Void => "cell empty cell-reveal-void",
_ => "cell empty cell-reveal"
};
}
}
// Tutorial invalid placement hint
var tut = TutorialOverlay.Instance;
if (tut?.IsActive == true && tut.Step == 4 && cell.IsEmpty && !HasChestNearby(r, c))
{
return "cell empty tut-invalid";
}
string spawnCls = "";
if (cell.HasChest && !_spawnedThisWave.Contains((r, c)))
{
_spawnedThisWave.Add((r, c));
spawnCls = " chest-spawn";
}
// differentiate bombs (including chest) from pure chests (coins)
bool destroyed = _destroyedCells.Contains((r, c));
bool fired = _firedCells.Contains((r, c));
// Destroyed chest — dark grey persistent state
if (destroyed)
return "cell empty cell-scorched";
// Fired bomb cell — dark after explosion
if (fired && !cell.HasChest && !cell.HasBomb)
return "cell empty cell-fired";
// give css to cells (allow animation spawn)
if (cell.HasChest && ChainReactionGame.Instance?.IsMysteryWaveActive == true)
return $"cell chest mystery{spawnCls}";
if (cell.HasBomb) return $"cell bomb{spawnCls}";
if (cell.HasChest) return cell.Chest switch
{
GridManager.ChestType.Boss => $"cell boss{spawnCls}",
GridManager.ChestType.Secret => $"cell secret{spawnCls}",
GridManager.ChestType.Locked => $"cell locked{spawnCls}",
GridManager.ChestType.Cursed => $"cell cursed{spawnCls}",
GridManager.ChestType.Void => $"cell void{spawnCls}",
_ => IsDangerColumn(c) ? $"cell chest danger{spawnCls}" : $"cell chest{spawnCls}"
};
// Empty cell in danger column also gets warning glow
return IsDangerColumn(c) ? "cell empty danger-col" : "cell empty";
}
bool IsDangerColumn(int c)
{
// Danger = any chest in the last row of this column
return Grid?.Grid[Grid.Rows - 1, c]?.HasChest == true;
}
string CellIcon( GridManager.Cell cell )
{
// Random Event (Mystery Wave) active?
if (cell.HasChest && ChainReactionGame.Instance?.IsMysteryWaveActive == true)
return "❓"; // hide chest type
if (cell.HasBomb)
{
if ( cell.Bomb == GridManager.BombType.Sniper )
{
string arrow = cell.BombRot switch {
0 => "↑",
1 => "→",
2 => "↓",
3 => "←",
_ => "↑"
};
return arrow;
}
return cell.Bomb switch
{
GridManager.BombType.Cross => "✛",
GridManager.BombType.Diagonal => "✕",
GridManager.BombType.Square => "■",
GridManager.BombType.Chain => "⛓",
_ => "?"
};
}
if (cell.HasChest) return cell.Chest switch
{
GridManager.ChestType.Gem => "💎",
GridManager.ChestType.BombChest => "💣",
GridManager.ChestType.Secret => "❓",
GridManager.ChestType.Boss => "👑",
GridManager.ChestType.Locked => "🔒",
GridManager.ChestType.Cursed => "💀",
GridManager.ChestType.Void => "🌑",
_ => "🎁"
};
return "";
}
int GetOrder(int r, int c)
{
var order = Hand?.PlacementOrder;
var slots = Hand?.PlacedSlots;
if (order is null || slots is null) return 0;
int key = -1;
foreach (var kv in slots)
if (kv.Value == (r,c)) { key = kv.Key; break; }
if (key < 0) return 0;
int idx = order.IndexOf(key);
return idx >= 0 ? idx + 1 : 0;
}
void OnBombFiredGrid( int r, int c, int depth )
{
// track fired cells for dark grey after explosion
_firedCells.Add((r, c));
string originCls = depth == 0 ? "flash-direct" : "flash-chain";
string blastCls = depth == 0 ? "flash-blast-direct" : "flash-blast-chain";
// Handle flash animation thing'
if ( !_flashedThisDetonation.Contains( (r,c) ) )
{
_flashedThisDetonation.Add( (r,c) );
_flashCells[(r,c)] = originCls;
}
var grid = Grid;
var sim = Hand?.LastSimResult;
if ( grid == null || sim == null ) return;
var node = sim.Sequence.FirstOrDefault( n => n.R == r && n.C == c );
if ( node == null ) return;
foreach ( var cell in grid.GetBlastCells( r, c, node.Type, node.Rot ) )
{
var key = (cell.Row, cell.Col);
if ( _flashedThisDetonation.Contains( key ) ) continue;
_flashedThisDetonation.Add( key );
_flashCells[key] = blastCls;
}
// no StateHasChanged here — prevents animation restart
}
public (float x, float y) GetCellScreenPos( int row, int col )
{
var rect = Panel.Box.Rect;
float availableWidth = rect.Width - 130f;
float gridWidth = 5 * 110f + 4 * 6f;
float gridLeft = rect.Left + (availableWidth - gridWidth) / 2f;
float gridTop = rect.Top + (rect.Height - gridWidth) / 2f;
float x = gridLeft + col * 116f + 55f;
float y = gridTop + row * 116f + 55f;
return (x, y);
}
// -------------------------- //
// Hover Bomb Preview on Cell //
// -------------------------- //
string GetHoverCss( GridManager.Cell cell, int r, int c )
{
if ( _hoverRow != r || _hoverCol != c ) return "";
if ( !cell.IsEmpty ) return "";
var hand = Hand;
if ( hand == null || hand.SelectedSlot < 0 || hand.IsDetonating ) return "";
return "hover-preview";
}
string HoverBombIcon()
{
// block tutorial
var tut = TutorialOverlay.Instance;
bool blockClickDuringTutorial = (tut?.IsActive == true && tut.Step != 2 && tut.Step != 3 && tut.Step != 8 && tut.Step != 9);
if(blockClickDuringTutorial) return "";
if ( Hand == null ) return "";
var bombType = Hand.BombAt( Hand.SelectedSlot );
if ( bombType == GridManager.BombType.Sniper )
{
return _hoverRot switch {
0 => "↑",
1 => "→",
2 => "↓",
3 => "←",
_ => "↑"
};
}
return bombType switch {
GridManager.BombType.Cross => "✛",
GridManager.BombType.Diagonal => "✕",
GridManager.BombType.Square => "■",
GridManager.BombType.Chain => "⛓",
_ => ""
};
}
// GridPanel.razor
@* protected override void OnMouseOver( MousePanelEvent e )
{
// Find which cell was hovered by checking event target
var panel = e.Target;
// We need row/col from the panel — store them as data attributes
base.OnMouseOver( e );
Log.Warning(panel);
} *@
void OnCellHoverEvent( PanelEvent e, int r, int c )
{
// Block during tutorial
var tut = TutorialOverlay.Instance;
bool blockClickDuringTutorial = (tut?.IsActive == true && tut.Step != 2 && tut.Step != 3 && tut.Step != 8 && tut.Step != 9);
if(blockClickDuringTutorial) return;
e.StopPropagation();
if ( _hoverRow == r && _hoverCol == c ) return;
_hoverRow = r;
_hoverCol = c;
RebuildPreview();
StateHasChanged();
}
void OnCellLeaveEvent( PanelEvent e, int r, int c )
{
e.StopPropagation();
// Only clear if we're leaving the cell we think is hovered
if ( _hoverRow != r || _hoverCol != c ) return;
_hoverRow = -1;
_hoverCol = -1;
RebuildPreview();
StateHasChanged();
}
// -------------------------------------------------------- //
// -------------- Hover Bomb Preview on Cell -------------- //
// -------------------------------------------------------- //
// Apply a css class only on spawn - avoids animation when rebuilding state (cells spawn anim)
// -> problem was : when hovering,it was rebuilding the state (and cells were reconstructed spawn anim' was triggered again)
// -> same problem : when we hit Detonate() button, state was rebuilt and not died cells were animating again
// so, we check if we changed wave, then Clear() the _spawnedThisWave cells list to make sure they animate only once/wave
HashSet<(int,int)> _spawnedThisWave = new();
int _lastKnownWave = -1; // wave mode
int _lastKnownSurvivalTick = -1; // survival mode
string GetSpawnDelay(int r, int c)
{
if (!_spawnedThisWave.Contains((r, c))) return "";
float delay = (r + c) * 0.04f; // diagonal stagger
return $"animation-delay:{delay.ToString("F2")}s;";
}
// ------------------------------------------------------------------------------------------------ //
// -------------- Leaves the cell dark grey after explosion (chest/hand-placed bomb) -------------- //
// ------------------------------------------------------------------------------------------------ //
HashSet<(int,int)> _destroyedCells = new();
HashSet<(int,int)> _firedCells = new();
// handler - called via delegate h.OnChestCleared
void OnChestClearedGrid(int r, int c, int coins, float mult)
{
_destroyedCells.Add((r, c));
}
}