Unofficial Pokemon Board Game – Gym Battle

In this lesson we will finish implementing gym battles. This flow is a little different than the capture battle flow and contains its own new set of related states and view controllers for us to finish. Once we finish this step, a player will finally be able to earn badges, which will eventually be used to determine the winner of our game.

Combatant Factory

Although we can reuse the models for our Battle and Combatant, the configuration we will need in a gym battle doesn’t match the one we provided for a capture battle. This is because a gym battle will use a team of pokemon to fight with. Let’s add a couple of new methods to our factory.

public static Combatant CreateGymChallenger (List<Pokemon> team) {
	Combatant retValue = new Combatant ();
	foreach (Pokemon pokemon in team) {
		retValue.pokemon.Add (pokemon);
	}
	retValue.mode = ControlModes.Player;
	retValue.waitTime = retValue.CurrentPokemon.FastMove.duration;
	return retValue;
}

public static Combatant CreateGymLeader (Gym gym) {
	Combatant retValue = new Combatant();
	foreach (Pokemon p in gym.pokemon) {
		retValue.pokemon.Add(p);
	}
	retValue.mode = ControlModes.Computer;
	retValue.waitTime = retValue.CurrentPokemon.FastMove.duration;
	return retValue;
}

Until now, all of the methods in this factory had been an overloaded version of the “Create” method – by passing a different type of parameter I got a differently configured Combatant. While it worked fine and made enough sense with just a single type of combat, adding two more overloads of the same name feels a bit much. It may not be as obvious to other developers, or even myself in the future, what version I should use in what scenario. To help, I provided more descriptive names this time such as “CreateGymChallenger” and “CreateGymLeader” so it is clear what the Combatant configuration will be intended for. If you like, you could refactor the other methods to have more descriptive names as well.

Battle Factory

Since I used better name descriptions on the Combatant Factory, I also decided to be more descriptive here. Note that I also take the opportunity to reset the gym anytime I create a new battle so that the experience will be the same challenge every time.

public static Battle CreateGymBattle (List<Pokemon> playerTeam, Gym gym) {
	GymSystem.Reset (gym);
	Battle retValue = new Battle ();
	retValue.combatants.Add (CombatantFactory.CreateGymChallenger(playerTeam));
	retValue.combatants.Add (CombatantFactory.CreateGymLeader(gym));
	return retValue;
}

Gym Encounter View Controller

This is the first new screen you will see. It appears when crossing a gym site tile, assuming you are able to challenge the gym. If you’ve followed along and added the assets, the screen should look something like this:

The code for this view controller looks pretty similar to the code we used on the equivalent screen for a random encounter. In both cases you can choose whether to ignore the encounter or engage it. Therefore we provide an “Exits” enum that will be passed along in a “didFinish” delegate so that we know what a user selected. The only special code is a new version of the “Show” method where we pass along a Gym instance, with which we can populate the screen. We will show the kinds of Pokemon you will face as well as their CP stat to better inform a player of the challenge they will be facing.

public class GymEncounterViewController : BaseViewController {

	public enum Exits {
		Challenge,
		Ignore
	}
	public Action<Exits> didFinish;

	[SerializeField] List<Image> avatars;
	[SerializeField] List<Text> cpLabels;
	[SerializeField] Text titleLabel;

	public void Show (Gym gym, Action didShow = null) {
		titleLabel.text = string.Format("{0} Gym", gym.type);
		for (int i = 0; i < avatars.Count; ++i) {
			avatars[i].sprite = gym.pokemon[i].GetAvatar();
			cpLabels[i].text = string.Format("CP: {0}",gym.pokemon[i].CP);
		}
		Show (didShow);
	}

	public void ChallengeButtonPressed () {
		if (didFinish != null)
			didFinish(Exits.Challenge);
	}

	public void IgnoreButtonPressed () {
		if (didFinish != null)
			didFinish(Exits.Ignore);
	}
}

Attack Team View Controller

If a user decides to challeng a gym, then this is the next screen they will see. This is where they can decide which of their Pokemon to use in the battle against the gym. If you’ve followed along and added the assets, the screen should look something like this:

Technically this screen has a branching flow as well, although in this case I didn’t create an Exits enum. Rather I provided a “didFinish” delegate that accepts a Battle instance. If the Battle is “null” then it means the user canceled, otherwise, we should have a fully formed battle instance ready to go.

public Action<Battle> didFinish;

This screen is fairly complex, and has a duplicated view hierarchy where we show a candidate for the attack team complete with labels, navigation buttons, etc. I use a special view component class called “AttackCandidateView” to help manage this work. These, as well as the buttons for confirming or canceling, are already connected in the prefab for you.

