teaching machines

CS 491: Lecture 5 – Pathlete Continued

March 5, 2018 by . Filed under gamedev, lectures, spring 2018.

Dear students:

Today we don’t introduce any new hardware, but we do explore a common mechanism for designing levels: text assets. Unity’s drag-n-drop editor is great, but some times ASCII does the job more quickly. We’ll use a plain text editor to lay out levels for Pathlete, the game we started last week. We’ll also add some sound, prefab organization, and end game detection.

Let’s start by coding up a level in our favorite text editor. We need three characters to represent the three tile types:

Here’s one possible form:

xxxxxxxx
x......x
x.x..x.x
x......x
x+.....x
xx......

If we place this file in Unity and give it the extension of common text file format, Unity will automatically make it available as a TextAsset in our game. Few assumptions are made about such assets. You are expected to know how to read them.

Let’s add a public to our controller:

public TextAsset level;

When we’re ready to build up the level, we call level.text to get the file’s contents. In our case, we’d like to process the content by line by line. How do we get C# to give us lines? The Split method does the trick:

string[] lines = level.text.Split(new string[] {"\r\n", "\n", "\r"}, StringSplitOptions.RemoveEmptyEntries);

All maintainable graphical systems are composed of a view and model. The view is responsible for the visual display, and the model is responsible for tracking the state of the data with no concern for its presentation. The controller arbitrates between the two. We don’t have the cleanest separation here, but we do need a model of the current board layout independent of the view. Let’s turn our lines into an array.

What kind of array? Tiles can be in one of three states: wall/obstacle, vacant passage, or filled passage. For simplicity, let’s use a char array built directly off the lines we got from the file:

private char[][] board;

void Start() {
  // ...
  board = new char[lines.Length][];
  for (int i = 0; i < lines.Length; ++i) {
    board[i] = lines[i].ToCharArray();
  }
}

Next we’ll create three sprite prefabs: one for the walls, one for the filled passages, and one for the moving platform. We’ll wire them up into publics:

public GameObject wallPrefab;
public GameObject pathPrefab;
public GameObject platformPrefab;

Let’s process our board and instantiate prefabs at the walls and the platform locations:

for (int r = 0; r < board.Length; ++r) {
  for (int c = 0; c < board[r].Length; ++c) {
    if (board[r][c] == 'x') {
      GameObject wall = Instantiate(wallPrefab, new Vector2(c, r), Quaternion.identity);
    } else if (board[r][c] == '+') {
      GameObject platform = Instantiate(platformPrefab, new Vector2(c, r), Quaternion.identity);
      board[r][c] = '.';
    }
  }
}

How does this look? Okay. There are three issues:

Let’s fix these one by one. To flip the board, we take the complement of r:

int flippedR = board.Length - 1 - r;
if (board[r][c] == 'x') {
  GameObject wall = Instantiate(wallPrefab, new Vector2(c, flippedR), Quaternion.identity);
} else if (board[r][c] == '+') {
  GameObject platform = Instantiate(platformPrefab, new Vector2(c, flippedR), Quaternion.identity);
  board[r][c] = '.';
}

To center the camera, we can reposition it according the board dimensions. We could make a public for the camera, but tweaking the camera is something that’s done so often, it is available as a static object in Camera class. Here’s how we center it:

Camera.main.transform.position = new Vector3((board[0].Length - 1) * 0.5f,
                                             (board.Length - 1) * 0.5f,
                                             Camera.main.transform.position.z);

And finally, regarding the Hierarchy, it’s good practice to hook up all your prefab instances to a dummy parent. We’ll make an empty game object, name it Prefabs, make sure it’s positioned at the origin, and then make our instances children of it with this code:

private GameObject prefabs;

void Start() {
  prefabs = GameObject.Find("/Prefabs"); // query instead of public

  // ...
  if (board[r][c] == 'x') {
    GameObject wall = Instantiate(wallPrefab, new Vector2(c, flippedR), Quaternion.identity);
    wall.transform.parent = prefabs.transform;
  }
}

The only thing weird here is that the hierarchy is formed through the transforms of our GameObjects.

Okay, things are coming together. Let’s track the platform’s position on the board. We add some instance variables:

private platformX;
private platformY;

Initialize them in Start:

platformX = c;
platformY = r;

And update them in Nudge:

platformX += deltaX;
platformY += deltaY;

As we visit a tile, let’s drop a prefab there:

GameObject tile = Instantiate(pathPrefab, new Vector2(platformX, board.Length - 1 - platformY), Quaternion.identity);
tile.transform.parent = prefabs.transform;

We can stop the nudging if we go off the board:

while (platformX >= 0 && platformX < board[0].Length &&
       platformY >= 0 && platformY < board.Length) {
  // ...
}

We also want to stop if we grind into an non-empty tile—a tile which is not .. We’ll also need to mark the tiles we visit in some ways. Let’s change them to _:

while (platformX >= 0 && platformX < board[0].Length &&
       platformY >= 0 && platformY < board.Length &&
       board[platformY][platformX] == '.') {
  
  // ...
  board[platformY][platformX] = '_';
}

After this loop, we want to check if the board is filled. If it contains no ., the player wins. Let’s add a helper method:

private bool IsFilled() {
  for (int r = 0; r < board.Length; ++r) {
    for (int c = 0; c < board[r].Length; ++c) {
      if (board[r][c] == '.') {
        return false;
      }
    }
  }
  return true;
}

If the player wins, let’s play an 8-bit cheer. If not, let’s play an 8-bit grunt. We add an AudioSource component and these instance variables:

public AudioClip winClip;
public AudioClip loseClip;
private AudioSource speaker;

Initialize speaker in Start:

speaker = GetComponent<AudioSource>();

We add this at the end of Nudge:

if (IsFilled()) {
  speaker.PlayOneShot(winClip);
} else {
  speaker.PlayOneShot(loseClip);
}

At the end of all this, we have a pretty playable game. We just need more levels! We can do this without a lot of changes. First, we make our TextAsset an array:

private TextAsset[] levels;
private int iLevel;

And then we populate it with our various level assets. We factor out our level-loading code to a helper method:

void LoadCurrent() {
  string[] lines = levels[iLevel].text.Split(new string[] {"\r\n", "\n", "\r"}, StringSplitOptions.RemoveEmptyEntries);
  // ...
}

Finally, we tweak the end of Nudge:

if (IsFilled()) {
  speaker.PlayOneShot(winClip);
  ++iLevel;
} else {
  speaker.PlayOneShot(crashClip);
}

yield return new WaitForSeconds(2.0f);

Destroy(platform);
foreach (Transform child in prefabs.transform) {
  Destroy(child.gameObject);
}
nudger = null;
LoadCurrent();

And there we go!

Here’s your TODO list:

See you next time!

Sincerely,

P.S. It’s time for a haiku!

From easy to hard?
No, go from interesting
To interesting