What is it
- 3D puzzle-platformer inspired by Nintendo64-era platformers, but with a focus on exploration and environmental puzzles. The player can use wind, fire and ice to interact, destroy or move dynamic objects in the environment.
- Made with Unity in 7 weeks of production as third and final game project of the first year of Futuregames school.
- The game is made of two large levels, with a playtime of 30/40 minutes.
I wrote about the making of this game and some of its systems in an article on Made with Unity.
What I did in this project
Lead Designer, Programmer and Product Owner. I wrote almost all the code, designed the systems in the game and I led the team during development as well as designing some of the puzzles.
What am I most proud of
I made a machine state system that lets level designers decide if an object can be frozen or set on fire, if it can be destroyed by fire, if it propagates fire, how long it takes to thaw if it’s frozen and so on.
This system is the base of the puzzles in the game. Here is the script in its entirety:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; [RequireComponent(typeof(NavMeshObstacle))] [RequireComponent(typeof(Rigidbody))] [RequireComponent(typeof(Collider))] public class EffectsState : MonoBehaviour { public enum State { Neutral, Fire, Ice, Electric }; State currentState; //These are the effects we are using. They are placed in Resources GameObject FireEffectPrefab; Material IceMaterial; PhysicMaterial OriginalPhysicalMat; GameObject DestroyEffect; GameObject IcePuffEffect; PhysicMaterial IcePhysMat; [SerializeField] float TimeToPropagate = 1f; [SerializeField] float TimeToDestroyFromFire = 10f; [SerializeField] bool CanBeDestroyedByFire = true; [SerializeField] bool CanCatchFire = true; [SerializeField] float IceToNeutralDelay = 1f; [Tooltip("If 0 then the the ice never thaws")] [SerializeField] float IceThawTime = 0f; [Tooltip("Standard particle amount is usually 12")] [SerializeField] int FireParticlesAmount = 80; public bool TestFire; public bool TestIce; public bool CanChangeState = true; private Material originalMaterial; private Mesh fireMesh; bool destructionStarted = false; bool canChangeState; Vector3 fireColliderBoxSize; Collider objectCollider; GameObject SmokeTrail; // Use this for initialization void Start() { IceMaterial = Resources.Load("M_IceMat", typeof(Material)) as Material; currentState = State.Neutral; DestroyEffect = Resources.Load("PE_SmokePuffsBurn", typeof(GameObject)) as GameObject; FireEffectPrefab = Resources.Load("PF_FireEffect", typeof(GameObject)) as GameObject; IcePuffEffect = Resources.Load("PE_MagicPoof", typeof(GameObject)) as GameObject; SmokeTrail = Resources.Load("PE_SmokeTrail", typeof(GameObject)) as GameObject; canChangeState = true; originalMaterial = gameObject.GetComponent<Renderer>().materials[0]; OriginalPhysicalMat = gameObject.GetComponent<Collider>().sharedMaterial; IcePhysMat = Resources.Load("PM_Ice", typeof(PhysicMaterial)) as PhysicMaterial; fireMesh = gameObject.GetComponent<MeshFilter>().mesh; /*This chooses the shape of the trigger collider that will propagate fire. We only use box for performance reasons so we try to convert the size of a box, capsule or sphere to the box trigger. If the collider is not a box, capsule or sphere we assign a 1 1 1 size The trigger has a bigger size than the collider on the object to allow propagation. We use the local scale to avoid big objects having huge trigger areas*/ objectCollider = gameObject.GetComponent<Collider>(); if (objectCollider.GetType() == typeof(BoxCollider)) { fireColliderBoxSize = new Vector3(gameObject.GetComponent<BoxCollider>().size.x + (1 / transform.localScale.x), gameObject.GetComponent<BoxCollider>().size.y + (1 / transform.localScale.y), gameObject.GetComponent<BoxCollider>().size.z + (1 / transform.localScale.z)); } else if (objectCollider.GetType() == typeof(CapsuleCollider)) { fireColliderBoxSize = new Vector3(gameObject.GetComponent<CapsuleCollider>().radius * 2f, gameObject.GetComponent<CapsuleCollider>().height * 1.2f, gameObject.GetComponent<CapsuleCollider>().radius * 2f); } else if (objectCollider.GetType() == typeof(SphereCollider)) { fireColliderBoxSize = new Vector3(gameObject.GetComponent<SphereCollider>().radius * 2.5f, gameObject.GetComponent<SphereCollider>().radius * 2.5f, gameObject.GetComponent<SphereCollider>().radius * 2.5f); } else { fireColliderBoxSize = new Vector3(1, 1, 1); } //For test reasons we have this call to change the state of the object via inspector if (TestFire) { ChangeState(State.Fire); } if (TestIce) { ChangeState(State.Ice); } } //This is the core state machine. It allows changing state, with or without delay //Fire and ice cannot transition to each other directly but go through neutral state public void ChangeState(State newState) { if (CanChangeState) { if (currentState != newState && canChangeState) { canChangeState = false; switch (newState) { case State.Fire: if (currentState == State.Ice) { Invoke("Neutral", IceToNeutralDelay); currentState = State.Neutral; } else { if (CanCatchFire) { Fire(); currentState = State.Fire; } else { currentState = State.Neutral; } } break; case State.Ice: if (currentState == State.Fire) { Neutral(); currentState = State.Neutral; } else { Ice(); currentState = State.Ice; } break; case State.Electric: Electric(); currentState = State.Electric; break; default: Neutral(); currentState = State.Neutral; break; } Invoke("delayPossibleStateChange", 1f); } } } void delayPossibleStateChange() { canChangeState = !canChangeState; } public void GetState(out string outState) { outState = currentState.ToString(); } //We use ontriggerenter to manage the propagation and allow fire to spread to other objects void OnTriggerEnter(Collider other) { if (other.tag == "Fire") { ChangeState(State.Fire); } if (other.tag == "Ice") { ChangeState(State.Ice); } if (other.GetComponent<EffectsState>() != null && currentState == State.Fire) { StartCoroutine(PropagateState(other.gameObject, currentState, TimeToPropagate)); } if (currentState == State.Fire && other.tag == "Player") { other.GetComponent<CharacterControls>().FireBump(gameObject); } } //Individual States void Fire() { //We instantiate the fire effect and functionality GameObject FireGO = Instantiate(FireEffectPrefab, gameObject.transform.position, Quaternion.identity); FireGO.transform.SetParent(gameObject.transform); FireGO.transform.localScale = new Vector3(1, 1, 1); FireGO.transform.rotation = gameObject.transform.rotation; FireGO.tag = "Effect"; //Both the particle system and the trigger collider get the same shape as the parent object var fireps = FireGO.GetComponent<ParticleSystem>(); if (gameObject.tag != "Torch") { var firesh = fireps.shape; firesh.shapeType = ParticleSystemShapeType.Mesh; firesh.mesh = fireMesh; } var emission = fireps.emission; emission.rateOverTime = FireParticlesAmount; fireps.Play(); var firecoll = FireGO.GetComponent<BoxCollider>(); firecoll.size = fireColliderBoxSize; firecoll.isTrigger = true; } //Ice makes object slippery and changes the material void Ice() { Vector3 originalPos = gameObject.transform.localPosition; StartCoroutine(Vibrate(originalPos)); gameObject.GetComponent<Renderer>().material = IceMaterial; gameObject.GetComponent<Collider>().sharedMaterial = IcePhysMat; Instantiate(IcePuffEffect, gameObject.transform.position, Quaternion.identity); if (IceThawTime > 0) { StartCoroutine(ThawIce()); } } //We have support for a possible new state even if not implemented in the game void Electric() { } void Neutral() { Transform[] children = GetComponentsInChildren<Transform>(); foreach (Transform child in children) { if (child.CompareTag("Effect")) { Destroy(child.gameObject); } } gameObject.GetComponent<Renderer>().material = originalMaterial; gameObject.GetComponent<Collider>().sharedMaterial = OriginalPhysicalMat; StartCoroutine(Vibrate(gameObject.transform.localPosition)); } //Counter for destroying an object on fire void Update() { if (currentState == State.Fire && CanBeDestroyedByFire == true && destructionStarted == false) { TimeToDestroyFromFire -= Time.deltaTime; if (TimeToDestroyFromFire <= 0) { destructionStarted = true; StartCoroutine(DestroyObject()); } } } IEnumerator DestroyObject() { Collider[] colls = gameObject.GetComponentsInChildren<Collider>(); foreach (Collider co in colls) { co.enabled = false; } Renderer[] rends = gameObject.GetComponentsInChildren<Renderer>(); foreach (Renderer re in rends) { re.enabled = false; } GameObject desEff = Instantiate(DestroyEffect, new Vector3(gameObject.transform.position.x, gameObject.transform.position.y + 1, gameObject.transform.position.z), Quaternion.identity); yield return new WaitForSeconds(1f); //Spawns a smoke trail if the object has been burned down Vector3 smokeTrailPos = gameObject.transform.position; RaycastHit hit; if (Physics.Raycast(transform.position, Vector3.down, out hit, 10f)) { if (hit.collider != null) { smokeTrailPos = hit.point; } Instantiate(SmokeTrail, smokeTrailPos, Quaternion.identity); } Destroy(gameObject); } IEnumerator PropagateState(GameObject target, State state, float time) { yield return new WaitForSeconds(time); target.GetComponent<EffectsState>().ChangeState(state); } //A small vibration effect for state changes: it gives a more physical feedback IEnumerator Vibrate(Vector3 op) { int i = 15; while (i > 0) { gameObject.transform.localPosition = op + Random.insideUnitSphere * 0.05f; i--; yield return null; } gameObject.transform.localPosition = op; } IEnumerator ThawIce() { yield return new WaitForSeconds(IceThawTime); Instantiate(IcePuffEffect, new Vector3(gameObject.transform.position.x, gameObject.transform.position.y, gameObject.transform.position.z), Quaternion.identity); ChangeState(State.Neutral); StartCoroutine(Vibrate(gameObject.transform.localPosition)); } }
And these are some examples of what the above code does with related code snippets:
void Fire() { //We instantiate the fire effect and functionality GameObject FireGO = Instantiate(FireEffectPrefab, gameObject.transform.position, Quaternion.identity); FireGO.transform.SetParent(gameObject.transform); FireGO.transform.localScale = new Vector3(1, 1, 1); FireGO.transform.rotation = gameObject.transform.rotation; FireGO.tag = "Effect"; //Both the particle system and the trigger collider get the same shape as the parent object var fireps = FireGO.GetComponent<ParticleSystem>(); var firesh = fireps.shape; firesh.shapeType = ParticleSystemShapeType.Mesh; firesh.mesh = fireMesh; var emission = fireps.emission; emission.rateOverTime = FireParticlesAmount; fireps.Play(); var firecoll = FireGO.GetComponent<BoxCollider>(); firecoll.size = fireColliderBoxSize; firecoll.isTrigger = true; } (...) IEnumerator PropagateState(GameObject target, State state, float time) { yield return new WaitForSeconds(time); target.GetComponent().ChangeState(state); }
//Ice makes object slippery and changes the material void Ice() { Vector3 originalPos = gameObject.transform.localPosition; StartCoroutine(Vibrate(originalPos)); gameObject.GetComponent<Renderer>().material = IceMaterial; gameObject.GetComponent<Collider>().sharedMaterial = IcePhysMat; Instantiate(IcePuffEffect, gameObject.transform.position, Quaternion.identity); if (IceThawTime > 0) { StartCoroutine(ThawIce()); } } IEnumerator ThawIce() { yield return new WaitForSeconds(IceThawTime); Instantiate(IcePuffEffect, new Vector3(gameObject.transform.position.x, gameObject.transform.position.y, gameObject.transform.position.z), Quaternion.identity); ChangeState(State.Neutral); StartCoroutine(Vibrate(gameObject.transform.localPosition)); }
//Counter for destroying an object on fire void Update() { if (currentState == State.Fire && CanBeDestroyedByFire == true && destructionStarted == false) { TimeToDestroyFromFire -= Time.deltaTime; if (TimeToDestroyFromFire <= 0) { destructionStarted = true; StartCoroutine(DestroyObject()); } } }
I am also very happy about how the creature mechanic turned out. One of the inspirations for this project was Pikmin, and I worked hard to try and make the creatures follow the player in a way that was both believable and cute.
I wrote more about the making of this game and some of its systems in an article on Made with Unity.
Where you can find it
You can download it here (PC version. Please contact me if you would like to play the Mac version).