[SerializeField] AttackCandidateView[] candidateViews;
[SerializeField] Button confirmButton;
[SerializeField] Button cancelButton;

When the screen is enabled, I create two lists of Pokemon. The first list is a copy of all of the current player’s Pokemon, but only those which are not KO’d. The second list is to hold the team that will actually be used in the battle. The team list is prepopulated with the first two candidates, and then I also display them in the candidate views.

List<Pokemon> candidates;
List<Pokemon> team;

void OnEnable () {
	candidates = new List<Pokemon> ();
	foreach (Pokemon p in currentPlayer.pokemon) {
		if (p.hitPoints > 0) {
			candidates.Add (p);
		}
	}
	team = new List<Pokemon> (candidateViews.Length);
	for (int i = 0; i < candidateViews.Length; ++i) {
		team.Add (candidates [i]);
		candidateViews [i].Display (candidates [i]);
	}
}

Because this view controller inherits from BaseViewController I can override the “DidShow” and “Hide” methods to perform special setup after the screen has fully animated onto the view. In this case, I will use it to connect and disconnect listeners to my button events. For each of the candidate views I will have both a “previous” and “next” button for cycling through the options, and I also have the main “confirm” and “cancel” buttons.

Technically it wasn’t necessary to connect and disconnect the confirm and cancel button handlers because the “didFinish” delegate that they invoke is also connected and disconnected such that even if the handler for those buttons are pressed they will not succeed in performing an action that affects anything. The candidate views however could have been triggered while the screen was animating away. Although that also wouldn’t have “broken” anything, it might have confused a player who thought they were going to be able to change a Pokemon at the last second. For consistency I just enabled and disabled all of the buttons at the same time.

protected override void DidShow (Action complete) {
	base.DidShow (complete);
	for (int i = 0; i < candidateViews.Length; ++i) {
		int index = i;
		candidateViews [i].previousButton.onClick.AddListener (delegate {
			ChangeCandidate (index, -1);
		});
		candidateViews [i].nextButton.onClick.AddListener (delegate {
			ChangeCandidate (index, 1);
		});
	}
	confirmButton.onClick.AddListener (ConfirmButtonPressed);
	cancelButton.onClick.AddListener (BackButtonPressed);
}

public override void Hide (Action didHide) {
	for (int i = 0; i < candidateViews.Length; ++i) {
		candidateViews [i].previousButton.onClick.RemoveAllListeners ();
		candidateViews [i].nextButton.onClick.RemoveAllListeners ();
	}
	confirmButton.onClick.RemoveAllListeners ();
	cancelButton.onClick.RemoveAllListeners ();
	base.Hide (didHide);
}

void BackButtonPressed () {
	if (didFinish != null)
		didFinish (null);
}

void ConfirmButtonPressed () {
	var gym = GymSystem.GetCurrentGym(game, board);
	if (didFinish != null)
		didFinish (BattleFactory.CreateGymBattle(team, gym));
}

When scrolling through the candidates, I can return early if I dont have more than the number of views, because I dont want to allow duplicates. Otherwise, I gather some information such as grabbing a reference to the correct view, figuring out where the currently viewed Pokemon sits in the candidate list, and then looping over the list until finding a Pokemon that isn’t included in the team yet. When I find this new candidate, I update the view and list with it.

void ChangeCandidate (int viewIndex, int direction) {
	if (candidates.Count == candidateViews.Length)
		return;

	var view = candidateViews [viewIndex];
	int pokemonIndex = candidates.IndexOf (team[viewIndex]);
	int nextIndex = pokemonIndex;

	while (true) {
		nextIndex = (nextIndex + direction + candidates.Count) % candidates.Count;
		var nextPokemon = candidates [nextIndex];
		if (team.IndexOf (nextPokemon) == -1) {
			view.Display (nextPokemon);
			team [viewIndex] = nextPokemon;
			break;
		}
	}
}

Attack Candidate View

This is the view component script that manages a subsection of the view hierarchy in the Attack Team View Controller. Mostly it just contains reference to its own set of views and knows how to update them when displaying a given Pokemon.

public class AttackCandidateView : MonoBehaviour {

	public Action didPressLeft;
	public Action didPressRight;

	public Text nameLabel;
	public Text cpLabel;
	public Text hpLabel;
	public Image avatarImage;
	public Button previousButton;
	public Button nextButton;

	public void Display (Pokemon pokemon) {
		nameLabel.text = pokemon.Name;
		cpLabel.text = string.Format ("CP:{0}", pokemon.CP);
		hpLabel.text = string.Format ("HP: {0}/{1}", pokemon.hitPoints, pokemon.maxHitPoints);
		avatarImage.sprite = pokemon.GetAvatar ();
	}
}

