Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

How To Match Puzzle Shapes Using Bitmasks

$
0
0

In this tutorial, I will walk you through how to analyze a board of tiles, iterate through them, and find matches. We will be creating a game where you need to connect lines together to form completely closed paths with no open ends. In order to simplify things, we will use bitmasking as part of our algorithm by assigning each tile (plus its rotation) its very own bitmask number. Don't worry if you don't know what bitmasking is. It's actually very simple!


Play the Demo

I will be creating the project in C# using Unity with the Futile framework, but the code will be applicable to pretty much any 2D framework with few changes. Here is the Github repo with the entire Unity project. And below is a playable demo of the game we'll be making:


Click the arrows to slide rows and columns. Try to make closed shapes.

Going Beyond Match-3

When I started creating Polymer, I wanted to create something other than a match-3 game. My internal nickname for it was a "match-any" game. Match-3 puzzle games are everywhere. While they can certainly be fun, one reason they are so common may be because the algorithm to find a match of three tiles is rather simple.

I wanted to be able to match multiple tiles that could weave in and out of rows and columns, snaking their way across the board. Not only that, but I didn't want a simple color-matching game. I wanted the matches to be based on specific sides of the tiles (for example, one shape could only connect to other shapes on the left and right sides, but not the top and bottom.) This turned out to be much more complex than just a normal match-3 algorithm.

This tutorial will be split up into three sections: The Tile, The Match Group, and The Game Board. In this tutorial, I will try to avoid as much Futile-specific code as possible. If you want to see the Futile-specific stuff, look at the source code. Also, I am not going to show every method and variable in this post. Just the most important ones. So if you think something is missing, again, look at the source code.

What Is a Bitmask?

The word 'bitmask' refers to the way you can store a series of true/false values in a single numeric variable. Because numbers are represented by ones and zeros when represented in binary, by changing the number you can switch on or off values by toggling whether a bit is 1 or 0.

For more detail, please see this article on bitwise operators and this article on binary numbers.


The Tile

Our first class is called LineTile. Before the beginning of the class, let's define each kind of tile.

// The different tile types:
public enum LineTileType {
	Nub,
	Line,
	Corner,
	Threeway,
	Cross,
	MAX
}

Here's what the pieces look like:

thePieces

Next, since we will only allow rotations of 90 degrees, let's make an enum for rotation.

// I'm using this instead of exact degrees since the
// tiles should only have four distinct rotations:
public enum RotationType {
	Rotation0,
	Rotation90,
	Rotation180,
	Rotation270,
	MAX
}

Next is a struct called TileIndex, which is basically the same as a Vector2, except with ints instead of floats. It will be used to keep track of where a tile is in the game board.

public struct TileIndex {
	public int xIndex;
	public int yIndex;

	public TileIndex(int xIndex, int yIndex) {
		this.xIndex = xIndex;
		this.yIndex = yIndex;
	}
}

Finally, let's define the three types of connections between two tiles.

public enum TileConnectionType {
	// A mismatch.
	Invalid,

	// The tiles don't directly connect,
	// but not because of an unmatched edge.
	ValidWithOpenSide,

	// The tiles directly connect.
	ValidWithSolidMatch
}

Next, within the class itself, define a bitmask to each side of a generic tile.

tileBitmask
// Here are the bits I assigned each side of the tile:
// ===== 1 =====
// |           |
// |           |
// 8           2
// |           |
// |           |
// ===== 4 =====

// 1 == 0001 in binary
// 2 == 0010 in binary
// 4 == 0100 in binary
// 8 == 1000 in binary

public const int kBitmaskNone   = 0;
public const int kBitmaskTop    = 1;
public const int kBitmaskRight  = 2;
public const int kBitmaskBottom = 4;
public const int kBitmaskLeft   = 8;

Then define the instance variables each tile will have.

// The sprite representation of the tile:
public FSprite sprite;

// The type of the tile:
public LineTileType lineTileType {get; private set;}

// The rotation of the tile:
public RotationType rotationType {get; private set;}

// The bitmask that represents the tile with its rotation:
public int bitmask {get; private set;}

// The tile's location on the board:
public TileIndex tileIndex = new TileIndex();

For the constructor, create the sprite and set it up at the correct rotation. There is some Futile-specific code in here but it should be very easy to understand.

public LineTile(LineTileType lineTileType, RotationType rotationType) {
		this.lineTileType = lineTileType;
		this.rotationType = rotationType;

		// Set up sprite:
		switch (lineTileType) {
		case LineTileType.Nub:
			sprite = new FSprite("lineTileNub");
			break;
		case LineTileType.Line:
			sprite = new FSprite("lineTileLine");
			break;
		case LineTileType.Corner:
			sprite = new FSprite("lineTileCorner");
			break;
		case LineTileType.Threeway:
			sprite = new FSprite("lineTileThreeway");
			break;
		case LineTileType.Cross:
			sprite = new FSprite("lineTileCross");
			break;
		default:
			throw new FutileException("invalid line tile type");
		}

		AddChild(sprite);

		// Set up sprite rotation:
		switch (rotationType) {
		case RotationType.Rotation0:
			sprite.rotation = 0;
			break;
		case RotationType.Rotation90:
			sprite.rotation = 90;
			break;
		case RotationType.Rotation180:
			sprite.rotation = 180;
			break;
		case RotationType.Rotation270:
			sprite.rotation = 270;
			break;
		default:
			throw new FutileException("invalid rotation type");
		}

Now, one of the most important parts. We assign each tile, combined with its rotation, a bitmask that is determined by which of its sides are solid and which are open.

allPieces
// Set up bitmask by doing bitwise OR with each side that is included in the shape.

		// So for example, a tile that has all four sides solid (e.g. the cross tile) would be
		// 1 | 2 | 4 | 8 = 15, which is the same as 0001 | 0010 | 0100 | 1000 = 1111 in binary.

		if (lineTileType == LineTileType.Nub) {
			if (rotationType == RotationType.Rotation0) 	bitmask = kBitmaskTop;
			if (rotationType == RotationType.Rotation90) 	bitmask = kBitmaskRight;
			if (rotationType == RotationType.Rotation180)	bitmask = kBitmaskBottom;
			if (rotationType == RotationType.Rotation270) 	bitmask = kBitmaskLeft;
		}

		if (lineTileType == LineTileType.Line) {
			if (rotationType == RotationType.Rotation0 || rotationType == RotationType.Rotation180)		bitmask = kBitmaskTop | kBitmaskBottom;
			if (rotationType == RotationType.Rotation90 || rotationType == RotationType.Rotation270) 	bitmask = kBitmaskRight | kBitmaskLeft;
		}

		if (lineTileType == LineTileType.Corner) {
			if (rotationType == RotationType.Rotation0) 	bitmask = kBitmaskTop | kBitmaskRight;
			if (rotationType == RotationType.Rotation90) 	bitmask = kBitmaskRight | kBitmaskBottom;
			if (rotationType == RotationType.Rotation180) 	bitmask = kBitmaskBottom | kBitmaskLeft;
			if (rotationType == RotationType.Rotation270) 	bitmask = kBitmaskLeft | kBitmaskTop;
		}

		if (lineTileType == LineTileType.Threeway) {
			if (rotationType == RotationType.Rotation0) 	bitmask = kBitmaskTop | kBitmaskRight | kBitmaskBottom;
			if (rotationType == RotationType.Rotation90)	bitmask = kBitmaskRight | kBitmaskBottom | kBitmaskLeft;
			if (rotationType == RotationType.Rotation180)	bitmask = kBitmaskBottom | kBitmaskLeft | kBitmaskTop;
			if (rotationType == RotationType.Rotation270) 	bitmask = kBitmaskLeft | kBitmaskTop | kBitmaskRight;
		}

		if (lineTileType == LineTileType.Cross) {
			bitmask = kBitmaskTop | kBitmaskRight | kBitmaskBottom | kBitmaskLeft;
		}
	}

