Categories
Game Dev Unity

Sanctum of the Evergreen: Solitude

Play It Here

A 2D wave defence game created in Unity by 8 people for a uni project.

Our task was to create a game in 3 weeks while emulating a small studio.
The team was comprised of core lead roles;
The Producer,
Design Director,
Art Director,
Tech Director,
QA Director,

Plus additional developers we “hired” for our project.
2 x Artists
1 x Programmer.

(note, a few of us wore a few extra hats to help share the load)


My role was that of the Tech Director.

It was up to me to select the software, set up coding standards, ensure build quality, write code, overseer code written for the game & deal with any Tech issues that would arise.

I lead 2 other talented programmers who did majority of the heavy lifting code-wise, while I made sure the programming milestones were met.


My Contributions

Sprite Animations

I set up a sprite animator script to display the correct sprite, dependent on the direction the player was travelling.
This worked simply by a function which tracks the facing direction, which I then feed to the Animator. See below;

  public class PlayerAnimator : MonoBehaviour
    {
        Animator playerAnimator;
        PlayerMovement playerMovement;
        public bool isMoving;

        void Start()
        {
            playerAnimator = GetComponent<Animator>();
            playerMovement = GetComponentInParent<PlayerMovement>();
        }

        void Update()
        {
            HandleAnimations(playerMovement.p_movementDirection);
        }

        //Function to handle animations variables
        private void HandleAnimations(Vector2 direction)
        {
            //track angle
            float angle = GetDirectionAngle(direction);
            playerAnimator.SetFloat("angle", angle);
            playerAnimator.SetBool("isMoving", isMoving);

            //track moving
            if (Input.GetKey(KeyCode.D))
                isMoving = true;
            else if (angle <= 0.5 && angle >= -0.5)
                isMoving = false;
        }

        //Function which returns the facing angle between 0-360 for correct sprite rotation 
        private float GetDirectionAngle(Vector2 direction)
        {
            //Thank god for ATAN2 !
            float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;

            if (angle < 0)
            {
                angle += 360;
            }

            return angle;
        }
    }

I then set up the Animator with the necessary connections to have the player character behave as needed.

Rewind Mechanic

I also created the time reversal abilities the player character wielded.
These were an ability to reverse the magic flame balls shot along their path; and the ability to rewind enemies along their previous path.

Rewind Enemy

In the beginning, each enemy would host a ‘RewindTime’ script which would store their position each frame.
Upon a button being held down, those positions would be lerped through in reverse to give the feeling of rewinding time for the enemies.

Rewind Projectile

The same functionality was implemented for the projectiles, storing their positions and running through previous positions.

After a few tests, I realised how inefficient this method was. Particularly with how many enemies and projectiles could be on the screen at once.
I went back to the drawing board and created a separate script for the projectiles which would simply reverse their velocity back to their original start point for the time in which the player held the reverse button.

This was much more efficient as now there was 50% less objects on the screen constantly storing and updating their previous positions.

Issues & Unification

A few problems arose with my mechanic along the way, mainly was how many scripts were calling an Update() function and how many were listening for input. With so many objects independently listening for input from the same script, many odd things began to happen on the screen!

With help from my programming team, I learnt some great lessons in inheritance, delegates & events.

First, as the projectile and enemy rewind scripts were very similar I created a ‘Rewind Time’ script which both the ‘Rewind Enemy’ & ‘Rewind Projectile’ scripts would inherit from.
This would house the Rewind and Record functions akin to the ability, where the specific scripts attach to the game objects would have the object specific overrides for their functions.

Second, to control the input problem I had I set up a ‘RewindTimeManager’ script, to handle all the inputs and variables related and set events in which each object with the ‘RewindEnemy’ or ‘RewindProjectile’ scripts attached would listen for.

This process taught me a good deal about delegates and events, and how to properly use inheritance which I have brought forward into a few of my other projects.

See the final mechanics below;

Rewind Projectile
Rewind Enemy
Both mechanics at play

The Scripts

Rewind Time Manager

