Monday, April 6, 2020

Tutorial: How to Make a 2d Platformer in Unity - Custom Physics Shapes on Tiles and Slopes

Creating Custom Physics Shapes:

The source code used for this post is available here.
Open the sprite Editor and click on the ground_46 sprite.  It looks like this:
At the top left of the Window click on the dropdown that says Sprite Editor.  Select Custom Physics Shape.  Then, click in drag inside that sprite to begin creating a shape or you can click the generate button to have Unity generate a shape.  Next, click on the white squares and drag them to create the shape that best fits the sprite.  Also make sure to toggle on snap at the top of the window to make the process easier.  The most important thing is to make sure that neighboring sprites have shapes that line up well.  Once you have the shape you want hit apply to save the shape.  The Tile Palette will automatically be updated.  
 Create Custom Physics Shapes for the following Tiles (ground_1, ground_33, ground_47, ground_48,) and add them all to the Tile Palette to paint with. Below is a screen shot of the Tile Palette I created.

If the Tilemap Collider doesn't update correctly try disabling and reenabling it.

Another error I encountered while making this Tilemap is in the screenshot below.  If you encounter this problem you can solve it by making a sprite material Pixel Snap enabled and apply it to your TilemapRenderer.


Your Tilemap should look like the screenshot below.  Some shortcut keys will help you achieve this result.  
Press [ : Rotate Tile Counter Clockwise
Press ] : Rotate Tile Clockwise
Press Shift+[ : Flip Tile on X axis
Press Shift+] : Flip Tile on Y axis

Our character will have a few issues if we hit play right, now.  The character will launch into the air when reaching the top of a hill and if you move against a wall while in the air character will get stuck.  I'll illustrate this below. 

To fix the stuck to wall problem the character needs a physics material with no friction.  Right click in the Project window and click Create>Physics Material 2D.  Set the material's friction to 0.  This creates another problem which is illustrated below.  This character while lacking friction will simply slide down hills.

To fix this we need to add these we need to freeze the Rigidbody2D's X position when the character isn't moving.  We can do this by adding these lines that are highlighted in red:
     if (x < 0)  
     {  
       playerRig.constraints =  
         RigidbodyConstraints2D.FreezeRotation;  
       playerAnimator.SetFloat(movingFloatName, 1f);  
       playerSpriteRenderer.flipX = true;  
       playerAnimator.speed = 1f;  
     }  
     else if (x > 0)  
     {  
       playerRig.constraints =  
         RigidbodyConstraints2D.FreezeRotation;  
       playerAnimator.SetFloat(movingFloatName, 1f);  
       playerSpriteRenderer.flipX = false;  
       playerAnimator.speed = 1f;  
     }  
     else  
     {  
       playerRig.constraints =  
         RigidbodyConstraints2D.FreezeRotation |  
         RigidbodyConstraints2D.FreezePositionX;  
       playerAnimator.SetFloat(movingFloatName, 0f);  
       playerAnimator.speed = .5f;  
     }  

Next, we need to fix this problem the causes the character to fly up in the air when hitting the top of a wall:
This is caused by the bottom of the capsule collider pressing against another collider and sliding by the collider.  The character gains upward momentum while the sliding against the other collider.  To fix this we will just keep the character from gaining this momentum.  We need to create a new variable to determine when the character is jumping off the ground.
bool leftGround; 

Then we need to add code to use this variable:

In the Idle code block, add this line:
 if (IsGrounded())  
       {  
         playerRig.gravityScale = groundedGravityScale;  
         leftGround = false;  
         if (Input.GetKeyDown(KeyCode.Space))  
         {  
           Jump();  
         }  
       }  


In the Jump state code block add this code:
       if (IsGrounded())  
       {  
         if (playerRig.velocity.y <= 0)  
         {  
           playerAnimator.SetBool(jumpBoolName, false);  
           currentPlayerPhase = PlayerPhase.IDLE;  
           playerRig.gravityScale = groundedGravityScale;  
         }  
         else if (leftGround)  
         {  
           playerRig.gravityScale = groundedGravityScale;  
         }  
       }  
       else if (playerRig.velocity.y <= 0)  
       {  
         playerRig.gravityScale = fallingGravityScale;  
       }  
       else  
       {  
         leftGround = true;  
       }  
What this does is apply higher gravity when the character returns to the ground after jumping.  The result is this:




That finishes up navigating slopes.  The next post will cover parallax backgrounds.


The source code used for this post is available here.

Sunday, April 5, 2020

Tutorial: How to Make a 2d Platformer in Unity - Platformer Basics

Setting Up the Player Character:

The source code for this post can be found here or at the bottom of the page.

