Tuesday, August 25, 2015

[Unity 3D] Tutorial 4: Roguelike

It's been almost three weeks since I finished this tutorial and it was about time that I wrote a post about it. It's been a very interesting one, since it introduces procedural generation of levels and tile-based scenes for the first time. With this one we also move from basic to intermediate difficulty.

The project is called Roguelike because its elements are based on "Rogue", a computer game that was released in 1980 and which established a new video game subgenre. Roguelike games are 2D and tile-based, with levels generated procedurally and a turn-based gameplay. Another characteristic is the so called "permadeath", which means that when the character dies the player has to start all over again from the beginning.

Setup
The first part of the tutorial focuses on creating game elements out of the sprite assets so that we can use them later on in our game. We learn to create animations from sprites, which basically consists on dragging the desired sprites from the sprite sheet and dropping them on to the corresponding game object. This will create the animation, an AnimationController and a SpriteRenderer components automatically. One of the important things to set properly when working with sprites is their render order in the scene, so that we can establish which ones will be rendered on the foreground and which ones on the background. This is done by setting the sorting layer attribute on the SpriteRenderer component.

Next we need to create the tile prefabs that will be used in the procedural generation of the levels. This is again very simple, since we just need to create an empty GameObject and add a SpriteRenderer component to it, in which we will set the sprite that we want to use and its sorting layer.

Game logic
The game logic is taken care of mainly by the GameManager script, which is in charge of initializing the scene every time a level is completed and managing the turns. Initialization of a level consists of generating a new board and setting the UI to show the player which level we are going to play next. The GameManager makes sure that neither the enemies nor the player can move until initialization has been completed with the use of boolean flags.

Turn management also makes use of flags to decide whether the enemies or the player can move. It will be the player's turn until the user moves, and then it will be the enemies' turn until all of them have moved. Movement and actions performed by the units are managed by separate scripts (Player and Enemy, which inherit from a common script MovingObject).

Finally, the GameManager takes care of showing the game over message when the player has run out of lifes (in this case, when he has no food left).

New useful commands introduced in the GameManager script are:

  • DontDestroyOnLoad (GameObject): Don't destroy the game object when loading a new scene, which is the default behaviour. This allows to keep track of values such as the score throughout the levels.
  • Invoke ("NameOfMethod", delay): Automatically call a method after the specified delay in seconds has passed.
  • Use of the attribute [HideInInspector] to serialize a variable without it being visible in the inspector
Generation of levels is delegated to the BoardController script through the method SetupScene(int level). The level number will be used to calculate how many enemies will be placed on the board, making the difficulty increase logarithmically.

To understand how the Board works we need to picture it as a grid of N x N positions. The outer ring of this grid won't be usable, which means that the player and the enemies won't be able to walk here. Instead it will be filled with outer wall tiles to visually delimit the playing area, which will therefore have N-1 x N-1 positions. The area inside this ring will be filled with floor tiles.

Next we need to place the Exit tile and fill the board with items. To avoid generating impossible levels we will leave a second outer ring free of obstacles and always place the Exit tile at the top-rightmost position of this ring (see the image below for reference). The area available for placing enemies, walls and food will therefore have N-2 x N-2 tiles.

We will use a list of Vector3 to store the x,y positions available within this N-2 x N-2 grid that we have defined. Then we will place a random number (within specified min and max limits) of walls and food on those tiles, which will be randomly selected from the list. As soon as we place an item on a tile we need to remove it from the list to avoid placing more than one item on the same place. Since we have several sprite variations for each type of element we will also select which sprite to use at random.

In the case of the enemies the process is practically the same, with the only difference that we will always place an exact number of them depending on the current level.

Note that enemies, walls and food elements are being placed at the same position as the floor tiles that we set above. Here is where having set the sorting layers correctly will come in handy.

Highlights of this script are:

  • Using an empty GameObject created programatically to set as parent for all the sprite prefabs instanciated. Doing this will allow us to keep the Hierarchy view clean while running the game.
  • Defining a singleton class by using a static reference to the only instance allowed
  • Use of the attribute [Serializable] as opposed to implementing the interface Serializable in Java

Interaction of units
This part of the code has been implemented in a very smart way. I will try to explain it as clearly as possible.

Players and enemies share the fact that they can move around a delimited area. The best way to deal with this is to implement the code that allows them to move in a common place so that the Player and the Enemy scripts can share it. This place is the abstract class MovingObject from which both scripts inherit.

The MovingObject script takes care of determining whether a unit can or can't move, and to perform a different action depending on the case. A unit won't be able to move when a ray cast from the current position to the final position hits anything in the blocking layer. Enemies, walls, the exit tile and the player are placed on this layer.

