Part 21: Victory Conditions
With Audio now in place it is time to look at working out when a game is finished. Many real-time-strategy games present a Player with multiple ways to win. This may be for a particular scenario, or several general conditions that anyone can reach. The goal this time is to build in the architecture needed to support this, and then to implement a variety of different victory conditions to show some of the options that are available to us.
Core Architecture
Even before getting started I think we can agree that victory conditions can be varied. For example, we can have things like conquest, resource accumulation, survival, regicide, infiltration, wonder construction, and the list goes on. What we want, therefore, is a nice concise way to define a victory condition that is independent of the actual implementation. That way we can enable our game to handle the concept of "victory" without caring how that looks.
Start by creating a new folder called VictoryConditions in your assets directory to hold all of the victory conditions that we are going to define. Inside that folder create a new C# script called VictoryCondition.cs with the following code in it.
using UnityEngine; using System.Collections; public abstract class VictoryCondition : MonoBehaviour { protected Player[] players; public void SetPlayers(Player[] players) { this.players = players; } public Player[] GetPlayers() { return players; } public virtual bool GameFinished() { if(players == null) return true; foreach(Player player in players) { if(PlayerMeetsConditions(player)) return true; } return false; } public Player GetWinner() { if(players == null) return null; foreach(Player player in players) { if(PlayerMeetsConditions(player)) return player; } return null; } public abstract string GetDescription(); public abstract bool PlayerMeetsConditions(Player player); }
We are using an abstract class to define the outline that a Victory Condition must have. We are extending MonoBehaviour primarily so that we can attach specific instances of these to objects inside Unity. Each Victory Condition needs to know about the Players that are present on the map. The default way to know if a game is finished is if one of the Players meets the specified conditions for victory. Similarly, the winner of this particular game is the Player that meets the specified conditions. Since this is an abstract class, we do not need to provide the definition for PlayerMeetsConditions(). Any child class that extends VictoryCondition.cs must, however, provide an implementation for that method. So we can guarantee that an actual Victory Condition will have a definition for that method. It turns out that this framework is enough to calculate a) when the game is finished and b) who won that game.
What we want now is a sensible place to monitor any Victory Conditions we might have present in a map. Create a new C# script in the Resources folder called GameManager.cs and add the following code to it.
using UnityEngine; using System.Collections; using RTS; /** * Singleton that handles the management of game state. This includes * detecting when a game has been finished and what to do from there. */ public class GameManager : MonoBehaviour { private static bool created = false; private bool initialised = false; private VictoryCondition[] victoryConditions; private HUD hud; void Awake() { if(!created) { DontDestroyOnLoad(transform.gameObject); created = true; initialised = true; } else { Destroy(this.gameObject); } if(initialised) { LoadDetails(); } } void OnLevelWasLoaded() { if(initialised) { LoadDetails(); } } private void LoadDetails() { Player[] players = GameObject.FindObjectsOfType(typeof(Player)) as Player[]; foreach(Player player in players) { if(player.human) hud = player.GetComponentInChildren< HUD >(); } victoryConditions = GameObject.FindObjectsOfType(typeof(VictoryCondition)) as VictoryCondition[]; if(victoryConditions != null) { foreach(VictoryCondition victoryCondition in victoryConditions) { victoryCondition.SetPlayers(players); } } } void Update() { if(victoryConditions != null) { foreach(VictoryCondition victoryCondition in victoryConditions) { if(victoryCondition.GameFinished()) { ResultsScreen resultsScreen = hud.GetComponent< ResultsScreen >(); resultsScreen.SetMetVictoryCondition(victoryCondition); resultsScreen.enabled = true; Time.timeScale = 0.0f; Screen.showCursor = true; ResourceManager.MenuOpen = true; hud.enabled = false; } } } } }
We want only one instance of this on our map, in the same way that we only want one instance of our LevelLoader, and so we use the same logic as we did there to enforce this. We also want to make sure that we only call LoadDetails() once so that we are not doing more work than we need to. Inside LoadDetails() we find all of the Players in the map, as well as all of the Victory Conditions. Once we know these we set the Players for each Victory Condition. Finally, each update we want to check all of the Victory Conditions to see if any of them have been met. If one has been met then we want to stop the game and show a victory screen of some sort. For now we will go with a simple screen that says who won the game (or a simple "Game Over" if no one one has) and provides the options to start a new game or return to the Main Menu. When this screen is displayed we also want to make sure that the display of other elements (e.g. the HUD) is stopped, as well as any time-based calculations in our game. Of course, this screen does not exist yet so add a new C# script called ResultsScreen.cs to the Scripts folder located inside the Menu folder and add the following code to it.
using UnityEngine; using System.Collections.Generic; using RTS; public class ResultsScreen : MonoBehaviour { public GUISkin skin; public AudioClip clickSound; public float clickVolume = 1.0f; private AudioElement audioElement; private Player winner; private VictoryCondition metVictoryCondition; void Start () { List< AudioClip > sounds = new List< AudioClip >(); List< float > volumes = new List< float >(); sounds.Add(clickSound); volumes.Add (clickVolume); audioElement = new AudioElement(sounds, volumes, "ResultsScreen", null); } void OnGUI() { GUI.skin = skin; GUI.BeginGroup(new Rect(0, 0, Screen.width, Screen.height)); //display float padding = ResourceManager.Padding; float itemHeight = ResourceManager.ButtonHeight; float buttonWidth = ResourceManager.ButtonWidth; float leftPos = padding; float topPos = padding; GUI.Box(new Rect(0, 0, Screen.width, Screen.height), ""); string message = "Game Over"; if(winner) message = "Congratulations " + winner.username + "! You have won by " + metVictoryCondition.GetDescription(); GUI.Label(new Rect(leftPos, topPos, Screen.width - 2 * padding, itemHeight), message); leftPos = Screen.width / 2 - padding / 2 - buttonWidth; topPos += itemHeight + padding; if(GUI.Button(new Rect(leftPos, topPos, buttonWidth, itemHeight), "New Game")) { PlayClick(); //makes sure that the loaded level runs at normal speed Time.timeScale = 1.0f; ResourceManager.MenuOpen = false; Application.LoadLevel("Map"); } leftPos += padding + buttonWidth; if(GUI.Button(new Rect(leftPos, topPos, buttonWidth, itemHeight), "Main Menu")) { ResourceManager.LevelName = ""; Application.LoadLevel("MainMenu"); Screen.showCursor = true; } GUI.EndGroup(); } private void PlayClick() { if(audioElement != null) audioElement.Play(clickSound); } public void SetMetVictoryCondition(VictoryCondition victoryCondition) { if(!victoryCondition) return; metVictoryCondition = victoryCondition; winner = metVictoryCondition.GetWinner(); } }
Since this screen is basically a Menu we are making sure that a click sound will be played whenever a button is clicked. We use the Victory Condition that is passed in to work out certain things to display, such as the name of the winner and how they won. Buttons are then provided for starting a new game or returning to the Main Menu. For this to be shown when required we need to attach it somewhere. Since this is being treated in the same way as one of our in game Menus, we should attach it to a Player in the same way that we attached those Menus. Expand the Player prefab found in your Player folder, select the HUD element, and add ResultsScreen.cs to it. Attach the MenuSkin to the appropriate field, as well as the ButtonClick sound, so that the Results Screen will look and sound the same as our other Menus. Also make sure to disable the script so that it does not show before we mean it to.
At a later date I may look at updating this screen to include things like match statistics for Players to make it much more interesting. But for now a simple workflow is best. And this clearly shows the final result of a game that we play, which is the most important aspect to convey to a user.
The final things we need to do before our framing is complete are to attach our Game Manager and to provide a place to attach any Victory Conditions that we define. Inside your Map scene create an Empty Object called VictoryConditions. We will use this object later on when we come to attach specific Victory Conditions. (Note: any other Map that you create should also include one of these so that you can attach any Victory Conditions for that Map to it) Finally, create a new Empty Object called GameManager and attach GameManager.cs to it, so that we have an instance of our GameManager present in our map. We should also create an Empty Object called GameManager with GameManager.cs attached to it inside our MainMenu scene so that whenever we start / load a game from there (which is the normal process for a Player) we know that a Game Manager will be present in that game.
This provides us with a good structure to build on when it comes to implementing specific Victory Conditions. Unfortunately, there is no way to test this yet - you just need to trust me for now that this does all work as expected. The one test we have is whether the compiler is complaining or not. If no exceptions are being thrown in the console then we are good to carry on.
Specific Conditions
Now it is time to build on the foundations that we have established and define some specific Victory Conditions. Once we are done we will have defined the following ways to win:
- Accumulate a certain amount of money
- Build a wonder
- Destroy all Units and Buildings of all other Players
- Escort a convoy truck to a certain point of the map
- Survive for a certain number of minutes
Let's start by defining the accumulation of money. I believe this requires the least amount of work on our part, so it will also allow us to test our core workflow very soon as well. Inside the VictoryConditions folder create a new C# script called AccumulateMoney.cs with the following code in it.
using UnityEngine; using System.Collections; using RTS; public class AccumulateMoney : VictoryCondition { public int amount = 1050; private ResourceType type = ResourceType.Money; public override string GetDescription () { return "Accumulating Money"; } public override bool PlayerMeetsConditions (Player player) { return player && !player.IsDead() && player.GetResourceAmount(type) >= amount; } }
This defines the type of Resource that we care about collecting, as well as the amount that needs to be collected. The default amount we are specifying is a little bit more than our Player normally starts with, so that when testing this we are not waiting around for a long time. The amount is also public, so that we can manipulate this within Unity (e.g. different maps might have different amounts specified). A Player meets this Victory Condition if a) they are not dead, and b) the amount of the specified resource type that they own is more than the required amount. This is a nice simple check to make. Unfortunately, there are a couple of methods that we need to add to Player.cs before we can actually complete this check. First we need to add
public bool IsDead() { Building[] buildings = GetComponentsInChildren< Building >(); Unit[] units = GetComponentsInChildren< Unit >(); if(buildings != null && buildings.Length > 0) return false; if(units != null && units.Length > 0) return false; return true; }
to work out whether the Player is classified as 'dead' or not. Our current definition for 'dead' is that they own no Units or Buildings, which seems reasonable enough. Then we need to add
public int GetResourceAmount(ResourceType type) { return resources[type]; }
to allow us to query the Player as to how much of a certain Resource they currently own.
With these changes in place it is time to perform some testing. Attach AccumulateMoney.cs to the VictoryConditions object in the main map scene we have been working on. If you run your game and get your Harvester collecting Ore you should notice the Results Screen show up once you have 1050 money. The screen should look something like this screenshot.
Unfortunately, there are a couple of bugs here that you may notice. The first is that the deposit sound for the Harvester is played over and over again. It turns out that setting Time.timeScale to 0 stops time from running, but it does not stop Update() from being called. In the case of our Harvester this means that Deposit() is still being called, even though our game is technically 'finished'. This also means that if our game is finished and any WorldObject has a method that plays a sound as part of the update loop then this will play when we no longer want it to. It turns out that the only cases I can think of where this will happen are when a World Object fires a weapon, when a Harvester is collecting resources, and when a Harvester is depositing resources. In each of those cases we have a check
if(audioElement != null)
which we should update to
if(audioElement != null && Time.timeScale > 0)
This change needs to be made in the following places:
- UseWeapon() in WorldObject.cs
- Collect() and Deposit() in Harvester.cs
The other annoying bug (and you may have already noticed this while opening the Pause Menu) is that the selection box is still being drawn. Thankfully we are already indicating whether we are in a Menu or not, so we can fix this by updating OnGUI() in WorldObject.cs to be the following code.
protected virtual void OnGUI() { if(currentlySelected && !ResourceManager.MenuOpen) DrawSelection(); }
Right, with those annoyances out of the way we can turn our attention to implementing our next Victory Condition. Create a new C# script inside the VictoryConditions folder called BuildWonder.cs and set the code in there to be the following.
using UnityEngine; using System.Collections; public class BuildWonder : VictoryCondition { public override string GetDescription () { return "Building Wonder"; } public override bool PlayerMeetsConditions (Player player) { Wonder wonder = player.GetComponentInChildren< Wonder >(); return player && !player.IsDead() && wonder && !wonder.UnderConstruction(); } }
Once again we have a fairly simple check to perform: does the Player have a Wonder, and has it finished being built yet? Now, this check is not possible to complete until we define what a Wonder is and provide the Player with the ability to create one. Neither of these things are particularly difficult to do, they will just take a little bit of time. Start by creating a new folder in the Buildings folder called Wonder and create a new C# script in there called Wonder.cs. This just needs the following code in it to provide our current definition for a Wonder.
using UnityEngine; using System.Collections; public class Wonder : Building { //nothing special to specify }
Now create a new Empty Object located at (0, 0, 0) called Wonder, which we will use to build up our Wonder. The Wonder itself will be a simple ziggurat structure made up of 5 layers. Each layer will be made out of a simple cube, with varying dimensions to get the shape that we want. The properties for each layer should be as follows:
- Layer 1: position = (0, 0.5, 0), scale = (10, 1, 10)
- Layer 2: position = (0, 1.5, 0), scale = (8, 1, 8)
- Layer 3: position = (0, 2.5, 0), scale = (6, 1, 6)
- Layer 4: position = (0, 3.5, 0), scale = (4, 1, 4)
- Layer 5: position = (0, 4.5, 0), scale = (2, 1, 2)
Once you have created the Wonder inside Unity you should turn it into a prefab by dragging it into the Wonder folder. Now delete it from the scene so that we do not have an unwanted Wonder taking up space. If we want to be able to build a Wonder we need to be able to access it, so the prefab needs to be added to the Buildings list for our GameObjectList - in both the MainMenu scene and our Map scene. Since we want to construct the Wonder with our Worker, we need to update what it can build to include a Wonder. Update the actions list in Start() for Worker.cs to be as follows.
actions = new string[] {"Refinery", "WarFactory", "Wonder"};
The final thing to do for this Victory Condition is to add BuildWonder.cs to the VictoryConditions object so that it is present in our map. Once this has been done you should be able to run your game, select your Worker, build a Wonder, and win the game.
As you can see, the requirements for building a Wonder were quite different from those for accumulating money. But it turns out that it was not very difficult to implement at all. This is where the value of reasonable architecture can be see quite clearly.
Now that we can see how simple it is to add in new Victory Conditions let's define one of the most common conditions out there: conquest. Create a new C# script called Conquest.cs inside the VictoryConditions folder with the following code in it.
using UnityEngine; using System.Collections; public class Conquest : VictoryCondition { public override string GetDescription () { return "Conquest"; } public override bool GameFinished () { if(players == null) return true; int playersLeft = players.Length; foreach(Player player in players) { if(!PlayerMeetsConditions(player)) playersLeft--; } return playersLeft == 1; } public override bool PlayerMeetsConditions (Player player) { return player && !player.IsDead(); } }
The definition for whether a Player meets the conditions is simply that they are not dead. This works great for determining the winner of the game. However, working out whether the game is finished is actually a little bit more complicated. It turns out that for conquest we cannot use the default definition we provided for a Victory Condition. Instead we need to provide a custom definition that determines the game is over when there is only one Player left that meets the victory conditions - that is, when there is only one Player left alive. When this condition is met then the game is over.
Attach this script to the VictoryConditions object and run your game. You should be able to win when your Tanks have destroyed all of the Buildings and Units belonging to the other Player on the map.
It turns out that conquest was still really easy to define, despite needing a custom definition of one of the core methods. Let's try something a little bit more difficult now, escorting a Unit to a particular location. Create a new C# script in the VictoryConditions folder called EscortConvoy.cs with the following code in it.
using UnityEngine; using System.Collections; public class EscortConvoy : VictoryCondition { public Vector3 destination = new Vector3(0.0f, 0.0f, 0.0f); public Texture2D highlight; void Start() { GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.name = "Ground"; cube.transform.localScale = new Vector3(3, 0.01f, 3); cube.transform.position = new Vector3(destination.x, 0.005f, destination.z); if(highlight) cube.renderer.material.mainTexture = highlight; cube.transform.parent = this.transform; } public override string GetDescription () { return "Escort Convoy Truck"; } public override bool PlayerMeetsConditions (Player player) { ConvoyTruck truck = player.GetComponentInChildren< ConvoyTruck >(); return player && !player.IsDead() && TruckInPosition(truck); } private bool TruckInPosition(ConvoyTruck truck) { if(!truck) return false; float closeEnough = 3.0f; Vector3 truckPos = truck.transform.position; bool xInPos = truckPos.x > destination.x - closeEnough && truckPos.x < destination.x + closeEnough; bool zInPos = truckPos.z > destination.z - closeEnough && truckPos.z < destination.z + closeEnough; return xInPos && zInPos; } }
Our definition of victory here is that the Player has a Convoy Truck and has moved it to the specified position. If it was critical that the truck survived then you could always override GameFinished() to make sure that the game ends if the truck is destroyed. The reality is that you can do all sorts of complex things inside a Victory Condition. The thing I am showing can be done here, though, is the goal of getting to a specific location - so we will keep things simple. The core thing that needs to be defined for this Victory Condition is where that destination is. But simply defining that is not really enough. How is the Player to know where that location is? We can tell them "Take the convoy truck to the pass in the north", but that is still quite vague. Therefore, to help the Player out, we will create a simple cube at the destination and position it just above the ground. (This is called "Ground" so that all of our mouse click code will just work) If a texture has been specified (from inside Unity) we will use that on the cube - this allows us to make a nice looking spot on the ground. We also want to make sure that our check for whether the truck is in position is a bit vague, since getting a precise location match is going to be almost impossible for a Player to manage. This should also mean that it will trigger when a portion of the truck enters the cube we are showing on the map, which is when the Player will expect victory to occur.
Obviously, if we are to escort a truck somewhere then we first need a truck to escort. Inside Unity, create a new EmptyObject called ConvoyTruck and make sure that it is located at (0, 0, 0). Add the following components to it:
- Body: a cube at (0, 1, 0) with scale = (1.5, 1, 3.75)
- Cab: a cube at (0, 2, 1.15) with scale = (1, 1, 1)
- SmokestackL: a cylinder at (-0.25, 2.6, 0.85) with scale = (0.2, 0.4, 0.2)
- SmokestackR: a cylinder at (0.25, 2.6, 0.85) with scale = (0.2, 0.4, 0.2)
- Tray: a cube at (0, 2.25, -0.7) with and scale = (1.7, 1.5, 2.5)
- WheelFL: a sphere at (-0.75, 0.5, 1.85) with and scale = (1, 1, 1)
- WheelFR: a sphere at (0.75, 0.5, 1.85) with and scale = (1, 1, 1)
- WheelRL: a sphere at (-0.75, 0.5, -1.85) with and scale = (1, 1, 1)
- WheelRR: a sphere at (0.75, 0.5, -1.85) with and scale = (1, 1, 1)
using UnityEngine; using System.Collections; public class ConvoyTruck : Unit { //nothing special to do }
Now attach the script to the EmptyObject and add sounds to the appropriate fields. I have used this image
for the build image, so add that to the ConvoyTruck folder and then attach it to the Build Image field in the inspector. Finally, drag the ConvoyTruck object into the ConvoyTruck folder to create a prefab and add this to the list of Units in GameObjectList (in both the Map and the MainMenu scenes).We want the Player to build a Convoy Truck first, so delete the Convoy Truck from the scene. Then update the actions list inside Start() for WarFactory.cs to be
actions = new string[] { "Tank", "ConvoyTruck" };
We need to add the Victory Condition still, so attach EscortTruck.cs to the VictoryConditions object. Set the destination position for the Victory Condition to be (5, 0, -5) and attach the BoxArea image (found in the Images folder inside the Menu folder) to the Highlight field so that the cube that we create to indicate the destination stands out a bit more. You should now be able to run your game, create a Convoy Truck from the War Factory, and then move it to the specified location to win the game.
The final Victory Condition we are going to implement this time is survival. The idea here is that all the Player has to do to win is to survive for a specified time. Unlike the other Victory Conditions we have implemented so far this one is designed to be used in single-player mode (not that we have multiplayer working yet, of course). So it will work within our project, but would need adjusting if we were to a) get multiplayer working and b) use this as a win condition. The main thing that would need adjusting, really, is determining the winner. Anyway, enough talking about theory for now. Inside the VictoryConditions folder create a new C# script called Survival.cs with the following code in it.
using UnityEngine; using System.Collections; public class Survival : VictoryCondition { public int minutes = 1; private float timeLeft = 0.0f; void Awake() { timeLeft = minutes * 60; } void Update() { timeLeft -= Time.deltaTime; } public override string GetDescription () { return "Survival"; } public override bool GameFinished () { foreach(Player player in players) { if(player && player.human && player.IsDead()) return true; } return timeLeft < 0; } public override bool PlayerMeetsConditions (Player player) { return player && player.human && !player.IsDead(); } }
We want to be able to specify the number of minutes to survive from inside Unity. This could have been seconds, but let's be honest, how often do we really want to be able to survive for part minutes? 10 minutes, 30 minutes, 45 minutes - these seem like much more sensible times to have the Player survive, given the type of game that we are making. The game will be finished when it has been running for the specified amount of minutes, or if the human player has already died. This is also the first Victory Condition where it is possible that there was no distinct winner. Well, technically the computer Player(s) won, but it turns out that in this scenario we do not really care about that, so having no winner gives the same effect. Attach this script to the VictoryConditions object, run the game, wait for a minute, and you should see that you have won.
Now, obviously you do not want the Player to be able to win automatically after being in the game for a minute. In fact, for further testing later on it will get annoying to even have the survival condition present. For this reason I am actually going to remove that from the game immediately. The point was more to show how it could be done - and how easily it could be done. Long term it would also be good to save the amount of time that has gone by, so that when a Player loads a game the timer will not be reset. But I will leave that up to you to work out if you decide that it is important enough to implement. (Hint: as a starting point you could get the SaveManager to find the GameManager and call save methods on it, and do a similar thing for the LoadManager)
Well, that basically brings us to a close. We have successfully implemented a number of different Victory Conditions and tested that they all work. Hopefully as a result of this you can see other interesting (and potentially complicated) ways to work out when a game is over and who won at the end of it. There are just a couple of small tweaks we still need to make as a result of glitches that I noticed during testing. First up, if you click 'New Game' from the results screen most things do not seem to work properly. It turns out (for some weird reason) that time is not being set to work again correctly. This can be fixed by adding
Time.timeScale = 1.0f; ResourceManager.MenuOpen = false;
to the end of OnLevelWasLoaded(), inside the if(initialised) check, in LevelLoader.cs. This guarantees that the time is set to work correctly whenever a level is loaded (which will be standard usage for 'normal' people). The other issue I noticed (while creating the Wonder) is that we are not actually removing resources from the Player when a Building is constructed or when a Unit is created. This means that any semblance of an economy that we have at the moment is actually a lie. To fix this, start by adding
public void RemoveResource(ResourceType type, int amount) { resources[type] -= amount; }
to Player.cs. Next, add the following call
RemoveResource(ResourceType.Money, tempBuilding.cost);
to the end of StartConstruction() in Player.cs to make sure that when the Player actually starts construction of a Building they are charged for it. Finally, add this code
GameObject unit = ResourceManager.GetUnit(unitName); Unit unitObject = unit.GetComponent< Unit >(); if(player && unitObject) player.RemoveResource(ResourceType.Money, unitObject.cost);
to the start of CreateUnit() in Building.cs to make sure that whenever we start the production of a Unit (that is, put it into a build queue) that the Player is charged for that Unit. Now, ideally we would also check that the Player has the resources available before allowing them to create a new Unit / Building, but that is a problem for another day. (Note: to implement this would involve making sure that the buttons in the orders bar cannot be clicked if the Player does not have the resources) When testing these changes, do make sure that the Unit / Building you are creating does have a sensible cost set (and remember to set these values on prefabs if they are not set properly).
And that, at last, brings us to the end of this part. I hope that you have found this process useful and enjoy making use of it to allow gameplay on certain maps to end. All of the code for this part can be found on github under the commit for Part 21.