Make a CCG – Drawing Exceptions

In the previous lesson, I mentioned two cases where drawing cards should not be allowed: when your deck is empty, and when your hand is full. In many cases an exception to a game action could result in the complete cancellation of the action, but what if the action could be partially performed? For example, imagine that you needed to draw three cards, and only had two in your deck. Rather than cancel the entire action, you should still draw the two cards that were available, and then apply fatigue for the third draw attempt only. Let’s try it out.

Go ahead and open the project from where we left off, or download the completed project from last week here. I have also prepared an online repository here for those of you familiar with version control.

Fatigue Action

The first exception, running out of cards in your deck, will lead to a player’s fatigue stat increasing (and in the future, damage will also be applied as a result). You might imagine that this modification of the data model should be done through a game action so that other systems can respond appropriately. If you did, then great job, you are catching on to the right idea!

Let’s create a new action, called FatigueAction which I have listed below. The context necessary for this is to record which player to apply fatigue to. In my current implementation of the concept of fatigue, I will always increment it by one, and therefore don’t need any other sort of context variables.

public class FatigueAction : GameAction {
	public FatigueAction(Player player) {
		this.player = player;
	}
}

Overdraw Action

The second exception, where you have cards available to draw, but no room in your hand to hold them, will lead to cards that are drawn and immediately discarded. For this action, I will need to know how many cards were overdrawn, and by which player. Coincidentally, this is exactly the same context I needed for the “DrawCardsAction”. Furthermore, when overdrawing, I will actually play an animation which is the same as for drawing a card, except that after a “preview” of the card, it will be discarded instead of sliding into the player’s hand. This means I can even reuse some of the viewer code as well. Because of all of the similarities, I felt that it made sense to simply subclass the “DrawCardsAction” as follows:

public class OverdrawAction : DrawCardsAction {
	public OverdrawAction(Player player, int amount) : base(player, amount) {
		
	}
}

Player System

Since the player holds the “hand” collection as well as the “fatigue” stat, I decided to handle both of these new game actions by the PlayerSystem. Just like before, we begin by observing the notifications:

public void Awake () {
	// ... other code omitted - add code to the end of the method
	this.AddObserver (OnPerformFatigue, Global.PerformNotification<FatigueAction> (), container);
	this.AddObserver (OnPerformOverDraw, Global.PerformNotification<OverdrawAction> (), container);
}

public void Destroy () {
	// ... other code omitted - add code to the end of the method
	this.RemoveObserver (OnPerformFatigue, Global.PerformNotification<FatigueAction> (), container);
	this.RemoveObserver (OnPerformOverDraw, Global.PerformNotification<OverdrawAction> (), container);
}

The application of fatigue is very simple, I simply increment the stat:

void OnPerformFatigue (object sender, object args) {
	var action = args as FatigueAction;
	action.player.fatigue++;
}

The application of over drawing is also fairly simple. It looks pretty much the same as it did when we first implemented drawing cards, except that this time we move the cards to the graveyard collection instead of to the hand:

void OnPerformOverDraw (object sender, object args) {
	var action = args as OverdrawAction;
	action.cards = action.player [Zones.Deck].Draw (action.amount);
	action.player [Zones.Graveyard].AddRange (action.cards);
}

In order to “trigger” the fatigue or overdraw, I modified the implementation of “OnPerformDrawCards” as follows:

void OnPerformDrawCards (object sender, object args) {
		var action = args as DrawCardsAction;

		int deckCount = action.player [Zones.Deck].Count;
		int fatigueCount = Mathf.Max(action.amount - deckCount, 0);
		for (int i = 0; i < fatigueCount; ++i) {
			var fatigueAction = new FatigueAction (action.player);
			container.AddReaction (fatigueAction);
		}

		int roomInHand = Player.maxHand - action.player [Zones.Hand].Count;
		int overDraw = Mathf.Max ((action.amount - fatigueCount) - roomInHand, 0);
		if (overDraw > 0) {
			var overDrawAction = new OverdrawAction (action.player, overDraw);
			container.AddReaction (overDrawAction);
		}

		int drawCount = action.amount - fatigueCount - overDraw;
		action.cards = action.player [Zones.Deck].Draw (drawCount);
		action.player [Zones.Hand].AddRange (action.cards);
	}