While Attacking…

We won’t need to make any changes to the combat screen itself, although you may notice that you have a different menu option than you used to. Instead of a choice to attempt an early “Capture”, you can now choose to “Retreat” if a battle is going poorly and you don’t want to waste extra potion and revive items.

Victory View Controller

If you are strong enough to defeat a gym, then this is the next screen you will see. A little animation of the badge rotating and scaling into place provides a simple bit of polish to help it feel more special. If you’ve followed along and added the assets, the screen should look something like this:

There isn’t much special in the code except that I override the “Show” and “DidShow” methods as opportunities to prepare the screen and run the animations.

public class VictoryViewController : BaseViewController {

	public Action didFinish;

	[SerializeField] Image reward;
	[SerializeField] Text infoLabel;

	public override void Show (Action didShow) {
		var gym = GymSystem.GetCurrentGym (game, board);
		infoLabel.text = string.Format ("You've earned the\n{0} badge.", gym.type);
		reward.sprite = GymSystem.GetBadge (gym);
		GymSystem.AwardBadge (currentPlayer, gym);

		reward.transform.localEulerAngles = Vector3.zero;
		reward.transform.RotateToLocal (new Vector3 (0, 360, 0), 1f, EasingEquations.EaseInExpo);
		reward.transform.localScale = Vector3.zero;

		base.Show (didShow);
	}

	protected override void DidShow (Action complete) {
		base.DidShow (complete);
		Tweener tweener = reward.transform.ScaleTo (Vector3.one);
		tweener.duration = 1f;
		tweener.equation = EasingEquations.EaseOutBounce;
	}

	public void OnContinueButtonPressed () {
		if (didFinish != null)
			didFinish ();
	}
}

Defeat View Controller

Should you ever lose in a gym battle, you will see a different screen where you are taunted to make you want to try again. If you’ve followed along and added the assets, the screen should look something like this:

The code for this screen is also very simple, and merely requires me to update the sprite showing the Pokemon you lost to.

public class DefeatViewController : BaseViewController {
	public Action didFinish;

	[SerializeField] Image avatarImage;

	void OnEnable () {
		var combatant = battle.combatants [0].mode == ControlModes.Computer ? battle.combatants [0] : battle.combatants [1];
		var pokemon =  combatant.CurrentPokemon;
		avatarImage.sprite = pokemon.GetAvatar ();
	}

	public void ContinueButtonPressed () {
		if (didFinish != null)
			didFinish ();
	}
}

Gym Battle Flow

The flow for this battle is more complex than the flow for a capture battle, so I think its a good idea to show the flow chart again:

Generally speaking each section of the flow chart has its own corresponding state, although in some cases the same state will handle more than one. To help you find the code related to each step of the flow, use the following mapping:

  • Fight? – GymState
  • Start Battle – AttackTeamState
  • Command – GymCommandState
  • Retreat – GymCommandState
  • Attack – GymAttackState
  • KO? – GymAttackState
  • Team – TagTeamState
  • Swap – TagTeamState
  • Result – TagTeamState
  • Win – VictoryState
  • Lose – DefeatState
  • Complete – GymCompleteState

Gym State

This is the state responsible for showing the “GymEncounterViewController” – the screen you first see when arriving at a gym site. The code follows the same pattern I have been using repeatedly – there are nested actions tied to the transitions of the screen itself. Once the screen is fully hidden we will either move on in the journey flow, or continue in the battle flow depending on whether a user chose to ignore the gym or not.

public partial class FlowController : MonoBehaviour {

	State GymState {
		get {
			if (_gymState == null)
				_gymState = new State(OnEnterGymState, null, "Gym");
			return _gymState;
		}
	}
	State _gymState;

	void OnEnterGymState () {
		var gym = GymSystem.GetCurrentGym(game, board);
		if (gym != null && GymSystem.CanChallenge(game.CurrentPlayer, gym)) {
			gymEncounterViewController.Show (gym, delegate {
				gymEncounterViewController.didFinish = delegate(GymEncounterViewController.Exits obj) {
					gymEncounterViewController.didFinish = null;
					gymEncounterViewController.Hide(delegate {
						switch(obj) {
						case GymEncounterViewController.Exits.Challenge:
							stateMachine.ChangeState (AttackTeamState);
							break;
						case GymEncounterViewController.Exits.Ignore:
							stateMachine.ChangeState (CheckDestinationState);
							break;
						}
					});
				};
			});
		} else {
			stateMachine.ChangeState (CheckDestinationState);
		}
	}
}

