]

Thorne Brandt

Mobile Menu

Portfolio > Development > Tileswipe

Tileswipe

Mobile Brain Training App for Unity

I was commissioned to develop some minigames for a mobile app for the brain training company MindKick LLC. I thought that it would be a useful opportunity for me to develop experince in Test Driven Development in Unity.

Unity has a built-in test tools that parse files for a [Test] Attribute within a C# files in the Editor folder, and displays the passing or failing describe blocks within a test window inside of the Unity Editor.

The first minigame I worked on was essentially a two-dimensional rubix cube. We won't be concerned with the parent game engine and focus on testing the individual units of the logic of what we'll call a 'TileSwipe' class for now.

In the Unity Test Suite, the [Setup] Attribute is basically like a beforeEach method used by other test suites.



using UnityEngine;
using BrainBuzz.MiniGame.TileSwipe;


public class TileSwipeTest {
  public GameObject go;
  public MG_tileSwipe mgc;

  [SetUp]
  public void Setup(){
    go = new GameObject();
    go.AddComponent<MG_tileSwipe>();
    mgc = go.GetComponent<MG_tileSwipe>();
  }

  [Test]
  public void loadsTileSwipe(){
    Assert.IsTrue(go.GetComponent<MG_tileSwipe> () != null, "GameObject does not have a TileSwipe component.");
  }
}


In the spirt of test driven development, what we are looking for is this failure in the console.

This error will only be resolved once we have the TileSwipe class.



using UnityEngine;
using Random = UnityEngine.Random;

using System;
using System.Collections;
using System.Collections.Generic;

namespace BrainBuzz.MiniGame.TileSwipe
{
  public class MG_tileSwipe : MiniGameController () {
    
  }
}


Now the first test we wrote earlier should pass.


Before I worry about the graphical interface, I want to develop a way for the tests to be aware of a state of a two dimentional matrix. I like to create an underlying logic layer that can later be represented in various different presentation layers. ( One being a readable string that will be easy to test against. )

I will refrain from going over each test, but the question we should always be asking ourselves is what is the next piece of functionality to test? We know that we need a two dimensional grid, and we know that the individual cells of this grid will need a unique color property. For now, this can be represented by an integer. Later, these integers can be mapped into a color array for the animated presentation layer.

I usually represent a matrix with a multi-dimensional or rectangular array, represented in c# by a [,] sytax like the following;


int[,] cells = new int[3, 3];

This will create a 3x3 multidimensional array of integers called cells.

We can test for the existence of a Grid class within the minigame tileSwipe class and then we can test that the Grid creates a two dimensional matrix of integers. But how do we even test the equality of a multidimensional array? And what do we expect the grid to have in it ?

We must first set up some tools to facilitate the creation and testing of matrices.


private bool mapsEqual(int[,] map1, int[,] map2){
  if(map1.Length != map2.Length){
    return false;
  }
  for(int i = 0; i < map1.GetLength(0); i++){
    for(int j = 0; j < map1.GetLength(1); j++){
      if(map1[j, i] != map2[j, i]){
        return false;
      }
    }
  }
  return true;
}

This method loops through two matrices and first quickly returns false if they are separate lengths, for obvious reasons. If this guard clause passes, with the help of a nested loop, it returns false if one discrepancy is found.



public class Grid{
  public int[,] cells;

  public Grid(int[,] map){
    cells = map.Clone() as int[,];
  }
}

We set up the Grid with a constructor in which we can pass the matrix into. We're calling the interdimensional array 'map' for short. Note that instead of simply referecing the map, we're creating a clone so that the original reference isn't mutated once we start adding functionality.


[Test]
public void createsAGrid(){
  int[,] map1 = new int[,]{
    { 1, 2, 3 },
    { 1, 1, 1 },
    { 2, 2, 2 }
  };
  Grid grid = new Grid(map1);
  Assert.True(mapsEqual(map1, grid));
}

We also set up a simple test to make sure everything is working correctly, in which we check the deep equality of the original map. All tests should now be passing.


