In the final part of this series, we put the finishing touches on our grid-based Unity puzzle game, and make it playable. By the end of this part, the player will be able to win or lose the game.
Now that you’ve completed the previous tutorials, our game can create a field of tiles, and assign mines randomly to them. We also have a nice light-up effect when the player hovers over a tile with the mouse, and it’s possible to place and remove flags.
Internally, each tile also knows about their neighboring tiles, and can already calculate how many mines are nearby.
Uncovering Tiles
We’ve already added the ability to place flags with a right click. Now, let’s add the ability to uncover tiles with a left click.
In the OnMouseOver()
function, where we have the click recognition code, we need to recognize a left click. Adapt the function so that it looks like this:
function OnMouseOver() { if(state == "idle") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(0)) UncoverTile(); if (Input.GetMouseButtonDown(1)) SetFlag(); } else if(state == "flagged") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(1)) SetFlag(); } }
When the left mouse button is pressed, the UncoverTile()
function will be called. Make sure that you create this function, so that this won’t cause a bug!
function UncoverTile() { if(!isMined) { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; } else Explode(); }
For this to work, we need to introduce a new material.
public var materialUncovered: Material;
Create something that has a different color than the basic tiles—so if your basic tiles are blue, you could choose green for the uncovered state. But don’t use red; we’ll need that later to show that we’ve triggered a mine.
When call that function, the following happens:
- First, we check whether the tile is actually mined.
- If not, we set the state to
uncovered
, activate the text display that shows us the number of nearby mines, and set the material to the uncovered material. - Afterwards, the tile cannot be clicked again, and also won’t light up again, which means the passive feedback of tiles reacting to the mouse cursor will only happen on tiles we can actually click.
Before we can try this out, we need to make sure that the material isn’t changed when the mouse cursor exits the tile. For this, adapt the OnMouseExit()
function like so:
function OnMouseExit() { if(state == "idle" || state == "flagged") renderer.material = materialIdle; }
This way, the color only gets switched back if the tile has not yet been uncovered.
Try it out! You should be able to uncover tiles. If a tile is mined, though, nothing will happen right now.
Making Empty Tiles Uncover Each Other
This will be a bit tricky. In Minesweeper, when you uncover a tile with no mines
next to, it will uncover all the tiles adjacent to it that also have no mines, and the tiles adjacent to them that have no mines, and so on.
Consider this field:
We don’t actually see the numbers or mines, only regular tiles.
When a tile with zero nearby mines is uncovered, all tiles next to it should automatically be uncovered, too. The uncovered tile then uncovers all neighbors.
These newly uncovered tiles will then check their neighbors, too, and, if there are no mines, uncover them as well.
This will ripple through the field until we reach tiles that actually have mines adjacent to them, where it will stop.
This creates the empty areas we can see in Minesweeper.
To make this work, we need two more functions, UncoverAdjacentTiles()
and UncoverTileExternal()
:
private function UncoverAdjacentTiles() { for(var currentTile: Tile in adjacentTiles) { //uncover all adjacent nodes with 0 adjacent mines if(!currentTile.isMined && currentTile.state == "idle" && currentTile.adjacentMines == 0) currentTile.UncoverTile(); //uncover all adjacent nodes with more than 1 adjacent mine, then stop uncovering else if(!currentTile.isMined && currentTile.state == "idle" && currentTile.adjacentMines > 0) currentTile.UncoverTileExternal(); } } public function UncoverTileExternal() { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; }
We also need to make this modification to the UncoverTile()
function:
function UncoverTile() { if(!isMined) { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; if(adjacentMines == 0) UncoverAdjacentTiles(); } }
When we uncover a tile, and there are no mines next to it, we call the UncoverAdjacentTiles()
function. This then checks each neighboring tile to see whether it has mines or not. too. If there aren’t any, it uncovers this tile as well, and initiates another round of checking. If there are mines nearby, it only uncovers the tile it is currently at.
Now, try it out. To get good chance of an empty field appearing, create a rather large field with a few mines—say, 81 tiles, with nine tiles per row, and 10 mines in total.
You can actually now play this as a game, except that you cannot trigger mines yet. We’ll add that feature next.
Triggering Mines
When we uncover a tile that is mined, the game stops and the player loses. Additionally, all other mined tiles become visible. For this to happen, we need one more material, for the detonated mine tiles:
public var materialDetonated: Material;
I suggest using something red for this.
Also, we need to add two more functions to handle exploding all of the mines:
function Explode() { state = "detonated"; renderer.material = materialDetonated; for (var currentTile: Tile in Grid.tilesMined) currentTile.ExplodeExternal(); } function ExplodeExternal() { state = "detonated"; renderer.material = materialDetonated; }
We trigger those methods in the UncoverTile()
function:
function UncoverTile() { if(!isMined) { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; if(adjacentMines == 0) UncoverAdjacentTiles(); } else Explode(); }
If a tile is mined, the tile explodes. The Explode()
function then sends an “explode” command to all other tiles with mines, revealing them all.
Winning the Game
The game is won once all tiles with mines have been flagged correctly. At this point, all tiles that are not uncovered are uncovered too. So how do we track that?
Let’s start by adding a state
variable to the Grid
class, so that we can track which part of the game we are currently in (still playing, lost, or won).
static var state: String = "inGame";
While we’re at it, we can also begin to add a simple GUI, so that we can display necessary information on the screen. Unity comes with its own GUI system which we’ll use for this.
function OnGUI() { GUI.Box(Rect(10,10,100,50), state); }
This will show us which state we are currently in. We’ll call these states inGame
, gameOver
, and gameWon
.
We can also add checks to the Tile, to make sure we can only interact with the tiles while the current game state is inGame
.
In the OnMouseOver()
and OnMouseExit
functions, move all the existing code into an if
block that checks whether Grid.state
is currently inGame
, like so:
function OnMouseOver() { if(Grid.state == "inGame") { if(state == "idle") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(0)) UncoverTile(); if (Input.GetMouseButtonDown(1)) SetFlag(); } else if(state == "flagged") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(1)) SetFlag(); } } } function OnMouseExit() { if(Grid.state == "inGame") { if(state == "idle" || state == "flagged") renderer.material = materialIdle; } }
There are actually two ways to check whether the game has been won: we can count how many mines have been marked correctly, or we can check whether all tiles that are not mines have been uncovered. For that, we need the following variables; add them to the Grid
class:
static var minesMarkedCorrectly: int = 0; static var tilesUncovered: int = 0; static var minesRemaining: int = 0;
Don’t forget to set minesRemaining
in the Start()
function to numberOfMines
, and the other variables to 0
. The Start()
function should now look like this:
function Start() { CreateTiles(); minesRemaining = numberOfMines; minesMarkedCorrectly = 0; tilesUncovered = 0; state = "inGame"; }
The last line sets the state for the game. (This will be important when we want to introduce a “restart” function later.)
We then check for our end-game conditions in the Update()
function:
function Update() { if(state == "inGame") { if((minesRemaining == 0 && minesMarkedCorrectly == numberOfMines) || (tilesUncovered == numberOfTiles - numberOfMines)) FinishGame(); } }
We finish the game by setting the state to gameWon
, uncovering all remaining tiles, and flagging all remaining mines:
function FinishGame() { state = "gameWon"; //uncovers remaining fields if all nodes have been placed for(var currentTile: Tile in tilesAll) if(currentTile.state == "idle" && !currentTile.isMined) currentTile.UncoverTileExternal(); //marks remaining mines if all nodes except the mines have been uncovered for(var currentTile: Tile in Grid.tilesMined) if(currentTile.state != "flagged") currentTile.SetFlag(); }
For all this to actually work, we need to increment the variables that track our progress at the right spots. Adapt the UncoverTile()
function to do that:
function UncoverTile() { if(!isMined) { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; Grid.tilesUncovered += 1; if(adjacentMines == 0) UncoverAdjacentTiles(); } else Explode(); }
…as well as the UncoverTileExternal()
function:
function UncoverTileExternal() { state = "uncovered"; displayText.renderer.enabled = true; renderer.material = materialUncovered; Grid.tilesUncovered += 1; }
We also need to increment and decrement the minesMarkedCorrectly
and minesRemaining
variables depending on whether a flag has been set:
function SetFlag() { if(state == "idle") { state = "flagged"; displayFlag.renderer.enabled = true; Grid.minesRemaining -= 1; if(isMined) Grid.minesMarkedCorrectly += 1; } else if(state == "flagged") { state = "idle"; displayFlag.renderer.enabled = false; Grid.minesRemaining += 1; if(isMined) Grid.minesMarkedCorrectly -= 1; } }
Losing the Game
In the same way, we need to make it possible to lose a game. We accomplish this via the Explode()
function within the tile.
Simply add this line to the Explode()
function:
Grid.state = "gameOver";
Once that line is run, the state of the game is switched to gameOver
, and the tiles can no longer be interacted with.
Adding a More Functional GUI
We used the Unity GUI a few steps ago to tell the player which game state they’re currently in. Now we’ll extend it to show some actual messages.
The framework for this looks like the following:
function OnGUI() { if(state == "inGame") { } else if(state == "gameOver") { } else if(state == "gameWon") { } }
Depending on the state, different messages get displayed in the GUI. If the game is lost or won, for example, we can display messages saying so:
function OnGUI() { if(state == "inGame") { } else if(state == "gameOver") { GUI.Box(Rect(10,10,200,50), "You lose"); } else if(state == "gameWon") { GUI.Box(Rect(10,10,200,50), "You rock!"); } }
We can also show the number of mines found so far, or add a button that reloads the level once the game is over:
function OnGUI() { if(state == "inGame") { GUI.Box(Rect(10,10,200,50), "Mines left: " + minesRemaining); } else if(state == "gameOver") { GUI.Box(Rect(10,10,200,50), "You lose"); if(GUI.Button(Rect(10,70,200,50), "Restart")) Restart(); } else if(state == "gameWon") { GUI.Box(Rect(10,10,200,50), "You rock!"); if(GUI.Button(Rect(10,70,200,50), "Restart")) Restart(); } } function Restart() { state = "loading"; Application.LoadLevel(Application.loadedLevel); }
You can try it all out in this final build!
Conclusion
That’s it! We have created a simple puzzle game with Unity, which you can use as a basis to create your own. I hope you’ve enjoyed this series; please ask any questions you have in the comments!