Skip to content

Latest commit

 

History

History
979 lines (704 loc) · 25.6 KB

C13.md

File metadata and controls

979 lines (704 loc) · 25.6 KB

Outline | Previous: Intro Timeline | Next: UI

13) Level Won Timeline

Create an animated sequence to play when the player beats level 1.

13.1) Win Condition

YouTube | Source before | Source after

The goal of the game is to save the beautiful mushroom. For level 1, that means getting close - but before you actually reach it the EvilCloud is going to carry the mushroom up to level 2.

Here we detect the end of the game, the cloud animation will be added later in the tutorial.

How

Create TouchMeToWin:

using UnityEngine;

public class TouchMeToWin : MonoBehaviour
{
  static int totalNumberActive;

  [SerializeField]
  Behaviour componentToEnableOnTouch;

  [SerializeField]
  LayerMask touchableLayers;
  
  protected void OnEnable()
  {
    totalNumberActive++;
  }

  protected void OnDisable()
  {
    totalNumberActive--;
  }

  protected void OnTriggerEnter2D(
    Collider2D collision)
  {
    if(enabled == false
      || touchableLayers.Includes(
        collision.gameObject.layer) == false)
    {
      return;
    }

    if(componentToEnableOnTouch != null)
    {
      componentToEnableOnTouch.enabled = true;
    }

    enabled = false;
    if(totalNumberActive == 0)
    {
      GameObject.FindObjectOfType<LevelController>().YouWin();
    }
  }
}


Design the win area:

  • Create a Layer "CharacterOnly":
    • Configure the collision matrix to only support CharacterOnly <-> Character collisions.
  • Create an empty GameObject named "WinArea".
    • Layer: CharacterOnly
    • Add TouchMeToWin:
      • Touchable Layers: Character
    • Add a BoxCollider2D
      • Move the GameObject and size the collider so it covers the area that when entered will end the level.
      • Check Is Trigger.
    • Add a sprite to lure the character to the win area. We are using Art/jumperpack_kenney/PNG/Environment/mushroom_red.
      • Order in Layer: -5
      • Make it a child of the WinArea.


Test:

  • When the Character reaches the win area, the message YouWin should appear in the Console window.


Explain the code

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

  • UnityEngine.Behaviour
  • UnityEngine.Collider2D
  • UnityEngine.GameObject
  • UnityEngine.LayerMask
  • UnityEngine.MonoBehaviour
  • UnityEngine.SerializeFieldAttribute
using UnityEngine;

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

public is optional here. Used for consistency.

public class TouchMeToWin : MonoBehaviour
{

This field is static, which means the data is shared across all instances of TouchMeToWin in the game.

This tracks how many are waiting to be touched before the level is won.

  static int totalNumberActive;

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

  [SerializeField]

This optionally holds a reference to a component which should be enabled when this was touched. Set in the Inspector.

  Behaviour componentToEnableOnTouch;

This defines which GameObjects to accept touches from by their layer.

  [SerializeField]
  LayerMask touchableLayers;

OnEnable is a Unity event which is called each time the component is enabled.

protected is optional here. Used for consistency.

  protected void OnEnable()
  {

Here we add one to the total number of active touchable regions.

    totalNumberActive++;
  }

OnDisabel is a Unity event which is called each time the component is disabled or the GameObject is destroyed.

protected is optional here. Used for consistency.

  protected void OnDisable()
  {

Here we subtract one from the total number of active touchable regions.

    totalNumberActive--;
  }

OnTriggerEnter2D is a Unity event which is called when a collider from another GameObject first begins to overlap a collider on this GameObject.

The collision variable here represents the collider on the other GameObject.

  protected void OnTriggerEnter2D(
    Collider2D collision)
  {

If this component has been disabled, or if the GameObject we hit is not part of the touchableLayers defined in the Inspector, then this script does nothing.

    if(enabled == false
      || touchableLayers.Includes(
        collision.gameObject.layer) == false)
    {
      return;
    }

If a component to enable when this is touched was defined in the Inspector, enable it now.

    if(componentToEnableOnTouch != null)
    {
      componentToEnableOnTouch.enabled = true;
    }

Here we disable this component to ensure that we do not run the logic in this method a second time.

This will also call OnDisable above, decrementing the totalNumberActive.

    enabled = false;

Check if this touch was the last one in the scene required to win the level.

    if(totalNumberActive == 0)
    {

Find the LevelController component in the scene and call its YouWin method.

      GameObject.FindObjectOfType<LevelController>().YouWin();
    }
  }
}