When a unit can move, a smooth movement towards the final destination will be performed. This behavior is the same for both the player and the enemies, and so it's implemented as a shared method in the parent class (SmoothMovement). But when the unit can't move, we will want something different to happen whether the unit is an enemy or the player. First of all, the type of object with which each of them can interact differs: the player can only interact with walls by breaking them, while enemies can only attack the player. And secondly, the animation and sound effects that we will show will be different on each case.

The way this is done is by defining an overridable method AttemptMove in MovingObject:

protected virtual void AttemptMove<T>(int xDir, int yDir) where T:Component

This is a template method that will allow the child classes to use it with different types of objects. In the case of Player, the method will be invoked as AttemptMove<Wall>, since we are expecting to collide with a Wall. In the case of the enemies, it will be invoked as AttemptMove<Player> because we are expecting to collide with it. This solves the first part of the problem.

To define what happens when the movement attempt has failed, the Player and the Enemy scripts implement the abstract method OnCantMove. Here is where the animation triggers are set, the sound effects are played and the damage to either the wall or the player is inflicted. This is the method signature on the child classes:

protected override void OnCantMove<T>(T component)

Highlights of these scripts are:
  • Using templates: using the syntax "where T:Class" as opposed to the Java syntax
  • Overriding methods: 
    • Usage of the modifiers "virtual" and "override" as opposed to the annotation @Override in Java
    • Usage of base.Method() as opposed to super() in Java
  • Usage of two different casting methods: 
    • Prefix-casting: MyType var = (MyType) aVar;
      • Throws an InvalidCastException if not allowed
    • As-casting: MyType var = aVar as MyType;
      • Returns null if not allowed
      • Better performance than prefix-casting
  • Finding out that multiplying is more efficient than dividing in C#

Picking up food
The food pick-up is implemented in the Player script on the OnTriggerEnter2D method, which is a Unity core callback called whenever an object enters a trigger collider attached to the current object. Here the tag assigned to each of the objects is used to distinguish whether we have collided with food or soda. Depending on the case, we will increase the food points by a different amount and play different sound effects.

Loading a new level
Collision with the Exit prefab is also detected on the OnTriggerEnter2D callback by using its tag. In this case we will invoke the Restart method after a specific delay with the use of Invoke, just as we did in the GameManager. The method Restart will simply reload the current level as follows:

Application.LoadLevel (Application.loadedLevel);

Now we will use another Unity callback, OnLevelWasLoaded, to update the level number and initialize the new board. We will place its implementation in the GameManager. This method will be called every time a scene is loaded. 

Other scripts
The Wall and the SoundManager scripts won't be discussed here as they are not particularly complicated. I will only mention a new syntax used in the SoundManager because it's new to me:
  • Usage of the syntax "params Class[] elements" as opposed to "Class... elements" in Java to send multiple arguments to a method

Adding mobile controls
Two new features introduced on this tutorial are the possibility to execute different codes depending on the device on which the game is running, and getting input from a touchscreen.

          #if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBPLAYER

               //Get input from keyboard as we have done so far

               int horizontal = (int)Input.GetAxisRaw ("Horizontal");
               int vertical = (int)Input.GetAxisRaw ("Vertical");
                    ...

          #else

               //Get input from touchscreen

               if(Input.touchCount > 0) {
                Touch myTouch = Input.touches[0];
                if(myTouch.phase == TouchPhase.Began) {

                //The finger has touched the screen
                //Save the starting position of the stroke
                touchOrigin = myTouch.position;

                } else if(myTouch.phase == TouchPhase.Ended && touchOrigin.x >= 0) {

                 //The finger has lifted off the screen
                //We also check that the touch is inside the bounds of the screen
                Vector2 touchEnd = myTouch.position;
                float x = touchEnd.x - touchOrigin.x;
                float y = touchEnd.y - touchOrigin.y;
                touchOrigin.x = -1;

                 if(Mathf.Abs(x) > Mathf.Abs(y)) {
                 //The touch has been more horizontal than vertical
                 horizontal = x > 0 ? 1 : -1;
                 } else {
                 vertical = y > 0 ? 1 : -1;
                 }
              }
              }

          #endif


If you have reached this point of the post I think you well deserve a break, so now it's time for you to play. Thank you for reading!

THE WEBGL BUILD DOESN'T WORK PROPERLY AT THE MOMENT. MEANWHILE YOU CAN DOWNLOAD THE VERSION FOR PC HERE. SORRY FOR THE INCONVENIENCE!



Final words
I thought that this would be the last game tutorial before my first solo project, but things have changed a bit. The Unity guys have come up with some new training projects, and there's also an updated version of the Survival Shooter for Unity 5.x. that I would like to check. So the good news is that learning opportunities increase and there's going to be a lot more after this :-)

No comments:

Post a Comment