RPG Dungeon

Project Type      Personal
Software Used     Unity
Languages Used    C#, HLSL
Primary Role(s)   Gameplay Programmer
                    

RPG Dungeon is my passion project for my ideal video game. It's an RPG platformer inspired by games like Vagante, DnD, and more. So far, it features Procedurally Generated Levels, Inventory and Items System, Enemy AI state Machines with Platformer A* Pathfinding, Custom Pixel Animations with many more systems and features planned to come at an unknown date, but their arrival is certain.

Introduction

It's the first big project I started with the intent of learning Unity systems and sharpening my programming skills, though actually finishing it one day would be a pretty pleasant bonus. It's a piece of art I keep coming back to and shaping in my free time. The core concept is a fantasy D&D-inspired experience in a procedurally generated dungeon with combat heavily influenced by your character's stats, abilities and environment - allowing for wide variety of playstyles.

Finite State Machines (for AI logic)

All NPC types in the game are driven by their own Finite State Machine (FSM), which share the same overall structure with their own states but support NPC-specific variables and functions within the State Machine Manager. For example, the goblin FSM includes custom stats like max health, knockback for attacks, as well as goblin-specific functions such as their pathfinding algorithm and arrow-shooting logic.


public class GoblinStateManager : MonoBehaviour, IDamageable
{
    GoblinBaseState currentState;
    public GoblinIdleState idleState = new GoblinIdleState();
    public GoblinPursuingState pursuingState = new GoblinPursuingState();
    public GoblinShootingState shootingState = new GoblinShootingState();
    public GoblinSearchingState searchingState = new GoblinSearchingState();
    public GoblinDeadState deadState = new GoblinDeadState();

    public Rigidbody2D m_Rigidbody2D;
    public Animator animator;
    public Transform target;
    public Vector3 targetLastSeen;
    public Transform bow;
    public GameObject arrow;
    public LayerMask groundLayerMask;
    public KnockbackScript knockbackScript;

    public bool m_FacingRight = false;
    // The variable is calculated and set during the GoblinShootingState
    public float bowAngle;

    // Timer that starts after character jumps, used for ground checks
    public float jumpCooldown;

    // Character stats
    public int maxHealth = 20;
    int currentHealth;
    private float knockbackStr = 80;

    // Pathfinding variables
    public Node[] path;
    int targetIndex;

    void Start()
    {
        // Gets the reference for bow GameObject
        bow = transform.GetChild(0).transform;

        currentState = idleState;
        currentState.EnterState(this);

        currentHealth = maxHealth;
        path = new Node[0];
    }

    // Update is called once per frame
    void Update()
    {
        currentState.UpdateState(this);
    }

    void FixedUpdate()
    {
        currentState.FixedUpdateState(this);
    }

    public void SwitchState(GoblinBaseState state)
    {
        currentState = state;
        state.EnterState(this);
    }

    public void Flip()
	{
		// Switch the way the player is labelled as facing.
		m_FacingRight = !m_FacingRight;

		// Multiply the player's x local scale by -1, to change facing direction.
		Vector3 theScale = transform.localScale;
		theScale.x *= -1;
		transform.localScale = theScale;
	}

    // Spins the character sprite to face their target. Used when attacking.
    public void FaceTarget()
    {
        if (transform.position.x < target.position.x)
        {
            Vector3 theScale = transform.localScale;
            theScale.x = -1;
            transform.localScale = theScale;
            m_FacingRight = false;
        }
        else
        {
            Vector3 theScale = transform.localScale;
            theScale.x = 1;
            transform.localScale = theScale;
            m_FacingRight = true;
        }
    }

    // Spawns an arrow with force added at an angle the bow is positioned.
    public void ShootArrow()
    {
        if (m_FacingRight)
        {
            GameObject projectile = Instantiate(arrow, transform.position, Quaternion.identity);
            projectile.GetComponent().AddForce(1f * GetVectorFromAngle(bowAngle + 180), ForceMode2D.Impulse);
            animator.SetBool("Shooting", false);
            SwitchState(pursuingState);
        }
        else
        {
            GameObject projectile = Instantiate(arrow, transform.position, Quaternion.identity);
            projectile.GetComponent().AddForce(1f * GetVectorFromAngle(bowAngle), ForceMode2D.Impulse);
            animator.SetBool("Shooting", false);
            SwitchState(pursuingState);
        }
    }

    private static Vector3 GetVectorFromAngle(float angle) {
        float angleRad = angle * (Mathf.PI/180f);
        return new Vector3(Mathf.Cos(angleRad), Mathf.Sin(angleRad));
    }

    public void TakeDamage(int DMG, Vector3 point)
    {
        currentHealth -= DMG;
        Debug.Log("Character dealt " + DMG + " damage");
        
        //Play hurt animation
        
        targetLastSeen = target.position;

        if(currentHealth <= 0)
        {
            SwitchState(deadState);
        } 
        else if (currentState == idleState) 
        {
            SwitchState(pursuingState);
        }

        
        knockbackScript.knockback(point.x, knockbackStr);
    }


    public bool GroundCheck()
    {
        // The GroundCheck has a cooldown so that the raycast is not performed immediately after jump function is called, 
        // otherwise jump function is called multiple times in a single instance of a jump
        if (jumpCooldown >= 0.3f)
        {
            RaycastHit2D groundCheck = Physics2D.Raycast(transform.position, Vector2.down, 0.15f, groundLayerMask);
            if (groundCheck.collider != null)
            {
                return true;
            }
            else
            {
                return false;
            }
        }
        else
        {
            return false;
        }
    }

    public void OnPathFound(Node[] newPath, bool pathSuccessful) 
    {
        if (pathSuccessful) {
            path = newPath;

            StopCoroutine("FollowPath");
            StartCoroutine("FollowPath");
        } 
        
    }

    public IEnumerator FollowPath()
    {
        animator.SetFloat("Speed", 1);
        Vector3 currentWaypoint = path[0].worldPosition;
        targetIndex = 0;

        while (true) {
            if (Vector2.Distance(transform.position,currentWaypoint) < 0.1f) {
                targetIndex++;
                if (targetIndex >= path.Length) {
                    path = new Node[0];

                    SwitchState(idleState);

                    yield break;
                }
                currentWaypoint = path[targetIndex].worldPosition;
            }

            // Movement towards nodes logic
            //Debug.Log(path.Length + " is the length of array");
            Debug.Log(targetIndex + " is the index searched");
            if (path[targetIndex].jumpToNode && GroundCheck()){
                jumpCooldown = 0;
                m_Rigidbody2D.velocity = new Vector2(pursuingState.horizontalMove*Time.deltaTime, 0);
                yield return new WaitForSeconds(0.1f);
                m_Rigidbody2D.AddForce(new Vector2(0, 3.25f), ForceMode2D.Impulse);
            }
            if(transform.position.x < currentWaypoint.x){
                pursuingState.horizontalMove = 50;
            } 
            else if (Mathf.Abs(transform.position.x - currentWaypoint.x) < 0.02f) {
                m_Rigidbody2D.velocity = new Vector2(0, m_Rigidbody2D.velocity.y);
            }
            else {
                pursuingState.horizontalMove = -50;
            }

            yield return null;
        }
    }
}
                

Pathfinding

I've already made a Devlog video explaining the pathfinding algorithm and uploaded it to YouTube, so I'll give a quick rundown on here.

In an nutshell, The algorithm starts by scanning the map and generating traversable nodes that are above walkable ground (duh), information about neighbouring nodes and edge nodes is also calculated in this process. So now when an NPC is chasing a player, the npc uses A* pathfinding algorithm to find the most optimal route to the node that the player was last seen on.

Inventory System

RPG Dungeon features a wide variety of types of items that have their own uses and functions, such as consumables, weapons, miscellaneous and so on. For this reason, I developed an item scriptable object (SO) code to make the process of adding a new item to the game seemless. There is a default item SO script if an item doesn't fall into any particular category, and there are type specific SO scripts which include functions for that specific item type. Below is the example of the default item SO script.


[CreateAssetMenu(fileName = "NewItem", menuName = "Items/Item")]

public class ItemSO : ScriptableObject
{
    [field: SerializeField]
    public bool IsStackable {get; set;}

    public int ID => GetInstanceID();

    [field: SerializeField]
    public int MaxStackSize {get; set;} = 1;

    [field: SerializeField]
    public string Name {get; set;}

    [field: TextArea]
    [field: SerializeField]
    public string Description {get; set;}

    [field: SerializeField]
    public Sprite ItemSprite {get; set;} // Sprite used in the inventory UI

    [field: SerializeField]
    public GameObject ItemSpriteObject {get; set;} 

    [field: SerializeField]
    public GameObject item; //Game object on the world map

    [field: SerializeField]
    public ItemType itemType; // An enum that defines the item's type

    public virtual void Use()
    {
        // This method can be overridden by child classes to define item-specific behavior
        Debug.Log("Using item: " + Name);
    }

    public virtual void Equip()
    {
        Debug.Log("Equiping item: " + Name);
    }

    public virtual void Unequip()
    {
        Debug.Log("Unequiping item: " + Name);
    }

    public void DropItem(Vector2 playerPosition)
    {
        //instantiates the specified item game object at the player position
        Instantiate(item, playerPosition, Quaternion.identity);
    }
}

public enum ItemType
{
    Weapon,
    Armor,
    Accessory,
    Consumable,
    // Add more types as needed
}
                

The main inventory code is designed to be a list of "slots", which are game objects occupying each slot of the inventory that contain information about the item in that slot and functions which are specific to slots, like displaying the sprite of the item on the UI or placing an "Equipped" marker when the item is equipped (shocker). Additionally, the inventory script holds a list of item scriptable objects. The reason for this second list of items is because the first list uses functions that interact with the UI, whereas the list of scriptable objects contains functions that are relevant to the game mechanics and the character, like changing of the stats when armour is equipped.


public class InventoryScript : MonoBehaviour
{
    public bool[] isFull;
    public GameObject[] slots;
    public ItemSO[] SOslots;

    private Image selector;
    public int lastEquippedSlot;
    private int lastSlot;
    public bool weaponEquipped;

    void Start ()
    {
        selector = slots[0].GetComponent();
        selector.color = new Color(selector.color.r, selector.color.g, selector.color.b, 0.4f);
        lastEquippedSlot = 34;
        lastSlot = 0;
        weaponEquipped = false;
    }

    public void UpdateSelectedSlot(int slot)
    {
        selector = slots[slot].GetComponent();
        selector.color = new Color(selector.color.r, selector.color.g, selector.color.b, 0.4f);
        selector = slots[lastSlot].GetComponent();
        selector.color = new Color(selector.color.r, selector.color.g, selector.color.b, 0f);
        lastSlot = slot;
    }

    public void EquipWeapon(int slot)
    {   
        //slots[lastEquippedSlot].GetComponent().RemoveEquipMarker();
        SOslots[slot].Equip();
        slots[slot].GetComponent().PlaceEquipMarker();
        weaponEquipped = true;
        lastEquippedSlot = slot;
    }

    public void SwitchWeapon(int slot){
        SOslots[lastEquippedSlot].Unequip();
        slots[lastEquippedSlot].GetComponent().RemoveEquipMarker();
        SOslots[slot].Equip();
        slots[slot].GetComponent().PlaceEquipMarker();
        //weaponEquipped = true;
        lastEquippedSlot = slot;
    }

    public void UnequipWeapon(int slot)
    {
        slots[slot].GetComponent().RemoveEquipMarker();
        SOslots[slot].Unequip();
        weaponEquipped = false;
    }
}