public class RewindTimeManager : MonoBehaviour
    {
        // Rewind events
        // Events for Projectile
        public delegate void StartProjectileRewind();
        public event StartProjectileRewind OnStartProjectileRewind;

        public delegate void StopProjectileRewind();
        public event StopProjectileRewind OnStopProjectileRewind;

        // Events for Enemy
        public delegate void StartEnemyRewind();
        public event StartEnemyRewind OnStartEnemyRewind;

        public delegate void StopEnemyRewind();
        public event StopEnemyRewind OnStopEnemyRewind;

        [Header("Tracking Variables")]
        public float rt_recordTime = 5f;
        public bool rt_isRewinding;
        public float rewindSpeed = 0.5f;
        public bool rewindingProjectile = false;
        public bool rewindingEnemy = false;
        public bool canRewind;
        public bool IsRewindingEnemies { get; private set; }

        [Header("Projectile Rewind")]
        public float pr_CurrentAmount;
        public float pr_MaxAmount;
        public float pr_RechargeAmount;
        public float pr_DrainAmount;

        [Header("Enemy Rewind Ability")]
        public int currentCharges;
        public int maxCharges = 3;
        public float chargeCost;
        public float rewindTimer;
        public float rewindDuration = 4f;


        [Header("UI Elements")]
        public GameObject charge1;
        public GameObject charge2;
        public GameObject charge3;

        private void Start()
        {
            // Set up initial values
            pr_CurrentAmount = pr_MaxAmount;
            currentCharges = maxCharges;
            rewindTimer = 0;
            canRewind = true;
        }

        private void Update()
        {
            DrainTimeAbilityInput();
            RegenerateProjectileAbility(pr_RechargeAmount);
            TrackCanRewindBool();
            TrackChargeVisuals();
            HandleInput();
        }

        //Update the UI visual elements for rewind charges available
        private void TrackChargeVisuals()
        {
            if (currentCharges == 3)
            {
                charge1.SetActive(true);
                charge2.SetActive(true);
                charge3.SetActive(true);
            }
            else if (currentCharges == 2)
            {
                charge1.SetActive(true);
                charge2.SetActive(true);
                charge3.SetActive(false);
            }
            else if (currentCharges == 1)
            {
                charge1.SetActive(true);
                charge2.SetActive(false);
                charge3.SetActive(false);
            }
            else
            {
                charge1.SetActive(false);
                charge2.SetActive(false);
                charge3.SetActive(false);
            }
        }

        // Function to drain the projectile rewind amount on use
        private void DrainTimeAbilityInput()
        {
            // drain ability
            if (Input.GetMouseButton(1)) // mouse button
                DrainProjectileAbility(pr_DrainAmount);
        }

        //Function to track whether players can use their ability, based on their 'magic' left
        private void TrackCanRewindBool()
        {
            if (pr_CurrentAmount > 0)
            {
                canRewind = true;
            }
            else if (pr_CurrentAmount <= 0)
            {
                pr_CurrentAmount = 0;
                canRewind = false;
            }
        }

        private void HandleInput()
        {
            // track projectile shot..
            if (Input.GetMouseButtonDown(1) && OnStartProjectileRewind != null && pr_CurrentAmount > 0)
            {
                OnStartProjectileRewind.Invoke();
                rewindingProjectile = true;
            }
            else if (Input.GetMouseButtonUp(1) && OnStopProjectileRewind != null)
            {
                OnStopProjectileRewind.Invoke();
                rewindingProjectile = false;
            }

            // track enemy reverses..
            if (Input.GetKeyDown(KeyCode.Space) && OnStartEnemyRewind != null && currentCharges > 0 && !rewindingEnemy)
            {
                OnStartEnemyRewind.Invoke();
                rewindingEnemy = true;
                StartCoroutine(RewindEnemies());
            }
        }
        // Coroutine to set the bools to initiate enemy rewind
        IEnumerator RewindEnemies()
        {
            currentCharges -= 1;
            IsRewindingEnemies = true;
            yield return new WaitForSeconds(rewindDuration);
            IsRewindingEnemies = false;
            if(OnStopEnemyRewind != null)
                OnStopEnemyRewind.Invoke();
            rewindingEnemy = false;
        }

        //function to call to increment charge amount for enemy rewind
        public void RechargeCharges(int num)
        {
            if(currentCharges < maxCharges)
                currentCharges += num;
        }

        //function to call from rewind scripts to use ability points
        public void DrainProjectileAbility(float amount)
        {
            pr_CurrentAmount -= amount * Time.deltaTime;
        }

        //Function which slowly regenrates rewind ability
        public void RegenerateProjectileAbility(float amount)
        {
            if (pr_CurrentAmount <= pr_MaxAmount)
            {
                pr_CurrentAmount += amount * Time.deltaTime;
            }

            // bool check
            if (pr_CurrentAmount > 0)
                canRewind = true;
        }

        //function to call to instantly recharge ability, used for item pickup
        public void RechargeProjectileAbility(float amount)
        {
            pr_CurrentAmount += amount;
            if (pr_CurrentAmount > pr_MaxAmount)
                pr_CurrentAmount = pr_MaxAmount;
        }
    }

