Check out Pick Some Axe on: Steam, itch.io

My Role: Gameplay / UI Programmer

  • Made in Unity | Team Size: 13
  • Refactored existing UI systems to make them more maintainable, and created new UI systems with a focus on sustainability
  • Designed new gameplay features for combat, puzzle-solving, and exploration
  • Created developer tools with specific functions requested by other programmers and designers to optimize the testing process of new features

Code Snippets

BreakableBarrel.cs
using System;
using UnityEngine;
using FMODUnity;

[Serializable]
public struct BarrelDrop
{
    public GameObject item;
    public float percentChance;
}

public class BreakableBarrel : MonoBehaviour
{
    [SerializeField] GameObject barrelBreakVFX;
    [SerializeField] BarrelDrop[] drops;
    [SerializeField] EventReference BreakSound;

    void Awake()
    {
        BreakSound = RuntimeManager.PathToEventReference("event:/SFX/Interactions/Barrel Break");
    }
    
    void OnTriggerEnter(Collider other)
    {
        // break when player hits this with pickaxe
        if(other.CompareTag("PickaxeHitbox")) { Break(); }
    }

    private void OnCollisionEnter(Collision collision)
    {
        // break when enemy is knocked back into this
        if (collision.collider.CompareTag("Enemy"))
        {
            EnemyScript enemy = collision.collider.GetComponent<EnemyScript>();

            // only break if enemy is actually in knockback
            if (enemy != null && enemy.stunned) { Break(); }
        }

        // break when player belly bashes this
        BellyDash bellyDash = collision.gameObject.GetComponent<BellyDash>();
        if (bellyDash != null && bellyDash.IsDashing) { Break(); }
    }

    public void Break()
    {
        DropItem();

        if(barrelBreakVFX) { Instantiate(barrelBreakVFX, transform.position, Quaternion.identity); }
        RuntimeManager.PlayOneShot(BreakSound, transform.position);

        // set persistence key so the barrel stays destroyed between play sessions
        PersistenceKey key = GetComponent<PersistenceKey>();
        if (null != key) { key.SetDestroyed(true); }

        Destroy(gameObject);
    }

    public void DropItem()
    {
        float rand = UnityEngine.Random.Range(0f, 100f);
        float accumulation = 0f;

        // loop through each item until the RNG number is within that item's "window"
        // accumulation creates windows by stacking each chance on top of each other
        // without having to define the ranges manually
        for(int i = 0; i < drops.Length; i++)
        {
            accumulation += drops[i].percentChance;
            if(rand <= accumulation)
            {
                if(null != drops[i].item) { Instantiate(drops[i].item, transform.position, Quaternion.identity); }
                i = drops.Length;
            }
        }
    }
}
GembitsUI.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class GembitsUI : MonoBehaviour
{
    [SerializeField] float visibleTime = 3.0f;
    [SerializeField] float fadeOutTime = 1.0f;
    float visibleTimer = 0f;
    bool fadingOut = false;
    bool forceVisible = false;

    Image gembitsImage;
    TMP_Text gembitsText;

    void Awake()
    {
        gembitsImage = GetComponentInChildren<Image>();
        gembitsText = GetComponentInChildren<TMP_Text>();
    }

    void Start()
    {
        PlayerState.ListenToGembits(GembitsChanged);
        GembitsChanged(PlayerState.GetGembits());

        ShopHandler[] shops = FindObjectsOfType<ShopHandler>();
        foreach(ShopHandler shop in shops) { shop.ListenToInShop(InShop); }
    }

    void OnDestroy()
    {
        PlayerState.ListenToGembits(GembitsChanged, false);

        ShopHandler[] shops = FindObjectsOfType<ShopHandler>();
        foreach(ShopHandler shop in shops) { shop.ListenToInShop(InShop, false); }
    }

    void Update()
    {
        visibleTimer += Time.deltaTime;
        if(!forceVisible && !fadingOut && visibleTimer >= visibleTime)
        {
            fadingOut = true;
            gembitsImage.CrossFadeAlpha(0f, fadeOutTime, false);
            gembitsText.CrossFadeAlpha(0f, fadeOutTime, false);
        }
    }

    void ResetFadeOut()
    {
        visibleTimer = 0f;
        fadingOut = false;
        gembitsImage.CrossFadeAlpha(1f, 0f, true);
        gembitsText.CrossFadeAlpha(1f, 0f, true);
    }

    void GembitsChanged(int amount)
    {
        gembitsText.text = amount.ToString("D6");
        ResetFadeOut();
    }

    void InShop(bool inShop)
    {
        forceVisible = inShop;
        ResetFadeOut();
    }
}
LastSafePosition.cs
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(ThirdPersonController))]
public class LastSafePosition : MonoBehaviour
{
    [SerializeField, Tooltip("Layers that count as safe ground.")] LayerMask groundLayers;
    [SerializeField] float raycastLength = 1.5f;

