Game/2D/GridManager.cs

GridManager component that owns a 2D grid of cells for a tile-based game. It defines cell types (chests, bombs), initializes and resizes the grid, converts between grid and world positions, computes blast patterns for bomb types and provides helpers to query empty/chest cells and column state.

Native Interop
using Sandbox;
using System;
using System.Collections.Generic;
using static GridManager;
using static Sandbox.Services.Inventory;
using static Sandbox.Services.Leaderboards;

/// <summary>
/// Owns the grid state. No MonoBehaviour deps — pure logic.
/// Attach this to a GameObject called "GridManager" in your scene.
/// </summary>
public sealed class GridManager : Component
{
	// ── Config ────────────────────────────────────────────────────────────
	[Property] public int Cols { get; set; } = 5;
	[Property] public int Rows { get; set; } = 5;
	[Property] public float CellSize { get; set; } = 64f;   // world units
	[Property] public float CellGap { get; set; } = 4f;

	// ── Cell Data ─────────────────────────────────────────────────────────
	public enum ChestType { None, Normal, Gem, BombChest, Secret, Boss, Void, Locked, Cursed}
	public enum BombType { None, Cross, Sniper, Diagonal, Square, Chain }

	// Definition of a cell in a grid (foundational for a cell-based game)
	public class Cell
	{
		public int Row, Col;  // cells coords

		// Chest
		public ChestType Chest = ChestType.None;
		public int HitsLeft = 0;   // gem/locked need 2 hits

		// Placed bomb
		public BombType Bomb = BombType.None;
		public int BombRot = 0;   // sniper: 0=up 1=right 2=down 3=left

		public bool HasChest => Chest != ChestType.None;
		public bool HasBomb => Bomb != BombType.None;
		public bool IsEmpty => !HasChest && !HasBomb;

		public bool CanRotate = false;
	}

	// ── State ─────────────────────────────────────────────────────────────
	public Cell[,] Grid { get; private set; }

	public static GridManager Instance { get; private set; }

	protected override void OnStart()
	{
		Instance = this;
		InitGrid();
	}

	public void InitGrid()
	{
		Grid = new Cell[Rows, Cols];
		for ( int r = 0; r < Rows; r++ )
			for ( int c = 0; c < Cols; c++ )
				Grid[r, c] = new Cell { Row = r, Col = c };
	}

	// ── World Position ────────────────────────────────────────────────────

	// These two functions exist for the 3D game world layer — specifically for things like playing sounds at the right position in space
	// or spawning effect at a specific 3D world position on a tile (but we're making the game 2D for now)

	/// Returns the 2-D centre of a cell in world space (Z=0).
	public Vector3 CellWorldPos( int row, int col )
	{
		float step = CellSize + CellGap;
		float x = col * step;
		float y = -row * step;          // rows grow downward
		return WorldPosition + new Vector3( x, y, 0f );
	}

	// TryGetCellAtWorld is the inverse — if you ever raycast from a camera into the world and get a hit position, you can reverse it back to a grid cell. 
	// It's not used yet but would be needed if you ever added 3D input (clicking on world-space tiles rather than the HTML panel).
	public bool TryGetCellAtWorld( Vector3 worldPos, out Cell cell )
	{
		float step = CellSize + CellGap;
		Vector3 local = worldPos - WorldPosition;
		int c = (int)MathF.Round( local.x / step );
		int r = (int)MathF.Round( -local.y / step );
		if ( InBounds( r, c ) ) { cell = Grid[r, c]; return true; }
		cell = null; return false;
	}

	// ── Bounds ────────────────────────────────────────────────────────────

	// This just asks: does this row/column coordinate actually exist in the grid? 
	// On a 5×5 grid, valid rows are 0–4, valid cols are 0–4. Anything outside that is off the edge.
	public bool InBounds( int r, int c ) => r >= 0 && r < Rows && c >= 0 && c < Cols;

