Game Architecture is a series of blog posts where we take a high-level look at the code architecture behind free game projects and learn from them. In today’s post, we’ll be studying the Creator Kit: Puzzle project by Unity.

Quick Summary

Before we dive into the scripts, here’s a summary of what you can learn from this Unity project:

  • This project is a great example of using Scriptable Objects to store level information. This way, scripts can access important information without relying on a Data Manager script that persists across all levels using DontDestroyOnLoad().
  • The project illustrates proper segregation between scripts: SceneMenu does the logic, SceneMenuIcon displays the information, SceneReference contains the data.
  • The BaseInteractivePuzzlePiece.cs script demonstrates the Open-Closed Principle and the Dependency Inversion Principle of SOLID. The user-controlled puzzle pieces inherit from an abstract instead of a concrete class. Moreover, the user can easily add another puzzle piece without altering the code of the base class.

Code Architecture

Main Menu

In the main menu, we see scripts that manage level loading.

  • SceneMenu – manages the main menu. Contains the logic to create a grid layout for the SceneMenuIcons and display their information. This script also references the SceneReference scriptable objects. You can edit the details of each level such as Display Name, Total Required Stars, and 1-2-3 Star Time.
  • SceneReference – scriptable objects that contain information for each scene such as level index, earned stars, pointing system, etc.
  • SceneMenuIcon – an icon the shows the user relevant information regarding each level. SceneMenu.cs grabs information from each SceneReference and places them to the corresponding SceneMenuIcon.
Main menu
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Canvas))]
public class SceneMenu : MonoBehaviour
{
    [Serializable]
    public struct LevelInfo
    {
        public SceneReference level;
        public string displayName;
        public int totalStarsRequired;
        public float oneStarTime;
        public float twoStarTime;
        public float threeStarTime;

        public bool LoadLevel (ScreenFader screenFader, int totalEarnedStars)
        {
            if (totalEarnedStars >= totalStarsRequired)
            {
                level.ReloadLevel (screenFader);
                return true;
            }

            return false;
        }
    }

    public AudioClip accessDeniedClip;
    public ScreenFader screenFader;
    public SceneMenuIcon sceneMenuIconPrefab;
    public RectTransform parentRectTransform;
    public List<LevelInfo> levels = new List<LevelInfo> ();
    
    List<SceneMenuIcon> m_Icons = new List<SceneMenuIcon> ();

    const string k_AssetReferencerName = "AssetReferencer";

    void Start ()
    {
        CreateGrid ();

        int totalEarnedStars = GetTotalEarnedStars ();
        for (int i = 0; i < m_Icons.Count; i++)
        {
            m_Icons[i].UpdateMenuUI (totalEarnedStars, levels[i].totalStarsRequired, levels[i].level.earnedStars);
        }

        for (int i = 0; i < levels.Count; i++)
        {
            levels[i].level.UpdateActions ();
            levels[i].level.oneStarTime = levels[i].oneStarTime;
            levels[i].level.twoStarTime = levels[i].twoStarTime;
            levels[i].level.threeStarTime = levels[i].threeStarTime;
        }

        if(GameObject.Find (k_AssetReferencerName) != null)
            return;
        
        GameObject assetReferencerGo = new GameObject("AssetReferencer");
        AssetReferencer assetReferencer = assetReferencerGo.AddComponent<AssetReferencer> ();
        assetReferencer.assets = new ScriptableObject[levels.Count];

        for (int i = 0; i < assetReferencer.assets.Length; i++)
        {
            assetReferencer.assets[i] = levels[i].level;
        }
    }

    void CreateGrid() ...
    
    public int GetTotalEarnedStars() ...

