CardConquest GameDev Blog #5: Collapsing Multiple Units

Disclaimer: I am neither a professional game developer nor am I a professional programmer. I doubt I am following best practices. This is just what worked for me at the time. Hopefully it works for you if you’re playing along while reading!

Version of Unity used: 2019.4.13f1 Personal

The previous posts in this series can be found under the CardConquest category on this blog. The github for the project can be found here. Zip archives for specific posts can be found here.

In my previous post I mentioned that I next want to do two things:
1.) Put limitations on unit movement, such as making it so a unit can only move one tile away from its current position
2.) When multiple units of the same type are on a tile, I want those units to “collapse” into one unit, and text to display something like “x2”

In this post, I am going to work on #2

The Concept

Because this game has simple/bad graphics, it doesn’t work well displaying multiple units on a land tile. I thought of maybe limiting it to one unit per type on a tile, but I decided to try and make multiple units work.

I first just tried to move the “second” unit slightly to the side of any units that were already on the tile. It would look like this:

This quickly cluttered up the tile with the unit sprites and made it hard to see. Instead, what I wanted to try and do is if there were two infantry on a tile, it would display like this:

So the “x2” text would be created and tell the player the number of units on the tile. This is the multiple units “collapsing” into one.

Then, if the player clicked on the infantry, the units would “expand” back out to 2 units and the text would disappear. When the player un-selected or clicked away, the units would collapse back.

Setting Up the Land Tiles

The way I decided to do this was by having a script attached to the land tiles that kept track of units that had been placed on the tile. When the script detected multiple units of the same type, it would spawn the text. The script would take care of collapsing/expanding units, and so on.

So, first, create a new script and name it LandScript:

The script will need to be attached to every land tile, so it will need to be attached to the land tile prefab. Open the prefab in the Inspector window. Click on “Add Component” and search for the Land Script to add.

Save, and you should see that the LandScript script was added to all the land tiles in the game. Note, you do not need to add the script to the water tile prefab. The water tiles don’t do anything as of yet.

Configuring the Land Script

Open the LandScript.cs script in visual studio so you can edit it. A few public variables will be added.

public List<GameObject> infantryOnLand;
public List<GameObject> tanksOnLand;
public bool multipleUnitsOnLand = false;

The first two, infantryOnLand and tanksOnLand, are lists of GameObjects. These lists will be used to store the infantry and tank objects that are “occupying” the land tile. The third variable is a boolean that will be used to quickly reference when multiple units of the same type are on a land tile.

The two lists need to be initialized in the script before they can be used. I changed the default Start() function to Awake(), and initialized them there.

void Awake()
{
	infantryOnLand = new List<GameObject>();
	tanksOnLand = new List<GameObject>();
}

I did this in Awake() instead of Start() because Awake() will execute before any Start() functions, so I wanted to make sure that the lists are initialized as soon as possible in case I later have other Scripts accessing the lists during their Start() functions or whatever.

The next thing I am going to add to the LandScript is a very simple function that will detect when multiple units of the same type are on the land tile, and if so, set the multipleUnitsOnLand boolean variable to true.

void CheckIfMultipleUnitsOnLand()
{
	if (infantryOnLand.Count < 2 && tanksOnLand.Count < 2)
	{
		multipleUnitsOnLand = false;
	}
	else 
	{
		multipleUnitsOnLand = true;
	}
}

If the counts for the infantryOnLand and tanksOnLand lists are both less than 2, then multipleUnitsOnLand will be false. If not, then one of those lists has to have more than one unit in them, so multipleUnitsOnLand is set to true.

Adding the Unit to the LandScript’s Lists

Now that the land tiles have a way to track what units are on them through the infantryOnLand and tanksOnLand lists, I needed to make sure that the units get added to those lists when they move to a new land tile.

To do this, the UnitScript.cs script will be used to add and remove units from the LandScript.cs’s lists as the player moves their units.

First, in UnitScript.cs, a GameObject variable called currentLandOccupied will be created to store the landObject a unit has been moved to.

public GameObject currentLandOccupied;

Next, a new function called UpdateUnitLandObject will be created. This function will take a GameObject as a parameter, where that GameObject is the land tile the unit was moved to.

