Author: Dan Hassett

Tales From Cat Space: Forever

Tales From Cat Space: Forever is an infinite runner where the goal is to survive as long as possible and get a high score!

The game was originally made for a mobile app development course in college. I was a solo developer on the project (beyond some help from tutorials) and I handled both C# scripting and Unity implementation of the various mechanics.

In 2023, I dove back into the project to add some updates to various aspects of the game which included expanding the power-ups, updating the UI, adding things like tracking the score of the previous run, and just generally polishing various aspects of the game.

CONTROLS

  • Space/Left-Click – Jump/Double Jump
  • Escape – Pause

Subtension

BaseWeaponUpgradeSO.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// All weapon upgrades use this class for their type, status, multiplier, and activation.
/// Each new weapon upgrade is meant to override the ActivateUpgrade() method with the desired behavior.
/// 
/// Weapon upgrades are loaded in the battle scene by the PlayerUpgrades script/manager.
/// 
/// The PlayerUpgrades script reads the upgradeStatus which is set to true by the ShipUpgradeManager script
/// when an upgrade is purchased from the shop.
/// 
/// Dan Hassett, 10/8/22
/// </summary>

public enum UpgradeTypes {Damage, WeaponCharge, CriticalHitRate, ShieldCharge, AntiHazard}

public class BaseWeaponUpgradeSO : ScriptableObject
{
    [Tooltip("A multiplier of the weapon's type of upgrade. For example, if you want to cut " +
        "the the value in HALF, set it to 0.5. If you want to DOUBLE it, set it to 2.")]
    public float upgradeMultiplier = 1.0f;
    public bool upgradeStatus = false;

    public virtual void SetUpgradeStatus (bool status)
    {
        upgradeStatus = status;
    }

    public virtual void ActivateUpgrade() { }

}

PlayerUpgrades.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// This class exists to load the upgrades purchased from the shop into the battle scenes
/// Dan Hassett, 10/8/22
/// </summary>
public class PlayerUpgrades : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        LoadUpgrades();
    }

    // This method loads any upgrades which have been purchased from the shop by reading data from a static dictionary
    void LoadUpgrades()
    {
        foreach (KeyValuePair<BaseWeaponUpgradeSO, bool> entry in ShipUpgradeManager.weaponUpgradeDictionary)
        {
            if (entry.Value == true)
            {
                entry.Key.ActivateUpgrade();
                EventBus.Publish(new UpgradeAddedEvent(entry.Key.GetType(), entry.Key, false));
            }
        }
    }
}

ShipUpgradeManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// This class handles the various upgrade systems of the ship which are purchased in the shop.
/// The upgrades are loaded in the battle scene by the PlayerUpgrades script/manager
/// 
/// Dan Hassett, 10/1/22
/// </summary>

public class ShipUpgradeManager : MonoBehaviour
{
    [Header("Weapon Upgrade Scriptable Objects")]
    public BaseWeaponUpgradeSO[] upgrades;

    public WeaponRoomSO[] weaponRooms;
    public ShieldRoomSO[] shieldRooms;

    public static Dictionary<BaseWeaponUpgradeSO, bool> weaponUpgradeDictionary = new Dictionary<BaseWeaponUpgradeSO, bool>();
    public static Dictionary<string, WeaponRoomSO[]> weaponRoomSODictionary = new Dictionary<string, WeaponRoomSO[]>();
    public static ShieldRoomSO[] staticShieldRooms;

    // Awake() loads the necessary upgrade and room scriptable objects into the static Dictionaries.
    // The weaponRooms array must be filled with WeaponRoomSO scriptable objects in the inspector in Unity.
    private void Awake()
    {
        for(int a = 0; a < upgrades.Length; a++)
        {
            if (weaponUpgradeDictionary.ContainsKey(upgrades[a]) == false)
            {
                weaponUpgradeDictionary.Add(upgrades[a], false);
            }
        }

        if (weaponRoomSODictionary.ContainsKey("weaponRooms") == false)
        {
            weaponRoomSODictionary.Add("weaponRooms", weaponRooms);
        }     
    }

    // This method should be attached to the "Purchase" button in the Ship Upgrades section of the Shop Scene.
    // When an upgrade is purchased in the Shop scene, this method changes the value to "true"
    // so that the PlayerUpgrade script knows which upgrades to activate in the Battle scenes
    public void UpgradePurchased(UpgradeTypes upgrade)
    {
        switch (upgrade)
        {
            case UpgradeTypes.Damage:
                //Debug.Log("ShipUpgradeManager: DAMAGE UPGRADE UPGRADE purchased");
                upgrades[0].SetUpgradeStatus(true);
                break;

            case UpgradeTypes.WeaponCharge:
                //Debug.Log("ShipUpgradeManager: CHARGE SPEED UPGRADE purchased");
                upgrades[1].SetUpgradeStatus(true);
                break;

            case UpgradeTypes.CriticalHitRate:
                //Debug.Log("ShipUpgradeManager: CRITICAL HIT UPGRADE purchased");
                upgrades[2].SetUpgradeStatus(true);
                break;

            case UpgradeTypes.ShieldCharge:
                upgrades[3].SetUpgradeStatus(true);
                break;

            case UpgradeTypes.AntiHazard:
                upgrades[4].SetUpgradeStatus(true);
                break;

            default:
                Debug.LogError(upgrade + " is an invalid upgrade type");
                break;
        }
    }
}