    public void LoadLevel (SceneReference sceneReference) ...
}
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class SceneReference : ScriptableObject
{
    public int levelBuildIndex;    // BUG: this is 0 in builds. needs some code to set it before building...
    public int menuBuildIndex;
    public int earnedStars;
    public float oneStarTime;
    public float twoStarTime;
    public float threeStarTime;

#if UNITY_EDITOR
    public SceneAsset levelScene;
    public SceneAsset menuScene;
#endif

    Action m_LoadLevelAction;
    Action m_LoadMenuAction;

    public void UpdateActions ()
    {
        m_LoadLevelAction = () => SceneManager.LoadSceneAsync (levelBuildIndex, LoadSceneMode.Single);
        m_LoadMenuAction = () => SceneManager.LoadSceneAsync (menuBuildIndex, LoadSceneMode.Single);
    }

    public void LoadMenu (ScreenFader screenFader)
    {
        if(m_LoadMenuAction == null)
            UpdateActions ();
        
        screenFader.FadeOut(m_LoadMenuAction);
    }

    public void ReloadLevel (ScreenFader screenFader)
    {
        if(m_LoadLevelAction == null)
            UpdateActions ();
        
        screenFader.FadeOut(m_LoadLevelAction);
    }
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class SceneCompletion : MonoBehaviour
{
    public ScreenFader screenFader;
    public GameObject panel;
    public PlayableDirector zeroStarDirector;
    public PlayableDirector oneStarDirector;
    public PlayableDirector twoStarDirector;
    public PlayableDirector threeStarDirector;
    public SceneReference sceneReference;        // Note that this is assigned automatically when the level is created by the SceneMenuEditor class.
    
    // Called when target of level is achieved
    public void CompleteLevel (float time)
    {
        panel.SetActive (true);
        
        int earnedStars = 0;

        if (time <= sceneReference.threeStarTime)
        {
            earnedStars = 3;
            threeStarDirector.Play();
        }
        else if (time <= sceneReference.twoStarTime)
        {
            earnedStars = 2;
            twoStarDirector.Play();
        }
        else if (time <= sceneReference.oneStarTime)
        {
            earnedStars = 1;
            oneStarDirector.Play();
        }
        else
        {
            zeroStarDirector.Play();
        }
        
        if(sceneReference.earnedStars < earnedStars)
            sceneReference.earnedStars = earnedStars;
    }

    // UI Button
    public void ReloadLevel() ...

    // UI Button
    public void LoadMenu() ...
}

Gameplay

  • InteractivePuzzlePiece – an abstract class that is the parent of Flipper.cs, TrapDoor.cs, and SwingHammer.cs.
  • TargetTrigger – triggers a bunch of events when the player reaches the goal. Calls the GoalReached() method in TimingRecording.cs.
  • TimingRecording – on Awake, this script searches for all objects of type BaseInteractivePuzzlePiece and enables user control for each. It also updates the Timer text. After reaching the goal, this script passes the Timer information to SceneCompletion.cs.
  • SceneCompletion – using on the time received from TimingRecording.cs, this script compares it to the pointing system in the level’s SceneReference scriptable object, record 1-2-3 stars, shows the results to the player, and reloads the menu level.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class InteractivePuzzlePiece<TComponent> : BaseInteractivePuzzlePiece
where TComponent : Component
{
    public TComponent physicsComponent;
}

public abstract class BaseInteractivePuzzlePiece : MonoBehaviour
{
    public KeyCode interactKey = KeyCode.Space;
    public Rigidbody rb;
    public AudioClip activateSound;
    public AudioClip deactivateSound;
    public AudioSource puzzleAudioSource;

    bool m_IsControlable;
    
    protected void FixedUpdate ()
    {
        if (Input.GetKey (interactKey) && m_IsControlable)
        {
            ApplyActiveState ();
        }
        else
        {
            ApplyInactiveState ();
        }
    }
    
    void Update()
    {
        if (deactivateSound != null && Input.GetKeyUp(interactKey))
        {
            puzzleAudioSource.pitch = Random.Range(0.8f, 1.2f);
            puzzleAudioSource.PlayOneShot(deactivateSound);
        }
        if (activateSound != null && Input.GetKeyDown(interactKey))
        {
            puzzleAudioSource.pitch = Random.Range(0.8f, 1.2f);
            puzzleAudioSource.PlayOneShot(activateSound);
        }
    }

    protected abstract void ApplyActiveState ();

    protected abstract void ApplyInactiveState ();

    public void EnableControl ()
    {
        m_IsControlable = true;
    }
}
using System;
using System.Collections;
using TMPro;
using UnityEngine;

public class TimingRecording : MonoBehaviour
{
    public KeyCode resetKeyCode = KeyCode.R;
    public Rigidbody startingMarble;
    public SceneCompletion sceneCompletion;
    public TextMeshProUGUI textMesh;
    public Action enableControlAction;
    [HideInInspector]
    public float timer;

    bool m_IsTiming;
    BaseInteractivePuzzlePiece[] m_PuzzlePieces;

    void Awake ()
    {
        m_PuzzlePieces = FindObjectsOfType<BaseInteractivePuzzlePiece> ();
        
        enableControlAction = EnableControl;
    }

    void EnableControl ()
    {
        startingMarble.isKinematic = false;
        m_IsTiming = true;
        for (int i = 0; i < m_PuzzlePieces.Length; i++)
        {
            m_PuzzlePieces[i].EnableControl ();
        }
    }

    void Update () ...

    public void GoalReached (float uiDelay) ...

    IEnumerator CompleteLevelWithDelay (float delay) ...
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class SceneCompletion : MonoBehaviour
{
    public ScreenFader screenFader;
    public GameObject panel;
    public PlayableDirector zeroStarDirector;
    public PlayableDirector oneStarDirector;
    public PlayableDirector twoStarDirector;
    public PlayableDirector threeStarDirector;
    public SceneReference sceneReference;        // Note that this is assigned automatically when the level is created by the SceneMenuEditor class.
    
    // Called when target of level is achieved
    public void CompleteLevel (float time) ...
    
    // UI Button
    public void ReloadLevel () ...

    // UI Button
    public void LoadMenu () ...
}

Audio

  • MarbleAudio – the marble’s audio changes based on speed and IsGrounded calculated by AudioAdjustmentSettings.cs. It’s impact sound also changes depending on the other collider’s layer mask.
  • AudioAdjustmentSettings – a struct that calculates the volume and pitch of a sound based on a number of variables.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public struct AudioAdjustmentSettings
{
    public readonly float speedTo;
    public readonly float min;
    public readonly float max;
    public readonly float changeRate;

    public AudioAdjustmentSettings (float speedTo, float min, float max, float changeRate)
    {
        this.speedTo = speedTo;
        this.min = min;
        this.max = max;
        this.changeRate = changeRate;
    }
    
    public static float ClampAndInterpolate (float value, float speed, AudioAdjustmentSettings settings)
    {
        float speedBasedRollingVolume = Mathf.Clamp(speed * settings.speedTo, settings.min, settings.max);
        return Mathf.Lerp(value, speedBasedRollingVolume, settings.changeRate * Time.deltaTime);
    }
}

Send me a message.


Recent posts: