Saturday, September 21, 2019

Tutorial: Making A Minesweeper Game

OVERVIEW

To make a Minesweeper game we need to implement a few elements:
1) Arrange the cells in a grid fashion and determine the grid location of user mouse clicks.
2) Interact with the cells and spawn mines.
3) Uncover clicked cell and recursively uncover nearby cells.
4) Place flags to mark suspected mines.
5) Create a win/loss condition.
6) Set up functions to allow the game to be started/reset by an outside script.

For this example I will be using Unity and C# but all the code can be adapted to whatever language and game engine you choose to use.

SETTING UP THE GRID

First we need to create a function to create the grid.
void InitializeGameBoard(Vector2Int bDimensions, int cSize,Vector2Int bOffset,int nOfMines)

Then, we set some values.
     boardDimensions = bDimensions;  
     cellSize = cSize;  
     offSet = bOffset;  
     numberOfMines = nOfMines;  
We need the board dimensions(rows and columns) to determine how many cells to initialize.  We need the cell size to determine spacing.  We need an offset if we want to place the grid anywhere but the center of the screen.  We need the number of mines for later use when spawning mines.  We store all these values for later use.

I decided to set the top-left most position to be grid element (0,0).  First we need to determine the starting position.  To do this we need to know the cell size and the number rows and columns.

 Vector2 startingPos = new Vector2(  
       ((-boardWidthAndHeight.x * boardCellSize) / 2f) + boardCellSize / 2f,  
       ((-boardWidthAndHeight.y * boardCellSize) / 2f) + boardCellSize / 2f); 

We subtract the width and height of the grid and add 1/2 the size of the cell to get the correct starting position(the mid point of the cell [0,0]). 


Next, we need to a two dimensional array to store the cells.  

cells = new MineSweeperCell[boardWidthAndHeight.x, boardWidthAndHeight.y];


Then, we need to position each individual cell and instantiate them.

for (int y = 0; y < boardDimensions.y; y++)
{ 
 for (int x = 0; x < boardDimensions.x; x++)
 {
                cells[x, y] = InitiateCell();

                cells[x, y].cellImage.transform.localPosition = new Vector3(
                    (x* cellSize) +startingPos.x+offSet.x,
                    ((boardDimensions.y-(y+1))* cellSize) +startingPos.y+offSet.y,
                    0);
 }
}

First we instantiate the cell and then assign the correct position.  We use boardDimensions.y-(y+1) instead of y to place the cells in descending order(y=0 at the top) because Unity sets the y=0 pixels at the bottom of the screen.

 MineSweeperCell InitiateCell()
 {
        GameObject tempGO = new GameObject();
        GameObject tempTextGO = new GameObject();
        Text tempText = tempTextGO.AddComponent<Text>();

        tempText.rectTransform.sizeDelta = new Vector2(cellSize - 10, cellSize - 10);
        tempText.alignment = TextAnchor.MiddleCenter;
        tempText.font = Resources.GetBuiltinResource(typeof(Font), "Arial.ttf") as Font;
        tempText.fontSize = cellSize - 20;

        tempGO.transform.parent = cellContainer.transform;
        tempGO.transform.localScale = new Vector3(1f,1f,1f);

        tempTextGO.transform.parent = tempGO.transform;
        tempTextGO.transform.localScale = new Vector3(1f, 1f, 1f);
        tempTextGO.transform.localPosition = Vector3.zero;

        Image tempCell=tempGO.AddComponent<Image>();
        tempCell.rectTransform.sizeDelta = new Vector2(cellSize, cellSize);
        tempCell.sprite = cellCover;

        MineSweeperCell tempMineCell = new MineSweeperCell(tempCell,tempText);

        return (tempMineCell);
 }

For the InitiateCell function we need to create GameObjects to assign an Image and Text to.  The GameObjects need to be placed into the CellContainer to make sure unity draws the cells over the mines or you can use .enabled=false to hide the mine images.  The Text needs to be aligned to the center and assigned a font in order for it to be displayed correctly or you can use a prefab and instantiate that.

public class MineSweeperCell
{
    public Image cellImage;
    public Text cellText;
    public bool isCovered;

    public MineSweeperCell(Image i, Text t)
    {
        cellImage = i;
        cellText = t;
        isCovered = true;
    }
}

The MineSweeperCell class consists of a bool, Image, and Text.  I created a constructor to make it easier to work with.

Finally, we need functions that will use the mouse position on screen to determine the current highlighted cell.  In Unity I'm using a canvas that scales to screen size so we need to use a helper function that converts mouse position to canvas position.


Vector2Int HighlightCell(Vector2Int boardOffSet, int boardCellSize)
{
        Vector2 uiPos = ConvertScreenSpaceToGridSpace();

        Vector2 startingPos = new Vector2(
            ((-cells.GetLength(0) * boardCellSize) / 2f) + boardCellSize / 2f,
            ((-cells.GetLength(1) * boardCellSize) / 2f) + boardCellSize / 2f);

        for (int y = 0; y < cells.GetLength(1); y++)
        {
            for (int x = 0; x < cells.GetLength(0); x++)
            {
                Vector2 cellPos= new Vector3(
                    (x * boardCellSize) + startingPos.x + boardOffSet.x,

                    ((cells.GetLength(1) - (y + 1)) * boardCellSize) + startingPos.y + boardOffSet.y
                    );

                if (CellContainsPoint(cellPos, uiPos, boardCellSize))
                {
                    return (new Vector2Int(x,y));
                }
            }
        }


        return (new Vector2Int(-1,-1));
}
First we have to the mouse position to the UI position using the function ConvertScreenSpaceToGridSpace.  Then we iterate through all cells to search for a cell that contains the position using the CellContainsPoint function and return the cell indexes (x,y).  If none are found we return invalid indexes.

bool CellContainsPoint(Vector2 cellPos, Vector2 uiPos, int cellBounds)
{
        if (uiPos.x < cellPos.x - (cellBounds / 2f) || uiPos.x > cellPos.x + (cellBounds / 2f))
 {
            return (false);
        }
        else if (uiPos.y < cellPos.y - (cellBounds / 2f) || uiPos.y > cellPos.y + (cellBounds / 2f))
        {
            return (false);
        }

        return (true);
}
We simply check if the x and y positions are outside of the cell bounds.  The cell bounds is half the cell size in four directions from the center point (x - cellsize/2 to x + cellsize/2, y - cellsize/2 to y + cellsize/2).

Vector2 ConvertScreenSpaceToGridSpace()
{
        Vector2 temp;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, Input.mousePosition, null, out temp);

        return (new Vector2(temp.x,temp.y));
}
We use a Unity standard function to convert a screen position to a position inside the provided RectTransfom.  In this case the RectTransfom we use is the parent object of our cells.

SPAWNING MINES

I decided to make it impossible for the user to lose on the first turn.  To accomplish this we need the bombs to spawn after the player uncovers a cell.  I used an enum GameState to determine when the player first clicks a cell.

enum GameState
{
    ENDED,INPROGRESS,FIRSTCLICK
}

I created a function to handle player interactions and placed it in the Update function.
void HandleInteraction()
{
        if (Input.GetMouseButtonDown(0))
        {
            if (currentGameState == GameState.INPROGRESS)
            {
                if (Input.GetMouseButtonDown(0))
                {
                    if (!flagMode)
                    {
                        Vector2Int temp = HighlightCell(offSet, cellSize);
                        if (temp.x != -1)
                        {
                            UncoverCell(temp);
                        }
                    }
                    else
                    {
                        Vector2Int temp = HighlightCell(offSet, cellSize);
                        if (temp.x != -1)
                        {
                            PlaceFlag(temp);
                        }
                    }
                }
            }
            else if (currentGameState == GameState.FIRSTCLICK)//spawn bombs after click
            {
                if (Input.GetMouseButtonDown(0))
                {
                    if (!flagMode)
                    {
                        Vector2Int temp = HighlightCell(offSet, cellSize);
                        if (temp.x != -1)
                        {
                            SpawnMines(numberOfMines, temp);
                            UncoverCell(temp);
                        }
                    }
                    else
                    {
                        Vector2Int temp = HighlightCell(offSet, cellSize);
                        if (temp.x != -1)
                        {
                            PlaceFlag(temp);
                        }
                    }
                }
            }
 }
}
We have two sections: The game has started and the mines have spawned or the mines have not spawned yet.  Then we check if we are placing flags or not.


Now we need to spawn the bombs.  I use the rules that bombs cannot spawn in the cell the player tapped on or any adjacent cells(x+-1, y+-1). The easiest way I can think of to spawn the mines is to create a List all valid spawn points and then remove random entries with a for loop.  This leaves us with a List with a count equal to the number of mines we want to spawn. 
void SpawnMines(int n,Vector2Int excludedCell)
{
        currentGameState = GameState.INPROGRESS;

        List<Vector2Int> tempList = new List<Vector2Int>();

        for (int y = 0; y < cells.GetLength(1); y++)
        {
            for (int x = 0; x < cells.GetLength(0); x++)
            {
                if (excludedCell.x==x && excludedCell.y==y)
                {                  
                }
                else if (excludedCell.x-1 == x && excludedCell.y == y)
                {
                }
                else if (excludedCell.x + 1 == x && excludedCell.y == y)
                {
                }
                else if (excludedCell.x == x && excludedCell.y == y+1)
                {
                }
                else if (excludedCell.x - 1 == x && excludedCell.y == y+1)
                {
                }
                else if (excludedCell.x + 1 == x && excludedCell.y == y+1)
                {
                }
                else if (excludedCell.x == x && excludedCell.y == y - 1)
                {
                }
                else if (excludedCell.x - 1 == x && excludedCell.y == y - 1)
                {
                }
                else if (excludedCell.x + 1 == x && excludedCell.y == y - 1)
                {
                }
                else
                {
                    tempList.Add(new Vector2Int(x,y));
                }
            }
        }

        int length = tempList.Count - n;

        for (int i = 0; i < length; i++)
        {
            int tempR = Random.Range(0, tempList.Count);
            tempList.RemoveAt(tempR);
        }

        mineLocations = tempList.ToArray();
        mines = new Image[tempList.Count];

        for (int i = 0; i < tempList.Count; i++)
        {
            mines[i] = InitiateMine();
            mines[i].transform.position = cells[mineLocations[i].x, mineLocations[i].y].cellImage.transform.position;
        }
}
First, we change the game state to indicate that bombs are spawned.  Then, we use two for loops to create a List of all valid cell positions for the mines while excluding the cells that are invalid.  Once we have this List,we calculate the length based on the number of mines and we remove random entries with a for loop to make the List have the same number of entries as number of mines. Then, we convert the List to an array to store for later use.  Finally, we can use the InitiateMine function to create the images and use the array to determine where to place the mine images. 

Uncover Cells

We want to be able to click a cell to uncover it.  If the cell has no mines nearby we recursively uncover all nearby cells until we find a mine.  We use the UncoverCell function to handle when ever we click on a cell.
void UncoverCell(Vector2Int location)
{
        if (CellContainsMine(location))//lose game
        {
            UncoveredMine();
        }

        if (cells[location.x, location.y].isCovered)
        {
            cells[location.x, location.y].cellImage.sprite = cellBG;
            cells[location.x, location.y].isCovered = false;

            //check nearby cells
            RecursivelyUncoverCells(new Vector2Int(location.x, location.y));
        }
        else
        {
            //uncover all nearby cells if bombs are labeled
            UncoverNearbyCells(location);
        }
}
First, we check if there is a bomb in the location and if so lose the game(more on that later).  If the cell is covered we uncover it and use the RecursivelyUncoverCells function to uncover nearby cells until we find a cell with an adjacent mine.  If the cell is not covered we use the UncoverNearbyCells function as a quality of life function to add the ability to uncovered all nearby cells that are not labeled with a flag(more on this later).

The CellContainsMine function is simple.
bool CellContainsMine(Vector2Int location)
{
        for (int i = 0; i < mineLocations.Length; i++)
        {
            if (mineLocations[i].x==location.x && mineLocations[i].y==location.y)
            {
                return (true);
            }
        }
        return (false);
}
Just iterate through mineLocations and return true if we find a match.

We need to use a recursive function to uncover cells that don't have surrounding mines.
It is very important that we are careful not create an infinite loop here.
void RecursivelyUncoverCells(Vector2Int location)
{
        int mineCount = 0;

        for (int x = 0; x < 3; x++)
        {
            for (int y = 0; y < 3; y++)
            {
                if (location.x - 1 + x >= 0 && location.x - 1 + x < cells.GetLength(0))
                {
                    if (location.y - 1 + y >= 0 && location.y - 1 + y < cells.GetLength(1))
                    {
                        if (CellContainsMine(new Vector2Int(location.x - 1 + x, location.y - 1 + y)))
                        {
                            mineCount++;
                        }
                    }
                }
            }
        }

        if (mineCount > 0)
        {
            cells[location.x,location.y].cellText.text = mineCount.ToString();
            cells[location.x, location.y].cellText.gameObject.SetActive(true);
        }
        else//uncover all neighboring squares
        {
            for (int x = 0; x < 3; x++)
            {
                for (int y = 0; y < 3; y++)
                {
                    if (location.x - 1 + x >= 0 && location.x - 1 + x < cells.GetLength(0))
                    {
                        if (location.y - 1 + y >= 0 && location.y - 1 + y < cells.GetLength(1))
                        {
                            if (cells[location.x - 1 + x, location.y - 1 + y].isCovered)
                            {
                                cells[location.x - 1 + x, location.y - 1 + y].cellImage.sprite = cellBG;
                                cells[location.x - 1 + x, location.y - 1 + y].isCovered = false;
                                RecursivelyUncoverCells(new Vector2Int(location.x - 1 + x, location.y - 1 + y));
                            }
                        }
                    }
                }
            }
        }
}
First, we check all nearby cells for mines.  Every uncovered cell needs to have it's sprite updated and the isCovered variable set to false.  If we find mines we don't uncover anymore cells and we label the cell with number of nearby mines using the cellText variable.  If we find no mines we have to repeat the function again with the new location.
It is very important that we never uncover a cell more than once because that will result in an infinite loop.  We need to make sure that every cell that is uncovered has it's isCovered variable set to false.

PLACE FLAGS

Next, we need to allow the player to place flags in order to make it easier for the player to remember which cells have mines under them.  For my implementation I simply created a button the player can press to toggle between placing flags and uncovering cells.  The button links to the function
public void FlagButton()
{
        if (flagMode = !flagMode)
        {
            flagModeSprite.color = Color.white;
        }
        else
        {
            flagModeSprite.color = Color.black;
        }
}
First, we set the flagMode variable equal to it's opposite in an if statement.  Depending on the outcome we set an Image to the color black(false) or white(true) to give the player an indication of when flag mode is on or off.

Earlier we used the HandleInteraction function to handle mouse clicks.  In that function we checked for flagMode on or off and included a function PlaceFlags.
void PlaceFlag(Vector2Int location)
{
        if (flags == null)
        {
            flags = new List<FlagPlacement>();
        }

        if (cells[location.x, location.y].isCovered)
        {
            //first check for flag already there
            int index=-1;

            for (int i = 0; i < flags.Count; i++)
            {
                if (flags[i].flagLocation.x == location.x && flags[i].flagLocation.y == location.y)
                {
                    index = i;
                }
            }


            if (index != -1)
            {
                Destroy(flags[index].flagImage);

                flags.RemoveAt(index);
            }
            else
            {
                flags.Add(InitializeFlag(new Vector2Int(location.x, location.y)));
            }
        }
}
First, we need to initialize our flag List if it is null.  Then, check if the cell is covered.  If the cell is covered we can either add a flag there or remove one that already exists.  We iterate through the flag list to check if a flag is already in the location.  If not we initialize a flag at the that location using the InitializeFlag function and add it to the List.

FlagPlacement InitializeFlag(Vector2Int location)  
{  
      GameObject temp = new GameObject();  
      temp.transform.parent =flagContainer.transform;  
      temp.transform.localScale =new Vector3(1f,1f,1f);  
      temp.gameObject.SetActive(true);  
 temp.transform.position =   
cells[location.x, location.y].cellImage.transform.position; 

 temp.AddComponent<Image>().sprite = flagSprite;  
      temp.GetComponent<Image>().rectTransform.sizeDelta =   
new Vector2(cellSize-20f,cellSize-20f);  

      FlagPlacement flpl = new FlagPlacement(temp, location);  
      return (flpl);  
}  
We set up the flag using some default values and set it parent transform to flagContainer Object(to make sure that we draw the flags in front of the cells) that is created in the start game function.  We also need to set the flag position using the cell position.  We need to add an image component and assign a sprite.  Then we need to adjust the size of the image to fit inside the cells(I chose to shrink the the flag image by 20 pixels on the x and y).  Finally, we create a FlagPlacement and return it.