Target Exploder

Target Exploder is a Project created in Unity. The goal of the game is to time jumps correctly and to hit moving targets.

Miscellaneous Scripts

EnemyHealth.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using MoreMountains.Feedbacks;

public class EnemyHealth : MoreMountains.TopDownEngine.Health
{
    [SerializeField]
    UIManager uiManager;
    public MoreMountains.TopDownEngine.Loot loot;
    public MMFeedbacks destroyMMFeedbacks;

    protected override void Start()
    {
        base.Start();
    }

    public override void Kill()
    {
        base.Kill();
        uiManager.UpdateRemainingEnemyCount();
        destroyMMFeedbacks?.PlayFeedbacks(this.transform.position);
    }

    public override void Initialization()
    {
        base.Initialization();

        if (uiManager == null)
        {
            uiManager = FindObjectOfType<UIManager>();
        }

        if (loot == null)
        {
            loot = gameObject.GetComponent<MoreMountains.TopDownEngine.Loot>();
        }

        destroyMMFeedbacks?.Initialization(this.gameObject);
    }

    void DestroyFeedback()
    {
        destroyMMFeedbacks?.PlayFeedbacks(this.transform.position);
    }
}

Cannon.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Cannon : MonoBehaviour
{
    public GameObject projectile;
    public Material[] materials;
    public GameObject[] projectiles;

    bool objectFired = false;

    void Update()
    {
        LaunchCannonball();
    }

    //The main method that fires projectiles when the appropriate UI button is pressed
    public void LaunchCannonball()
    {
        if (objectFired == true)
        {
            Instantiate(projectile, transform.position, Quaternion.identity);
            objectFired = false;
        }
    }

    //Prevents a steady stream of projectiles from being fired in the Update tick
    public void SetObjectFiredBoolToTrue()
    {
        if (objectFired != true)
        {
            objectFired = true;
        }
    }

    //Method is called when the appropriate UI button is pressed
    public void ChangeMaterial(int materialIndex)
    {
        projectile.GetComponent<Renderer>().material = materials[materialIndex];
    }

    //Method is called when the appropriate UI button is pressed
    public void ChangeProjectile(int projectileIndex)
    {
        projectile = projectiles[projectileIndex];
    }
}

Bricks.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bricks : MonoBehaviour
{

    public GameObject brickParticle;
    public GameObject ballSpeedUpPickup;
    public GameObject ballSlowDownPickup;
    public GameObject paddleSpeedUpPickup;
    public GameObject paddleSlowDownPickup;
    public int hitsToDestroyBrick;
    public int randomNumber;


    void Start()
    {
        hitsToDestroyBrick = 3;
    }

    void Update()
    {
        if (GM.instance.playerOnesTurn == true)
        {
            GM.instance.playerScore.text = "Player 1: " + GM.instance.playerOnesScore;
        }

        if (GM.instance.playerOnesTurn == false)
        {
            GM.instance.playerScore.text = "Player 2: " + GM.instance.playerTwosScore;
        }
    }

    //Handles the "health" of bricks and instantiates particle effects, drops pick-ups,
    //and increases player 1 or 2's score on destruction in the Game Manager.
    void OnCollisionEnter()
    {
        if (other.gameObject.CompareTag("Ball"))
        {
            hitsToDestroyBrick--;

            switch (hitsToDestroyBrick)
            {
                case 4:
                    gameObject.GetComponent<Renderer>().material.SetColor("_Color", Color.gray);
                    break;

                case 3:
                    gameObject.GetComponent<Renderer>().material.SetColor("_Color", Color.magenta);
                    break;

                case 2:
                    gameObject.GetComponent<Renderer>().material.SetColor("_Color", Color.cyan);
                    break;

                case 1:
                    gameObject.GetComponent<Renderer>().material.SetColor("_Color", Color.red);
                    break;

                case 0:
                    Instantiate(brickParticle, transform.position, Quaternion.identity);

                    if (randomNumber <= 1)
                    {
                        Instantiate(ballSpeedUpPickup, transform.position, Quaternion.identity);
                    }
                    else if (randomNumber <= 2)
                    {
                        Instantiate(ballSlowDownPickup, transform.position, Quaternion.identity);
                    }
                    else if (randomNumber <= 3)
                    {
                        Instantiate(paddleSpeedUpPickup, transform.position, Quaternion.identity);
                    }
                    else if (randomNumber <= 4)
                    {
                        Instantiate(paddleSlowDownPickup, transform.position, Quaternion.identity);
                    }

                    GM.instance.DestroyBrick();
                    Destroy(gameObject);

                    if (GM.instance.playerOnesTurn == true)
                    {
                        GM.instance.playerOnesScore++;
                        GM.instance.playerScore.text = "Player 1: " + GM.instance.playerOnesScore;
                    }

                    if (GM.instance.playerOnesTurn == false)
                    {
                        GM.instance.playerTwosScore++;
                        GM.instance.playerScore.text = "Player 2: " + GM.instance.playerTwosScore;
                    }

                    break;

                default:
                    break;
            }
        }
    }
}

