Sunday, December 14, 2014

How to make a game like Fez in Unity: Part 3


In the final part of this tutorial series, we'll finish building our Fez-like game. Now that we have the basic level and character out of the way, we'll need to add code to handle the 3D rotation.









In order to do our 3D rotation, we will need to build a 3D level. Rotations in the level will occur at 90 degree intervals. Depending on which direction we're facing, we will show the front, right, back or left side of the level. 

In 3D, we have a width, height and depth to consider. However when playing the game, the player will experience the location of all 3D platforms in 2D, essentially the depth is ignored and only the X and Y coordinates on the screen are occupied by platforms. Depending on the orientation of the platforms and the direction the player is facing, it's possible that the 3-dimensional location of the player and platform will not align, so even though it appears the player could jump onto a platform, they will end up missing it. To prevent this, we will create invisible cubes (invisicubes) for the player to land on. This will make it look like they have landed on their desired platform.



When playing, invisicubes will be created at the appropriate locations to give the illusion of standing on a platform when viewed with a 2D orthographic camera

Basic Rules

We will setup a script to handle the following:
When we aren't jumping, check if we're on an invisible platform, if so move to the closest physical platform. Try to move to the closest platform to the Camera, this will be the platform the player expects to be on. If we made any changes to the player depth, update the location of our invisible cubes.
Note: If we allowed the player to rotate on invisible platforms, it becomes disorienting and places them at locations that are unintuitive. This is why we need to move the player to the correct physical platform.

Rotation

In Fez, the core gameplay mechanic is solving a 3D puzzle by rotating in the game world. At any one
time we will only view a single side of the world using an orthographic camera. This removes
perspective, meaning it will appear that platforms which are deeper or closer to the camera will
all be viewed at the same depth. We only get the sense of the 3D world when rotating, otherwise
it all looks 2D. 

Let's start by making an invisicube which we will be using extensively. To do this, create a new Cube in your scene, name it Invisicube. All we need to do now is disable the MeshRenderer component and drag the Invisicube into your Assets folder to create a Prefab.



Note that the position is not important, but you will need an enabled Box Collider. The Mesh Renderer should be disabled or removed



We will create a new script to manage some of these aspects. This script is a bit long, but I will describe the gist of it. Create a new C# script in your project and name it FezManager. Copy in the following code:


using UnityEngine;
using System.Collections;
using System.Collections.Generic;

/// 
/// Fez manager creates invisible cubes for the player to move on. The world is based in 3D to allow rotation, this means
/// there are varying levels of depth that the player could be at, these may not line up with the physical platforms we create.
/// The player is moving in 2D, so it looks like a platform is present where one may not be, depending on the depth of the platform
/// and the player. If they are different, we will create invisible cubes that the player can move on to fake the player being on
/// a 2D platform. When we have the chance, we will move the player's depth to the closest platform so when they next rotate
/// it will not disorient them.
/// 
public class FezManager : MonoBehaviour {
 //Script that controls the player sprite movement and animation control
 private FezMove fezMove;

    //Keeps track of the direction our player is oriented
 public FacingDirection facingDirection;

    //Access to the player gameObject, useful for getting spacial coordinates
 public GameObject Player;

    //Used to tell the FezMove script how much to rotate 90 or -90 degrees depending on input
 private float degree = 0;

    //Access to the Transform containing our Level Data - Platforms we can walk on
 public Transform Level;

    //Access to the Transform containing our Building Data - There for asthetics but we don't plan to move on it
 public Transform Building;

    //For simplicity we will use a cube with a collider that has the mesh renderer disabled. This will allow us to
    //create places for the player to walk when the player depth is different than the platform on which they 
    //appear to be standing.
 public GameObject InvisiCube;

    //Holds our InvisiCubes so we can look at their locations or delete them
 private List<Transform> InvisiList = new List<Transform>();

    //Keeps track of the facing direction from the last frame, helps prevent us from needlessly re-building the location
    //of our Invisicubes
 private FacingDirection lastfacing;
    //Keeps track of the player depth from the last frame, helps prevent us from needlessly re-building the location
    //of our Invisicubes
 private float lastDepth = 0f;

    //Dimensions of cubes used - so far only tested with 1. This could potentially be updated if cubes of a different
    //size are needed - Note: All cubes must be same size
 public float WorldUnits = 1.000f;

 // Use this for initialization
 void Start () {
 
        //Define our facing direction, must be the same as built in inspector
        //Cache our fezMove script located on the player and update our level data (create invisible cubes)
  facingDirection = FacingDirection.Front;
  fezMove = Player.GetComponent<FezMove> ();
  UpdateLevelData (true); 
 }
 
 // Update is called once per frame
 void Update () {
 
        //Logic to control the player depth
        //If we're on an invisible platform, move to a physical platform, this comes in handy to make rotating possible
        //Try to move us to the closest platform to the camera, will help when rotating to feel more natural
        //If we changed anything, update our level data which pertains to our inviscubes
  if(!fezMove._jumping)
  {
   bool updateData = false;
   if(OnInvisiblePlatform())
    if(MovePlayerDepthToClosestPlatform())
     updateData = true;
   if(MoveToClosestPlatformToCamera())
    updateData = true;
   if(updateData)
    UpdateLevelData(false);


  }


  //Handle Player input for rotation command
  if(Input.GetKeyDown(KeyCode.RightArrow))
  {
   //If we rotate while on an invisible platform we must move to a physical platform
   //If we don't, then we could be standing in mid air after the rotation
   if(OnInvisiblePlatform())
   {
    //MoveToClosestPlatform();
    MovePlayerDepthToClosestPlatform();

   }
   lastfacing = facingDirection;
   facingDirection = RotateDirectionRight();
   degree-=90f;
   UpdateLevelData(false);
   fezMove.UpdateToFacingDirection(facingDirection, degree);



  }
  else if( Input.GetKeyDown(KeyCode.LeftArrow))
  {
   if(OnInvisiblePlatform())
   {
    //MoveToClosestPlatform();
    MovePlayerDepthToClosestPlatform();

   }
   lastfacing = facingDirection;
   facingDirection = RotateDirectionLeft();
   degree+=90f;
   UpdateLevelData(false);
   fezMove.UpdateToFacingDirection(facingDirection, degree);


  }
 }
 /// 
 /// Destroy current invisible platforms
 /// Create new invisible platforms taking into account the
 /// player's facing direction and the orthographic view of the 
 /// platforms
 /// 
 private void UpdateLevelData(bool forceRebuild)
 {
  //If facing direction and depth havent changed we do not need to rebuild
        if(!forceRebuild)
  if (lastfacing == facingDirection && lastDepth == GetPlayerDepth ())
      return;
  foreach(Transform tr in InvisiList)
  {
   //Move obsolete invisicubes out of the way and delete

   tr.position = Vector3.zero;
   Destroy(tr.gameObject);
   
  }
  InvisiList.Clear ();
  float newDepth = 0f;

  newDepth = GetPlayerDepth ();
  CreateInvisicubesAtNewDepth (newDepth);


 }
 /// 
 /// Returns true if the player is standing on an invisible platform
 /// 
 private bool OnInvisiblePlatform()
 {
  foreach(Transform item in InvisiList)
  {

    if(Mathf.Abs(item.position.x - fezMove.transform.position.x) < WorldUnits && Mathf.Abs(item.position.z - fezMove.transform.position.z) < WorldUnits)
     if(fezMove.transform.position.y - item.position.y <= WorldUnits + 0.2f && fezMove.transform.position.y - item.position.y >0)
      return true;



  }
    return false;
 }
 /// 
 /// Moves the player to the closest platform with the same height to the camera
 /// Only supports Unity cubes of size (1x1x1)
 /// 
 private bool MoveToClosestPlatformToCamera()
 {
  bool moveCloser = false;
  foreach(Transform item in Level)
  {
   if(facingDirection == FacingDirection.Front || facingDirection == FacingDirection.Back)
   {

    //When facing Front, find cubes that are close enough in the x position and the just below our current y value
    //This would have to be updated if using cubes bigger or smaller than (1,1,1)
    if(Mathf.Abs(item.position.x - fezMove.transform.position.x) < WorldUnits +0.1f)
    {
     
     if(fezMove.transform.position.y - item.position.y <= WorldUnits + 0.2f && fezMove.transform.position.y - item.position.y >0 && !fezMove._jumping)
     {
      if(facingDirection == FacingDirection.Front && item.position.z < fezMove.transform.position.z)
       moveCloser = true;

      if(facingDirection == FacingDirection.Back && item.position.z > fezMove.transform.position.z)
       moveCloser = true;
      

      if(moveCloser)
      {

       fezMove.transform.position = new Vector3(fezMove.transform.position.x, fezMove.transform.position.y, item.position.z);
       return true;
      }
     }

    }
    
   }
   else{
    if(Mathf.Abs(item.position.z - fezMove.transform.position.z) < WorldUnits + 0.1f)
    {
     if(fezMove.transform.position.y - item.position.y <= WorldUnits + 0.2f && fezMove.transform.position.y - item.position.y >0 && !fezMove._jumping)
     {
      if(facingDirection == FacingDirection.Right && item.position.x > fezMove.transform.position.x)
       moveCloser = true;

      if(facingDirection == FacingDirection.Left && item.position.x < fezMove.transform.position.x)
       moveCloser = true;

      if(moveCloser)
      {
       fezMove.transform.position = new Vector3(item.position.x, fezMove.transform.position.y, fezMove.transform.position.z);
       return true;
      }

     }

    }
   }

   
  }
  return false;
 }


 /// 
 /// Looks for an invisicube in InvisiList at position 'cube'
 /// 
 /// true, if transform invisi list was found, false otherwise.
 /// Cube position.
 private bool FindTransformInvisiList(Vector3 cube)
 {
  foreach(Transform item in InvisiList)
  {
   if(item.position == cube)
    return true;
  }
  return false;

 }
 /// 
 /// Looks for a physical (visible) cube in our level data at position 'cube'
 /// 
 /// true, if transform level was found, false otherwise.
 /// Cube.
 private bool FindTransformLevel(Vector3 cube)
 {
  foreach(Transform item in Level)
  {
   if(item.position == cube)
    return true;
   
  }
  return false;
  
 }
 /// 
 /// Determines if any building cubes are between the "cube"
 /// and the camera
 /// 
 /// true, if transform building was found, false otherwise.
 /// Cube.
 private bool FindTransformBuilding(Vector3 cube)
 {
  foreach(Transform item in Building)
  {
   if(facingDirection == FacingDirection.Front )
   {
    if(item.position.x == cube.x && item.position.y == cube.y && item.position.z < cube.z)
     return true;
   }
   else if(facingDirection == FacingDirection.Back )
   {
    if(item.position.x == cube.x && item.position.y == cube.y && item.position.z > cube.z)
     return true;
   }
   else if(facingDirection == FacingDirection.Right )
   {
    if(item.position.z == cube.z && item.position.y == cube.y && item.position.x > cube.x)
     return true;
   
   }
   else
   {
    if(item.position.z == cube.z && item.position.y == cube.y && item.position.x < cube.x)
     return true;

   }
  }
  return false;

 }

 /// 
 /// Moves player to closest platform with the same height
 /// Intended to be used when player jumps onto an invisible platform
 /// 
 private bool MovePlayerDepthToClosestPlatform()
 {
  foreach(Transform item in Level)
  {

   if(facingDirection == FacingDirection.Front || facingDirection == FacingDirection.Back)
          {
    if(Mathf.Abs(item.position.x - fezMove.transform.position.x) < WorldUnits + 0.1f)
     if(fezMove.transform.position.y - item.position.y <= WorldUnits + 0.2f && fezMove.transform.position.y - item.position.y >0)
     {
      
      fezMove.transform.position = new Vector3(fezMove.transform.position.x, fezMove.transform.position.y, item.position.z);
      return true;
      
     }
          }
   else
   {
    if(Mathf.Abs(item.position.z - fezMove.transform.position.z) < WorldUnits + 0.1f)
     if(fezMove.transform.position.y - item.position.y <= WorldUnits + 0.2f && fezMove.transform.position.y - item.position.y >0)
     {

                     fezMove.transform.position = new Vector3(item.position.x, fezMove.transform.position.y, fezMove.transform.position.z);
      return true;
     }
   }
  }
  return false;
            
 }
 /// 
 /// Creates an invisible cube at position
 /// Invisicubes are used as a place to land because our current 
 /// depth level in 3 dimensions may not be aligned with a physical platform
 /// 
 /// The invisicube.
 /// Position.
 private Transform CreateInvisicube(Vector3 position)
 {
  GameObject go = Instantiate (InvisiCube) as GameObject;
  
  go.transform.position = position;
  
  return go.transform;
  
 }
 /// 
 /// Creates invisible cubes for the player to move on
 /// if the physical cubes that make up a platform
 /// are on a different depth
 /// 
 /// New depth.
 private void CreateInvisicubesAtNewDepth(float newDepth)
 {

  Vector3 tempCube = Vector3.zero;
  foreach(Transform child in Level)
  {

   if(facingDirection == FacingDirection.Front || facingDirection == FacingDirection.Back)
   {
    tempCube = new Vector3(child.position.x, child.position.y, newDepth);
    if(!FindTransformInvisiList(tempCube) && !FindTransformLevel(tempCube) && !FindTransformBuilding(child.position))
    {

     Transform go = CreateInvisicube(tempCube);
     InvisiList.Add(go);
    }
    
   }
   //z and y must match a level cube
   else if(facingDirection == FacingDirection.Right || facingDirection == FacingDirection.Left)
   {
    tempCube = new Vector3(newDepth, child.position.y, child.position.z);
    if(!FindTransformInvisiList(tempCube) && !FindTransformLevel(tempCube) && !FindTransformBuilding(child.position))
    {

     Transform go = CreateInvisicube(tempCube);
     InvisiList.Add(go);
    }
    
   }


  }


 }
    /// 
    /// Any actions required if player returns to start
    /// 
 public void ReturnToStart()
 {

  UpdateLevelData (true);
 }
    /// 
    /// Returns the player depth. Depth is how far from or close you are to the camera
    /// If we're facing Front or Back, this is Z
    /// If we're facing Right or Left it is X
    /// 
    /// The player depth.
 private float GetPlayerDepth()
 {
  float ClosestPoint = 0f;

  if(facingDirection == FacingDirection.Front || facingDirection == FacingDirection.Back)
  {
   ClosestPoint = fezMove.transform.position.z;
    
  }
  else if(facingDirection == FacingDirection.Right || facingDirection == FacingDirection.Left)
  {
   ClosestPoint = fezMove.transform.position.x;
  }

  
  return Mathf.Round(ClosestPoint);

 }


    /// 
    /// Determines the facing direction after we rotate to the right
    /// 
    /// The direction right.
 private FacingDirection RotateDirectionRight()
 {
  int change = (int)(facingDirection);
  change++;
        //Our FacingDirection enum only has 4 states, if we go past the last state, loop to the first
  if (change > 3)
   change = 0;
  return (FacingDirection) (change);
 }
    /// 
    /// Determines the facing direction after we rotate to the left
    /// 
    /// The direction left.
 private FacingDirection RotateDirectionLeft()
 {
  int change = (int)(facingDirection);
  change--;
        //Our FacingDirection enum only has 4 states, if we go below the first, go to the last state
  if (change < 0)
   change = 3;
  return (FacingDirection) (change);
 }

}
//Used frequently to keep track of the orientation of our player and camera
public enum FacingDirection
{
 Front = 0,
 Right = 1,
 Back = 2,
 Left = 3
 
}

When we start the game, the script will use the player's location to determine at what depth to create the invisible platforms composed of our invisicubes. We've added the player FacingDirection enum at the bottom of the script. This will track which direction the player is currently facing. When the player is facing Front, or Back, the Z position controls depth. When facing Right or Left, the depth will be the X position. Each time the player presses the Right or Left arrow on the keyboard, we will rotate the entire Player GameObject in the appropriate direction by 90 degrees. Because we have our Camera parented to the Player GameObject, it comes along for the rotation as well. This is convenient because it keeps our sprite character facing the camera the entire time.

In the Update function we will begin by making sure the player is not jumping, this is to ensure it's safe for us to change the player's depth. If we're on an invisible platform, we know that at least two of the coordinates of the player's position will match that of one of the cubes that makes up a platform. For example, if our facingDirection is Front, the X position of the player when rounded is the same as at least one of the cubes that make up a physical platform. The Y coordinate must also be the same as one of the cubes, but offset roughly by the size of the Character Controller. The Z coordinate is the main difference, it could be either closer or further from the camera.
Reading through the commented code may provide better insight into what is happening. Next, we will need to add something to our FezMove script to handle the rotation command. We will create a setter function for the FacingDirection of the player and check the facing direction during our MoveCharacter function to determine which direction we should be moving in now that we can rotate in 3D. Here is the updated script, you can copy this in place of the current FezMove script we wrote in Part 2 of this tutorial.


  
using UnityEngine;
using System.Collections;

public class FezMove : MonoBehaviour {

 private int Horizontal = 0;

 public Animator anim;
 public float MovementSpeed = 5f;
 public float Gravity = 1f;
 public CharacterController charController;
 private FacingDirection _myFacingDirection;
 public float JumpHeight = 0f;
 public bool _jumping = false;
 private float degree = 0;


 public FacingDirection CmdFacingDirection {

  set{ _myFacingDirection = value; 
   }

 }

 // Update is called once per frame
 void Update () {

  if (Input.GetAxis ("Horizontal") < 0)
   Horizontal = -1;
  else if (Input.GetAxis ("Horizontal") > 0)
   Horizontal = 1;
  else
   Horizontal = 0;

  if (Input.GetKeyDown (KeyCode.Space) && !_jumping) 
  {
   _jumping = true;
   StartCoroutine(JumpingWait());
  }

  if(anim)
  {
   anim.SetInteger("Horizontal", Horizontal);

   float moveFactor = MovementSpeed * Time.deltaTime * 10f;
   MoveCharacter(moveFactor);
   
  }

  transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(0, degree, 0), 8 * Time.deltaTime);

 }

 private void MoveCharacter(float moveFactor)
 {
  Vector3 trans = Vector3.zero;
  if(_myFacingDirection == FacingDirection.Front)
  {
   trans = new Vector3(Horizontal* moveFactor, -Gravity * moveFactor, 0f);
  }
  else if(_myFacingDirection == FacingDirection.Right)
  {
   trans = new Vector3(0f, -Gravity * moveFactor, Horizontal* moveFactor);
  }
  else if(_myFacingDirection == FacingDirection.Back)
  {
   trans = new Vector3(-Horizontal* moveFactor, -Gravity * moveFactor, 0f);
  }
  else if(_myFacingDirection == FacingDirection.Left)
  {
   trans = new Vector3(0f, -Gravity * moveFactor, -Horizontal* moveFactor);
  }
  if(_jumping)
  {
   transform.Translate( Vector3.up * JumpHeight * Time.deltaTime);
  }


  charController.SimpleMove (trans);
 }
 public void UpdateToFacingDirection(FacingDirection newDirection, float angle)
 {
  
  _myFacingDirection = newDirection;
  degree = angle;

 }
 
 public IEnumerator JumpingWait()
 {
  yield return new WaitForSeconds (0.35f);
  //Debug.Log ("Returned jump to false");
  _jumping = false;
 }
}


To use this script, create a new empty GameObject and attach the script. You will need to drag and drop the appropriate GameObjects from our scene into the FezManager fields. In the Level field, add the Platforms GameObject and the Buildings GameObject in the Building field. You will need to drag the Invisicube from the assets folder onto it's appropriate field as well. 



Your FezManager GameObject should look something like this


Platforms and Buildings

The layout of the level is ultimately up to you, however, there are some guidelines that should be observed. As you build the level and test it, you'll find some configurations work better than others.

When building your level, remember to place the cubes used to build platforms under the Platforms GameObject and the rest under Buildings




Start by building a level consisting of a central stone Building and surround it with some Platforms, don't forget to use our AutoSnap script from the first tutorial for placing cubes


Here's an example of the level I built



Mid-rotation


That's all for this tutorial, thanks for reading!


How did your game turn out? Please share any suggestions or issues you find along the way by commenting below.

5 comments:

  1. I loved your post and tutorials. Would it be possible for you to post the final folder? I don't know what I've been doing wrong but I'm not able to get a nice movement with the character and I'm wondering what I've done bad.

    ReplyDelete
  2. Sure, you can download the project here: https://github.com/gmatsura/gmatsura-UnityFezLike

    ReplyDelete
  3. Thank you so much! This really helped me a lot :)

    ReplyDelete
  4. Can you switch the horizontal movement in the fezmove to touch screen if so, how?

    ReplyDelete
  5. Really impressive. I'm following your posts and got in trouble.
    1. without jump, character can't go to another platform
    2. charcter can jump multiple times.

    and i found your git sources are different version from your vids one.
    can you send that vids version source to escandar2836@gmail.com?
    Thanks.

    ReplyDelete