UpdateUnitLandObject will be called from the MoveUnit() function

Here’s the code for UpdateUnitLandObject. It’s a bit long, so I will explain it after

public void UpdateUnitLandObject(GameObject LandToMoveTo)
{
	LandScript landScript = LandToMoveTo.GetComponent<LandScript>();

	if (currentLandOccupied != LandToMoveTo)
	{
		//Current land tile should only be null when the game is first started and the unit hasn't been "assigned" a land tile yet
		if (currentLandOccupied == null)
		{
			currentLandOccupied = LandToMoveTo;
		}
		Debug.Log("Unit moved to new land");
		if (currentLandOccupied != null)
		{
			if (gameObject.tag == "infantry")
			{
				//Remove unit from previous land tile
				Debug.Log("Removed infantry from previous land object at: " + currentLandOccupied.transform.position.x.ToString() + "," + currentLandOccupied.transform.position.y.ToString());
				currentLandOccupied.GetComponent<LandScript>().infantryOnLand.Remove(gameObject);
				//currentLandOccupied.GetComponent<LandScript>().UpdateUnitText();

				//Add Unit to new land tile
				Debug.Log("Added infantry unit to land object at: " + LandToMoveTo.transform.position.x.ToString() + "," + LandToMoveTo.transform.position.y.ToString());
				landScript.infantryOnLand.Add(gameObject);
				if (landScript.infantryOnLand.Count > 1)
				{
					//landScript.MultipleUnitsUIText("infantry");
					Debug.Log("More than 1 infantry on land");
				}

			}
			else if (gameObject.tag == "tank")
			{
				//Remove unit from previous land tile
				Debug.Log("Removed tank from previous land object at: " + currentLandOccupied.transform.position.x.ToString() + "," + currentLandOccupied.transform.position.y.ToString());
				currentLandOccupied.GetComponent<LandScript>().tanksOnLand.Remove(gameObject);
				// currentLandOccupied.GetComponent<LandScript>().UpdateUnitText();

				//Add unit to new land tile
				Debug.Log("Added tank unit to land object at: " + LandToMoveTo.transform.position.x.ToString() + "," + LandToMoveTo.transform.position.y.ToString());
				landScript.tanksOnLand.Add(gameObject);
				if (landScript.tanksOnLand.Count > 1)
				{
					//landScript.MultipleUnitsUIText("tank");
					Debug.Log("More than 1 tank on land");
				}
			}
			// Remove the land highlight when a unit moves
			//currentLandOccupied.GetComponent<LandScript>().RemoveHighlightLandArea();
		}
		
		currentLandOccupied = LandToMoveTo;
	}

}

This code:
1.) Checks to see if currentLandOccupied and LandToMoveTo are equal. This check is done because all of the following code should only be performed if the player moved a unit to a new land tile
2.) Checks if “currentLandOccupied” is null. It should only be null when the game first starts and the unit hasn’t “moved” to a tile yet. This sets currentLandOccupied to LandToMove so the rest of the function will work correctly
3.) Checks whether the unit is an infantry or tank unit
4.) Using the currentLandOccupied, removes the unit from that land’s unit list
5.) Using the land script of LandToMoveTo, the new land tile the unit was moved to, add the unit to that land tiles list
6.) After the lists have been updated, set “currentLandOccupied” to “LandToMoveTo.” This updates the unit’s land tile to the one the player moved it to

After the script is saved, go back into unity and add in at least one more unit. I copied and pasted the infantry and tank in the hierarchy so that there would be 4 total units.

Now, when I move units to the same land tile, that land tile will show the units in its list.

And then when I move a unit away, the unit is removed from the list.

This is a little weird right now, as the units currently “Stack” one on top of the other.

Creating Text to Display Multiple Units

Now when multiple units of the same type are on the same land tile, those unit gameobjects are added to the land tile’s Land Script. The next thing will be to spawn a text object to signify to the user that the tile has multiple units.

This will be done by spawning an empty gameobject that has a “TextMeshPro” object as it’s child. First, great an empty gameobject in the scene

Rename the empty gameobject to “infTextHolder” and reset it’s transform position to 0,0,0. Then, right click on infTextHolder, and add another empty gameobject as its child. Rename this as unitText. Then, select the unitText gameobject and select “Add Component.” Search for and select “Rect Transform.”

After some trial and error, I found it best to have the values for the rect transform as X:1.0, y:-1.5. Set these values for now.

Now, add a new component again by searching for and selecting “Mesh Renderer”

You shouldn’t need to change anything on the Mesh Renderer. This component allows for the text to be rendered on the screen.

Next, add another component called a “TextMeshPro – Text”

When this is added, Unity will likely prompt you to Import TMP Essentials. Click on the button to import

The TextMeshPro (TMP) will be the actual text to display. For right now, set some sample text such as “x2”, set it to bold font, set the font size to 5, and set the “Vertex Color” to black.

You’ll probably notice that you currently can’t see your text in the scene. It is “behind” your land object, or some other land object. This is because the “Sorting Layer” is set to 0, while the land object is set to 1.

To bypass this, you can create a new sorting layer that will be “above” the default layer. At the top of the inspector, click the “Layers” drop down, and select “Edit Layers…”

Expand the “Sorting Layers” section, click the “+” sign, and add a new sorting layer called “UI-Text.” This will ensure that UI-Text is rendered “on top” of anything else in the scene.

Go back to the TMP in unitText, click on “Extra Settings” and then add “UI-Text” as the sorting layer. You should now see the text rendered above the land in the scene.

Save infTextHolder as a prefab. When you open the prefab, you should see that it has a child object, unitText.

Next, you will make a variant of the infTextHolder prefab for tank’s, called tankTextHolder. Copy and paste infTextHolder in your scene. Rename the copy to tankTextHolder. Go into its child unitText, and set the Rect Transform position values to X:1.5, Y:0.75. This will line up with the tank text units.

Drag and drop tankTextHolder into the prefabs folder to save it as a prefab. Unity will prompt you to overwrite the original prefab, or to create a variant. Select “Prefab variant”

Once the prefabs have been saved, you can delete them from your scene.

Using infTextHolder in LandScript

LandScript.cs will be used to spawn the text object when multiple units are added to a tile. Open LandScript.cs in Visual Studio and make sure to add the following to the top, just below “Using UnityEngine;” This will allow the script to interact with TextMesPro objects.

using TMPro;

Then add the following variables:

public GameObject infTextHolder;
public GameObject tankTextHolder;

private GameObject infText;
private GameObject tankText;

Save LandScript.cs and infTextHolder and tankTextHolder will show up in the Unity Inpsector of the land tile prefab. Add the infTextHolder and tankTextHolder prefabs to your script in the Inspector.

Now back in LandScript.cs, you should be able to interact with the infTextHolder prefab and spawn/destroy it as necessary.

Two new functions will be created. First, MultipleUnitsUIText which will be the function that instantiates the new text object when there are multiple units. The second will be UpdateUnitText, which will update the value of the text objects as units are moved to/away from land tiles.

MultipleUnitsUI will take a string as a parameter called unitType. This will simply tell the function what unity type text to spawn. The code is as follows:

public void MultipleUnitsUIText(string unitType)
{

	if (unitType == "infantry")
	{
		if (infText == null)
		{
			Debug.Log("Creating text box for multiple infantry");
			infText = Instantiate(infTextHolder, gameObject.transform);
			infText.transform.position = transform.position;
			infText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + infantryOnLand.Count.ToString());
		}
		else
		{
			infText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + infantryOnLand.Count.ToString());
		}
	}
	else if (unitType == "tank")
	{
		if (tankText == null)
		{
			Debug.Log("Creating text box for multiple tanks");
			tankText = Instantiate(tankTextHolder, gameObject.transform);
			tankText.transform.position = transform.position;
			tankText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + tanksOnLand.Count.ToString());
		}
		else
		{
			tankText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + tanksOnLand.Count.ToString());
		}
	}
	multipleUnitsOnLand = true;
}

The function does the following after checking if unitType is “infantry” or “tank”:
1.) Checks if a text object currently exists. If it does not exist:
1.a.) use Instantiate to create a new object based on the infTextHolder prefab and save that object in infText/tankText
1.b.) Set the text object’s transform.position to the transform.position of the land tile the script is attached to
1.c.) Get the “TextMEshPro” component if the text holder’s child, unitText, and set the text to display the correct number of units
2.) If the text object already exists, simply update TextMeshPro’s text field
3.) Set “multipleUnitsOnLand” to true, as this function is only called when multiple units are detected