DivineIntervention.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

//This script is a spell which slows down enemies when they encounter it
public class DivineIntervention : MonoBehaviour 
{

    float enemySpeed;
    public float spellTimeInSeconds = 4.0f;
    public float enemySlowdownAmount = 0.5f;

    void DestroySpell()
    {
        Destroy(gameObject);
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.CompareTag("Enemy"))
        {
            StartCoroutine(SlowDownEnemy(other.gameObject));

            Invoke("DestroySpell", spellTimeInSeconds);
        }
    }

    IEnumerator SlowDownEnemy(GameObject other)
    {
        float secondsToFreeze = spellTimeInSeconds;

        //get enemy speed before slow down effects it
        enemySpeed = other.GetComponent<Enemy>().speed;

        other.GetComponent<Enemy>().speed *= enemySlowdownAmount;

        yield return new WaitForSeconds(secondsToFreeze - 0.25f);

        //set enemy speed back to normal
        other.GetComponent<Enemy>().speed = enemySpeed;

    }
}

SeeSeed.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

//This is a spell that stops an enemy in their tracks for a certain period of time
public class SeeSeed : MonoBehaviour 
{
	public float spellTimeInSeconds = 4.0f;
    float enemySpeed;

    //Activates the spell when an enemy walks into the trigger
    private void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.CompareTag("Enemy"))
        {

            StartCoroutine(StopEnemy(other.gameObject));

            //Destroys the spell after a certain duration
            Invoke("DestroySpell", spellTimeInSeconds);
        }
    }

    //Gets the speed of the enemy's NavMesh component, sets it to 0, and then restores it to the original speed after a duration
    IEnumerator StopEnemy(GameObject other)
    {
        float secondsToFreeze = spellTimeInSeconds;

        //Set enemy speed to enemy normal speed
        enemySpeed = other.GetComponent<Enemy>().speed;

        //Stop enemy
        other.GetComponent<Enemy>().speed = 0;
        yield return new WaitForSeconds(secondsToFreeze - 0.3f);

        //Reset enemy speed to enemy normal speed
        other.GetComponent<Enemy>().speed = enemySpeed;
        
    }

	private void DestroySpell()
	{
        Destroy(gameObject);
	}
}

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class Boundary
{
	public float xMin, xMax, zMin, zMax;
}

public class PlayerController : MonoBehaviour 
{
	public float speed;
	public float tilt;
	public Boundary boundary;

	public GameObject shot;
	public Transform[] shotSpawns;
	public float fireRate;
    public bool rapidFire = false;
    private float nextFire;

    //Handles shooting projectiles with a certain delay between shots
	void Update ()
	{
		if (Input.GetButton ("Fire1") && Time.time > nextFire) 
		{
			if (rapidFire == false)
			{
				nextFire = Time.time + fireRate;
				foreach (var shotSpawn in shotSpawns)
				{
					Instantiate (shot, shotSpawn.position, shotSpawn.rotation);
				}

				GetComponent<AudioSource> ().Play();
			}

			if (rapidFire == true)
			{
				nextFire = Time.time + (fireRate * 0.5f);
				foreach (var shotSpawn in shotSpawns)
				{
					Instantiate (shot, shotSpawn.position, shotSpawn.rotation);
				}

				GetComponent<AudioSource> ().Play();
			}
		}
	}

	void FixedUpdate () 
	{
		float moveHorizontal = Input.GetAxis ("Horizontal");
		float moveVertical = Input.GetAxis ("Vertical");

		Vector3 movement = new Vector3 (moveHorizontal, 0.0f, moveVertical);
		gameObject.GetComponent<Rigidbody> ().velocity = movement * speed;

		gameObject.GetComponent<Rigidbody> ().position = new Vector3 
			(
				Mathf.Clamp(gameObject.GetComponent<Rigidbody>().position.x, boundary.xMin, boundary.xMax),
				0.0f,
				Mathf.Clamp(gameObject.GetComponent<Rigidbody>().position.z, boundary.zMin, boundary.zMax)
			);

		gameObject.GetComponent<Rigidbody>().rotation = Quaternion.Euler (0.0f, 0.0f, gameObject.GetComponent<Rigidbody>().velocity.x * -tilt);
	}

	public void ActivatePickups ()
	{
		StartCoroutine (ActivateRapidFire (10));
	}

	IEnumerator ActivateRapidFire(float time)
	{
		float currentTime = 0.0f;

		do
		{
			rapidFire = true;
			currentTime += Time.deltaTime;
			yield return null;
		} while (currentTime <= time);

		rapidFire = false;
	}
}
Scroll to top