// GDnD wiki example // Demonstrates: a tile maze with pellets, cell<->world conversion, and BFS distance for ghost pathfinding // Related pages: [[overview-arcade-classics-as-learning-projects]], [[unity-tilemap]], [[pathfinding-algorithms]], [[csharp-collections]] using System.Collections.Generic; using UnityEngine; // The maze is the shared world for the player and the ghosts. It owns the wall // layout, the pellets, the conversion between grid cells and world positions, and // a BFS distance query the ghosts use to find their way. The layout is generated // as a "pillar" maze (border + isolated single-cell pillars) so it is always // fully connected — see the README for swapping in a hand-authored tilemap. public class MazeGrid : MonoBehaviour { public enum Pellet { None, Normal, Power } [SerializeField] private int width = 15; // use odd numbers [SerializeField] private int height = 11; // use odd numbers [SerializeField] private float cellSize = 0.6f; [Header("Tile prefabs")] [SerializeField] private Transform wallPrefab; [SerializeField] private Transform pelletPrefab; [SerializeField] private Transform powerPelletPrefab; public static readonly Vector2Int[] Directions = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right }; public float CellSize => cellSize; public int Width => width; public int Height => height; public Vector2Int PlayerStart { get; private set; } public List GhostStarts { get; } = new List(); public int PelletsRemaining { get; private set; } private bool[,] walls; private Pellet[,] pellets; private readonly Dictionary pelletVisuals = new Dictionary(); public void Build() { // Clear any visuals from a previous level. for (int i = transform.childCount - 1; i >= 0; i--) { Destroy(transform.GetChild(i).gameObject); } walls = new bool[width, height]; pellets = new Pellet[width, height]; pelletVisuals.Clear(); GhostStarts.Clear(); PelletsRemaining = 0; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { bool border = x == 0 || y == 0 || x == width - 1 || y == height - 1; bool pillar = x % 2 == 0 && y % 2 == 0; // isolated → never disconnects the maze walls[x, y] = border || pillar; } } PlayerStart = new Vector2Int(1, 1); GhostStarts.Add(NearestOpen(new Vector2Int(width / 2, height / 2))); GhostStarts.Add(NearestOpen(new Vector2Int(width / 2, height / 2 - 1))); var powerCells = new HashSet { new Vector2Int(1, height - 2), new Vector2Int(width - 2, 1), new Vector2Int(width - 2, height - 2), }; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { if (walls[x, y]) { continue; } Vector2Int cell = new Vector2Int(x, y); if (cell == PlayerStart || GhostStarts.Contains(cell)) { continue; } pellets[x, y] = powerCells.Contains(cell) ? Pellet.Power : Pellet.Normal; PelletsRemaining++; } } BuildVisuals(); } public bool IsWalkable(Vector2Int cell) { return InBounds(cell) && !walls[cell.x, cell.y]; } public Vector3 CellToWorld(Vector2Int cell) { float originX = -((width - 1) * cellSize) / 2f; float originY = -((height - 1) * cellSize) / 2f; return transform.position + new Vector3(cell.x * cellSize + originX, cell.y * cellSize + originY, 0f); } public Pellet EatPellet(Vector2Int cell) { if (!InBounds(cell)) { return Pellet.None; } Pellet pellet = pellets[cell.x, cell.y]; if (pellet != Pellet.None) { pellets[cell.x, cell.y] = Pellet.None; PelletsRemaining--; if (pelletVisuals.TryGetValue(cell, out Transform visual)) { if (visual != null) { Destroy(visual.gameObject); } pelletVisuals.Remove(cell); } } return pellet; } // Breadth-first shortest-path length between two walkable cells. This is the // "simple pathfinding" the ghosts rely on; for larger maps see A* on // [[pathfinding-algorithms]]. public int Distance(Vector2Int from, Vector2Int to) { if (from == to) { return 0; } if (!IsWalkable(from) || !IsWalkable(to)) { return int.MaxValue; } var visited = new HashSet { from }; var queue = new Queue<(Vector2Int cell, int dist)>(); queue.Enqueue((from, 0)); while (queue.Count > 0) { (Vector2Int cell, int dist) = queue.Dequeue(); foreach (Vector2Int dir in Directions) { Vector2Int next = cell + dir; if (!IsWalkable(next) || visited.Contains(next)) { continue; } if (next == to) { return dist + 1; } visited.Add(next); queue.Enqueue((next, dist + 1)); } } return int.MaxValue; } private bool InBounds(Vector2Int cell) { return cell.x >= 0 && cell.y >= 0 && cell.x < width && cell.y < height; } private Vector2Int NearestOpen(Vector2Int cell) { if (IsWalkable(cell)) { return cell; } foreach (Vector2Int dir in Directions) { if (IsWalkable(cell + dir)) { return cell + dir; } } return PlayerStart; } private void BuildVisuals() { for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { Vector2Int cell = new Vector2Int(x, y); if (walls[x, y]) { if (wallPrefab != null) { Place(wallPrefab, cell, cellSize); } continue; } if (pellets[x, y] == Pellet.Normal && pelletPrefab != null) { pelletVisuals[cell] = Place(pelletPrefab, cell, cellSize * 0.2f); } else if (pellets[x, y] == Pellet.Power && powerPelletPrefab != null) { pelletVisuals[cell] = Place(powerPelletPrefab, cell, cellSize * 0.45f); } } } } private Transform Place(Transform prefab, Vector2Int cell, float scale) { Transform tile = Instantiate(prefab, CellToWorld(cell), Quaternion.identity, transform); tile.localScale = Vector3.one * scale; return tile; } }