First, create a script named Player and attach it to your character's GameObject.  Next we need to change the character's layer.  In the Inspector, at the top right is the layer selector.  Click it and select add new layer.  Then, in the layer 8 field type Player.  Go back to your character's Inspector window and change the layer to Player.

To finish, the character needs a CapsuleCollider2D (it is important that we use a CapsuleCollider2D because the rounded edges are necessary to navigate slopes) and a Rigidbody2D.  The Rigidbody2D must have the following settings: In the Inspector set BodyType to Dynamic, click the checkbox next to Simulated, and click the arrow next to Constraints to expand it and click the checkbox next to FreezeRotation>Z.  If you don't freeze the Z rotation you will end up with the problem in the gif below.
Adjust the CapsuleCollider2D to encompass the character.  Below is a screen shot of the settings I used and all the required components.  Ensure that your character has the Player script, an Animator with attached AnimatorController, a CapsuleCollider2D, a RigidBody2D and a SpriteRenderer.  If you need help setting up an AnimatorController check out my Creating Animations Tutorial.

Let's start coding.  

Open the Player script.  First we need a list of Components.  Add the following code inside the Player class declaration.

   //components  
   Rigidbody2D playerRig;  
   Animator playerAnimator;  
   SpriteRenderer playerSpriteRenderer;  
   CapsuleCollider2D playerCapsuleCollider2D;  

Then, add these lines to the Start function.

   void Start()  
   {  
     playerRig=gameObject.GetComponent<Rigidbody2D>();  
     playerAnimator = gameObject.GetComponent<Animator>();  
     playerSpriteRenderer = gameObject.GetComponent<SpriteRenderer>();  
     playerCapsuleCollider2D = gameObject.GetComponent<CapsuleCollider2D>();  
   }  

These lines will grab the components we need or you can also set them to public and fill in the reference in the scene.

Next, we need to create new variables and constants.  Add the following to the class declaration.
   const string movingFloatName = "Moving";  
   const float movementSpeed = 5f, jumpPower=7f,  
     jumpGravityScale = 1f, fallingGravityScale = 5f;  
   public LayerMask groundedLayerMask;  
   Vector2 groundOverlapArea;  
   float groundOffset; 
movingFloatName is used so that we can use code auto completion when interacting with the Animator.  movementSpeed is used to control the speed that the character moves.  jumpPower is used to control jump height.  groundedGravityScale will be used to set the Rigidbody2D's gravity scale value when initially jumping.  fallingGravityScale is used to increase the fall speed of the character.  The groundedLayerMask will specify to Unity which layers are considered to be ground and setting it to public makes it much easier to adjust.  Make sure to set it include only Default.  groundOverlapArea is used to define an area that will detect when the player is on the ground.  By default Unity centers a sprite.  Because we want to use the feet of the sprite to determine when the character is standing on ground, we need to use an offset to determine the position of the feet.  groundOffset is a variable where we can store that offset value.  We need to add two lines of code to the Start function to set the last two values.
     groundOverlapArea = new Vector2((  
       playerCapsuleCollider2D.size.x / 2f) * .9f, .1f);  
     groundOffset = playerCapsuleCollider2D.offset.y -  
       playerCapsuleCollider2D.size.y / 2f; 
The X component of groundOverlapArea is set to half the width of the CapsuleCollider2D but we need to shrink the value down a little so that player won't be able to jump off of vertical walls.  The Y component is just a small value to determine how close the player needs to be to the floor to jump.  For the groundOffset we take the Y offset of the CapsuleCollider2D and subtract half the CapsuleCollider2D's height to get the position where it contacts the ground.

Now we need add some code to the Update function that will move the character around.
     //float x = Input.GetAxis("Horizontal");  
     float x = Input.GetAxisRaw("Horizontal");  
     playerRig.velocity = new Vector2(movementSpeed * x,  
     playerRig.velocity.y);  

We can use GetAxis or GetAxisRaw for horizontal movement.  The difference is that by default Unity adds smoothing to axes.  GetAxisRaw allows more precise movement because it doesn't have smoothing.  You can try both and select your preference.  To move the character we simply set the x velocity to horizontal axis multiplied by the character's movement speed and the Y velocity is just the current velocity.  If you hit play, your character will move but won't be facing the correct direction when moving left and won't play the run animation.

Next we need to set up the player state machine and fix the the character's facing error.  First we add an enum to the bottom (outside of the class declaration) of the script.
 public enum PlayerPhase  
 {  
   IDLE,JUMP  
 } 

Next, we need a state declaration that is under the class declaration.
 public PlayerPhase currentPlayerPhase; 