Now, MultipleUnitsUIText will need to be called from the UnitScript.cs script when a unit is moved:

if (landScript.infantryOnLand.Count > 1)
{
	landScript.MultipleUnitsUIText("infantry");
	Debug.Log("More than 1 infantry on land");
}

This will also be done for tank units. The full code in UnitScript.cs looks like this:

When you save the scripts and start the game, you see the text spawning:

Updating and Removing Text

The new text is added, but right now, the text will linger behind after units are moved away. Before I mentioned a second function in LandScript.cs called UpdateUnitText. UpdateUnitText will be what updates and removes text as needed.

Go back to LandScript.cs in Visual Studio. UpdateUnitText should be fairly simple for now. It will check how many units are on a land tile, and if there are more than one of a unit type, it will update the text appropriately. If the number of units is less than 2, the text will be destroyed. The code is as follows:

public void UpdateUnitText()
{
	if (infText != null)
	{
		Debug.Log("Updating inf text. Current number of infantry " + infantryOnLand.Count.ToString());
		if (infantryOnLand.Count > 1)
			infText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + infantryOnLand.Count.ToString());
		else
		{
			Destroy(infText);
		}

	}
	if (tankText != null)
	{
		Debug.Log("Updating tank text. Current number of tanks: " + tanksOnLand.Count.ToString());
		if (tanksOnLand.Count > 1)
			tankText.transform.GetChild(0).GetComponent<TextMeshPro>().SetText("x" + tanksOnLand.Count.ToString());
		else
		{
			Destroy(tankText);
		}
	}

	CheckIfMultipleUnitsOnLand();
}

UpdateUnitText does the following:
1.) Checks if the text object exists. If it does not, there isn’t anything to update
2.) Checks if multiple units still exist on the tile
3.) If the number of units is greater than 1, update the text field
4.) If the number of units is 1 or 0, destroy the text object
5.) Calls CheckIfMultipleUnitsOnLand() to update the multipleUnitsOnLand boolean variable

UpdateUnitText now needs to be called from UnitScript.cs to make sure the text objects are updated as units move:

UnitScript.cs
//Remove unit from previous land tile
Debug.Log("Removed infantry from previous land object at: " + currentLandOccupied.transform.position.x.ToString() + "," + currentLandOccupied.transform.position.y.ToString());
currentLandOccupied.GetComponent<LandScript>().infantryOnLand.Remove(gameObject);
currentLandOccupied.GetComponent<LandScript>().UpdateUnitText();

Now, when units are moved, the text should update correctly. I added two more units to test mine out. When two units are added, the x2 appears. When a third is added, the text updates to “x3.” When two units are removed, the text disappears, and so on.

Collapsing and Expanding Units

Text displayed tot he user to inform them there are multiple units on a tile is great, but right now it’s difficult for the user to select multiple units after they have been stack on top of each other. My goal is when the user sees this:

I want the units to “expand” out when the user clicks on them, like this:

This should allow the user to be able to select multiple units from a land tile that already has multiple units on it.

To get the ball rolling on this, you will need to have a way for Unity to detect what land object the unit you clicked on belongs to. I also want to visualize to the user which land object’s units are being expanded easily, so the land outline created in post #3 will now be used.

Highlighting Land Tiles

Back in Unity, go to the Sprites folder and drag the land-outline sprite into the scene.

Set the X and Y scale of the sprite to 0.5, and set it’s order in layer to 2 so it will be above land objects.

Then, save it as a prefab and delete it from the scene.

Open LandScript.cs in Visual Studio and create the following variables:

public GameObject landOutline;
private GameObject landOutlineObject;

Save the script, and then go back into Unity and open the land tile prefab. Add the land-outline prefab to the script.

To spawn and remove the highlight, two simple functions will be added to LandScript.cs: HighlightLandArea and RemoveHighlightLandArea