13.2) Win Timeline

YouTube | Source before | Source after

When the character reaches the win area, play a Timeline to animate the end of the level.

How

Win animation:

  • Create another animation for the EvilCloud to play when the player wins, Animations/CloudLevel1Exit.
    • FYI: you may not be able to record if the Timeline Editor window is open.
    • Select Animations/CloudLevel1Exit and disable Loop Time.


Win Timeline:

  • Right click in Assets/Animations -> Create -> Timeline named Level2Exit.
    • Select the EvilCloud's sprite GameObject:
      • Playable: Level2Exit
  • In the Timeline Editor window:
    • 'Add' an 'Animation Track'
      • Select the EvilCloud's child GameObject.
  • Right click in the timeline and 'Add Animation From Clip'
    • Select CloudLevel1Exit.
  • Select the box which appeared for the animation
    • Adjust the speed (hit play to preview).


Add the Mushroom:

  • Select the mushroom GameObject and drag it into the timeline.
    • Select Activation Track.
    • Adjust the timeframe so that it starts at the beginning of the timeline and ends when you want the mushroom to disappear.
    • Select the track's row:
      • Post-playback state: Inactive
  • Change the cloud's Playable back to Level1Entrance.


Update LevelController:

Existing code
using UnityEngine;

using UnityEngine.Playables; 
Existing code
public class LevelController : MonoBehaviour
{
  [SerializeField]
  GameObject playerPrefab;

  protected bool isGameOver;

  [SerializeField]
  PlayableDirector director; 

  [SerializeField]
  PlayableAsset youWinPlayable; 
Existing code
  [SerializeField]
  int levelNumber = 1; 

  protected void OnEnable()
  {
    GameController.instance.onLifeCounterChange
      += Instance_onLifeCounterChange;

    StartLevel();
  }
  
  protected void OnDisable()
  {
    GameController.instance.onLifeCounterChange
      -= Instance_onLifeCounterChange;
  }

  void Instance_onLifeCounterChange()
  {
    if(isGameOver)
    {
      return;
    }

    BroadcastEndOfLevel();
 
    if(GameController.instance.lifeCounter <= 0)
    {
      isGameOver = true;
      YouLose();
    }
    else
    {
      StartLevel();
    }
  }

  public void YouWin()
  {
    if(isGameOver == true)
    {
      return;
    }

    isGameOver = true;

    director.Play(youWinPlayable); 
Existing code
    DisableComponentsOnEndOfLevel[] disableComponentList 
      = GameObject.FindObjectsOfType<DisableComponentsOnEndOfLevel>();  
    for(int i = 0; i < disableComponentList.Length; i++)
    {
      DisableComponentsOnEndOfLevel disableComponent = disableComponentList[i];
      disableComponent.OnEndOfLevel();
    }
  }

  void StartLevel()
  {
    Instantiate(playerPrefab);
  }

  void BroadcastEndOfLevel()
  {
    PlayerDeathMonoBehaviour[] gameObjectList 
      = GameObject.FindObjectsOfType<PlayerDeathMonoBehaviour>();
    for(int i = 0; i < gameObjectList.Length; i++)
    {
      PlayerDeathMonoBehaviour playerDeath = gameObjectList[i];
      playerDeath.OnPlayerDeath();
    }

  }

  void YouLose()
  {
    // TODO
    print("YouLose");
  }
}


Configure LevelController:

  • Select the LevelController GameObject:
    • Select the director
    • YouWinPlayable: Level1Exit


Test:

  • When you win, the exit animation should play out.
    • It may end with the Cloud popping back to its original position.
    • In the next couple sections we will stop entities from moving during the animation and transition to level 2.