Rewind Time – Base

//Struct to hold positions and time taken to get to position
    [System.Serializable]
    public struct TimePosition
    {
        public float time;
        public Vector2 position;

        public TimePosition(float time, Vector2 position)
        {
            this.time = time;
            this.position = position;
        }
    }

    public class RewindTime : MonoBehaviour
    {
        RewindTimeManager rewindManager;
        public TimePosition timePosition;

        [Header("Tracking Variables")]
        public float rt_recordTime = 5f;
        public List<TimePosition> positions = new List<TimePosition>();

        protected virtual void Start()
        {
            //find manager and subscribe to time events
            rewindManager = FindObjectOfType<RewindTimeManager>();

            //initialize lists
            positions = new List<TimePosition>();
        }

        bool _isRewinding = false;
        // Run back through previous positions as items popped from list
        virtual protected void Rewind()
        {
            //if already rewinding or no positons
            if (_isRewinding || positions.Count ==0)
                return;

            TimePosition previousPosition = positions[0];  // previous
            StartCoroutine(RewindPositions(previousPosition, 0));

        }

        IEnumerator RewindPositions(TimePosition previousPos, int index)
        {
            _isRewinding = true;
            float timer = 0f;
            Vector2 currentPosition = transform.position;
            float timeToRewind = Time.time - previousPos.time;
            Vector3 totalDistance = previousPos.position - currentPosition;

            while (timer < timeToRewind)
            {
                // Interpolate at constant-ish rate WORKS A LITTLE BETETR
                float fractionOfDistance = Time.deltaTime / timeToRewind; // Calculate the fraction of the total distance to move
                transform.position += totalDistance * fractionOfDistance;// Add fraction of the total distance to the current position
                yield return null;
                timer += rewindManager.rewindSpeed * Time.deltaTime;

                //// Interpolate between positions at speed of time took betwen positions
                //transform.position = Vector2.Lerp(currentPosition, previousPos.position, timer / timeToRewind);
                //yield return null;
                //timer += rewindManager.rewindSpeed * Time.deltaTime;
            }

            positions.RemoveAt(index); // remove that position 
            _isRewinding = false;
        }

        float recordTime = 1f;
        // record objects travelled path up to a set amount of time
        virtual protected void Record()
        {
            if (recordTime <= 0.5f) // time between recorded positions
            {
                recordTime += Time.deltaTime;
                return;
            }
            recordTime = 0;

            // cap the amount of positions recorded to the amount of recordTime allocated
            if (positions.Count > 15)
            {
                positions.RemoveAt(positions.Count - 1); // if over amount, remove oldest position
            }

            // add current position and time to struct
            positions.Insert(0, new TimePosition(Time.time, transform.position));
        }

        // Functions to control bool state of rewinding
        public void StartRewind()
        {
            rewindManager.rt_isRewinding = true;
        }

        // Function to control bool state of rewinding
        public void StopRewind()
        {
            if (!rewindManager.IsRewindingEnemies || !rewindManager.rewindingProjectile)
                rewindManager.rt_isRewinding = false;
        }
    }

Rewind Enemy
(inherits from Rewind Time)