    // Minimum height for each level of safe positions
    [SerializeField] List<Transform> respawnFloors;
    float[] respawnFloorsY;
    Vector3[] safePositions;
    Vector3 overrideSafePosition = Vector3.positiveInfinity; // used during final climb
    bool safePosFound;

    readonly Vector3[] raycasts = {
        new(-1, -1, -1), new(0, -1, -1), new(1, -1, -1),
        new(-1, -1, 0),  new(0, -1, 0),  new(1, -1, 0),
        new(-1, -1, 1),  new(0, -1, 1),  new(1, -1, 1)
    };

    void Awake()
    {
        BubbleSortRespawnFloors();

        respawnFloorsY = new float[respawnFloors.Count];
        safePositions = new Vector3[respawnFloors.Count];
        for(int i = 0; i < respawnFloors.Count; i++)
        {
            respawnFloorsY[i] = respawnFloors[i].position.y;
            safePositions[i] = Vector3.positiveInfinity;
            Destroy(respawnFloors[i].gameObject);
        }
        respawnFloors.Clear();
    }

    void Start()
    {
        Vector3 returnPos = PlayerState.GetReturnPos();
        if(!Equals(Vector3.positiveInfinity, returnPos)
            && SceneState.GetScene() == PlayerState.GetReturnLevel())
        { GetComponent<ThirdPersonController>().Teleport(returnPos); }
    }

    void OnDestroy()
    {
        // try to set the return level
        ScenePSA prevScene = SceneState.GetPrevScene();
        bool success = PlayerState.SetReturnLevel(prevScene);

        if(ScenePSA.MAIN_MENU == prevScene) { return; } // special case: this is a purposeful failure
        else if(!success)
        {
            Debug.LogError("Failed to set return level to: " + SceneState.GetPrevScene());
            return;
        }

        // if that works, save last safe position as return position (if one exists)
        Vector3 returnPos;
        for(int i = 0; i < GetFloors(); i++)
        {
            returnPos = Get(transform.position.y, i);
            if(!Equals(Vector3.positiveInfinity, returnPos))
            {
                i = GetFloors();
                PlayerState.SetReturnPos(returnPos);
            }
        }

        PlayerState.Save(); // weird place to call this but it should be fine
    }

    void FixedUpdate()
    {
        safePosFound = true;

        // cast out a bunch of raycasts to determine what the ground is like around the player
        // if any of them return no ground collision, the position is not a valid respawn position
        for(int i = 0; i < 9; i++)
        {
            if(!Physics.Raycast(transform.position, raycasts[i], out _, raycastLength, groundLayers))
            {
                safePosFound = false;
                i = raycasts.Length;
            }
        }

        if(!safePosFound) { return; }

        // save safe position within highest possible floor
        for(int i = 0; i < safePositions.Length; i++)
        {
            if(transform.position.y > respawnFloorsY[i])
            {
                safePositions[i] = transform.position;
                //i = safePositions.Length;
            }
        }
    }

    // https://www.geeksforgeeks.org/dsa/bubble-sort-algorithm/
    void BubbleSortRespawnFloors()
    {
        int n = respawnFloors.Count;
        if(0 == n) { return; }

        bool swapped;
        for(int i = 0; i < n-1; i++) {
            swapped = false;
            for(int j = 0; j < n-i-1; j++)
            {
                // sort greatest to least
                if(respawnFloors[j].position.y < respawnFloors[j+1].position.y)
                {
                    (respawnFloors[j+1], respawnFloors[j]) = (respawnFloors[j], respawnFloors[j+1]);
                    swapped = true;
                }
            }

            if(!swapped) { i = n; }
        }
    }

    public Vector3 Get(float currentY, int backUp = 0)
    {
        // Safe position override has priority, used by final climb
        if(!Equals(Vector3.positiveInfinity, overrideSafePosition))
        {
            Vector3 temp = overrideSafePosition;
            overrideSafePosition = Vector3.positiveInfinity; // override decays after one respawn
            return temp;
        }

        // otherwise, determine which last safe position to use
        for(int i = 0; i < respawnFloorsY.Length; i++)
        {
            if(currentY >= respawnFloorsY[i])
            {
                if(backUp > i) { backUp = i; }
                return safePositions[i-backUp];
            }
        }

        Debug.LogWarning("Couldn't find a floor to respawn on!");
        return Vector3.positiveInfinity;
    }

    public int GetFloors() { return respawnFloorsY.Length; }

    public void SetOverrideSafePosition(Vector3 pos) { overrideSafePosition = pos; }
}