If there are no constraints on summoning minions, then this game would be almost entirely luck based. Whoever drew the strongest card first and played it would almost certainly win. Doesn’t sound fun to me. Let’s fix that by constraining the ability to play cards by their mana cost.
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.
Global
There are plenty of ways we could constrain an action, such as for playing cards. The solution I’ve chosen is to add an extra step to the Action System where we validate an action before preparing or performing the action’s phases. I also decided we should add a phase for canceling an action, which could come in handy such as if the view needs to do any cleanup.
Hopefully you remember that I created the Global class a while back. One of its primary purposes was to handle consistent naming for notifications that were specific per type of object, and we have used it a lot for notifications regarding the phases of a Game Action. Let’s add another couple of pairs of notifications. One will be based on the idea of a “Validate” notification and the other will be for a “Cancel” notification.
[csharp]
public static string ValidateNotification
return ValidateNotification (typeof(T));
}
public static string ValidateNotification (System.Type type) {
return string.Format (“{0}.ValidateNotification”, type.Name);
}
public static string CancelNotification
return CancelNotification (typeof(T));
}
public static string CancelNotification (System.Type type) {
return string.Format (“{0}.CancelNotification”, type.Name);
}
[/csharp]
Validator
Create a new class called a “Validator” – I added mine to the Scripts/Models folder. The purpose of this little object will be to indicate whether something is valid or not, and you will find it used as the argument in a “Validate” notification. Each instance will begin in a valid state, but if necessary, can become invalidated. Invalidated things will not ever become valid again – it is a one way flip. Here’s what it looks like in code:
[csharp]
public class Validator {
public bool isValid { get; private set; }
public Validator () {
isValid = true;
}
public void Invalidate () {
isValid = false;
}
}
[/csharp]
Thanks to the new validation notification functions we added to our global class, it will now be easy to add an extension so that any object can have a method to validate itself. I do this through an extension method on the “object” type:
[csharp]
public static class ValidatorExtensions {
public static bool Validate (this object target) {
var validator = new Validator ();
var notificationName = Global.ValidateNotification(target.GetType());
target.PostNotification (notificationName, validator);
return validator.isValid;
}
}
[/csharp]
Hopefully you can see what this code is doing. Any object or sub-type of object (which means everything) will be able to use this method, although at the moment I only intend to use it for Game Actions. Whenever we invoke the Validate method, we create a new instance of the Validator class. The validator begins in a valid state. Then we post a notification, unique to the type of the object that is being validated. Any observer of the notification can grab the validator as the argument of the notification and cause it to become invalid. After the notification has been posted (and all observer handlers invoked) then we simply return the current value of the validator’s isValid field.
Game Action
Since we are planning to be able to invalidate, and therefore cancel our game actions, let’s add a phase for the canceled state. Add a property:
[csharp]
public Phase cancel { get; protected set; }
[/csharp]
assign the property its value in the constructor:
[csharp]
cancel = new Phase (this, OnCancelKeyFrame);
[/csharp]
and give the phase a way to generate a keyframe notification like our other phases:
[csharp]
protected virtual void OnCancelKeyFrame (IContainer game) {
var notificationName = Global.CancelNotification (this.GetType ());
game.PostNotification (notificationName, this);
}
[/csharp]
Action System
Now we need to modify the action system so that all actions will be validated before going through their phases. Replace the Sequence method with the updated version below:
[csharp]
IEnumerator Sequence (GameAction action) {
this.PostNotification (beginSequenceNotification, action);
if (action.Validate () == false)
action.Cancel ();
var phase = MainPhase (action.prepare);
while (phase.MoveNext ()) { yield return null; }
phase = MainPhase (action.perform);
while (phase.MoveNext ()) { yield return null; }
phase = MainPhase (action.cancel);
while (phase.MoveNext ()) { yield return null; }
if (rootAction == action) {
phase = EventPhase (deathReaperNotification, action, true);
while (phase.MoveNext ()) { yield return null; }
}
this.PostNotification (endSequenceNotification, action);
}
[/csharp]
After posting the “beginSequenceNotification” we added the check to validate the action, and if the result was invalid we invoke the Cancel method of the action. In addition, we added a third “MainPhase” handler for the new “cancel” phase.
Now we need to modify the MainPhase method. We will replace the following…
[csharp]
if (phase.owner.isCanceled)
yield break;
[/csharp]
…with another version to determine when the various phases run. The new decision will be based on a combination of whether the action is canceled or not, and whether the current phase represents the cancel phase or not. Imagine a few use cases:
- False, False case – If the action is not canceled, and the phase is not the cancel phase, then the method should continue.
- True, False case – If the action is canceled, and the phase is not the cancel phase, then the method should be aborted.
- False, True case – If the action is not canceled, and the phase is the cancel phase, then the method should be aborted.
- True, True case – If the action is canceled, and the phase is the cancel phase, then the method should continue.
Of the four potential cases I have presented, I can see that I am looking for a condition where one and only one of the values is true. This pattern is called a Logical exclusive-OR and C# has a special operator to handle it. Here is what it looks like:
[csharp]
bool isActionCancelled = phase.owner.isCanceled;
bool isCancelPhase = phase.owner.cancel == phase;
if (isActionCancelled ^ isCancelPhase)
yield break;
[/csharp]
Mana System
Now that we have a foundation in place to support the cancelling of actions, let’s add our first system that will become an action constraint. We want to constrain the playing of a card based on the amount of mana a player has. Of course this also means that we will need to manage the rules around how a player generates and spends mana as well.
[csharp]
public class ManaSystem : Aspect, IObserve {
// Add code here
}
[/csharp]
As usual, we have created a system which is a type of Aspect so that we can add it to the same container as our other systems. I also will need to respond to a variety of notifications to handle all of the necessary work, so we have it implement the IObserve interface
[csharp]
public void Awake () {
this.AddObserver (OnPerformChangeTurn, Global.PerformNotification
this.AddObserver (OnPerformPlayCard, Global.PerformNotification
this.AddObserver (OnValidatePlayCard, Global.ValidateNotification
}
public void Destroy () {
this.RemoveObserver (OnPerformChangeTurn, Global.PerformNotification
this.RemoveObserver (OnPerformPlayCard, Global.PerformNotification
this.RemoveObserver (OnValidatePlayCard, Global.ValidateNotification
}
[/csharp]
Here, I have properly added and removed observers for a variety of notifications. Note that the “Validate” notification does not specify a sender, because the sender of that notification will be the object that needs to be validated, not the system container. By omitting the “sender” parameter, we will get the notifications for ANY object sending that notification.
[csharp]
void OnPerformChangeTurn (object sender, object args) {
var mana = container.GetMatch ().CurrentPlayer.mana;
if (mana.permanent < Mana.MaxSlots)
mana.permanent++;
mana.spent = 0;
mana.overloaded = mana.pendingOverloaded;
mana.pendingOverloaded = 0;
mana.temporary = 0;
this.PostNotification (ValueChangedNotification, mana);
}
[/csharp]
Whenever a turn changes, we actually need to perform quite a few steps. First, we get a reference to the mana object of the match’s current player. Each turn we increment the “permanent” amount of mana that a player is allowed to spend in a turn. So on turn 1, you have 1 mana to spend, and on turn 4 you have 4 mana. However, we constrain this increment by the “MaxSlots” constant. So that after the 10th turn, you are still only allowed to spend 10 mana on any given turn.
We reset the “spent” value to ‘0’ to indicate that a user has not spent any of the mana that they should be allowed to use. This value will raise during a turn as a player takes actions that consume his mana.
We set the player’s “overloaded” mana stat to be the same as what the “pendingOverloaded” stat was previously, and then reset the “pendingOverloaded” stat to zero. We wont actually be using either of these for awhile. In Hearthstone, certain extra-powerful actions will cause your mana to become overloaded, and that is where this comes in.
We reset the “temporary” mana stat to ‘0’ – this represents extra mana that you can use on a given turn under special circumstances, such as a spell that actually gives mana back to its caster.
Finally, since stats of the mana have changed, we post a notification so that any other system or view etc can be aware of the change.
[csharp]
void OnPerformPlayCard (object sender, object args) {
var action = args as PlayCardAction;
var mana = container.GetMatch ().CurrentPlayer.mana;
mana.spent += action.card.cost;
this.PostNotification (ValueChangedNotification, mana);
}
[/csharp]
Here we have observed the “perform” phase of the PlayCardAction and are using it as an opportunity to indicate that the user has spent some of their mana. We do this by incrementing the “spent” stat by the amount of the card’s “cost”. Since we have changed a mana stat we are posting a notification as well.
[csharp]
void OnValidatePlayCard (object sender, object args) {
var playCardAction = sender as PlayCardAction;
var validator = args as Validator;
var player = container.GetMatch().players[playCardAction.card.ownerIndex];
if (player.mana.Available < playCardAction.card.cost)
validator.Invalidate ();
}
[/csharp]
Pretty much everything we have done until now was to reach this point right here. We can cause the playing of a card to be canceled using this method. The sender of the notification is the action itself. The args of the notification is the validator instance which we are potentially going to invalidate. We grab a reference to the owner of the card, and then determine whether or not that player has enough mana to play it or not. If not, we invalidate the action.
Game Factory
Since we have added a new system, we will need to remember to attach it to our system container via the factory’s Create method. Add the following statement:
[csharp]
game.AddAspect
[/csharp]
Mana View
In order for the player to determine the amount of mana they have at any given time, I have provided the Mana View. A canvas already exists in the scene to represent this, at the left side of the screen. Let’s update the script by adding the following:
[csharp]
void OnEnable () {
this.AddObserver (OnManaValueChangedNotification, ManaSystem.ValueChangedNotification);
}
void OnDisable () {
this.RemoveObserver (OnManaValueChangedNotification, ManaSystem.ValueChangedNotification);
}
void OnManaValueChangedNotification (object sender, object args) {
var mana = args as Mana;
for (int i = 0; i < mana.Available; ++i) {
SetSpriteForImageSlot (available, i);
}
for (int i = mana.Available; i < mana.Unlocked; ++i) {
SetSpriteForImageSlot (unavailable, i);
}
for (int i = mana.Unlocked - mana.overloaded; i < mana.Unlocked; ++i) {
SetSpriteForImageSlot (locked, i);
}
for (int i = mana.Unlocked; i < Mana.MaxSlots; ++i) {
SetSpriteForImageSlot (slot, i);
}
}
void SetSpriteForImageSlot(Sprite sprite, int slotIndex) {
if (slotIndex >= 0 && slotIndex < slots.Count)
slots [slotIndex].sprite = sprite;
}
[/csharp]
We are observing the notification sent by the ManaSystem which lets us know when a player’s mana has changed. We can observe this regardless of the player, because we use the same view for each player – it shows the mana for the “current” player. I use a sequence of for loops to iterate over each of the “Image” slots that I have a reference to and have it be updated based on the combination of stats in the mana object. I want to differentiate between mana that is available to spend, which has been spent, which has been locked, and which has not been earned yet. In order not to need to worry about “out of bounds” indexes, I created a separate method to assign the sprite to an index and handle the bounds checking in just one place.
Hand View
At the moment, you can click a card to preview it, then click it again to play it. However, if the play action is canceled, the card remains in a preview position. In order to fix this, we need to implement a “viewer” for the “cancel” phase which can return the card back to the hand. Before we had added the “Validate” notification, we made use of “prepare” phase notifications to attach the viewers, but now I think we have an even better place inside of this new notification, because we could attach viewers to any of the phases.
We need to make a few changes to this script. Begin by replacing the OnEnable and OnDisable methods with the following updated versions, where I have replaced the notification to observe as well as the handler method for the notification:
[csharp]
void OnEnable () {
this.AddObserver (OnValidatePlayCard, Global.ValidateNotification
}
void OnDisable () {
this.RemoveObserver (OnValidatePlayCard, Global.ValidateNotification
}
[/csharp]
Next, replace the “OnPreparePlayCard” method with the following:
[csharp]
void OnValidatePlayCard (object sender, object args) {
var action = sender as PlayCardAction;
if (GetComponentInParent
action.perform.viewer = PlayCardViewer;
action.cancel.viewer = CancelPlayCardViewer;
}
}
[/csharp]
Finally, add a new “viewer” method which can cause the previewed card to return to the owners hand.
[csharp]
IEnumerator CancelPlayCardViewer (IContainer game, GameAction action) {
var layout = LayoutCards (true);
while (layout.MoveNext ())
yield return null;
}
[/csharp]
Demo
Run the scene. As you change turns, notice that the mana indicator will update. Try to play a card that has a mana cost which is greater than the amount of mana you have available. You should see it return to your hand instead of being played. Then try to play a card that you do have enough mana for. This time the action should succeed. You can still play more than one card in a turn, as long as you have enough mana for each.
Summary
In this lesson we expanded on the features of our action system so that all game actions would be validated, and so that we could run through a cancel phase as needed. These served an important role in the concept of a user’s mana, where we made this “stat” a constraint on the ability to play cards based on the cost of the card itself.
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!