class FlagPlacement
{
    public GameObject flagImage;
    public Vector2Int flagLocation;

    public FlagPlacement(GameObject f,Vector2Int l)
    {
        flagImage = f;
        flagLocation = l;
    }
}
I included a constructor to make it easier to use.  The flagImage GameObject is needed to destroy when we need to remove a flag.  We use the location to determine which cell the flag is occupying when we iterate through the List.

WIN/LOSS CONDITIONS

We need a way to determine if the player wins or loses.  We should also add a method of calculating a score to indicate player performance.

First we need a function to check for a win or a loss. 
bool CheckForWinCondition()//either all empty cells uncovered or all bombs flagged
{
        if (currentGameState == GameState.ENDED)
        {
            return (false); 
        }

        bool winCondition = true;

        if (flags != null && mines!=null)
        {
            if (mines.Length == flags.Count)
            {
                for (int y = 0; y < cells.GetLength(1); y++)
                {
                    for (int x = 0; x < cells.GetLength(0); x++)
                    {
                        if (!CellContainsFlag(new Vector2Int(x, y)) && CellContainsMine(new Vector2Int(x, y)))
                        {
                            Debug.Log("did not win");
                            winCondition = false;
                        }
                    }
                }
            }
            else
            {
                winCondition = false;
            }
        }
        else
        {
            winCondition = false;
        }
        
        if (!winCondition && currentGameState==GameState.INPROGRESS)//check if only cells with bombs are covered
        {
            Debug.Log("check other");

            winCondition = true;

            for (int y = 0; y < cells.GetLength(1); y++)
            {
                for (int x = 0; x < cells.GetLength(0); x++)
                {
                    if (cells[x,y].isCovered && !CellContainsMine(new Vector2Int(x, y)))
                    {
                        winCondition = false;
                    }
                }
            }
        }

        return (winCondition);
}
First we check if the number of mines is equal to the number of placed flags, if (mines.Length == flags.Count), and if all the mines are flagged by iterating through all the cells with a for loop.  If we find a cell with a mine and without a flag then we set winCondition = false.
If that check fails we need to check if all the cells without mines are uncovered.  Simply iterate through all the cells with a for loop and check if (cells[x,y].isCovered && !CellContainsMine(new Vector2Int(x, y))) using the CellContainsMine function from earlier.

In the Update function I added a check.
if (CheckForWinCondition())
{
            WinGame();
}
In the WinGame function you can place whatever code you need to handle a win but make sure to place this line to stop a check from occurring, again.
currentGameState = GameState.ENDED;
This line of code will notify any functions that operate during gameplay so that we can stop the code from running and will prevent the CheckForWinCondition function from returning true.

For the loss condition we want to stop input from the player, draw the mines in front of the cells, delete the flags, and show a game over popup.  Earlier in the UncoverCell function we had a check for clicking a mine that lead to the UncoveredMine function.
void UncoveredMine()
{
        currentGameState = GameState.ENDED;

        cellContainer.transform.SetAsFirstSibling();

        ClearFlags();
}
First, notify the code that the game has ended.  Then, place the cellContainer object behind the mineContainer object in order to show the mines to the player.  Finally, we clear the flags from the screen.  After that you can choose anyway you want to notify the player of a loss.

The ClearFlags function is a simple function that destroys all the flag objects.
void ClearFlags()
{
        if (flags != null)
        {
       for (int i = 0; i < flags.Count; i++)
             {
                 Destroy(flags[i].flagImage);
             }

             flags = null;
        }
}
 We check if the flags are null to avoid a null reference.  Then, we iterate through all flag entries and destroy the flag images and set the flags List to null

STARTING/ENDING GAMES