Attack Team State

This is the state responsible for displaying the “AttackTeamViewController” – the screen where a user will assemble an attack team for the gym battle. We are using the same pattern of code here as well, there are actions tied to the transitions of the screen. After hiding we either end the gym flow, or begin the battle based on the user input. Note that I didn’t use an Exits enum here and branch based on whether or not the view controller returned an instance of a Battle or not.

public partial class FlowController : MonoBehaviour {

	State AttackTeamState {
		get {
			if (_attackTeamState == null)
				_attackTeamState = new State(OnEnterAttackTeamState, null, "AttackTeam");
			return _attackTeamState;
		}
	}
	State _attackTeamState;

	void OnEnterAttackTeamState () {
		attackTeamViewController.Show (delegate {
			attackTeamViewController.didFinish = delegate(Battle obj) {
				attackTeamViewController.didFinish = null;
				attackTeamViewController.Hide(delegate {
					if (obj != null) {
						battle = obj;
//						musicController.PlayBattle(1);
						commandViewController.SetupGymBattle ();
						combatViewController.gameObject.SetActive(true);
						stateMachine.ChangeState (GymCommandState);
					} else {
						stateMachine.ChangeState (GymCompleteState);
					}
				});
			};
		});
	}
}

Gym Command State

This state is responsible for display of the command menu so that a player can determine what action to take. This can include specifying what kind of attack to use or retreating if necessary. When it is the gym leaders turn, this state also handles the “A.I.” by automatically choosing an action to take. The code should look familiar – its almost identical to the version we used for an encounter battle. We just make sure that it points to the correct states for our new flow.

public partial class FlowController : MonoBehaviour {

	State GymCommandState {
		get {
			if (_gymCommandState == null)
				_gymCommandState = new State(OnEnterGymCommandState, null, "GymCommand");
			return _gymCommandState;
		}
	}
	State _gymCommandState;

	void OnEnterGymCommandState () {
		CombatSystem.Next(battle);
		if (battle.attacker.mode == ControlModes.Player) {
			commandViewController.Show(delegate {
				commandViewController.didFinish = delegate(CommandViewController.Exits obj) {
					commandViewController.didFinish = null;
					commandViewController.Hide(delegate {
						switch (obj) {
						case CommandViewController.Exits.FastMove:
							SelectGymMove (battle.attacker.CurrentPokemon.FastMove);
							break;
						case CommandViewController.Exits.ChargeMove:
							SelectGymMove (battle.attacker.CurrentPokemon.ChargeMove);
							break;
						case CommandViewController.Exits.Retreat:
							stateMachine.ChangeState (DefeatState);
							break;
						}
					});
				};
			});
		} else {
			SelectGymMove (CombatSystem.PickMove (battle));
		}
	}

	void SelectGymMove (Move move) {
		battle.move = move;
		stateMachine.ChangeState (GymAttackState);
	}
}

Gym Attack State

This state is responsible for applying an attack move to the models through the use of the relevant system, and also for displaying the move via an animation on screen. When the animation completes it branches based on whether or not a KO occurred. This state also looks almost identical to the encounter attack state, just with new state targets specific to this flow.

public partial class FlowController : MonoBehaviour {

	State GymAttackState {
		get {
			if (_gymAttackState == null)
				_gymAttackState = new State(OnEnterGymAttackState, null, "GymAttack");
			return _gymAttackState;
		}
	}
	State _gymAttackState;

	void OnEnterGymAttackState () {
		CombatSystem.ApplyMove(battle);
		combatViewController.didCompleteMove = delegate {
			if (battle.defender.CurrentPokemon.hitPoints == 0) {
				stateMachine.ChangeState (TagTeamState);
			} else {
				stateMachine.ChangeState (GymCommandState);
			}
		};
		combatViewController.ApplyMove();
	}
}

Tag Team State

After a knockout is encountered, this state will attempt to swap out the KO’d Pokemon for another Pokemon on the same team. If there is a Pokemon to swap to, then we will update the screen and continue battling. Otherwise, we determine the result of the battle based on which combatant no longer can continue to fight.

public partial class FlowController : MonoBehaviour {

	State TagTeamState {
		get {
			if (_tagTeamState == null)
				_tagTeamState = new State(OnEnterTagTeamState, null, "TagTeam");
			return _tagTeamState;
		}
	}
	State _tagTeamState;

	void OnEnterTagTeamState () {
		if (CombatSystem.SwapIfNeeded(battle)) {
			combatViewController.Display();
		}
		if (battle.defender.CurrentPokemon.hitPoints == 0) {
			if (battle.attacker.mode == ControlModes.Player)
				stateMachine.ChangeState (VictoryState);
			else
				stateMachine.ChangeState (DefeatState);
		} else {
			stateMachine.ChangeState (GymCommandState);
		}
	}
}