	// ── Blast Patterns ────────────────────────────────────────────────────
	/// Returns every cell hit by a bomb of the given type/rotation at (r,c).
	public List<Cell> GetBlastCells( int r, int c, BombType type, int rot = 0, int rangeBonus = 0 )
	{
		var result = new List<Cell>();

		// loops through all the blast cells that this bomb type reaches
		foreach ( var (dr, dc) in GetDirs( type, rot, rangeBonus) )
		{
			// nr = new result (blast cell) > r + dr (= row = direction row > gives the blast row coord) - same thing for c + dc
			int nr = r + dr, nc = c + dc; 

			// if that new row ; new col is in bound 
			// (i.e : if a sniper bomb is placed at (4;4) (bottom right edge) of a 5x5 grid, in the right dir, it would reach 3 coords that are out of bouns :
			// since it affects 3 tiles (dir x1, x2, x3) ahead. We only add in bound blast cells to avoid crashes.
			if ( InBounds( nr, nc ) ) result.Add( Grid[nr, nc] );
		}

		// returns valid in bound blast cells
		return result;

		// Notes : 
		// 
		// dr and dc are deltas — offsets from the bomb's position. So if the bomb is at (2, 2) and the current direction is (-1, 0), 
		// then nr = 2 + (-1) = 1, nc = 2 + 0 = 2. That's the cell directly above.
		//
		// The InBounds check is the edge clipping guard.Without it, a Cross bomb sitting in the top row would try to access row -1 which doesn't exist 
		// = instant crash. With it, those out-of-bounds directions are silently skipped. The bomb just doesn't hit cells that don't exist.

		// dr = direction row ; dc = direction col
		// yields a list (enumeration) of multiple coords for each bomb (those coords indicates the range area reached per bomb)
	}

	public void ClearAllChests()
	{
		for (int r = 0; r < Rows; r++)
			for (int c = 0; c < Cols; c++)
			{
				var cell = Grid[r, c];
				if (cell.HasChest)
				{
					cell.Chest    = ChestType.None;
					cell.HitsLeft = 0;
				}
			}
	}

	static IEnumerable<(int dr, int dc)> GetDirs( BombType type, int rot, int rangeBonus = 0 )
	{
		switch ( type )
		{
			case BombType.Cross: // a square like cross bomb 
				yield return (0, 0);
				yield return (-1, 0); yield return (1, 0);
				yield return (0, -1); yield return (0, 1);
				// Range bonus extends cross arms
				if(rangeBonus >= 1)
				{
					yield return (-2, 0); yield return (2, 0);
					yield return (0, -2); yield return (0, 2);
				}
				break;

			// a sniper bomb that fires in one direction over 3 square (returns all these tiles coords)
			case BombType.Sniper:
				// rot: 0=up 1=right 2=down 3=left
				yield return (0, 0);
				
				// returns the direction we should aim for 
				// (r = -1, c = 0) is up (row decreases = moves toward row 0 = top of grid). Left would be (0, -1).
				(int dr, int dc) dir = rot switch
				{
					0 => (-1, 0), // top
					1 => (0, 1), // right
					2 => (1, 0), // down
					_ => (0, -1) // left
				};
				// gets squares on that direction
				yield return dir;
				yield return (dir.dr * 2, dir.dc * 2); 
				yield return (dir.dr * 3, dir.dc * 3);

				// Range bonus adds a 4th tile
				if(rangeBonus >= 1)
					yield return (dir.dr * 4, dir.dc * 4);
				break;

			// a diagonal bomb that goes over 2 square length in diagonals
			case BombType.Diagonal:
				yield return (0, 0);
				foreach ( var d in new[] { (-1, -1), (-1, 1), (1, -1), (1, 1) } ) yield return d;
				foreach ( var d in new[] { (-2, -2), (-2, 2), (2, -2), (2, 2) } ) yield return d;
				break;

			case BombType.Square: // bomb (+ shape) => a 3x3 square (-1, 0, 1) row ; (-1, 0, 1) col (3x3)
				for ( int dr = -1; dr <= 1; dr++ )
					for ( int dc = -1; dc <= 1; dc++ )
						yield return (dr, dc);
				break;

			case BombType.Chain:
				// Same as Cross — but ChainReactionGame handles the secondary explosions
				yield return (0, 0);
				yield return (-1, 0); yield return (1, 0);
				yield return (0, -1); yield return (0, 1);
				break;
		}
	}