public class RewindEnemy : RewindTime
    {
        public RewindTimeManager re_rewindManager;

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

            // Find and Subscribe to Rewind Manager events
            re_rewindManager = FindObjectOfType<RewindTimeManager>();
            re_rewindManager.OnStartEnemyRewind += StartRewind;
            re_rewindManager.OnStopEnemyRewind += StopRewind;
        }
        private void FixedUpdate()
        {
            //listen for spacebar press
            if (re_rewindManager.IsRewindingEnemies && re_rewindManager.rt_isRewinding)
            {
                Rewind();
            }
            else
            {
                Record();
            }
        }

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

Rewind Projectile
(inherits from Rewind Time)

public class RewindProjectile : RewindTime
    {
        [Header("Projectile Tracking Variables")]
        Rigidbody2D rb;
        Vector2 originalVelocity;
        public Vector3 originalPosition = new Vector3();
        public float distanceFromOriginalPosition;
        private bool reverseVelocity = true;
        RewindTimeManager rp_rewindManager;
        ProjectileBase projectile;
        SpriteRenderer sprite;
        Vector3 originalSize = new Vector3();


        protected void OnEnable()
        {
            // set variables for velocity manipulation
            StartCoroutine("OnEnableCoroutine");
        }

        //Coroutine to set up projectile stats from projectile pool
        // plays at the end of frame to ensure setting of correct variables
        IEnumerator OnEnableCoroutine()
        {
            yield return new WaitForEndOfFrame();
            rb = GetComponent<Rigidbody2D>();
            projectile = GetComponent<ProjectileBase>();
            sprite = GetComponent<SpriteRenderer>();
            originalVelocity = rb.velocity;
            originalPosition = transform.position;
            originalSize = transform.localScale;
        }

        protected override void Start()
        {
            base.Start();
            // Find and Subscribe to Rewind Manager events
            rp_rewindManager = FindObjectOfType<RewindTimeManager>();
            rp_rewindManager.OnStartProjectileRewind += StartRewind;
            rp_rewindManager.OnStopProjectileRewind += StopRewind;
        }

        private void FixedUpdate()
        {
            // base.FixedUpdate();
            if (rp_rewindManager.rewindingProjectile && rp_rewindManager.rt_isRewinding)
            {
                Rewind(); // overrided function local to this script
            }
            else
            {
                Record();
            }
        }

        // VELOCITY MANIPULATION
        protected override void Rewind()
        {
            if (rp_rewindManager.pr_CurrentAmount > 0)
            {
                if (reverseVelocity)
                {
                    reverseVelocity = false; // tick bool for one time run 
                    rb.velocity = -originalVelocity / 2; // slow it down by half and reverse it

                    //flip y, its sprite
                    sprite.flipY = true;
                    //change its size
                    gameObject.transform.localScale = new Vector3(originalSize.x + 0.35f, originalSize.y + 0.35f, originalSize.z);
                    //change its damage
                    projectile.damage = projectile.damage * 2;
                }

                //track distance from beginning
                distanceFromOriginalPosition = Vector3.Distance(transform.position, originalPosition);
                if (distanceFromOriginalPosition <= 0.35f)
                {
                    // hold it there
                    transform.position = originalPosition;
                }
            }
            else
            {
                Record();
            }
        
        }
        
        protected override void Record()
        {
            if(!reverseVelocity)
            {
                reverseVelocity = true;
                rb.velocity = originalVelocity;

                //revert flip, size & damage
                sprite.flipY = false;
                gameObject.transform.localScale = originalSize;
                projectile.damage = projectile.damage / 2;
            }
            
            //zero out tracking variables
            distanceFromOriginalPosition = 0;
        }
    }

Retrospective

Running a small team alongside my usual duties within the game development umbrella was an interesting experience. I’ve definitely garnered more respect for those that lead teams and keep the project a-float. I definitely enjoyed my time doing it and could see myself in the future in a role like this, but for now I still have so much to learn so will stick to focussing on my technical skills.

Constructing the time mechanic for this game was a fun journey. I was surprised at how simple making a rewind-effect was, and doubly surprised at the nuance between a working system, and a good, efficient system.
I learnt a lot from my fellow coders which I plan on bringing into my future projects.


If you made it down here,

Thanks For Your Time!

If you’d like to try out the mechanics yourself, head over to the Itch.io page and give it a go 🙂