We need to a function that can be used to display/initialize the grid, game UI, reset the game state, and spawn bombs. I used three hardcoded difficulty settings but you can handle this anyway you like.
public void NewGame(int i)
{
        //handle menu navigation
        ShowInGame();

        if (i == 1)//medium   20 by 12 grid with 30 bombs
        {
            InitializeGameBoard(new Vector2Int(20,12),80, new Vector2Int(0, -120), 30);
        }
        else if (i == 2)//hard  40 by 24 grid with 140 bombs
        {
            InitializeGameBoard(new Vector2Int(40, 24), 40, new Vector2Int(0, -120), 140);
        }
        else//easy  20 by 12 grid with 20 bombs
        {
            InitializeGameBoard(new Vector2Int(20, 12), 80,new Vector2Int(0,-120), 20);
        }

        timer = 0;
}

The i variable is used to select one of the presets.  The ShowInGame function is used to handle any menu navigation.  In my case I used a few GameObjects with child menu items that I set to active or inactive(mainMenuGO.SetActive(false);).  The InitializeGameBoard function uses the first variable to set the number rows and columns(new Vector2Int(20, 12)), the second variable sets the cell size(80), the third variable sets the offset for the grid(new Vector2Int(0,-120)), and the fourth variable sets the number of mines to spawn(20).  Finally, I reset the timer that I used to display to the player how long they have been playing for.  I added some code to smartly destroy the grid elements(change of grid size) if necessary or reuse them if restarting the game or selecting a new difficulty that uses the same grid size.
void InitializeGameBoard(Vector2Int bDimensions, int cSize,Vector2Int bOffset,int nOfMines)
{
        boardDimensions = bDimensions;
        cellSize = cSize;
        offSet = bOffset;
        numberOfMines = nOfMines;

        if (ShouldSpawnCells())
        {
            ClearCells();
            SpawnBoard();
        }
        else
        {
            RestartGame();
        }

        flagContainer.transform.SetAsFirstSibling();
        cellContainer.transform.SetAsFirstSibling();
        mineContainer.transform.SetAsFirstSibling();
        currentGameState = GameState.FIRSTCLICK;

        flagMode = false;
        flagModeSprite.color = Color.black;
}
First, we set the stored variables.  Then check if we need to destroy the cells with ClearCells and respawn them with SpawnBoard or simply reset the grid with the RestartGame function.  I also reordered the container objects in case they were out of order.  Finally, we need to turn off flag mode in case it is on.

bool ShouldSpawnCells()
{
        if (cells == null)
        {
            return (true);
        }

        if (boardDimensions.x != cells.GetLength(0) || boardDimensions.y != cells.GetLength(1))
        {
            return (true);
        }

        return (false);
}
Here we return true under to cases.  The cells are null or the number of rows or number of columns don't match.  Any other case we don't need to respawn the cells.

 void ClearCells()
{
        if (cells == null)
        {
            return;
        }

        for (int x = 0; x < cells.GetLength(0); x++)
        {
            for (int y = 0; y < cells.GetLength(1); y++)
            {
                Destroy(cells[x, y].cellImage.gameObject);
            }
        }

        cells = null;
}
 Here we destroy all the cells and null the cells variable.

In the Restart game function we need to reset the game state.
for (int y = 0; y < cells.GetLength(1); y++)
{
 for (int x = 0; x <  cells.GetLength(0); x++)
 {
       cells[x, y].cellImage.sprite = cellCover;
                cells[x, y].isCovered = true;

                cells[x, y].cellText.gameObject.SetActive(false);
                cells[x, y].cellText.text = "";
 }
}

ClearBombs();
ClearFlags();

currentGameState = GameState.FIRSTCLICK;
 First, we iterate through the cells to set the default cell sprite and hide the cell text.  Then we clear the flags with the ClearFlags function from earlier and clear the bombs with the ClearBombs function.  Finally, we need to set the GameState to first click.

In the ClearBombs function, we destroy all the mine GameObjects.  Then, we null mines and mineLocations.
if (mines != null)
{
 for (int i = 0; i < mines.Length; i++)
        {
                Destroy(mines[i].gameObject);
        }

        mines = null;
        mineLocations = null;
}

To exit the game you simply need to hide the in game UI elements, set the GameState to ENDED, and show the main menu. 


Further things we can do to enhance the game is add a score board or create a better UI.


I hope you enjoyed this tutorial and I welcome any feedback that can help improve this or future tutorials.