	// Note about IEnumerable<T> :
	// 
	// Normal functions compute everything and return one value.
	// 
	// A function returning IEnumerable with yield return is different — it's a lazy sequence generator. 
	//
	// Every time the caller asks for the next item, the function runs until it hits the next yield return, hands that value out, then pauses. 
	// It resumes from exactly where it left off when the next item is requested.

	// So this:
	//
	// yield return (0, 0);
	// yield return (-1, 0);
	// yield return (1, 0);
	//
	// > Doesn't return three things simultaneously. It's more like a vending machine — the caller pulls one item at a time.
	// The foreach in GetBlastCells is the caller doing the pulling.
	// The reason it's used here instead of just building a list is laziness and clarity :
	// 
	// => 1. You write the directions as a readable sequence, without needing a temporary list inside GetDirs. 
	// => 2. GetBlastCells then filters each one through InBounds as it receives them.

	// ── Helpers ───────────────────────────────────────────────────────────

	// Checks how many chests exist in a column. (loops through every rows in that col)
	public int ChestCountInColumn( int col )
	{
		int n = 0;
		for ( int r = 0; r < Rows; r++ ) // loops through every rows (0 - 4) (grid is 5x5 so far)
			if ( Grid[r, col].HasChest ) n++;
		return n;
	}

	// Is every row in that column, occupied by a chest ? If yes, then column is full (and we lose game)
	public bool IsColumnFull( int col ) => ChestCountInColumn( col ) >= Rows;

	// Rather checks if there's a chest or a bomb in last row
	public bool AnyChestInLastRow()
	{
		for ( int c = 0; c < Cols; c++ )
		{
			var cell = Grid[Rows - 1, c];
			if ( (cell.HasChest && cell.Chest != ChestType.Secret) || cell.HasBomb ) return true;
		}
		return false;
	}

	// returns every cell that is empty (no chest / no bomb)
	public List<Cell> AllEmptyCells()
	{
		var list = new List<Cell>();
		for ( int r = 0; r < Rows; r++ )
			for ( int c = 0; c < Cols; c++ )
				if ( Grid[r, c].IsEmpty ) list.Add( Grid[r, c] );
		return list;
	}

	// returns every cells that have a chest 
	public List<Cell> AllChestCells()
	{
		var list = new List<Cell>();
		for ( int r = 0; r < Rows; r++ )
			for ( int c = 0; c < Cols; c++ )
				if ( Grid[r, c].HasChest ) list.Add( Grid[r, c] );
		return list;
	}

// ------------- GRID DYNAMIC RESIZE ------------- //
	public void ResizeGrid(int newRows, int newCols)
	{
		var oldGrid = Grid;
		int oldRows = Rows;
		int oldCols = Cols;

		Rows = newRows;
		Cols = newCols;

		Grid = new Cell[Rows, Cols];
		for (int r = 0; r < Rows; r++)
			for (int c = 0; c < Cols; c++)
				Grid[r, c] = new Cell { Row = r, Col = c };

		// Preserve existing chest and bomb positions
		for (int r = 0; r < MathF.Min(oldRows, Rows); r++)
			for (int c = 0; c < MathF.Min(oldCols, Cols); c++)
			{
				Grid[r, c].Chest    = oldGrid[r, c].Chest;
				Grid[r, c].HitsLeft = oldGrid[r, c].HitsLeft;
				Grid[r, c].Bomb     = oldGrid[r, c].Bomb;
				Grid[r, c].BombRot  = oldGrid[r, c].BombRot;
			}
	}
}