The first thing we want to check for is the amount of cards remaining in the deck. Do we have enough to handle the amount specified by the draw cards action? I create a local variable called “fatigeCount” which holds the “max” of either the action’s intended amount to draw minus the deck count, or zero – whichever is larger. Let’s illustrate a couple of examples:

If the action’s draw amount is 3, and the deck count is 10, then we take the max of (3 – 10 = -7) or ‘0’. Since ‘0’ is larger than ‘-7’ then it is chosen as the amount of fatigue to apply. In other words, there is no fatigue because we had enough cards available to draw.

Now let’s consider an example where the draw amount is 4, but the deck only has 2 cards remaining. We would take the max of (4 – 2 = 2) or ‘0’. Since ‘2’ is larger than ‘0’ then it is chosen as the amount of fatigue to apply. In other words, because we were two cards short of the intended amount to draw, we will also receive two fatigue penalties.

It was important to handle the fatigue check before the overdraw check, because you can’t overdraw if there are no cards available. Next, we want to check whether or not the hand has enough capacity remaining for the cards we wish to draw. I created a local variable called “overDraw” which holds the “max” of either the action’s intended amount to draw, minus the fatigue count, and minus the hands remaining capacity, or the other option of zero – whichever is larger. Let’s illustrate a couple of examples for this as well:

If your draw amount is 2, your fatigue is 1, and your hand can still hold 3 more cards, then we are taking the max of (2 – 1 – 3 = -2) or ‘0’. Since ‘0’ is larger than ‘-2’, then it is chosen as the amount of overdraw. In other words, because our hand has enough room to hold the amount of cards we will be drawing then there will be no overdraw penalty.

Imagine that we wanted to draw 1, had 0 fatigue, and your hand was already full. Then we are taking the max of (1 – 0 – 0 = 1) or ‘0’. Since ‘1’ is larger than ‘0’, then it is chosen as the amount of overdraw. In other words, because we will be 1 card above the max hand’s capacity, there will be 1 overdraw penalty.

With both fatigue and overdraw counts evaluated we can determine the final amount of cards that will actually be drawn as the “action’s draw amount minus the fatigue amount minus the over draw amount”. We pull that number of cards from the deck and add it to the hand as we had done before modifying this method.

Fatigue View

To display the fatigue penalty to a user, I created a new branch of GameObjects off of the “Board” GameObject. It holds its own Canvas with a Text component so that I can display the word “Fatigue” along with a value which corresponds to the stat on the player. There is nothing special about it, though I might point out that it’s scale is zero so that it begins as invisible to the player. It will also not be visible in the editor for the same reason, though you can find it by looking in the hierarchy pane.

public class FatigueView : MonoBehaviour {

	[SerializeField] Text fatigueLabel;

	void OnEnable () {
		this.AddObserver (OnPrepareFatigue, Global.PrepareNotification<FatigueAction> ());
	}

	void OnDisable () {
		this.RemoveObserver (OnPrepareFatigue, Global.PrepareNotification<FatigueAction> ());
	}

	void OnPrepareFatigue (object sender, object args) {
		var action = args as FatigueAction;
		action.perform.viewer = FatigueViewer;
	}

	IEnumerator FatigueViewer (IContainer game, GameAction action) {
		yield return true;
		var fatigue = action as FatigueAction;

		fatigueLabel.text = string.Format ("Fatigue\n{0}", fatigue.player.fatigue);

		Tweener tweener = transform.ScaleTo (Vector3.one, 0.5f, EasingEquations.EaseOutBack);
		while (tweener != null)
			yield return null;

		tweener = transform.ScaleTo (Vector3.zero, 0.5f, EasingEquations.EaseInBack);
		while (tweener != null)
			yield return null;
	}
}

The code is pretty simple – I exposed a field to hold a reference to the Text component so that I can dynamically set the fatigue’s value in the label. I use the “OnEnable” and “OnDisable” methods to register and unregister for notifications – I am listening for the “Prepare” phase of the fatigue action so that I can attach a “viewer” to the “Perform” phase of the same action. The viewer itself merely scales the object back to its normal size so that it is visible, and then shrinks it back to zero again so it’s not in the way.

Draw Cards View

We won’t need to add any new GameObjects or components to support the display of overdraw – we will just tweak what we have for now. As is becoming the pattern, we use the “OnEnable” and “OnDisable” methods to handle our notification setup. We are looking for the “Prepare” phase of the “OverDrawAction” like so:

// Add to OnEnable
this.AddObserver (OnPrepareDrawCards, Global.PrepareNotification<OverdrawAction> ());

// Add to OnDisable
this.RemoveObserver (OnPrepareDrawCards, Global.PrepareNotification<OverdrawAction> ());

Note that we use the same handler method, “OnPrepareDrawCards” as we do for the “DrawCardsAction” – this works because we will simply modify the viewer to support both actions. Let’s change the last portion of the “DrawCardsViewer” as follows:

// ORIGINAL
var addCard = playerView.hand.AddCard (cardView.transform, showPreview);

// NEW
var overDraw = action is OverdrawAction;
var addCard = playerView.hand.AddCard (cardView.transform, showPreview, overDraw);

The difference here is that we will pass an extra parameter to the hand’s “AddCard” method which indicates whether or not the viewer is in “overdraw” mode or not.

Hand View

The hand view held the code that was originally responsible for showing a preview of the drawn card and then putting it in the player’s hand. When over-drawing I still wanted to show the same preview, so I decided to let the hand view also handle the display of this action as well. Part of me is tempted to refactor this to somewhere else since the card is no longer going into the player’s hand, but this is still sort of prototype code for me and we can always polish it later.

public IEnumerator AddCard (Transform card, bool showPreview, bool overDraw) {
	if (showPreview) {
		var preview = ShowPreview (card);
		while (preview.MoveNext ())
			yield return null;
	}

	if (overDraw) {
		var discard = OverdrawCard (card);
		while (discard.MoveNext ())
			yield return null;
	} else {
		cards.Add (card);
		var layout = LayoutCards ();
		while (layout.MoveNext ())
			yield return null;
	}
}

I modified the “AddCard” method to the version shown above. Note that we added an extra parameter to the method definition which accepts a bool that indicates whether the added card is from an overdraw action or not. In the body of the method we begin with the code which can show a preview, regardless of if it is an overdraw or not. After the preview, we check the new “overdraw” flag and use that to discard the card as necessary, or to add the card to the hand as we had done before.

IEnumerator OverdrawCard (Transform card) {
	Tweener tweener = card.ScaleTo (Vector3.zero, 0.5f, EasingEquations.EaseInBack);
	while (tweener != null)
		yield return null;

	card.gameObject.SetActive (false);
	card.localScale = Vector3.one;

	var poolable = card.GetComponent<Poolable> ();
	var pooler = GetComponentInParent<BoardView> ().cardPooler;
	pooler.Enqueue (poolable);
}

The “OverdrawCard” method was added, and is responsible for the animation that shows the card shrinking to nothing (represents discarding the card). After the card shrinks, I set its GameObject to inactive so that you won’t see it snap back to its full size. I reset the scale before enqueing the card back into the card pooler.

Demo

Feel free to play the scene and start drawing cards. Once your hand is full you should see the overdrawn cards shrink away to nothing (be discarded) rather than animating into your hand. If you deplete your deck you will also begin to see the fatigue message, and each time the message appears you should see that it mentions the correct amount of fatigue that the current player has accumulated.

During testing, you may wish to tweak some of the “Player” class constants to help produce these two scenarios a little quicker. For example, you could reduce the “maxHand” size to ‘3’ and the “maxDeck” size to ‘5’ – as long as the deck size is larger than the hand’s capacity you should be able to obvserve both of the new features. The video below shows a game-play recording of this lesson demo using the modified constant values listed here:

Summary

In this lesson we handled a few important exceptions to the rules of drawing cards. We handled the cases of running out of cards as well as not having room in your hand to hold new cards. In both cases, a new action was implemented as a reaction to another action. Since the action they were responding to was also a reaction to an action, you might start getting an idea of how deeply nested this process can get, and how powerful the action system is that supports it all.

You can grab the project with this lesson fully implemented right here. If you are familiar with version control, you can also grab a copy of the project from my repository.

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 *