One of the specifications for this app is that it needs to be randomly generated. Despite this, difficulty levels need to remain consistent. In order to accomplish this, we start a random position which represents the finished state, then we shift a set number of rows or columns of the map, randomly picked.

It was a mental challenge to create a test for randomness. How do we check that a map has been generated randomly? The common structure for a test is to check for equality. How do you check for consistent non-equality?


[Test]
public void createsARandomGrid(){
  bool same = true;
  Grid grid1 = mgc.createGrid();
  Grid grid2 = mgc.createGrid();
  Grid grid3 = mgc.createGrid();
  if(
    mapsEqual(grid1, grid2) ||
    mapsEqual(grid1, grid3) ||
    mapsEqual(grid3, grid2)
  ){
    same = false;
  }
  Assert.False(same);
}

This creates three grids and checks for independence between the three of them. This isn't a perfect test, in that it will fail a microscopic amount of time. ( The probability of nine numbers appearing directly in the same order twice. ) and it will also produce false positives for some kind of incremental change. If anyone has a better idea for how to test randomness, I'd love to hear your feedback.


Continuing, we'll use TDD to write the swiping methods.



[Test]
public void gridSwipesLeft(){
  int[,] map1 = new int[,]{
    { 1, 2, 3 },
    { 1, 2, 3 },
    { 1, 2, 3 }
  };
  int[,] map2 = new int[,]{
    { 2, 3, 1 },
    { 1, 2, 3 },
    { 1, 2, 3 }
  };
  Grid grid = new Grid(map1);
  grid.shiftRowLeft(0);
  Assert.True(mapsEqual(grid.cells, map2));
  int[] expectedRow = new int[]{2, 3, 1};
  int[] row1 = grid.getRow(0);
  Assert.AreEqual(row1, expectedRow);
}

We've introduced a shiftRowLeft() method, and a getRow() method, which each take a index. The tests will fail before those are created. Let's create those methods within the Grid class.


public class Grid{
  public int gridSize = 3;
...

  public int[] getRow(int rowIndex){
    int[] row = new int[gridSize];
    for(int i = 0; i < gridSize; i++){
      row[i] = cells[rowIndex, i];
    }
    return row;
  }

  public void shiftRowLeft(int rowIndex){
    int tempCell = cells[rowIndex, 0];
    for(int i = 0; i < gridSize - 1; i++){
      cells[rowIndex, i] = cells[rowIndex, i + 1];
    }
    cells[rowIndex, gridSize - 1] = tempCell;
  }

...

While we're at it, let's just make the tests and methods for all the shift events.



//Assets/Editor/tests/TileSwipeTests.cs

[Test]
public void gridSwipesRight(){
  int[,] map1 = new int[,]{
    { 1, 2, 3 },
    { 1, 2, 3 },
    { 1, 2, 3 }
  };
  int[,] map2 = new int[,]{
    { 1, 2, 3 },
    { 3, 1, 2 },
    { 1, 2, 3 }
  };
  Grid grid = new Grid(map1);
  grid.shiftRowRight(1);
  Assert.True(mapsEqual(grid.cells, map2));
  int[] expectedRow = new int[]{3, 1, 2};
  int[] row1 = grid.getRow(1);
  Assert.AreEqual(row1, expectedRow);
}

[Test]
public void gridSwipesDown(){
  int[,] map1 = new int[,]{
    { 1, 1, 1 },
    { 2, 2, 2 },
    { 3, 3, 3 }
  };
  int[,] map2 = new int[,]{
    { 3, 1, 1 },
    { 1, 2, 2 },
    { 2, 3, 3 }
  };
  Grid grid = new Grid(map1);
  grid.shiftColDown(0);
  Assert.True(mapsEqual(grid.cells, map2));
  int[] expectedColumn = new int[]{3, 1, 2};
  int[] col1 = grid.getColumn(0);
  Assert.AreEqual(col1, expectedColumn);
}

[Test]
public void gridSwipesUp(){
  int[,] map1 = new int[,]{
    { 1, 1, 1 },
    { 2, 2, 2 },
    { 3, 3, 3 }
  };
  int[,] map2 = new int[,]{
    { 1, 2, 1 },
    { 2, 3, 2 },
    { 3, 1, 3 }
  };
  Grid grid = new Grid(map1);
  grid.shiftColUp(1);
  Assert.True(mapsEqual(grid.cells, map2));
  int[] expectedColumn = new int[]{2, 3, 1};
  int[] col1 = grid.getColumn(1);
  Assert.AreEqual(col1, expectedColumn);
}


And to make them pass:


//Assets/Scripts/Minigames/MG_tileSwipe.cs

public void shiftRowRight(int rowIndex){
  int tempCell = cells[rowIndex, gridSize - 1];
  for(int i = gridSize - 1; i > 0; i--){
    cells[rowIndex, i] = cells[rowIndex, i - 1];
  }
  cells[rowIndex, 0] = tempCell;
}

public void shiftColUp(int colIndex){
  int tempCell = cells[0, colIndex];
  for(int i = 0; i < gridSize - 1; i++){
    cells[i, colIndex] = cells[i + 1, colIndex];
  }
  cells[gridSize - 1, colIndex] = tempCell;
}

public void shiftColDown(int colIndex){
  int tempCell = cells[gridSize - 1, colIndex];
  for(int i = gridSize - 1; i > 0; i--){
    cells[i, colIndex] = cells[i - 1, colIndex];
  }
  cells[0, colIndex] = tempCell;
}


These shift/swipe methods are meant to be shared by both a user event, and the process which randomly shuffles the finished position based on a skill level. Let's look at the test for that.


[Test]
public void gameShufflesGrid(){
  int[,] map1 = new int[,]{
    { 1, 1, 1 },
    { 2, 2, 2 },
    { 3, 3, 3 }
  };
  Grid grid = new Grid(map1);
  Grid grid2 = new Grid(map1);
  grid2.shuffleGrid(mgc.numMoves);
  int[,] map2 = grid2.cells;
  Assert.False(mapsEqual(map1, map2));
}

We essentially want to make sure that the shuffled grid ( which the user will have to unshuffle ) will not match the goal grid, which would eventually create an infinite loop.



public class Grid{

...

  public void shuffleGrid(int numMoves){
    //we don't need recursion, just make numMoves odd.
    if(numMoves%2 == 0){
      numMoves++;
    }
    for(int i = 0; i < numMoves; i++){
      int flipCoin = Random.Range(0, 2);
      int flipCoin2 = Random.Range(0, 2);
      int randomIndex = Random.Range(0, gridSize);
      if(flipCoin == 0){
        if(flipCoin2 == 0){
          shiftRowRight(randomIndex);
        } else {
          shiftRowLeft(randomIndex);
        }
      } else {
        if(flipCoin2 == 0){
          shiftColDown(randomIndex);
        } else {
          shiftColUp(randomIndex);
        }
      }
    }
  }

...

}

I use two coinFlips for the horizontal & vertical and a randomIndex for the gridSize.

We then use this shuffleGrid() method during the initialization of a question ( which in this example, in classicly inherited from a parent class.


namespace MiniGame.TileSwipe
public class MG_tileSwipe : MiniGameController {
...
  public override void MG_prepareQuestion (int index){
    goalGrid = createGrid();
    playerGrid = shufflePlayerGrid(goalGrid);
    populateGoalGrid(goalGrid);
    populatePlayerGrid(playerGrid);
  }

  public Grid shufflePlayerGrid(Grid goalGrid){
    Grid shuffledGrid = new Grid(goalGrid.cells);
    shuffledGrid.shuffleGrid(numMoves);
    if(shuffledGrid.mapsEqual(shuffledGrid.cells, goalGrid.cells)){
      return shufflePlayerGrid(goalGrid);
    }
    return shuffledGrid;
  }

  public void populatePlayerGrid(Grid goalGrid){
    int childIndex = 0;
    for(int i = 0; i < gridSize; i++){
      for(int j = 0; j < gridSize; j++){
        int colorIndex = goalGrid.cells[i, j];
        Color color = colors[colorIndex];
        GameObject cell = playerGridObject.transform.GetChild(
        childIndex).GetComponent<GameObject>();
        cell.color = color;
        childIndex++;
      }
    }
  }

...

}

Note that childIndex is referring to gameObjects that can be added to the scene programatically or manually.

Now all we need for a functional game is user event handlers. This is a mobile game, so we'll ideally by using swipe handlers. NGUI was already initialized for other parts of the interface, so I went with their UIEventListener and other API components.


public UIEventListener tileBG;

void Start (){
  tileBG.onDrag += swipeHandler;
  tileBG.onDragStart += swipeStartHandler;
}

private void swipeStartHandler(GameObject go){
  validSwiping = false;
}

private void swipeHandler(GameObject go, Vector2 delta){
  if(validSwiping || delta.SqrMagnitude() < 50){
    return;
  }
  Vector3 swipePosition = UICamera.currentTouch.pos;
  if(Mathf.Abs(delta.x) < Mathf.Abs(delta.y) / 2){
    validSwiping = true;
    int col = swipedColumn(swipePosition);
    if(col != -1){
      if(delta.y > 0){
        swipeColumn(1, col);
      } else {
        swipeColumn(-1, col);
      }
    }
  }
  else if(Mathf.Abs(delta.y) < Mathf.Abs(delta.x) / 2){
    validSwiping = true;
    int row = swipedRow(swipePosition);
    if(row != -1){
      if(delta.x > 0){
        swipeRow(1, row);
      } else{
        swipeRow(-1, row);
      }
    }
  }
}


First, we determine if something is in fact a swipe and is fast enough. Next, we calculate based on Vector2 delta of the touch information whether or not it is a vertical or horizontal swipe. Finally, we're setting a Vector3 variable called swipePosition and we're sending that to a swipeRow() method, after we get the index from a swipedRow() method.


public int swipedRow(Vector3 swipePosition){
  //swipeRect is a calculated from NGUI camera bounds.
  float yOffset = swipeRect.height + swipeRect.y;
  float colHeight = swipeRect.height/gridSize;
  float bottomBorder = yOffset - colHeight;
  if(swipeRect.Contains(swipePosition)){
    for(int i = 0; i < gridSize; i++){
      if(swipePosition.y > bottomBorder){
        return i;
      }
      yOffset -= colHeight;
      bottomBorder -= colHeight;
    }
  }
  return -1;
}

A NGUI UIEventListener is added to a rectangle called swipeRect that we'll bring into the scene and position graphically. We then know the bounds and we know how many elements are in the physical grid (gridSize), so we use simple division and cutting off a loop at the appropriate index with a greater-than operator.



public void swipeRow(int direction, int rowIndex){
  if(direction > 0){
    playerGrid.shiftRowRight(rowIndex);
  } else {
    playerGrid.shiftRowLeft(rowIndex);
  }
  populatePlayerGrid();
}

We'll add the mapsEqual(grid1, grid2) method that we created earlier into a populatePlayerGrid() method, and voila, we have a way to determine win state.


public void populatePlayerGrid(Grid goalGrid){
  if(mapsEqual(playerGrid, goalGrid)){
    if(playerGrid != null && playerGrid.Length != 0){
      MG_winning = true;
    }
  } else {
    int childIndex = 0;
    for(int i = 0; i < gridSize; i++){
      for(int j = 0; j < gridSize; j++){
        int colorIndex = goalGrid.cells[i, j];
        Color color = colors[colorIndex];
        GameObject cell = playerGridObject.transform.GetChild(
        childIndex).GetComponent<GameObject>();
        cell.color = color;
        childIndex++;
      }
    }
  }
}

We now have all the parts we need for a functional app. The last part of the app is the animation. In order to simulate the seamless rotation of the matrix, we need multiple sets of objects. Instead of actually setting up the types of physical joints that a rubix cube would use, we can have multiple objects occupying the same point in space, and swap them out based on which direction has been swiped.



public void populatePlayerGrid(Grid goalGrid){
  if(mapsEqual(playerGrid, goalGrid)){
    if(playerGrid != null && playerGrid.Length != 0){
      MG_winning = true;
    }
  } else {
    int childIndex = 0;
    for(int i = 0; i < gridSize; i++){
      for(int j = 0; j < gridSize; j++){
        populateColumns(playerGrid);
        populateRows(playerGrid);
        showColumns();
      }
    }
  }
}

private void populateRows(Grid playerGrid){
  float yOffset = offset;
  for(int i = 0; i < gridSize; i++){
    int childIndex = 0;
    int[] row = playerGrid.getRow(i);
    Transform rowObject = rows[i].transform;
    rowObject.transform.localPosition = new Vector3(0f, yOffset, 0f);
    yOffset -= offset;
    for(int repeat = 0; repeat < 3; repeat++){
      for(int j = 0; j < row.Length; j++){
        int colorIndex = row[j];
        Color color = colors[colorIndex];
        UISprite cell = rowObject.GetChild(childIndex).GetComponent<UISprite>();
        cell.color = color;
        childIndex++;
      }
    }
  }
}

private void populateColumns(Grid playerGrid){
  float xOffset = -offset;
  for(int i = 0; i < gridSize; i++){
    int childIndex = 0;
    int[] col= playerGrid.getColumn(i);
    Transform colObject = cols[i].transform;
    colObject.transform.localPosition = new Vector3(xOffset, 0f, 0f);
    xOffset += offset;
    for(int repeat = 0; repeat < 3; repeat++){
      for(int j = 0; j < col.Length; j++){
        int colorIndex = col[j];
        Color color = colors[colorIndex];
        UISprite cell = colObject.GetChild(childIndex).GetComponent<UISprite>();
        cell.color = color;
        childIndex++;
      }
    }
  }
}

private void showColumns(){
  for(int i = 0; i < cols.Length; i++){
    rows[i].SetActive(false);
    cols[i].SetActive(true);
  }
}

private void showRows(){
  for(int i = 0; i < cols.Length; i++){
    rows[i].SetActive(true);
    cols[i].SetActive(false);
  }
}

Notice that showColumns() is called by default, to hide the row version of the grid.

Now we can update the swipeRow() and swipeColumn() methods to incorporate the animation. When the animation is finished. We reset the extra tile positions with populatePlayerGrid() and we're ready to swipe again.


public void swipeRow(int direction, int rowIndex){
  Vector3 p1 = rows[rowIndex].transform.localPosition;
  Vector3 p2 = new Vector3(p1.x + (direction * offset), p1.y, p1.z);
  showRows();
  HOTween.To(
    rows[rowIndex].transform,
    0.15f,
    new TweenParms()
      .Prop("localPosition", p2)
      .Ease(EaseType.EaseOutBounce)
      .OnComplete(() => {
        if(direction > 0){
          playerGrid.shiftRowRight(rowIndex);
        } else {
          playerGrid.shiftRowLeft(rowIndex);
        }
        populatePlayerGrid(playerGrid);
      })
  );
}

public void swipeColumn(int direction, int colIndex){
  Vector3 p1 = cols[colIndex].transform.localPosition;
  Vector3 p2 = new Vector3(p1.x, p1.y + (direction * offset), p1.z);
  showColumns();
  HOTween.To(
    cols[colIndex].transform,
    0.15f,
    new TweenParms()
      .Prop("localPosition", p2)
      .Ease(EaseType.EaseOutBounce)
      .OnComplete(() => {
        if(direction > 0){
          playerGrid.shiftColUp(colIndex);
        } else {
          playerGrid.shiftColDown(colIndex);
        }
        populatePlayerGrid(playerGrid);
      })
  );
}

Now we have a fancy two dimensional rubix tileswipe that looks like the video at the top.

Things like timers and point systems should be handled by a parent game controller, which should listen for changes in the MG_winning boolean in the inherited MiniGameController class.