We continue building our grid-based puzzle-game by connecting the tiles to each other, making them light up with the mouse cursor, and adding the ability to place flags.
In the last part of this tutorial, we created a field of tiles which form the basis for our puzzle game. In this part, we'll make it playable. This tutorial follows on directly from the last part, so read that before starting this.
Lighting Tiles Up on Mouseover
When the mouse is over a tile, we want it to light up. This is a cool feature which gives even simple actions (such as moving your mouse pointer) instantaneous feedback.
We use the OnMouseOver()
function to accomplish this. It gets called automatically whenever the mouse cursor moves over the object the code is attached to. Add these variables to the Tile
script:
public var materialIdle: Material; public var materialLightup: Material;
Then assign your basic tile material to the materialIdle
slot. We also need a lightup material, which should have the same color, but uses a different shader. While the basic material can have a diffuse shader...
...the lightup material could have a specular shader. Many games use an additional rim shader for that effect too. Those don't come with Unity, but if you can figure out how to get one, you can use that instead!
Don't forget to actually assign the materials to the Material slots on the tile prefab, so that they can be used.
Then add this OnMouseOver()
function to the Tile
script
function OnMouseOver() { renderer.material = materialLightup; }
Try it out! When you move the mouse cursor over the tiles, they should change their appearance.
What you might have noticed is that the tiles change their appearance once the mouse is over them, but doesn't actually change back. For that, we need to use the OnMouseExit()
function:
function OnMouseExit() { renderer.material = materialIdle; }
And voilá; we now have tiles that light up and make the game much more interesting.
Assigning IDs to Tiles
In order to have the tiles communicate with each other (to find out how many mines are nearby), each tile needs to know its neighboring tiles. One way to accomplish that is using IDs, which each tile will be assigned.
Start by adapting the Tile code to include an ID
variable. Also, add a variable to hold the number of tiles per row, which we'll use in this computation:
public var ID: int; public var tilesPerRow: int;
Then modify the instantiate command in the Grid code to look like the following snippet. (The new line assigns IDs to the tiles as they are being created.)
var newTile = Instantiate(tilePrefab, Vector3(transform.position.x + xOffset, transform.position.y, transform.position.z + zOffset), transform.rotation); newTile.ID = tilesCreated; newTile.tilesPerRow = tilesPerRow;
The first tile will get the ID 0, the next one will be assigned the ID 1, and so on. You can check them by clicking the tiles during runtime, and seeing what number they have been assigned.
Getting Neighboring Tiles
Now we want each tile to know about its neighboring tiles. When we execute an action on a tile (such as uncovering it), we need to take the neighboring tiles into account.
In our case, this means counting the mines that are adjacent to the tile we just uncovered, and possibly uncovering other tiles as well—but we'll get to that later.
This can also be used to, say, check whether three or more tiles are next to each other in a Match-3 game.
Start by adding these variables to the Tile script:
public var tileUpper: Tile; public var tileLower: Tile; public var tileLeft: Tile; public var tileRight: Tile; public var tileUpperRight: Tile; public var tileUpperLeft: Tile; public var tileLowerRight: Tile; public var tileLowerLeft: Tile;
These will hold all the neighboring tiles. They are public so that we can check during runtime that they have actually been assigned correctly.
Since now every Tile has an ID, the number of tiles that appear in a column, and access to the static array that has all tiles saved in the Grid
class, we can calculate the positions of the neighboring tiles after they have been created.
That part looks like this:
tileUpper = Grid.tilesAll[ID + tilesPerRow]; tileLower = Grid.tilesAll[ID - tilesPerRow]; tileLeft = Grid.tilesAll[ID - 1]; tileRight = Grid.tilesAll[ID + 1]; tileUpperRight = Grid.tilesAll[ID + tilesPerRow + 1]; tileUpperLeft = Grid.tilesAll[ID + tilesPerRow - 1]; tileLowerRight = Grid.tilesAll[ID - tilesPerRow + 1]; tileLowerLeft = Grid.tilesAll[ID - tilesPerRow - 1];
With the IDs and the number of tiles per row, we can calculate which tiles are nearby. Suppose the tile doing the calculations has the ID 3
, and that there are five tiles per row. The tile above it will have the ID 8
(the selected tile's ID plus the number of tiles per row); the tile to the right will have the ID 6
(the selected tile's ID plus one), and so on.
Unfortunately, this isn't enough. The code correctly checks the numbers, but when it asks the allTiles
array to return the tiles, it can request index numbers that are out of range, producing a long list of errors.
In order to fix this, we need to check that the index we request from the array is actually valid. The most efficient way to do this is with a new inBounds()
function. Add it to the Tile:
private function inBounds(inputArray: Array, targetID: int): boolean { if(targetID < 0 || targetID >= inputArray.length) return false; else return true; }
Now we need to check that each possible neighboring tile is within the bounds of the array that holds all the tiles, before we actually try to get it from the array:
if(inBounds(Grid.tilesAll, ID + tilesPerRow)) tileUpper = Grid.tilesAll[ID + tilesPerRow]; if(inBounds(Grid.tilesAll, ID - tilesPerRow)) tileLower = Grid.tilesAll[ID - tilesPerRow]; if(inBounds(Grid.tilesAll, ID - 1) && ID % tilesPerRow != 0) tileLeft = Grid.tilesAll[ID - 1]; if(inBounds(Grid.tilesAll, ID + 1) && (ID+1) % tilesPerRow != 0) tileRight = Grid.tilesAll[ID + 1]; if(inBounds(Grid.tilesAll, ID + tilesPerRow + 1) && (ID+1) % tilesPerRow != 0) tileUpperRight = Grid.tilesAll[ID + tilesPerRow + 1]; if(inBounds(Grid.tilesAll, ID + tilesPerRow - 1) && ID % tilesPerRow != 0) tileUpperLeft = Grid.tilesAll[ID + tilesPerRow - 1]; if(inBounds(Grid.tilesAll, ID - tilesPerRow + 1) && (ID+1) % tilesPerRow != 0) tileLowerRight = Grid.tilesAll[ID - tilesPerRow + 1]; if(inBounds(Grid.tilesAll, ID - tilesPerRow - 1) && ID % tilesPerRow != 0) tileLowerLeft = Grid.tilesAll[ID - tilesPerRow - 1];
That code block checks all the possibilities. It also checks whether a tile is at the edge of the field. A tile on the right-hand edge of the grid, after all, does not actually have any tiles to its right.
Try it out! Check a few tiles and see whether you've retrieved all the neighboring tiles correctly. It should look like this:
As the tile in this screenshot is a tile on the right edge of the field, it has no neighbors to the right, upper right, or lower right. The variables without assigned tiles are correctly empty, and a test reveals that the remaining ones to have been assigned correctly too.
Finally, once we've made sure that this works, we add all neighboring tiles into an array, so that we can access them all at once later. We need to declare this array at the beginning:
public var adjacentTiles: Array = new Array();
You can then adapt each line of the algorithm we created above to enter each neighboring tile in that array, or add this block afterwards:
if(tileUpper) adjacentTiles.Push(tileUpper); if(tileLower) adjacentTiles.Push(tileLower); if(tileLeft) adjacentTiles.Push(tileLeft); if(tileRight) adjacentTiles.Push(tileRight); if(tileUpperRight) adjacentTiles.Push(tileUpperRight); if(tileUpperLeft) adjacentTiles.Push(tileUpperLeft); if(tileLowerRight) adjacentTiles.Push(tileLowerRight); if(tileLowerLeft) adjacentTiles.Push(tileLowerLeft);
Counting Nearby Mines
After all tiles have
been created, all mines have been assigned, and each tile has retrieved its
neighbors, we then need to check whether each of these neighboring tiles is mined or not.
The Grid code already assigns the specified number of mines to randomly chosen tiles. Now we only need each tile to check its neighbors. Add this code at the beginning of the Tile
script, so that we have a place to store the amount of mines:
public var adjacentMines: int = 0;
To count them, we run through the array into which we previously added all neighboring
tiles, and check each entry in turn to see if it is mined. If so, we increase the value of adjacentMines
by 1.
function CountMines() { adjacentMines = 0; for each(var currentTile: Tile in adjacentTiles) if(currentTile.isMined) adjacentMines += 1; displayText.text = adjacentMines.ToString(); if(adjacentMines <= 0) displayText.text = ""; }
This function also sets the text element of the tile to display the number of mines nearby. If there are no mines, it displays nothing (rather than 0).
Tracking the State of Each Mine
Let's add a state
to each tile. This way, we can keep track of which state it is currently in—idle
, uncovered
, or flagged
. Depending on which state the tile is in, it will react differently. Add it now, as we'll use it in a moment.
public var state: String = "idle";
Adding Flags
We want to be able to mark tiles as flagged. A flagged tile has a tiny little flag on top of it. If we right-click the flag it will disappear again. If all mined tiles have been flagged, and no incorrectly flagged tiles remain, the game is won.
Start by creating a flag object, and add it to the tile (you'll find a flag mesh in the source files).
We also need a variable to access the flag. Add this code:
public var displayFlag: GameObject;
Remember to drag the flag that is part of the tile onto the displayFlag slot.
Also, add this to the start()
function of the tile:
displayFlag.renderer.enabled = false; displayText.renderer.enabled = false;
This will disable the flag and text at the beginning. Later, we can then activate a flag, making it visible again, effectively putting it there. Alternatively, if we uncover a tile, we make the text visible again.
Placing and Removing Flags
We'll write a function that handles both placing and removing flags:
function SetFlag() { if(state == "idle") { state = "flagged"; displayFlag.renderer.enabled = true; } else if(state == "flagged") { state = "idle"; displayFlag.renderer.enabled = false; } }
Once you've added that, also add the code to handle a click event. To do this, adapt the OnMouseOver()
function we already have to check for a mouse click:
function OnMouseOver() { if(state == "idle") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(1)) SetFlag(); } else if(state == "flagged") { renderer.material = materialLightup; if (Input.GetMouseButtonDown(1)) SetFlag(); } }
This will recognize a right-click (button 1
) and activate the SetFlag()
function. It will then either activate or deactivate the flag on the current tile. Try it out!
Conclusion
We've extended our puzzle game with several vital features, made it visually more interesting, and have given the player the ability to affect the playing field.
Next time, we'll add uncovering of tiles, make a simple interface, and turn this into a proper game.