Our tiles are set up and we're ready to start matching them together!


The Match Group

Match groups are just that: groups of tiles that match (or don't). You can start on any tile in a match group and reach any other tile through its connections. All its tiles are connected. Each of the different colors indicates a different match group. The only one that is completed is the blue one in the center—it has no invalid connections.

matchGroups

The match group class itself is actually extremely simple. It's basically just a collection of tiles with a few helper functions. Here it is:

public class MatchGroup {
	public List<LineTile> tiles;
	public bool isClosed = true;

	public MatchGroup() {
		tiles = new List<LineTile>();
	}

	public void SetTileColor(Color color) {
		foreach (LineTile tile in tiles) tile.sprite.color = color;
	}

	public void Destroy() {
		tiles.Clear();
	}
}

The Game

This is by far the most complicated part of this process. We have to analyze the entire board, splitting it up into its individual match groups, then determine which, if any, are completely closed. I'm going to call this class BitmaskPuzzleGame, since it's the main class that encompasses the game logic.

Before we get into its implementation though, let's define a couple things. First is a simple enum that the arrows will be assigned based one which direction they are facing:

// To help us determine which arrow was pressed:
public enum Direction {
	Up,
	Right,
	Down,
	Left
}

Next is a struct that will be sent from an arrow that gets pressed so we can determine where it is in the board and which direction it's facing:

// When an arrow is pressed, it will contain this data to figure out what to do with the board:
public struct ArrowData {
	public Direction direction;
	public int index;

	public ArrowData(Direction direction, int index) {
		this.direction = direction;
		this.index = index;
	}
}

Next, within the class, define the instance variables we need:

// Contains all the map's tiles:
public LineTile[][] tileMap;

// Contains all the groups of connected tiles:
public List<MatchGroup> matchGroups = new List<MatchGroup>();

// When a row/column is shifted, this is set to true so HandleUpdate knows to refresh:
private bool matchGroupsAreDirty = true;

// How many tiles wide the board is:
private int tileMapWidth;

// How many tiles high the board is:
private int tileMapHeight;

Here is a function that takes a tile and returns all of its surrounding tiles (the ones above, below, to the left, and to the right of it):

// Helper method to get all the tiles that are above/below/right/left of a specific tile:
private List<LineTile> GetTilesSurroundingTile(LineTile tile) {
	List<LineTile> surroundingTiles = new List<LineTile>();

	int xIndex = tile.tileIndex.xIndex;
	int yIndex = tile.tileIndex.yIndex;

	if (xIndex > 0) surroundingTiles.Add(tileMap[xIndex - 1][yIndex]);
	if (xIndex < tileMapWidth - 1) surroundingTiles.Add(tileMap[xIndex + 1][yIndex]);

	if (yIndex > 0) surroundingTiles.Add(tileMap[xIndex][yIndex - 1]);
	if (yIndex < tileMapHeight - 1) surroundingTiles.Add(tileMap[xIndex][yIndex + 1]);

	return surroundingTiles;
}

Now two methods that return all the tiles in either a column or row so we can shift them:

// Helper method to get all the tiles in a specific column:
private LineTile[] GetColumnTiles(int columnIndex) {
	if (columnIndex < 0 || columnIndex >= tileMapWidth) throw new FutileException("invalid column: " + columnIndex);

	LineTile[] columnTiles = new LineTile[tileMapHeight];

	for (int j = 0; j < tileMapHeight; j++) columnTiles[j] = tileMap[columnIndex][j];

	return columnTiles;
}

// Helper method to get all the tiles in a specific row:
private LineTile[] GetRowTiles(int rowIndex) {
	if (rowIndex < 0 || rowIndex >= tileMapHeight) throw new FutileException("invalid column: " + rowIndex);

	LineTile[] rowTiles = new LineTile[tileMapWidth];

	for (int i = 0; i < tileMapWidth; i++) rowTiles[i] = tileMap[i][rowIndex];

	return rowTiles;
}

Now two functions that will actually shift a column or row of tiles in a specific direction. When a tile shifts off an edge, it loops around to the other side. For example, a right-shift on a row of Nub, Cross, Line will result in a row of Line, Nub, Cross.

// Shift the tiles in a column either up or down one (with wrapping).
private void ShiftColumnInDirection(int columnIndex, Direction dir) {
	LineTile[] currentColumnArrangement = GetColumnTiles(columnIndex);
	int nextIndex;

	// Move the tiles so they are in the correct spots in the tileMap array.
	if (dir == Direction.Up) {
		for (int j = 0; j < tileMapHeight; j++) {
			nextIndex = (j + 1) % tileMapHeight;

			tileMap[columnIndex][nextIndex] = currentColumnArrangement[j];
			tileMap[columnIndex][nextIndex].tileIndex = new TileIndex(columnIndex, nextIndex);
		}
	}
	else if (dir == Direction.Down) {
		for (int j = 0; j < tileMapHeight; j++) {
			nextIndex = j - 1;
			if (nextIndex < 0) nextIndex += tileMapHeight;

			tileMap[columnIndex][nextIndex] = currentColumnArrangement[j];
			tileMap[columnIndex][nextIndex].tileIndex = new TileIndex(columnIndex, nextIndex);
		}
	}
	else throw new FutileException("can't shift column in direction: " + dir.ToString());

	// Once the tileMap array is set up, actually visually move the tiles to their correct spots.
	for (int j = 0; j < tileMapHeight; j++) {
		tileMap[columnIndex][j].y = (j + 0.5f) * tileSize;
	}

	matchGroupsAreDirty = true;
}

// Shift the tiles in a row either right or left one (with wrapping).
private void ShiftRowInDirection(int rowIndex, Direction dir) {
	LineTile[] currentRowArrangement = GetRowTiles(rowIndex);
	int nextIndex;

	// Move the tiles so they are in the correct spots in the tileMap array.
	if (dir == Direction.Right) {
		for (int i = 0; i < tileMapWidth; i++) {
			nextIndex = (i + 1) % tileMapWidth;

			tileMap[nextIndex][rowIndex] = currentRowArrangement[i];
			tileMap[nextIndex][rowIndex].tileIndex = new TileIndex(nextIndex, rowIndex);
		}
	}
	else if (dir == Direction.Left) {
		for (int i = 0; i < tileMapWidth; i++) {
			nextIndex = i - 1;
			if (nextIndex < 0) nextIndex += tileMapWidth;

			tileMap[nextIndex][rowIndex] = currentRowArrangement[i];
			tileMap[nextIndex][rowIndex].tileIndex = new TileIndex(nextIndex, rowIndex);
		}
	}
	else throw new FutileException("can't shift row in direction: " + dir.ToString());

	// Once the tileMap array is set up, actually visually move the tiles to their correct spots.
	for (int i = 0; i < tileMapWidth; i++) {
		tileMap[i][rowIndex].x = (i + 0.5f) * tileSize;
	}

	matchGroupsAreDirty = true;
}

When we click an arrow (i.e. when the arrow button is released), we need to determine which row or column to shift, and in which direction.

// When an arrow is pressed and released, shift a column up/down or a row right/left.
public void ArrowButtonReleased(FButton button) {
	ArrowData arrowData = (ArrowData)button.data;

	if (arrowData.direction == Direction.Up || arrowData.direction == Direction.Down) {
		ShiftColumnInDirection(arrowData.index, arrowData.direction);
	}

	else if (arrowData.direction == Direction.Right || arrowData.direction == Direction.Left) {
		ShiftRowInDirection(arrowData.index, arrowData.direction);
	}
}

The next two methods are the most important ones in the game. The first one takes two tiles and determines what kind of connection they have. It bases the connection on the first tile input into the method (called baseTile). This is an important distinction. The baseTile could have a ValidWithOpenSide connection with the otherTile, but if you input them in the reverse order, it could return Invalid.

matchExplainer
// There are three types of connections two tiles can have:
// 1. ValidWithSolidMatch—this means the tiles are accurately matched with their solid sides connected.
// 2. ValidWithOpenSide—this means the baseTile has an open side touching the other tile, so it doesn't matter what the other tile is.
// 3. Invalid—this means the baseTile's solid side is matched with the other tile's open side, resulting in a mismatch.
private TileConnectionType TileConnectionTypeBetweenTiles(LineTile baseTile, LineTile otherTile) {
	int baseTileBitmaskSide = baseTile.bitmask; // The bitmask for the specific baseTile side that is touching the other tile.
	int otherTileBitmaskSide = otherTile.bitmask; // The bitmask for the specific otherTile side that is touching the base tile.

	// Depending on which side of the base tile the other tile is on, bitwise and each side together. with
	// the bitwise constant for that individual side. If the result is 0, then the side is open. Otherwise,
	// the side is solid.
	if (otherTile.tileIndex.yIndex < baseTile.tileIndex.yIndex) {
		baseTileBitmaskSide &= LineTile.kBitmaskBottom;
		otherTileBitmaskSide &= LineTile.kBitmaskTop;
	}
	else if (otherTile.tileIndex.yIndex > baseTile.tileIndex.yIndex) {
		baseTileBitmaskSide &= LineTile.kBitmaskTop;
		otherTileBitmaskSide &= LineTile.kBitmaskBottom;
	}
	else if (otherTile.tileIndex.xIndex < baseTile.tileIndex.xIndex) {
		baseTileBitmaskSide &= LineTile.kBitmaskLeft;
		otherTileBitmaskSide &= LineTile.kBitmaskRight;
	}
	else if (otherTile.tileIndex.xIndex > baseTile.tileIndex.xIndex) {
		baseTileBitmaskSide &= LineTile.kBitmaskRight;
		otherTileBitmaskSide &= LineTile.kBitmaskLeft;
	}

	if (baseTileBitmaskSide == 0) return TileConnectionType.ValidWithOpenSide; // baseTile side touching otherTile is open.
	else if (otherTileBitmaskSide != 0) return TileConnectionType.ValidWithSolidMatch; // baseTile side and otherTile side are solid and matched.
	else return TileConnectionType.Invalid; // baseTile side is solid but otherTile side is open. Mismatch!
}

Finally, UpdateMatches. This is the most important method of all. This is the one that goes through the board, analyzes all the pieces, determines which connect to each other, and which match groups are completely closed. Everything is explained in the comments.

// Go through the board and analyze all the tiles, looking for matches:
private void UpdateMatches() {
	// Match groups are being updated so they're no longer dirty:
	matchGroupsAreDirty = false;

	// Since sliding columns and rows can mess up everything, we need to get rid of the old match groups and start over.
	// Keep in mind there's probably a way to use the algorithm where we don't have to get rid of all the matches and
	// start over every time (say, just update the matches that are disrupted by a shift), but that can come later if
	// you need to improve performance.
	foreach (MatchGroup matchGroup in matchGroups) matchGroup.Destroy();
	matchGroups.Clear();

	// We'll start analyzing the board from the bottom left tile. The current base tile will be the one
	// that we are currently starting from and building match groups off of.
	LineTile currentBaseTile = tileMap[0][0];

	List<LineTile> tileSurrounders; // Variable that will store surrounding tiles of various base tiles.
	List<LineTile> checkedTiles = new List<LineTile>(); // We'll store base tiles here once they've been analyzed so we don't reanalyze them.
	MatchGroup currentMatchGroup; // The match group we're analyzing that includes the current base tile.

	// Loop continuously through the board, making match groups until there are no more tiles to make match groups from.
	while (currentBaseTile != null) {
		// Create a new match group, add the current base tile as its first tile.
		currentMatchGroup = new MatchGroup();
		currentMatchGroup.tiles.Add(currentBaseTile);

		// Loop through the tiles starting on the current base tile, analyze their connections, find a new base tile,
		// and loop again, and so on until you find no more possible connections with any of the tiles in the match group
		bool stillWorkingOnMatchGroup = true;

		while (stillWorkingOnMatchGroup) {
			// Populate the tileSurrounders list with all the tiles surrounding the current base tile:
			tileSurrounders = GetTilesSurroundingTile(currentBaseTile);

			// Iterate through all the surrounding tiles and check if their solid sides are aligned with the base tile's solid sides:
			foreach (LineTile surroundingTile in tileSurrounders) {
				TileConnectionType connectionType = TileConnectionTypeBetweenTiles(currentBaseTile, surroundingTile);

				// If there's a solid match, add the surrounder to the match group.
				// If there's a mismatch, the matchgroup is not a perfect "closed" match group.
				// If there's a mismatch because of an open side of the base tile, that doesn't actually matter
				// since there's not a solid side being cut off (this is called TileConnectionType.ValidWithOpenSide).
				if (connectionType == TileConnectionType.ValidWithSolidMatch) currentMatchGroup.tiles.Add(surroundingTile);
				else if (TileConnectionTypeBetweenTiles(currentBaseTile, surroundingTile) == TileConnectionType.Invalid) currentMatchGroup.isClosed = false;
			}

			// If the base tile has a closed/solid side that touches the edge of the board, the match group can't be closed.
			if (((currentBaseTile.bitmask & LineTile.kBitmaskTop) != 0 && currentBaseTile.tileIndex.yIndex == tileMapHeight - 1) ||
			    ((currentBaseTile.bitmask & LineTile.kBitmaskRight) != 0 && currentBaseTile.tileIndex.xIndex == tileMapWidth - 1) ||
			    ((currentBaseTile.bitmask & LineTile.kBitmaskBottom) != 0 && currentBaseTile.tileIndex.yIndex == 0) ||
			    ((currentBaseTile.bitmask & LineTile.kBitmaskLeft) != 0 && currentBaseTile.tileIndex.xIndex == 0)) currentMatchGroup.isClosed = false;

			// Add our base tile to an array so we don't check it again later:
			if (!checkedTiles.Contains(currentBaseTile)) checkedTiles.Add(currentBaseTile);

			// Find a new base tile that we've added to the match group but haven't analyzed yet:
			for (int i = 0; i < currentMatchGroup.tiles.Count; i++) {
				LineTile tile = currentMatchGroup.tiles[i];

				// If the checkedTiles array has the tile in it already, check to see if we're on the last
				// tile in the match group. If we are, then there are no more base tile possibilities so we are
				// done with the match group. If checkedTiles DOESN'T have a tile in the array, it means
				// that tile is in the match group but hasn't been analyzed yet, so we need to set it as
				// the next base tile.
				if (checkedTiles.Contains(tile)) {
					if (i == currentMatchGroup.tiles.Count - 1) {
						stillWorkingOnMatchGroup = false;
						matchGroups.Add(currentMatchGroup);
					}
				}
				else {
					currentBaseTile = tile;
					break;
				}
			}
		}

		// We're done with a match group, so now we need to find a new un-analyzed tile that's
		// not in any match groups to start a new one from. So we'll set currentBaseTile to
		// null then see if we can find a new one:
		currentBaseTile = null;

		for (int i = 0; i < tileMapWidth; i++) {
			for (int j = 0; j < tileMapHeight; j++) {
				LineTile newTile = tileMap[i][j];

				if (!TileIsAlreadyInMatchGroup(newTile)) {
					currentBaseTile = newTile;
					break;
				}
			}
			if (currentBaseTile != null) break;
		}
	}
}

All we have left is the HandleUpdate function! Every frame, update the match groups if they need updating (i.e. matchGroupsAreDirty == true), and set their colors.

public void HandleUpdate() {
	if (matchGroupsAreDirty) UpdateMatches();
}

Here's what the algorithm would look like if every step were animated:

boardAnimation

And that's it! While some of the code in this is specific to Futile, it should be pretty clear how to extend it to any other language or engine. And to reiterate, there's a lot of non-essential stuff missing in this post. Please look at the source code to see how it all works together!


Viewing all articles
Browse latest Browse all 728

Trending Articles