Game/2D/UI/GridPanel.razor

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.

NetworkingFile Access
@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));
    }
}