Finally, we need to add these lines to the update function.  To establish our player state and fix the character's facing error.
     if (currentPlayerPhase == PlayerPhase.IDLE)  
     {  
       if (x < 0)  
       {  
         playerAnimator.SetFloat(movingFloatName, 1f);  
         playerSpriteRenderer.flipX = true;  
         playerAnimator.speed = 1f;  
       }  
       else if (x > 0)  
       {  
         playerAnimator.SetFloat(movingFloatName, 1f);  
         playerSpriteRenderer.flipX = false;  
         playerAnimator.speed = 1f;  
       }  
       else  
       {  
         playerAnimator.SetFloat(movingFloatName, 0f);  
         playerAnimator.speed = .5f;  
       }  
     }  

We simply use the flipX value to determine where the player is facing and leave the value alone when the character is not moving to continue facing in the direction he was last moving.  We set the Animator float to 0 or 1 depending if the player is moving (if the Animator was set up the way I showed you in the second tutorial this should work and below I have included screenshot of the blend tree values I have used).  

So let's hit play and see how it works.
You may notice that this character's idle animation seems a little fast compared to the run animation.  This is because the idle animation has fewer frames but you can fix that easily if you wish to.  Just add this line of code any time you change the animation.
 playerAnimator.speed = .5f; 
Because we use a blend tree we can't just set speed value in the Animator.

Now we need to add jumping.  First we need a function that will determine if the player is on the ground.
   bool IsGrounded()  
   {   
     Vector2 a = new Vector2(  
       transform.position.x - groundOverlapArea.x,  
       transform.position.y + groundOffset);  
     Vector2 b = new Vector2(  
       transform.position.x + groundOverlapArea.x,  
       transform.position.y + groundOffset -  
       groundOverlapArea.y);  
   
     return (Physics2D.OverlapArea(a, b, groundedLayerMask));  
   }  
Point B is set to the bottom and left of the character's collider.  Point B extends below the character's feet and is on the right side of the collider.   We use the physics system to check if that logical box overlaps with anything included in our LayerMask.  Next, we need to add a call this function in the Update function and we need a Jump function.

Here is the Jump function:
   void Jump()  
   {  
     playerRig.gravityScale = jumpGravityScale;  
     playerRig.velocity = new Vector2(playerRig.velocity.x,  
       jumpPower);  
     currentPlayerPhase = PlayerPhase.JUMP;  

   }  
We set the gravityScale equal to jumpGravityScale (this blows the player to have a slower acceleration to his jump peak), Y velocity equal to jumpPower, and we set the Jump state.

Now we add calls to the IsGrounded and Jump functions in the Update function.  Below is the code added to the Update function in the Idle state code block.
       if (IsGrounded())  
       {  
         playerRig.gravityScale = fallingGravityScale;  
         
         if (Input.GetKeyDown(KeyCode.Space))  
         {  
           Jump();  
         }  
       }  

First, we check if the player is grounded.  Then, we check if the the spacebar was pressed. If so we make a call to the Jump function.  We also want to set the gravity scale back to 5f while the player is on the ground.

Then, we need to add the Jump state code block.
     else if (currentPlayerPhase == PlayerPhase.JUMP)  
     {  
       if (x < 0)  
       {  
         playerSpriteRenderer.flipX = true;  
       }  
       else if (x > 0)  
       {  
         playerSpriteRenderer.flipX = false;  
       }  
       if (IsGrounded())  
       {  
         if (playerRig.velocity.y <= 0)  
         {  
           currentPlayerPhase = PlayerPhase.IDLE;  
           playerRig.gravityScale = groundedGravityScale;  
         }  
       }  
       else if (playerRig.velocity.y <= 0)  
       {  
         playerRig.gravityScale = fallingGravityScale;  
       }  
     }  

Here is the result:
The character is affected by stronger gravity as soon as he reaches the arch of the jump.

The Next thing we should do is add a jump animation.  Create an animation using the jump sprites (adventurer-jump-00, adventurer-jump-01, adventurer-jump-03, adventurer-jump-03).  Then, add the animation to your Animator and add a bool parameter called Jump.  Create two transitions with the parameter Jump set to true and false.
Below I have included my transition settings:


Now we just need to add two lines of code.  First, to the Jump function:
 playerAnimator.SetBool(jumpBoolName, true); 

Then, add this line of code to the Update function after the grounded check in the Jump state code block:
       if (IsGrounded())  
       {  
         if (playerRig.velocity.y <= 0)  
         {  
           playerAnimator.SetBool(jumpBoolName, false);  
           currentPlayerPhase = PlayerPhase.IDLE;  
           playerRig.gravityScale = groundedGravityScale;  
         }  
       }  

