Skip to content

Latest commit

 

History

History
635 lines (438 loc) · 20.2 KB

C10.md

File metadata and controls

635 lines (438 loc) · 20.2 KB

Outline | Previous: Ladders | Next: Character Animations

10) Hammer

Add Hammers around the level which when picked up may be used to smash enemies.

10.1) Create a Hammer

YouTube | Source before | Source after

Create a Hammer prefab and then layout several in the world.

How

Create WeaponHolder:

using UnityEngine;

public class WeaponHolder : MonoBehaviour
{
  public GameObject currentWeapon;
}


Create Weapon:

  • Create script Code/Weapons/Weapon:
using UnityEngine;

public class Weapon : MonoBehaviour
{
  Quaternion rotationWhenEquip;

  [SerializeField]
  Vector2 positionWhenEquip = new Vector2(.214f, .17f);

  [SerializeField]
  Vector3 rotationWhenEquipInEuler = new Vector3(0, 0, -90);

  [SerializeField]
  MonoBehaviour[] componentListToEnableOnEquip;

  WeaponHolder currentHolder;

  protected void Awake()
  {
    rotationWhenEquip = Quaternion.Euler(rotationWhenEquipInEuler);
  }

  protected void OnDestroy()
  {
    if(currentHolder != null)
    {
      currentHolder.currentWeapon = null;
    }
  }

  protected void OnTriggerEnter2D(
    Collider2D collision)
  {
    WeaponHolder holder = collision.GetComponent<WeaponHolder>();
    if(holder != null
      && currentHolder == null
      && holder.currentWeapon == null)
    {
      currentHolder = holder;
      currentHolder.currentWeapon = gameObject;

      transform.SetParent(currentHolder.transform);
      transform.localRotation = rotationWhenEquip;
      transform.localPosition = positionWhenEquip;

      for(int i = 0; i < componentListToEnableOnEquip.Length; i++)
      {
        MonoBehaviour component = componentListToEnableOnEquip[i];
        component.enabled = true;
      }
    }
  }
}


Configure entities:

  • Add WeaponHolder to the Character.
  • Select the SpikeBall and HoverGuy prefabs:
    • Add DeathEffectSpawn:
      • GameObject to spawn: Explosion


Create Hammer:

  • Change the sprite's pivot to Bottom. We are using Art/Hammer.
  • Add a Hammer to the scene:
    • Scale to about .2
    • Add a PolygonCollider2D:
      • Check Is Trigger.
    • Add FadeInThenEnable.
    • Add SuicideIn:
      • Time Till Death: 10
      • Disable the component.
    • Add KillOnContactWith:
      • Layers to kill: Enemy
      • Disable the component.
    • Add Weapon:
      • Add SuicideIn and KillOnContactWith components to the list 'To Enable On Equip'.
  • Create a prefab.
  • Add several Hammers and lay them out for the level.
  • Create a parent "Hammers" and add each of the Hammers.


Test:

  • When the game starts, the hammers should fade in. Touch one to pick it up, and then hit an enemy with it. The enemy should die with an explosion. After a few seconds, the hammer should disappear.


Explain the code

TODO

using UnityEngine;

public class WeaponHolder : MonoBehaviour
{
  public GameObject currentWeapon;
}


Weapon:

using UnityEngine;

public class Weapon : MonoBehaviour
{
  [SerializeField]
  Vector2 positionWhenEquip = new Vector2(.214f, .17f);

  [SerializeField]
  Vector3 rotationWhenEquipInEuler = new Vector3(0, 0, -90);

  [SerializeField]
  MonoBehaviour[] componentListToEnableOnEquip;

  WeaponHolder currentHolder;

  protected void OnDestroy()
  {
    if(currentHolder != null)
    {
      currentHolder.currentWeapon = null;
    }
  }

  protected void OnTriggerEnter2D(
    Collider2D collision)
  {
    WeaponHolder holder = collision.GetComponent<WeaponHolder>();
    if(holder != null && currentHolder == null && holder.currentWeapon == null)
    {
      currentHolder = holder;
      currentHolder.currentWeapon = gameObject;

      transform.SetParent(currentHolder.transform);
      transform.localPosition = positionWhenEquip;
      transform.localRotation = Quaternion.Euler(rotationWhenEquipInEuler);

      for(int i = 0; i < componentListToEnableOnEquip.Length; i++)
      {
        MonoBehaviour component = componentListToEnableOnEquip[i];
        component.enabled = true;
      }
    }
  }
}

Why use pivot bottom?

We will be equipping the hammer on the character and have him swing. Moving the pivot point to bottom sets it to approximately where the character will grip the hammer.

When rotating the hammer for a swing, the bottom pivot causes the bottom of the handle to keep its position while the hammer's head swings. The default middle pivot would create equal motion at the hammer's head and the base of the hammer's handle.


Why use a polygon collider and not a box or capsule?

You could.

The hammer's shape does not match either a Box or Capsule collider. If you were to use one of those, the difference between the collider and the sprite art could be great enough that collisions in the game feel wrong. e.g., you may miss picking up a hammer you thought you got or not kill an enemy you clearly hit.

The hammer's shape could be approximated well by using 2 box colliders. A polygon collider does require more processing time, although not a significant difference, so this may be a potential optimization worth the tradeoff sacrificing some precision on collisions.


Why use Is Trigger?

When the character jumps for the hammer to pick it up, we do not want the character to bounce off of it. The collider used on the hammer when the hammer is a pick up item shouldn't respond to anything expect equipping when the character touches it. This is best achieved with 'Is Trigger'.


Why not simply sum the time used in WaitForSeconds instead of max with deltaTime?

In the following example, we are requesting the coroutine sleep for a period of time:

yield return new WaitForSeconds(timePerColorChange);
timePerColorChange = Mathf.Max(Time.deltaTime, timePerColorChange);

Unity does not make any guarantee that the amount of time before the coroutine resumes aligns with the wait time requested. If we request a near zero time to wait, Unity will wait for a single frame -- we want to ensure that the effect progresses by at least that amount of time as well.

Additionally, this simplistic algorithm may drive the variable timePerColorChange to zero. If that number got small enough, the loop would never terminate. Ensuring that we progress by at least deltaTime each frame ensures that the loop will end.

Alternatively this method could be rewritten to use Time.timeSinceLevelLoaded. With that we do not need to sum each iteration but instead can make decisions based off of the current time vs the time the effect began.


Why use GetComponentsInChildren instead of a single sprite?

Flexibility. Some use cases would work with GetComponent or GetComponentInChildren. We get all the sprites in this GameObject and its children, and then update all so if something is composed of multiple sprites this script just works.


Could we reset the timer instead of preventing a second pickup?

Yes, in fact that would better match how most games would implement this feature. There are various ways, as always, to achieve this. For example when the character touches a second hammer, you could:

  • Destroy the first and then simply allow the second to play out. However the animation of the hammer swing may visibly skip.
  • Reset the SuicideIn countdown and Destroy the second hammer.

Why serialize the rotation as Vector3 instead of Quaternion?

Quaternions are confusing for people. This is why the Transform rotation is modified in the Inspector as an Euler. Unfortunately when you ask Unity to expose a Quaternion in the Inspector it appears as X, Y, Z, W and not the Euler X, Y, Z like they did for the Transform.

You could switch to Quaternion, and it would be slightly more performant that way. But I recommend using Euler, in case you ever want to modify the rotation used.


What's localPosition / localRotation and how do they differ from position / rotation?

When modifying the Transform position - you can do so with either .position or .localPosition. When the GameObject is a child of another GameObject these methods differ; they do the same thing when the GameObject has no parent.

  • .position: Sets the Transform position so that the GameObject appears at that location after considering the parent's Transform (position, rotation, and scale).
  • .localPosition: Sets the Transform position to the value specified. If the GameObject has a parent, the parent's Transform will impact the final position you see in the scene.

10.2) Hammer Animation

YouTube | Source before | Source after

Create an animation for the hammer swinging.

How

Create animation:

  • Open menu Window -> Animation (not the Animator).
  • Select a Hammer.
  • Click create, save as Animations/HammerSwing.anim
  • Click the red record button.
  • Click on 1:00, the white line should move.
  • Modify the rotation, then set it back to 0, creating two keyframes for the default rotation.
  • Double click under 1:00 to create another keyframe.
  • Switch the current time position (the white line) to 0:10.
  • Change rotation to (0, 0, -90).
  • Click record to stop recording and close the Animation window.
  • Apply changes to the Hammer prefab.


Test:

  • All of the hammers should start swinging as soon as they appear. We will fix that next.


Why use a 1:00, what if I want to speed up the animation?

Unity offers a few different ways you could speed up an animation. They are all valid, use what you are comfortable with.

I prefer to get the sequence and relative timing for animation correct using the Animation timeline, and then using the Animator Controller state to modify the playback speed for that animation. As animations get more complex, making updates to the animation timeline is more tedious which is why I prefer using the 'speed' field.


How do keyframes work / what happens between keyframes?

A keyframe is a datapoint on the timeline. Between each keyframe, Unity will smoothly transition from the previous keyframe to the next. If you open the "Curves" tab you can see a graph showing how this transition occurs, and you make make modifications there directly.


10.3) Start Swinging on Equip

YouTube | Source before | Source after

Add a script to the hammer to start the swing animation when it's equip.

How

Stop swinging by default:

  • Select a Hammer.
  • Open menu Window -> Animator (not the Animation).
    • Right click -> Create State -> Empty.
    • Select the box which appeared and in the Inspector name it "Idle".
    • Right click "Idle" and 'Set as Layer Default State'.


Create PlayAnimationOnEnable:

using UnityEngine;

public class PlayAnimationOnEnable : MonoBehaviour
{
  [SerializeField]
  string animationToPlay;

  Animator animator;

  protected void Awake()
  {
    animator = GetComponentInChildren<Animator>();
  }

  protected void OnEnable()
  {
    animator.Play(animationToPlay);
  }
}


Configure Hammer:

  • Select a Hammer:
    • Add PlayAnimationOnEnable.
      • Animation to play: "HammerSwing"
      • Disable the PlayAnimationOnEnable component.
    • Add the PlayAnimationOnEnable component to the Weapon component's 'To Enable' list.
    • Click 'Apply' to update the prefab


Test:

  • The hammers should not move until picked up by the Character. Then they swing until it disappears a few moments later.


Does the name matter?

The name of the state serves two purposes:

  • You can reference that state by name from code; for example, to have a script change the current state like we do above.
  • It acts like a comment, making it easier to maintain the animator state machine.

How does animator.Play work?

Calling Play on the animator will interrupt the current animation, if there is one, and start playing the one requested. You pass the name of the Animator State from its Animator Controller, which in turn has a reference to the animation clip to play. Any parameters defined in the animator state apply, including Speed.


10.4) Flash

YouTube | Source before | Source after

Add a script to the Hammer to make it flash before it's gone.

How

Create DeathEffectFlash:

using System.Collections;
using UnityEngine;

public class DeathEffectFlash : DeathEffect
{
  [SerializeField]
  float lengthToFlashFor = 5;

  [SerializeField]
  float timePerColorChange = .75f;

  [SerializeField]
  float colorChangeTimeFactorPerFlash = .85f;
  
  public override float PlayDeathEffects()
  {
    StartCoroutine(FlashToDeath());

    return lengthToFlashFor;
  }

  IEnumerator FlashToDeath()
  {
    SpriteRenderer[] spriteList
      = GetComponentsInChildren<SpriteRenderer>();
    float timePassed = 0;
    bool isRed = false;
    while(timePassed < lengthToFlashFor)
    {
      SetColor(spriteList, isRed ? Color.red : Color.white);
      isRed = !isRed;

      yield return new WaitForSeconds(timePerColorChange);
      timePerColorChange = Mathf.Max(Time.deltaTime, timePerColorChange);
      timePassed += timePerColorChange;
      timePerColorChange *= colorChangeTimeFactorPerFlash;
    }
  }

