Time to take a look at our player shields system. So far, it’s been pretty simple. A single strike or a five second timeout takes it away. So today, we’ll add a bit more life to them. First, we’ll allow three strikes with a visual indicator that it’s been hit. Second, we’ll extend the time limit and finally, we’ll give a visual indicator the time limit is about to expire.
First things first. I’ve never liked that my shield code was embedded in the Player.cs script. The shields have their own dedicated game object, so it seems most sensible to me that it have its own script and manage its own behavior. So the first thing we’ll do is create a new C# script and call it Shields
. We can create the script, then select the Shields game object in our Hierarchy view then drag and drop our script into the inspector.
The next thing we need to do is recreate the previous behavior using the new script. The bulk of the behavior involves a couple variables and two methods. The two methods are the TakeDamage()
method which checks to see if the shields are active before applying damage to the player and the second is a coroutine used to disable the shield after a prescribed timeout.
[SerializeField] private float shieldsUpTimer = 5f;
[SerializeField] private bool shieldsUp = false;
[SerializeField] private GameObject shieldAnim;
private void TakeDamage()
{
if (shieldsUp)
{
shieldsUp = false;
shieldAnim.SetActive(false);
return;
}
playerLives--;
uiManager.CurrentLives(playerLives); // report current lives count to dashboard
if (playerLives < 1) { DeathScene(); return; }
fireEngineAnims[ChooseEngine].SetActive(true); // damage animation
}
private IEnumerator PowerUpShield()
{
shieldsUp = true;
shieldAnim.SetActive(true);
yield return new WaitForSeconds(shieldsUpTimer);
shieldsUp = false;
shieldAnim.SetActive(false);
}
So the first thing our new script will need is some sort of boolean that can be set and queried to see if the shield is active or not. There are several ways to do something like this, but to me, it sounds like a property. In C# this can be done with a get/set syntax.
[SerializeField] private bool isActive = false;
public bool IsActive
{
get { return isActive; }
set { isActive = value; }
}
Notice this is a two parter. First is the private variable isActive
. This should never be accessed directly, not even in the Shield.cs
code, except in the variable’s setter. The [SerializedField] attribute is not even necessary, I just tend to make a habit of exposing most variables in the inspector for debugging purposes. The second part is the get/set method IsActive
. Notice the change in the initial capitalization i/I. This is by convention but can be a source of pain if you don’t pay attention. The private variable is Camel Cased (initial lower-case letter) and the getter/setter method is Pascal Cased (initial upper-case letter).
Now, we already know from our player code that we need to do a few things upon setting our isActive
variable. Thus, our setter can be used to ensure those things happen. So let’s expand it right now.
[SerializeField] private bool isActive = false;
public bool IsActive
{
get { return isActive; }
set
{
switch (value)
{
case false:
isActive = value;
gameObject.SetActive(value);
break;
case true:
isActive = value;
gameObject.SetActive(value);
break;
}
}
}
We know either way, we need to set the private variable, so both branches of our switch statement should include that. But we also know form our Player code we need to actually disable/enable our Shield animation itself. So we also need to set gameObject.SetActive()
to the same value.
Now, one might question the need for our isActive
private variable when we already have the built-in SetActive()
method and I’m not sure I could make a very strong argument for the apparent duplication. My main reason is I want to make this a single switch interface. When you disable the shields, everything should happen that needs to. Same on enable. However, I might still be able to do so by just removing the isActive
private variable, so I might tinker with that later and keep the wrapping method. Behind the scenes, C# will still make an automatically generated private variable so while it might make a slight difference in the appearance of the code, I’m not sure it will make a real difference.
When the shields are enabled, we need to start our timer. This is what the coroutine in the Player script currently does. It should not exactly be surprising that we will next move that coroutine from Player to Shields.
[SerializeField] private int shieldsUpTimer = 5;
[SerializeField] private bool isActive = false;
public bool IsActive
{
get { return isActive; }
set
{
switch (value)
{
case false:
isActive = value;
gameObject.SetActive(value);
break;
case true:
isActive = value;
StartCoroutine(ShieldTimer());
gameObject.SetActive(value);
break;
}
}
}
private IEnumerator ShieldTimer()
{
yield return new WaitForSeconds(shieldsUpTimer);
this.IsActive = false;
}
At this point, we need only make a few small changes to our Player code and we should have successfully replicated our current features.
[SerializeField] private Shields shieldAnim;
private void OnTriggerEnter2D(Collider2D other)
{
switch (other.tag)
{
case "Enemy":
TakeDamage();
break;
case "Enemy Laser":
if (other.transform.parent.GetComponent<EnemyFire>().HasHit) { return; }
other.transform.parent.GetComponent<EnemyFire>().HasHit = true;
TakeDamage();
break;
case "TripleShotPU":
if (!tripleShot) { StartCoroutine(PowerUpTripleShot()); }
break;
case "SpeedPU":
if (speedUp == 0) { StartCoroutine(PowerUpSpeed()); }
break;
case "ShieldPU":
if (!shieldAnim.IsActive) { shieldAnim.IsActive = true; }
break;
default:
break;
}
}
private void TakeDamage()
{
if (shieldAnim.IsActive)
{
return;
}
playerLives--;
uiManager.CurrentLives(playerLives); // report current lives count to dashboard
if (playerLives < 1) { DeathScene(); return; }
fireEngineAnims[ChooseEngine].SetActive(true); // damage animation
}
First, our shieldAnim
variable needs to be changed from a generic GameObject
type to type Shields
. Then, all the calls formerly to shieldAnim.Active()
need to be changed to shieldAnim.IsActive
. This calls the new method we created rather than the old GameObject method. Finally, the original call to the coroutine within the Player script is removed from the switch statement in the OnTriggerEnter2D()
method and replaced with simply setting the shieldAnim.IsActive = true
. The original PowerUpShield()
coroutine can now be removed from the Player script.
To add our first new feature, we need to add some components to our Shield game object. Previously, we simply relied on the Player detecting collisions. But in the spirit of every game object managing itself, our shields should detect their own collisions. To do this we have to keep a few things in mind. First, we need to add both RigidBody2D and a BoxCollider2D components with the usual setting Gravity Scale to zero on the RigidBody2D component and Is Trigger to true on the Box Collider. However, we also need to make sure we tag the Shield game object as “Player.” This ensures anything that collides with it reacts in the same way it would to colliding with the Player object. We also need to ensure the box collider for the shield is larger than the collider for the player. Otherwise, the shield will never actually collide with anything. In fact, the Player game object will make the collision.
Obviously, we need to an OnTriggerEnter2D()
method to take advantage of all this. This is basically the same switch statement as in the Player script, minus the PowerUp branches. Just as in the Player OnTriggerEnter2D()
method, if our shield collides with an Enemy ship, it takes damage. If it collides with an Enemy Laser, it manages the “double tap” issue, and then takes damage. We also need to manage the hit counts, so we add that to our IsActive()
getter/setter. Finally, if we cut short the timer by taking three hits, we need to make sure we stop the running coroutine.
[SerializeField] private int hitsLeft = 0;
[SerializeField] private int hitLimit = 3;
[SerializeField] private bool isActive = false;
public bool IsActive
{
get { return isActive; }
set
{
switch (value)
{
case false:
isActive = value;
gameObject.SetActive(value);
StopCoroutine(ShieldTimer());
hitsLeft = 0;
break;
case true:
isActive = value;
gameObject.SetActive(value);
hitsLeft = hitLimit;
StartCoroutine(ShieldTimer());
break;
}
}
}
private void OnTriggerEnter2D(Collider2D other)
{
switch (other.tag)
{
case "Enemy":
TakeDamage();
break;
case "Enemy Laser":
if (other.transform.parent.GetComponent<EnemyFire>().HasHit) { return; }
other.transform.parent.GetComponent<EnemyFire>().HasHit = true;
TakeDamage();
break;
default:
break;
}
}
private void TakeDamage()
{
hitsLeft--;
if (hitLimit < 1) { this.IsActive = false; }
}
Adding the visual indicator to the shield is fairly straight forward. We simply need to change the color based on the current hit count. We need to grab the Sprite Renderer Component and add the following code:
[SerializeField] private SpriteRenderer spriteRenderer;
void Start()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
private void TakeDamage()
{
hitsLeft--;
if (hitsLeft < 1) { this.IsActive = false; }
ShieldColor();
}
private void ShieldColor()
{
switch (hitsLeft)
{
case 3:
spriteRenderer.color = Color.white;
break;
case 2:
spriteRenderer.color = new Color(1f, .37f, 0, 1); // Weird multiplier intended to get a yellowish color.
break;
case 1:
spriteRenderer.color = Color.red;
break;
case 0:
spriteRenderer.color = Color.white;
break;
}
}
The final feature, a warning indicator is a bit messier, but doable. We increase the time limit from five to fifteen. Then when five seconds remain, start flashing. To do this, we add a loop to ShieldTimer()
coroutine. When we hit the five second mark we loop five more times, pausing a half second twice in the loop so we can change the color of our Sprite Renderer. Changing the color black essentially makes it disappear. We pause for another half second and then return to the color appropriate for the current hit count. When we finally exit the loop, we change the color back to white and disable the shield.
[SerializeField] private int shieldsUpTimer = 15;
[SerializeField] private int shieldsWarningTimer = 5;
private IEnumerator ShieldTimer()
{
//
// Set timer
//
yield return new WaitForSeconds(shieldsUpTimer - shieldsWarningTimer);
//
// Warning flash
//
for (int i=0; i < shieldsWarningTimer; i++)
{
spriteRenderer.color = Color.black;
yield return new WaitForSeconds(0.5f);
ShieldColor();
yield return new WaitForSeconds(0.5f);
}
//
// timeout/reset
//
spriteRenderer.color = Color.white;
this.IsActive = false;
}
Easy-peasy…