Victory State

This state is only entered when the player is the victor of a gym battle. It is responsible for showing the “VictoryViewController” – the screen where a user sees the badge they have just earned. The code here follows the usual pattern with actions tied to screen transitions:

public partial class FlowController : MonoBehaviour {

	State VictoryState {
		get {
			if (_victoryState == null)
				_victoryState = new State(OnEnterVictoryState, null, "Victory");
			return _victoryState;
		}
	}
	State _victoryState;

	void OnEnterVictoryState () {
//		musicController.PlayVictory (1);
		victoryViewController.Show (delegate {
			victoryViewController.didFinish = delegate {
				victoryViewController.didFinish = null;
				victoryViewController.Hide(delegate {
					stateMachine.ChangeState (GymCompleteState);
				});
			};
		});
	}

}

Defeat State

When a player loses to the gym leader, we will enter the “DefeatState” and display the “DefeatViewController” – the screen which shows a taunting challenge to try again. This is another simple state which is largely there to tie actions to screen transitions:

public partial class FlowController : MonoBehaviour {

	State DefeatState {
		get {
			if (_defeatState == null)
				_defeatState = new State(OnEnterDefeatState, null, "Defeat");
			return _defeatState;
		}
	}
	State _defeatState;

	void OnEnterDefeatState () {
		game.CurrentPlayer.destinationTileIndex = game.CurrentPlayer.tileIndex;
		defeatrViewController.Show (delegate {
			defeatrViewController.didFinish = delegate {
				defeatrViewController.didFinish = null;
				defeatrViewController.Hide(delegate {
					stateMachine.ChangeState (GymCompleteState);
				});
			};
		});
	}
}

Gym Complete State

The final state in the gym battle flow is the “GymCompleteState”. This allows us a common place to perform cleanup such as hiding the combat screen, and to make sure there is only one point where we transition out of the battle flow and into the next step in the journey.

public partial class FlowController : MonoBehaviour {

	State GymCompleteState {
		get {
			if (_gymCompleteState == null)
				_gymCompleteState = new State(OnEnterGymCompleteState, null, "GymComplete");
			return _gymCompleteState;
		}
	}
	State _gymCompleteState;

	void OnEnterGymCompleteState () {
		combatViewController.gameObject.SetActive(false);
//		musicController.PlayJourney (game.CurrentPlayer.badges.Count);
		stateMachine.ChangeState (CheckDestinationState);
	}

}

Connections

We are almost done… a few more steps though. First, we need to plug in the gym battle states to the rest of the game flow. It can be entered from the “RandomEncounterState” if there was no encounter, or if the player chooses to ignore an encounter. There are two lines in this script already marked with a “TODO” comment. Likewise after a random encounter battle, in the “EncounterCompleteState” we have a “TODO” comment. In all three places we need to change from this:

stateMachine.ChangeState (CheckDestinationState); // TODO: GymState

…to this:

stateMachine.ChangeState (GymState);

And for our last little change, let’s show the badge we earned on the “Get Set” screen. Open the “GetSetViewController” and change the “TODO” comment from this:

// TODO: Show earned badges

…to this:

for (int i = 0; i < badges.Length; ++i) {
	if (player.badges.Count > i)
		badges[i].sprite = GymSystem.GetBadge(player.badges[i]);
	else
		badges[i].sprite = emptyBadge;
}

Demo

It’s time to try out the game! Travel around the board and capture a wild pokemon. Optionally train and level up your Pokemon team. Then the next time you cross a gym site you should see a Gym Encounter screen. Feel free to try all of the different flow paths. Complete a battle with a win and then with a loss, ignore a gym, cancel a team configuration, and try an early retreat. It should all be working. When you defeat a gym, pay close attention to the badges in the upper right corner of your screen at the Get Set screen. You should see the placeholders get replaced with your new accomplishment!

Summary

Whew – that was a pretty long lesson. I thought about breaking it up between the view controllers and states, but the code in the states is so familiar that it didn’t feel like much was accomplished. I guess it’s good practice though!

We added several new screens, and a bunch of new states. We finished out a couple of factories and added connections for our flow into the existing journey. As I’ve said before, none of this should feel challenging by now, because we are just filling in the content with the same kinds of patterns we had already established beforehand.

Don’t forget that there is a repository for this project located here. Also, please remember that this repository is using placeholder (empty) assets so attempting to run the game from here is pretty pointless – you will need to follow along with all of the previous lessons first.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *