top of page

Unity Group Project - Part 1 - Block Builder In Unity

Updated: Apr 20, 2023




After finishing UNI, I had the chance to participate in a game jam, where the theme was to "Create a Game", which would show off the three main disciplines (Design, Art and Programming), and how they work together to make a game. My team decided we would split the disciplines into 3 separate mini-games. For Design, we thought it was a cool idea if we allowed the player to build their own world using floating blocks (puzzle pieces) around them, with the ultimate goal of getting to the other side of the world you are in.



The Idea


To start off, I first designed how the puzzle pieces would work so they snapped together in different directions. Here is what I came up with:


Each block (or puzzle piece) would consist of multiple singular pieces. Each singular piece is a square block, in which we will use the sides of these pieces to attach other blocks. Each side of the singular piece will have a box collider which will serve the purpose of finding the correct place to snap another block to.


When you snap these pieces together, they will automatically orient themselves to the correct rotation.


Implementation


First, I needed to create a side class. Each side needed the following information:

  1. A normal direction, which tells the attached block which way to face.

  2. If a block can be placed on this side. (We need this so we snap blocks inside each other, only on the surrounding sides).


PuzzlePiece_Side Class


    public Vector3 m_normalDirection;

    [Tooltip("True if there is no piece blocking this side.")]
    [SerializeField] private bool m_isPlaceable = true;

    public bool IsPlaceable { get { return m_isPlaceable; } set { m_isPlaceable = value; } }

    // Start is called before the first frame update
    void Start()
    {
        CalculateNormals();
    }

    public void CalculateNormals()
    {
        if (transform.position.x > transform.parent.position.x) m_normalDirection = Vector3.right;
        else if (transform.position.z > transform.parent.position.z) m_normalDirection = Vector3.forward;
        else if (transform.position.x < transform.parent.position.x) m_normalDirection = Vector3.left;
        else if (transform.position.z < transform.parent.position.z) m_normalDirection = Vector3.back;
    }



When we create our singular piece, we can manually set the normal direction for each side in the inspector to save on performance at the start of the game. However, for the sake of saving time, I called the "CalculateNormals()" function in Start(). Also, when we place a puzzle piece, we will need to recalculate these normals, so it will be a useful function to have.


To calculate the normals, I compared the position of the side, to its parent object. Meaning if the side was in front of the block, the normal would be Vector3.forward. If it was behind, it would be Vector3.back, etc...


Next, onto the puzzle piece. The code for this is extremely simple, I just made an array of sides, and cached the transform. When this script is attached to the piece, we can manually add all the sides into the array in the inspector.



PuzzlePiece Class


    private Transform m_transform;

    [SerializeField] private PuzzlePiece_Side[] m_sides;

    private void Awake()
    {
        m_transform = GetComponent<Transform>();
    }

Now to make the actual block itself. In this class, we have the following variables:

  1. isPlacedAsFloor - A boolean that checks if this block has been placed.

  2. isPickupable - A boolean that checks if this block can be picked up.

  3. firstSide - A puzzle piece side reference that acts as the side that will connect to other blocks.

Other than this, we make a few getter functions, and disable any colliders on any blocks that are already counted as floor blocks in the Start() function.


The following is what a singular puzzle piece looks like in Unity...




PuzzleBlock Class


    [SerializeField] private bool m_isPlacedAsFloor = false;
    [SerializeField] private bool m_isPickupable = true;

    [Tooltip("The side that will connect to another piece.")]
    public PuzzlePiece_Side m_firstSide;

    public bool IsPickupable { get { return m_isPickupable; } set {m_isPickupable = value;} }
    public bool IsPlacedAsFloor { get { return m_isPlacedAsFloor; } set{m_isPlacedAsFloor = value;} }

    // Start is called before the first frame update
    void Start()
    {
        if (m_isPlacedAsFloor)
        {
            GetComponent<Collider>().enabled = false;
        }
    }

The following is what a complete puzzle piece looks like in Unity...





Finally, to bring it all together, I added all the functionality into a single script that attaches to the player. Since this script has 150 lines of code, I will go through it function by function.


First, we need to set up all the variables:

  1. isHoldingObject - A bool that checks if we are holding an object.

  2. currentlyHeldObject - A gameobject that holds a reference to the block that is currently being held.

  3. ignoreRaycast - A layermask which our raycast will ignore.

  4. rayOrigin - A transform where the origin of the ray cast will be.

  5. holdingPosition - The position where the block will be when it is being held.

  6. Since we are using the new input system, we need an InputActionReference called "GrabObject".

  7. sweeper - A reference to an instance of our SweeperController class which I will explain soon.

  8. lerpTime - A float that represents how long it will take for the blocks to lerp to our holding position.


    private bool m_isHoldingObject = false;
    [HideInInspector] public GameObject m_currentlyHeldObject;

    [SerializeField] private LayerMask ignoreRaycast;

    [Tooltip("The origin of the ray being cast.")]
    [SerializeField] private Transform m_rayOrigin;

    [Tooltip("The position the object will be held at.")]
    [SerializeField] private Transform m_holdingPosition;
    [Tooltip("The gameobject the object will be parented to.")]
    [SerializeField] private Transform m_objectToParent;

    [SerializeField] private InputActionReference GrabObject;

    [Tooltip("The sweeper prefab")]
    [SerializeField] private SweeperController m_sweeper;

    [Tooltip("The lerp time to the holding position")]
    [SerializeField] private float m_lerpTime = 0.3f;
    

The point of having a sweeper is to get the nearest side when holding an object. This isn't necessary, although it makes the feel of the snapping much smoother.



SweeperController Class

public class SweeperController : MonoBehaviour
{
    private PuzzlePiece_Side m_currentlySelectedSide;

    [SerializeField] private Player_HoverObject m_playerHover;

    public void OnReleaseObject()
    {
        m_currentlySelectedSide.IsPlaceable = false;
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.GetComponent<PuzzlePiece_Side>())
        {
            m_currentlySelectedSide = other.GetComponent<PuzzlePiece_Side>();

            PuzzlePiece_Side side = other.GetComponent<PuzzlePiece_Side>();

            if (side.IsPlaceable && m_playerHover.m_currentlyHeldObject != null)
            {
                m_playerHover.m_currentlyHeldObject.transform.position = other.transform.position;
                m_playerHover.m_currentlyHeldObject.transform.forward = side.m_normalDirection;
                m_playerHover.m_currentlyHeldObject.transform.parent = null;
                m_playerHover.m_currentlyHeldObject.GetComponent<PuzzleBlock>().IsPlacedAsFloor = true;
            }
        }
    }
}

Whenever the sweeper detects a puzzle piece side, it sets the position of the currently held object to that side, and rotates it to the correct rotation.


Update Function


    void Update()
    {
        RaycastHit hit;

        if (Physics.Raycast(m_rayOrigin.position, m_rayOrigin.forward, out hit, Mathf.Infinity, ~ignoreRaycast))
        {
            if (hit.collider)
            {
                m_text.text = hit.transform.name;
                m_sweeper.transform.position = hit.point;
            }
        }
    }

In the Update() function, I simply raycast from the middle of the screen, and set the sweeper to the hit position.



Holding Object IEnumerator


    private IEnumerator LerpToHoldingPosition()
    {
        float elapsedTime = 0;
        float waitTime = m_lerpTime;

        Vector3 oldPos = m_currentlyHeldObject.transform.position;

        while (elapsedTime < m_lerpTime)
        {
            m_currentlyHeldObject.transform.position =
                Vector3.Lerp(oldPos, m_holdingPosition.position, elapsedTime / waitTime);

            elapsedTime += Time.deltaTime;
            yield return null;
        }

        m_currentlyHeldObject.transform.position = m_holdingPosition.position;
    }

This simple IEnumerator function lerps the position of an object. This is called whenever the player grabs one of the floating puzzle pieces.



GrabObject Function

    void OnGrabObject(InputAction.CallbackContext obj)
    {
        RaycastHit hit;

        if (Physics.Raycast(m_rayOrigin.position, m_rayOrigin.forward, out hit, Mathf.Infinity))
        {
            if (hit.transform && hit.transform.GetComponent<PuzzleBlock>())
            {
                if (!m_isHoldingObject && hit.transform.GetComponent<PuzzleBlock>().IsPickupable)
                {
                    m_isHoldingObject = true;
                    m_currentlyHeldObject = hit.transform.gameObject;

                    DisableColliders();
                    SetHeldObjectParent();
                    StartCoroutine(LerpToHoldingPosition());
                }
            }
        }
    }

When the player grabs an object, we cast another ray, and check if the object we are hitting is a puzzle piece. If we aren't already holding an object and the puzzle piece allows us to pick it up, then call the lerp function.


OnReleaseObject Function


    void OnReleaseObject(InputAction.CallbackContext obj)
    {
        StopAllCoroutines();

        if (m_isHoldingObject)
        {
            m_isHoldingObject = false;
            m_currentlyHeldObject.transform.parent = null;
            EnableColliders();

            //If the object has been placed...
            if (m_currentlyHeldObject.GetComponent<PuzzleBlock>().IsPlacedAsFloor)
            {
                m_currentlyHeldObject.GetComponent<PuzzleBlock>().IsPickupable = false;
                m_currentlyHeldObject.GetComponent<Collider>().enabled = false;
                m_currentlyHeldObject.GetComponent<PuzzleBlock>().m_firstSide.IsPlaceable = false;
                m_sweeper.OnReleaseObject();

                foreach (var side in m_currentlyHeldObject.GetComponentsInChildren<PuzzlePiece_Side>())
                {
                    side.CalculateNormals();
                }
            }

            m_currentlyHeldObject = null;
        }
    }

When we release an object, we want to reset it back to the way it was before we grabbed it. In the case we placed the object, we want to recalculate the normals for all the sides on that puzzle piece ready for when we want to place another object on it.


I didn't include the other little functions that do simple things, but below is the complete script that I made in the game jam...


Player_HoverObject Class


public class Player_HoverObject : MonoBehaviour
{
    private bool m_isHoldingObject = false;
    [HideInInspector] public GameObject m_currentlyHeldObject;

    [SerializeField] private LayerMask ignoreRaycast;

    [SerializeField] private TMP_Text m_text;

    [Tooltip("The origin of the ray being cast.")]
    [SerializeField] private Transform m_rayOrigin;

    [Tooltip("The position the object will be held at.")]
    [SerializeField] private Transform m_holdingPosition;
    [Tooltip("The gameobject the object will be parented to.")]
    [SerializeField] private Transform m_objectToParent;

    [SerializeField] private InputActionReference GrabObject;

    [Tooltip("The sweeper prefab")]
    [SerializeField] private SweeperController m_sweeper;

    [Tooltip("The lerp time to the holding position")]
    [SerializeField] private float m_lerpTime = 0.3f;


    private void OnEnable()
    {
        GrabObject.action.performed += OnGrabObject;
        GrabObject.action.canceled += OnReleaseObject;
    }

    private void OnDisable()
    {
        GrabObject.action.performed -= OnGrabObject;
        GrabObject.action.canceled -= OnReleaseObject;
    }

    // Update is called once per frame
    void Update()
    {
        RaycastHit hit;

        if (Physics.Raycast(m_rayOrigin.position, m_rayOrigin.forward, out hit, Mathf.Infinity, ~ignoreRaycast))
        {
            if (hit.collider)
            {
                m_text.text = hit.transform.name;
                m_sweeper.transform.position = hit.point;
            }
        }
    }

    private IEnumerator LerpToHoldingPosition()
    {
        float elapsedTime = 0;
        float waitTime = m_lerpTime;

        Vector3 oldPos = m_currentlyHeldObject.transform.position;

        while (elapsedTime < m_lerpTime)
        {
            m_currentlyHeldObject.transform.position =
                Vector3.Lerp(oldPos, m_holdingPosition.position, elapsedTime / waitTime);

            elapsedTime += Time.deltaTime;
            yield return null;
        }

        m_currentlyHeldObject.transform.position = m_holdingPosition.position;
    }

    public void SetHeldObjectParent()
    {
        m_currentlyHeldObject.transform.parent = m_objectToParent;
    }

    void EnableColliders()
    {
        foreach (var collider in m_currentlyHeldObject.GetComponentsInChildren<Collider>())
        {
            collider.enabled = true;
        }
    }

    void DisableColliders()
    {
        foreach (var collider in m_currentlyHeldObject.GetComponentsInChildren<Collider>())
        {
            collider.enabled = false;
        }
    }

    void OnGrabObject(InputAction.CallbackContext obj)
    {
        RaycastHit hit;

        if (Physics.Raycast(m_rayOrigin.position, m_rayOrigin.forward, out hit, Mathf.Infinity))
        {
            if (hit.transform && hit.transform.GetComponent<PuzzleBlock>())
            {
                if (!m_isHoldingObject && hit.transform.GetComponent<PuzzleBlock>().IsPickupable)
                {
                    m_isHoldingObject = true;
                    m_currentlyHeldObject = hit.transform.gameObject;

                    DisableColliders();
                    SetHeldObjectParent();
                    StartCoroutine(LerpToHoldingPosition());
                }
            }
        }
    }

    void OnReleaseObject(InputAction.CallbackContext obj)
    {
        StopAllCoroutines();

        if (m_isHoldingObject)
        {
            m_isHoldingObject = false;
            m_currentlyHeldObject.transform.parent = null;
            EnableColliders();

            //If the object has been placed...
            if (m_currentlyHeldObject.GetComponent<PuzzleBlock>().IsPlacedAsFloor)
            {
                m_currentlyHeldObject.GetComponent<PuzzleBlock>().IsPickupable = false;
                m_currentlyHeldObject.GetComponent<Collider>().enabled = false;
                m_currentlyHeldObject.GetComponent<PuzzleBlock>().m_firstSide.IsPlaceable = false;
                m_sweeper.OnReleaseObject();

                foreach (var side in m_currentlyHeldObject.GetComponentsInChildren<PuzzlePiece_Side>())
                {
                    side.CalculateNormals();
                }
            }

            m_currentlyHeldObject = null;
        }
    }
}

The final result...



bottom of page