Explain the code

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

  • UnityEngine.Playables.PlayableAsset
  • UnityEngine.Playables.PlayableDirector
using UnityEngine.Playables; 

This holds a reference to the director which owns the playable selected below. Set in the Inspector.

  [SerializeField]
  PlayableDirector director; 

A reference to the playable timeline to use when the player wins. Set in the Inspector.

  [SerializeField]
  PlayableAsset youWinPlayable; 

Here we tell the Playable Director on the EvilCloud to start the Timeline created to play when you win.

    director.Play(youWinPlayable); 

Why switch the Playable when editing Timelines?

Unity 2017 is the first release of Timeline, it's still a work in progress.

At the moment you cannot edit Timelines unless they are active in the scene. You can only partially view the Timeline by selecting the file. So anytime you want to modify the Level1Exit Timeline, you need to change the Playable Director and then when you are complete change it back.

On a related note, you can't edit an animation if the Timeline window is open. When working with Animations and Timelines, it seems to work best if you only have one open at a time.


13.3) Stop Everything When the Level is Over

YouTube | Source before | Source after

When the level is over, stop the spawners and freeze the character and enemies while the win timeline plays.

How

Create DisableComponentsOnEndOfLevel:

using UnityEngine;

public class DisableComponentsOnEndOfLevel : MonoBehaviour
{
  [SerializeField]
  Component[] componentsToDisable;

  public void OnEndOfLevel()
  {
    for(int i = 0; i < componentsToDisable.Length; i++)
    {
      Component component = componentsToDisable[i];
      if(component is Rigidbody2D)
      {
        Rigidbody2D myBody = (Rigidbody2D)component;
        myBody.simulated = false;
      }
      else if(component is Behaviour)
      {
        Behaviour behaviour = (Behaviour)component;
        behaviour.enabled = false;
        if(behaviour is MonoBehaviour)
        {
          MonoBehaviour monoBehaviour = (MonoBehaviour)behaviour;
          monoBehaviour.StopAllCoroutines();
        }
      }
      else
      {
        Destroy(component);
      }
    }
  }
}


Configuration:

  • Select the Character:
    • Add DisableComponentsOnEndOfLevel and to the components list, add:
      • Its Rigidbody2D.
      • Its PlayerController.
      • The character's animator (which is on the child GameObject). You can do this by:
        • Open a second Inspector by right click on the Inspector tab and select Add Tab -> Inspector.
        • With the Character's parent GameObject selected, hit the lock symbol in one of the Inspectors.
        • Select the character's child sprite, then drag the Animator from one Inspector into the other.
  • Unlock the Inspector.
  • Select the HoverGuy prefab.
    • Add DisableComponentsOnEndOfLevel:
      • Component list: Rigidbody2D, Animator, FadeInThenEnable.
  • Select the SpikeBall prefab.
    • Add DisableComponentsOnEndOfLevel:
      • Component list: Rigidbody2D.
  • For each the EvilCloud and the Door:
    • Add DisableComponentsOnEndOfLevel
      • Component list: Spawner.


Update LevelController:

  • Update Code/Controllers/LevelController:
Existing code
using UnityEngine;

public class LevelController : MonoBehaviour
{
  [SerializeField]
  GameObject playerPrefab;

  protected bool isGameOver;

  [SerializeField]
  int levelNumber = 1; 

  protected void OnEnable()
  {
    GameController.instance.onLifeCounterChange
      += Instance_onLifeCounterChange;

    StartLevel();
  }
  
  protected void OnDisable()
  {
    GameController.instance.onLifeCounterChange
      -= Instance_onLifeCounterChange;
  }

  void Instance_onLifeCounterChange()
  {
    if(isGameOver)
    {
      return;
    }

    BroadcastEndOfLevel();
 
    if(GameController.instance.lifeCounter <= 0)
    {
      isGameOver = true;
      YouLose();
    }
    else
    {
      StartLevel();
    }
  }

  public void YouWin()
  {
    if(isGameOver == true)
    { 
      return;
    }

    isGameOver = true;

    director.Play(TimelineEventPlayable);

    DisableComponentsOnEndOfLevel[] disableComponentList 
      = GameObject.FindObjectsOfType<DisableComponentsOnEndOfLevel>();  
    for(int i = 0; i < disableComponentList.Length; i++)
    {
      DisableComponentsOnEndOfLevel disableComponent = disableComponentList[i];
      disableComponent.OnEndOfLevel();
    }
Existing code
  }

  void StartLevel()
  {
    Instantiate(playerPrefab);
  }

  void BroadcastEndOfLevel()
  {
    PlayerDeathMonoBehaviour[] gameObjectList 
      = GameObject.FindObjectsOfType<PlayerDeathMonoBehaviour>();
    for(int i = 0; i < gameObjectList.Length; i++)
    {
      PlayerDeathMonoBehaviour playerDeath = gameObjectList[i];
      playerDeath.OnPlayerDeath();
    }
  }

  void YouLose()
  {
    // TODO
    print("YouLose");
  }
}


Test:

  • When you win the level:
    • The Character, HoverGuys, and SpikeBalls should freeze.
    • The spawners should stop spawning.



Explain the code

DisableComponentsOnEndOfLevel:

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

  • UnityEngine.Behaviour
  • UnityEngine.Component
  • UnityEngine.MonoBehaviour
  • UnityEngine.Rigidbody2D
  • UnityEngine.SerializeFieldAttribute
using UnityEngine;

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

public is optional here. Used for consistency.

public class DisableComponentsOnEndOfLevel : MonoBehaviour
{

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

  [SerializeField]

This is an array of references to other components which should be disabled when the level ends.

  Component[] componentsToDisable;

This method will be called by the LevelManager when the level ends.

This method is public so that the LevelManager may call it.

  public void OnEndOfLevel()
  {

Loop over each of the components which should be disabled at the end of the level.

    for(int i = 0; i < componentsToDisable.Length; i++)
    {
      Component component = componentsToDisable[i];

Check if the component is a rigidbody.

      if(component is Rigidbody2D)
      {
        Rigidbody2D myBody = (Rigidbody2D)component;

To disable a rigidbody, we set the simulated bool to false.

        myBody.simulated = false;
      }

Check if the component is a Behaviour, which includes all MonoBehaviours and therefore all the custom scripts we have written.

      else if(component is Behaviour)
      {
        Behaviour behaviour = (Behaviour)component;

To disable a behaviour, we set the enabled bool to false.

        behaviour.enabled = false;

Check if the component is also a MonoBehaviour (vs another kind of Behaviour such as an Animator).

        if(behaviour is MonoBehaviour)
        {
          MonoBehaviour monoBehaviour = (MonoBehaviour)behaviour;

In addition to disabling the MonoBehaviour, we stop any coroutines which are currently running.

          monoBehaviour.StopAllCoroutines();
        }
      }

This section handles any other component types.

      else
      {

For any other component type, disable it by destroying the component.

        Destroy(component);
      }
    }
  }
}


LevelController:

This searches the scene for all the DisableComponentsOnEndOfLevel components.

    DisableComponentsOnEndOfLevel[] disableComponentList 
      = GameObject.FindObjectsOfType<DisableComponentsOnEndOfLevel>();  

Loop over each of the components found.

    for(int i = 0; i < disableComponentList.Length; i++)
    {
      DisableComponentsOnEndOfLevel disableComponent = disableComponentList[i];

For each component, call teh OnEndOfLevel method for it to react to the level ending.

      disableComponent.OnEndOfLevel();
    }

Why not just set timeScale to 0?

You could, but some things would need to change a bit.

We don't want everything to pause. The EvilCloud animation needs to progress. If you change the timeScale, you will need to modify the Animators to use Unscaled time -- otherwise the animations would not play until time resumed.


Why not just destroy all the components instead?

Destroying a component is an option. Once destroyed, that component stops but the rest of the GameObject is still in-tact.

Errors occur if we attempt to destroy the components mentioned above due to other components requiring the ones we removed. If we wanted to switch to destroying components instead, we would need to be more selective in which components are included to avoid dependency issues. Because of this, it's simpler to disable than destroy.


What's rigidbody simulated=false do?

Setting simulated to false on the rigidbody effectively disables the component. The rigidbody does not support an 'enabled' flag like scripts do - 'simulated' is their equivalent.


What's the lock symbol do?

Many of the windows in Unity have a lock symbol in the top right. Clicking this will freeze the selection for that window. So if you select a GameObject you can freeze the Inspector, allowing you to continue navigating other files while still having that same GameObject's properties displayed in the Inspector.

This is handy for various things such as above where we want one GameObject to reference another GameObject's component. Open two Inspectors, select the first GameObject and lock one of the Inspector windows... now you can select the other GameObject and you have one Inspector for each.


13.4) Transition Scenes

YouTube | Source before | Source after

After the level ends, load level 2.

How

Create ChangeScenePlayable:

  • Create script Code/Animations/ChangeScenePlayable:
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;

public class ChangeScenePlayable : BasicPlayableBehaviour
{
  [SerializeField]
  string sceneNameToLoad;

  public override void OnBehaviourPlay(
    Playable playable, 
    FrameData info)
  {
    base.OnBehaviourPlay(playable, info);

    SceneManager.LoadScene(sceneNameToLoad);
  }
}


Configure builds:

  • Add scene to build settings with menu File -> Build Settings.
    • Click "Add Open Scenes" to add the current scene (level 1).
  • Create a new scene with File -> New Scene.
    • Save it as Assets/Scenes/Level2.
    • Add level 2 to the Build Settings.
  • Double click Assets/Scenes/Level1 to return to that scene.


Configure timeline:

  • Change the EvilCloud Director to Level1Exit and open the Timeline.
    • Drag the ChangeScenePlayable script into the Timeline.
      • Scene to load: Level2
    • Position it to start after the animation completes. The size of the box does not matter.
  • Change the EvilCloud Director back to Level1Entrance.


Test:

  • When you win the level, after the end sequence plays the scene should change and you will see nothing but a blue screen.


Explain the code

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

  • UnityEngine.Playables.FrameData
  • UnityEngine.Playables.Playable
  • UnityEngine.SceneManagement.SceneManager
  • UnityEngine.SerializeFieldAttribute
  • UnityEngine.Timeline.BasicPlayableBehaviour
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;

We inherit from BasicPlayableBehaviour which allows this script to be added to as an event in a Timeline.

public is optional here. Used for consistency.

public class ChangeScenePlayable : BasicPlayableBehaviour
{

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

  [SerializeField]

This defines which scene will be loaded when this behaviour appears in the Timeline. Set in the Inspector.

  string sceneNameToLoad;

OnBehaviourPlay is a Unity event which is called when this script begins in a Timeline.

We are going to ignore the parameters here.

  public override void OnBehaviourPlay(
    Playable playable, 
    FrameData info)
  {

As a general best practice when override methods, we call the base first so that this script supplements instead of replaces any logic the basic class may have for this method.

    base.OnBehaviourPlay(playable, info);

This loads the next scene.

Loading a scene first destroys all the GameObjects in this scene and then adds GameObjects for the next scene.

    SceneManager.LoadScene(sceneNameToLoad);
  }
}

Why not use just one scene for the game?

You could. But I would not advise it.

Using multiple scenes, one for each level, makes it easy to customize the layout and behaviour for the level. Technically this could all be achieved in a single scene but that could make level design confusing.

GameObjects which are shared between levels can use a prefab so that they have a common definition. With a prefab, you can make a modification and have that change impact every instance. You can also override a setting from a prefab for a specific use case, such as making enemies move faster in level 2.


What's SceneManager.LoadScene do?

Calling LoadScene will Destroy every GameObject in the scene, except for any which are DontDestroyOnLoad like our GameController, and then loads the requested scene.

The scenes available to load are defined in Build Settings. You must add scenes you want to load there. Once in Build Settings you can load a scene by its filename, as we do here ('Level2'), or you can load by index (the order of the scene in build settings.)


To Review

Testing / debugging tips
  • TODO

Up Next

Chapter 14 UI



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.