Unity3D Endless Runner Part II – Scrolling Worlds

Happy New Year, all! And here it is, my first blog post of 2017. Unfortunately, if last year’s track record is any indication, this may also be my last blog post of the year. I’d like to believe not, though. Hopefully this year will be a little more productive (at least in terms of writing). And, as for pretty much everyone in the world, last year was a bit trying, to say the least. Here’s to a better one for all.

In any case, this post will pick up where my last left off – the second part of a series on creating an endless runner game. If you never saw it or need to remind yourself what the hell I wrote six months ago (as I did), you can check out the first part here.

So, if playing along, at the end of the last post you wound up with three prefabs: a CurveController (actually, if you didn’t turn the CurveController into a prefab object, go ahead and do so), and two tiles, Tile01 and Tile02, each with a Curved shader applied. In this episode, we’ll get a random (but not *too* random) collection of those 2 tiles to endlessly scroll so our runner has something on which to run.

Let’s start out by creating a new scene to give us a main camera and a directional light. Within the editor, set the camera’s xyz position to (0, 1.25, 0). The 1.25 value was found through experimentation – you can tweak it to taste later if so desired. Just leave the directional light as is, but, again, you can play around with it later, if you’d like. Also, drag a copy of the CurveController prefab up into the scene. Finally, using the menu GameObject->Create Empty, add two blank GameObjects to the scene leaving them at their default position of (0,0,0). Rename one of the GameObjects ‘WorldTiles’ and the other one ‘GameController’. We’ll be adding scripts to those shortly. Go ahead and save the scene (which I’ve just named ‘Step_2’). At this early point, the hierarchy panel should look something like this:

Scene Hierarchy

The first script we’ll take a look at is the one we’ll be adding to the WorldTiles GameObject, as it’s the one that will really be doing all the work. Inside a scripts directory (or wherever you like putting your Unity scripts) create a new C# script named ‘WorldTileManager’ that inherits from MonoBehaviour. This class will be responsible for creating our world tiles, maintaining a specified number in a List, then updating those tiles in the list by animating them towards the camera. As each tile animates past the camera, it will be removed and new one will be added to the end, conveyor belt style. In order to save on garbage collection, we’ll use an object pool to ‘create’ new tiles. Although the class extends MonoBehaviour, it won’t actually use any of the MonoBehaviour methods. We still want the inheritance, though, in order to attach the class to our WorldTiles GameObject and use the Unity3D ide to set properties.

This, then is the WorldTileManager class:

//
// WorldTileManager.cs
//
// Author:
//       Devon O. <devon.o@onebyonedesign.com>
//
// Copyright (c) 2017 Devon O. Wolfgang
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

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

public class WorldTileManager : MonoBehaviour
{
    /** Max Number of Tiles visible at one time */
    static int MAX_TILES = 6;

    /** Max speed of tiles */
    [Range(0f, 50f)]
    public float maxSpeed = 10f;

    /** Collection of Types of Tiles */
    public GameObject[] tileTypes;

    /** Size of Tiles in z dimension */
    private float tileSize = 7.62f;

    /** Current Speed of Tiles */
    private float speed;

    /** Collection of active Tiles */
    private List<GameObject> tiles;

    /** Pool of Tiles */
    private TilePool tilePool;

	/** Initialize */
	public void Init() 
    {
        this.speed = 0f;
        this.tiles = new List<GameObject>();
        this.tilePool = new TilePool(this.tileTypes, MAX_TILES, this.transform);
        InitTiles();
	}
	
    /** Increase speed by given amount */
    public void IncreaseSpeed(float amt)
    {
        this.speed += amt;
        if(this.speed > this.maxSpeed)
            this.speed = maxSpeed;
    }
	
    /** Update Tiles */
    public void UpdateTiles(System.Random rnd) 
    {
        for (int i=tiles.Count-1; i>=0; i--)
        {
            GameObject tile = tiles[i];
            tile.transform.Translate(0f, 0f, -this.speed*Time.deltaTime);

            // If a tile moves behind the camera release it and add a new one
            if (tile.transform.position.z < Camera.main.transform.position.z)
            {
                this.tiles.RemoveAt(i);
                this.tilePool.ReleaseTile(tile);
                int type = rnd.Next(0, this.tileTypes.Length);
                AddTile(type);
            }
        }
	}
        
    /** Add a new Tile */
    private void AddTile(int type)
    {
        GameObject tile = this.tilePool.GetTile(type);

        // position tile's z at 0 or behind the last item added to tiles collection
        float zPos = this.tiles.Count==0 ? 0f : this.tiles[this.tiles.Count-1].transform.position.z+this.tileSize;
        tile.transform.Translate(0f, 0f, zPos);
        this.tiles.Add(tile);
    }

    /** Initialize Tiles */
    private void InitTiles()
    {
        for(int i = 0; i < MAX_TILES; i++)
        {
            AddTile(0);
        }
    }

    /** Object Pool for World Tiles */
    class TilePool
    {
        /** Pool of Tiles */
        private List<GameObject>[] pool;

        /** Model Transform */
        private Transform transform;