public void HighlightLandArea()
{
	if (landOutlineObject == null)
	{
		Debug.Log("Creating land outline");
		landOutlineObject = Instantiate(landOutline, transform.position, Quaternion.identity);
		landOutlineObject.transform.SetParent(gameObject.transform);
	}
}
public void RemoveHighlightLandArea()
{
	if (landOutlineObject != null)
	{
		Destroy(landOutlineObject);
	}
}

HighlightLandArea simply checks to see if the outline object exists, and if it does not, spawns it. RemoveHighlightLandArea checks if the outline exists, and destroys it if it does.

Calling Highlight Functions

The HighlightLandArea and RemoveHighlightLandArea functions will be called when the user clicks on a unit, so they will be called from MouseClickManager.cs

To call the HighlightLandArea function, we will do that when the user clicks on a unit and that unit has a “currentLandOccupied” object saved. If the unit does have a currentLandObject, the land script from currentLandObject will be used to call HighlightLandArea

if (unitScript.currentLandOccupied != null)
{
	LandScript landScript = unitScript.currentLandOccupied.GetComponent<LandScript>();
	if (landScript.multipleUnitsOnLand)
	{
		Debug.Log("Selected unit on land with multiple units.");
		landsSelected.Add(unitScript.currentLandOccupied);
	}
	// Highlight the land selected below the unit and space out the units for user selection
	landScript.HighlightLandArea();
}

If you save the script now and run the game, you should see the land highlight spawn after you have moved the unit once (it doesn’t work until the unit has moved once, because currentLandObject is set for a unit until they have moved).

Unfortunately, the highlighted area stays after you move your unit:

So, when a unit is moved the highlighted land area needs to be removed. In UnitScript.cs, under the UpdateUnitLandObject function, the following can be added:

currentLandOccupied.GetComponent<LandScript>().RemoveHighlightLandArea();

So, when a unit moves, currentLandOccupied should hold the land tile the unit was previously on. currentLandOccupied will then be used to call RemoveHighlightLandArea and remove the highlight.

The next issue, then, is how to remove a land highlight when a unit is deselected. This will be a little more complicated. First, in LandScript.cs, a new function will be created that checks if any units on that tile are part of the currentlySelected units. Remember, a player can deselect one unit while still keeping other units selected.

public void CheckForSelectedUnits()
{
	bool anySelected = false;
	if (tanksOnLand.Count > 0)
	{
		foreach (GameObject unit in tanksOnLand)
		{
			UnitScript unitScript = unit.GetComponent<UnitScript>();
			if (unitScript.currentlySelected)
			{
				anySelected = true;
				break;
			}
		}
	}
	if (infantryOnLand.Count > 0)
	{
		foreach (GameObject unit in infantryOnLand)
		{
			UnitScript unitScript = unit.GetComponent<UnitScript>();
			if (unitScript.currentlySelected)
			{
				anySelected = true;
				break;
			}
		}
	}
	if (!anySelected)
	{
		RemoveHighlightLandArea();
	}

}

CheckForSelectedUnits will first set a boolean variable, anySelected, to false. Then it will:
1.) Check if any of the tanksOnLand are “currentlySelected”
1.a.) if yes, set anySelected to true
2.) Check if any infantryOnLand are “currentlySelected”
2.a.) If yes, set to true
3.) check if anySelected was ever set to true. If anySelected remains false, call RemoveHighlightLandArea() to remove the land highlight

CheckForSelectedUnits will be called from UnitScript.cs in a new function called CheckLandForRemainingSelectedUnits. Here is the code:

public void CheckLandForRemainingSelectedUnits()
{
	if (currentLandOccupied != null)
	{
		LandScript landScript = currentLandOccupied.GetComponent<LandScript>();
		landScript.CheckForSelectedUnits();
	}
}

CheckLandForRemainingSelectedUnits will use the land object in currentLandOccupied to get its land script, and then call CheckForSelectedUnits in LandScript.cs

Now, CheckLandForRemainingSelectedUnits will be called in two locations in MouseClickManager.cs. First, when a user clicks on a unit to deselect it:

unitScript.CheckLandForRemainingSelectedUnits();

And then next, it will be called from ClearUnitSelection when the user deselects all units:

The highlights should now properly spawn and disappear as units are selected and deselected

Back to Expanding / Collapsing units

Now back to what we were all here for: expanding and collapsing units on land tiles. The following functions will be added to LandScript.cs:
ExpandUnits
CollapseUnits
HideUnitText
UnHideUnitText

HideUnitText and UnHideUnitText are simple and easy to go over first. All they do is hide the unit text when the units are expanded, and then un-hide the text when the units are collapsed back. The code:

public void HideUnitText()
{
	if (infText != null)
	{
		infText.SetActive(false);
	}
	if (tankText != null)
	{
		tankText.SetActive(false);
	}
}
public void UnHideUnitText()
{
	if (infText != null)
	{
		infText.SetActive(true);
	}
	if (tankText != null)
	{
		tankText.SetActive(true);
	}
}

Next, ExpandUnits will be used to place units at specific locations when the user tries to select a unit from a land tile that has multiple units.

void ExpandUnits()
    {
        Vector3 temp;
        if (infantryOnLand.Count > 1)
        {
            for (int i = 1; i < infantryOnLand.Count; i++)
            {
                if (i == 1)
                {
                    temp = infantryOnLand[i].transform.position;
                    temp.x += 0.65f;
                    infantryOnLand[i].transform.position = temp;
                }
                else if (i == 2)
                {
                    temp = infantryOnLand[i].transform.position;
                    temp.x -= 0.6f;
                    infantryOnLand[i].transform.position = temp;
                }
                else if (i == 3)
                {
                    temp = infantryOnLand[i].transform.position;
                    temp.y -= 0.8f;
                    infantryOnLand[i].transform.position = temp;
                }
                else if (i == 4)
                {
                    temp = infantryOnLand[i].transform.position;
                    temp.y += 0.8f;
                    infantryOnLand[i].transform.position = temp;
                }
            }
        }
        if (tanksOnLand.Count > 1)
        {
            for (int i = 1; i < tanksOnLand.Count; i++)
            {
                if (i == 1)
                {
                    temp = tanksOnLand[i].transform.position;
                    temp.x += 0.95f;
                    tanksOnLand[i].transform.position = temp;
                }
                else if (i == 2)
                {
                    temp = tanksOnLand[i].transform.position;
                    temp.x -= 0.95f;
                    tanksOnLand[i].transform.position = temp;
                }
                else if (i == 3)
                {
                    temp = tanksOnLand[i].transform.position;
                    temp.y += 0.6f;
                    tanksOnLand[i].transform.position = temp;
                }
            }
        }
        HideUnitText();
    }

ExpandUnits check if there is more than 1 tank/infantry on the land tile, then expands them by adjusting their position as appropriate. I found the new position values by moving around units manually until they were where I liked them. ExpandUnits then calls HideUnitText so the user doesn’t see the “x2” while all the units are expanded.

CollapseUnits is similar and simply moves all units back to the “standard” position for tanks and infantry. UnHideUnitText is then call to bring back any unit text that may have existed.

void CollapseUnits()
{
	Vector3 temp;
	// move units back?
	if (infantryOnLand.Count > 0)
	{

		foreach (GameObject inf in infantryOnLand)
		{
			temp = transform.position;
			temp.y -= 0.5f;
			inf.transform.position = temp;
		}
	}
	if (tanksOnLand.Count > 0)
	{
		foreach (GameObject tank in tanksOnLand)
		{
			temp = transform.position;
			temp.y += 0.5f;
			tank.transform.position = temp;
		}
	}

	UnHideUnitText();
}

Next, ExpandUnits and CollapseUnits will need to be called. ExpandUnits should only need to be called when a unit on the land is selected. This can be done in LandScript.cs’s HighlightLandArea function. ExpandUnits will only be called if there is more than 1 tank and/or infantry.

if (infantryOnLand.Count > 1 || tanksOnLand.Count > 1)
	ExpandUnits();

CollapseUnits will be called from a few locations. First, at the end of RemoveHighlightLandArea if the unit count conditions are met:

Then, it will also be called in UpdateUnitText (twice, once for infantry and once for tanks):

And with that, everything works! Or, so I hope so. Lets see if a video will work?

Next Steps

Next, I will work on movement limitations so that units can only move one space at a time. Hopefully that is easy!