And here is the result:

In the next post you will learn how to navigate slopes using physics materials.

The source code for this post can be found here.

Tuesday, March 31, 2020

Tutorial: How to Make a 2d Platformer in Unity - Tilemap Basics

Setting up the Sprite Sheet:

In the Project window, select ground.png from the pixel2dcastle1.1.zip.  Then, in the inspector change the Texture Type to Sprite (2D and UI), change Sprite Mode to Multiple, change the Pixels Per Unit to 16, and change the Filter Mode to Point.  Next, open the Sprite Editor window, click on Slice, change Type to Grid By Cell Size, change Pixel Size to 16x16, click the Slice button, and before you close the Sprite Editor window click Apply in upper right corner to save the changes.

Creating the Tile Palette:

First, create a folder where you would like to store your Tile Palette and click on Window>2D>Tile Palette.  In the Tile Palette Window, click on Create New Palette and save the palette in the folder you created.  Find the ground.png file in the project view and click the arrow next to it expand the sprite list.  Drag ground_1 and ground_17 into the Tile Palette Window and save the tiles to your folder.  

Creating the Tilemap:

Click on the menu option GameObject>2D Object>Tilemap to create a Tilemap in your scene.  Next, click on the arrow next to the Grid object in the Hierarchy view to expand it and click on the Tilemap object.  Open the Tile Palette window and click on the paint brush icon, select the tile you want to paint with.  Then, paint a row of tiles that we can move the player around on.  Finally, in the Inspector view with the tile map object selected click Add Component and add Tilemap Collider to the Tilemap object.

We're finally ready to start writing code.  In the next part we'll be animating our character implementing running and jumping.

Follow this link to the next post.

Monday, March 30, 2020

Tutorial: How to Make a 2d Platformer in Unity - Creating Animations

Creating Animations:

Before we get started programming movement, we need to create our character and our animations.  To create a sprite animation in Unity simply select the sprites in the project window and drag them into the scene.  Highlight the idle sprites (adventurer-idle-00, adventurer-idle-01, adventurer-idle-02) from the adventurer character we downloaded in the last post and drag them into the scene to create an Idle animation.

This will create a GameObject with a SpriteRenderer and an Animator.  The Animator an Idle animation that is set as the default state.  If you hit the play button you will see the character looping through the idle animation.  Do the same with running animation sprites (adventurer-run-00, adventurer-run-01, adventurer-run-02, adventurer-run-03, adventurer-run-04, adventurer-run-05) and the jumping animation sprites (adventurer-jump-00, adventurer-jump-01, adventurer-jump-02, adventurer-jump-03) and save the animations with a name you'll remember.

Setting Up The Animator:

Delete all but one of the SpriteRenderers.  Click on the SpriteRenderer in the scene view and make sure it has an Animator and AnimatorController(if not add an Animator component and right in project view>create>Animator Controller).  Then click on Window>Animation>Animator to bring up the Animator window.  You should see the Animator that is attached to the SpriteRenderer.  Delete all the Animator states.  On the left side of the window click on the parameters tab and click on the plus sign to create a new float.  Name the float Moving.  Right click in the Animator window and select Create State>From New Blend Tree.  Click on the Blend Tree and rename it Idle.  Double click on the blend tree to edit it.  In the inspector, make sure that the blend type is set to 1D and that the parameter is set to Moving.  Click on the plus sign and add 2 motion fields.  Then, click on the circle to show the available animations and choose the idle and running animation for the fields.  The idle animation field should have its threshold set to 0 and the running animation field should have its threshold set to 1.


With the animation set up we're ready to move on the next tutorial!  Next we need to create an environment that our the player can move around in.  We will need to slice a sprite sheet and create a tilemap.
Follow this link to the next post.

Sunday, March 29, 2020

Tutorial: How to Make a 2d Platformer in Unity - Overview

This is the first in a series of tutorials showing You how to create a 2d platformer in Unity using the Unity physics system.  Before I show you the code I want to point you to the assists I will be using and I want to provide an overview of the series.  Below you will find links to the other posts and links to the assets I will be using.

Overview:

1.  Getting Started.
6.  Parallax Background.
7.  Climbing Ladders and Double Jump.
8.  Grabbing and Climbing Ledges.
9.  Enemy AI.


Getting started:

First make sure you have Unity installed.  You can grab it here.  I will be using Unity 2019.2 but any version after 5 should work.

We also need to download the assets I will be using.  Download Adventurer-1.5.zip, pixel2dcastle1.1.zip, and Grassy_Mountains_Parallax_Background-vnitti.zip.

Create a new project and move the files into the asset folder and let's get started programming!
Follow this link to the next post.

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.