  void SetColor(
    SpriteRenderer[] spriteList,
    Color color)
  {
    for(int i = 0; i < spriteList.Length; i++)
    {
      SpriteRenderer sprite = spriteList[i];
      sprite.color = color;
    }
  }
}


Configure hammers:

  • Select a Hammer:
    • Add DeathEffectFlash (which automatically adds DeathEffectManager).
    • Click 'Apply' to update the prefab.


Test:

  • Each of the hammers, when picked up, should animate like normal for a few seconds and then flash shortly before they disappear.

Explain the code

'using' clauses at the top of a file brings APIs into scope. Used for:

  • System.Collections.IEnumerator
  • UnityEngine.Color
  • UnityEngine.Mathf
  • UnityEngine.SerializeFieldAttribute
  • UnityEngine.SpriteRenderer
  • UnityEngine.WaitForSeconds
using System.Collections;
using UnityEngine;

We inherit from DeathEffect which is a MonoBehaviour, which allows this script to be added as a component on a GameObject.

public is optional here. Used for consistency.

public class DeathEffectFlash : DeathEffect
{

This is a Unity-specific attribute that exposes a field in the Inspector, allowing you to configure it for the object.

  [SerializeField]

This defines how long the effect lasts for. You can change the default in the Inspector.

  float lengthToFlashFor = 5;

This defaults the initial time between color changes. You can change the default in the Inspector.

  [SerializeField]
  float timePerColorChange = .75f;

This defines a multiple which is used to reduce the time between flashes. You can change the default in the Inspector.

  [SerializeField]
  float colorChangeTimeFactorPerFlash = .85f;

This overrides DeathEffect's method which will be called when this GameObject dies.

  public override float PlayDeathEffects()
  {

Here we start a coroutine which will play out the effect. This call returns promptly, Unity will then play back the coroutine over a period of time.

    StartCoroutine(FlashToDeath());

We then return how long until this effect is expected to be complete. This tells the DeathEffectManager how long until the GameObject may be destroyed.

    return lengthToFlashFor;
  }

This is the coroutine to play out the death effect.

  IEnumerator FlashToDeath()
  {

Here we get a list of all the sprites on this GameObject or its children.

    SpriteRenderer[] spriteList
      = GetComponentsInChildren<SpriteRenderer>();

Here we track the time passed since the effect started and loop until it has run for the desired length of time.

    float timePassed = 0;
    bool isRed = false;
    while(timePassed < lengthToFlashFor)
    {

This uses a helper method defined below to change the color of each of the sprites found. It starts as white, which is the default for sprites. Then each loop it switches between red and white.

      SetColor(spriteList, isRed ? Color.red : Color.white);
      isRed = !isRed;

This pauses the coroutine for a period of time before the next color change.

      yield return new WaitForSeconds(timePerColorChange);

Here we ensure that the delta time is at least one frame. For example if we requested to wait for 1 ms, Unity would actually wait for 1 frame or about 16 ms.

Then take that time and add it to the total timePassed since the effect started.

      timePerColorChange = Mathf.Max(Time.deltaTime, timePerColorChange);
      timePassed += timePerColorChange;

This reduces the timePerColorChange, causing the flash to speed up as the effect gets closer to the end.

      timePerColorChange *= colorChangeTimeFactorPerFlash;
    }
  }

This is a helper method to set the color on a list of sprites.

  void SetColor(
    SpriteRenderer[] spriteList,
    Color color)
  {

Here we loop ever each sprite in the list.

    for(int i = 0; i < spriteList.Length; i++)
    {
      SpriteRenderer sprite = spriteList[i];

Assign the color to the sprite.

      sprite.color = color;
    }
  }
}

To Review

Testing / debugging tips
  • TODO

Up Next

Chapter 11 Character Animations



Questions, issues, or suggestions? Please use the YouTube comments for the best fit section.

Support on Patreon, with Paypal, or by subscribing on Twitch (free with Amazon Prime).

License. Created live at twitch.tv/HardlyDifficult August 2017.