        /** Create a new TilePool */
        public TilePool(GameObject[] types, int size, Transform transform)
        {
            this.transform=transform;
            int numTypes=types.Length;
            this.pool=new List<GameObject>[numTypes];
            for (int i=0; i<numTypes; i++)
            {
                this.pool[i]=new List<GameObject>(size);
                for (int j=0; j<size; j++)
                {
                    GameObject tile=(GameObject)Instantiate(types[i]);
                    tile.SetActive(false);
                    this.pool[i].Add(tile);
                }
            }
        }

        /** Get a Tile */
        public GameObject GetTile(int type)
        {
            for(int i = 0; i < this.pool[type].Count; i++)
            {
                GameObject tile = this.pool[type][i];
                // Ignore active tiles until we find the 1st inactive in appropriate list
                if(tile.activeInHierarchy)
                    continue;
                
                // reset the tile's transform to match the model transform
                tile.transform.position = this.transform.position;
                tile.transform.rotation = this.transform.rotation;

                // set to active and return
                tile.SetActive(true);
                return tile;
            }

            // This will never be reached, but compiler requires it
            return null;
        }

        /** Release a Tile */
        public void ReleaseTile(GameObject tile)
        {
            // Inactivate the released tile
            tile.SetActive(false);
        }
    }
}

A few things to note about this class. The MAX_TILES property is the maximum number of world tiles that will be shown at one time. You can play around with this, but I’ve found 6 gives a good trade off on performance and aesthetics (also works well on mobile devices). The maxSpeed property is the fastest the tiles will animate towards the camera. Because we’ve declared this some Range metadata, we can adjust it with a slider within the Unity3D ide (though the default 10 works pretty well). The tileTypes property is an Array of our tiles that we’ll be creating. It’s public so we can easily set those tile types through the ide. The tileSize property is the magic 7.62 number we saw in the last post. The UpdateTiles method iterates backwards through the tiles List in order to safely remove an element from the List instance within the loop. That method also takes a System.Random argument which we’ll be creating elsewhere. The System.Random class is a seeded pseudo random number generator. We use this to get the ‘illusion’ of randomness, but, because it’s seeded, it’s repeatable, so game replay will be predictable. The rest of the class should be fairly self evident and is fairly well commented to give an idea of what’s going on.

Back in the Unity3D ide, drag this class onto the WorldTiles GameObject in the Hierarchy panel so that it appears as a component in the Inspector panel. In the Inspector panel, twirl down the ‘World Tile Manager (Script)’ component then twirl down the ‘Tile Types’ array. Set the the size to 2 then drag and drop the Tile01 prefab to the Element 0 box and the Tile02 prefab to the Element 1 box. The WorldTiles Inspector panel should now look something like the image below:

WorldTiles Inspector

Now let’s turn to the script we’ll add the GameController GameObject. For now, this will be a very simple bare bones class that will just update our Tile Manager.

So, here is our Game class:

//
// Game.cs
//
// Author:
//       Devon O. <devon.o@onebyonedesign.com>
//
// Copyright (c) 2017 Devon O. Wolfgang
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

using UnityEngine;
using System.Collections;

public class Game : MonoBehaviour
{
    /** Speed Increase Value */
    static float SPEED_INCREASE=.05f;

    /** Seeded Randomizer */
    static System.Random RND;

    /** Tileholder */
    public GameObject TileHolder;

    /** TileManager */
    private WorldTileManager tileManager;

    /** On Awake */
    void Awake()
    {
        // 32 is just an arbitrary seed number. Could be anything.
        RND = new System.Random(32);
        this.tileManager = TileHolder.GetComponent<WorldTileManager>();
    }

    /** On Start */
    void Start()
    {
        this.tileManager.Init();
    }
    
    /** On Update */
    void Update()
    {
        this.tileManager.IncreaseSpeed(SPEED_INCREASE);
        this.tileManager.UpdateTiles(RND);
    }
}

Some things to note about Game.cs. The public TileHolder GameObject property is really just our WorldTiles GameObject – but since it’s public, we can just do the ol’ drag and drop within the ide. The class really only consists of overriding 3 MonoBehavior methods: Awake() will create a new System.Random instance and use GetComponent() to assign the WorldTileManager class instance to the tileManager property. Start() will initialize the tileManager. And Update() will update the speed and tiles of the tileManager.

Drag and drop this script onto the GameController GameObject in the Hierarchy panel. In the Inspector panel ‘Tile Holder’ box, drag and drop the WorldTiles GameObject.

And that is it. Fingers crossed, test the ‘game’ by hitting the play button. If all went well, within the Game display panel, you’ll see a row of tiles (i.e. pieces of roads) passing underneath the camera. The bad thing is, you can see tiles being abruptly added in the distance as each one in front passes behind. We could obscure this with fog, but we didn’t make that Curve shader and CurveController for nothing. While the game is running in the ide click on the CurveController in the Hierarchy panel and try playing around with the levers. Below is what what you may see setting the x and y values to -40 and the Falloff to 4:

And that is it for now. Next time around we’ll add the Runner character and finally some obstacles to dodge.

Date:
Category: