Project description

An ancient evil arises once more from the sands of Egypt and threatens to engulf the mortal world in darkness. Only an Avatar of the gods could challenge this dark force and save mankind from devastation. In The Quill Sisters you take on the role of archaeologist Ashley “Ash” Quill and delve into hidden and secret parts of one of the Great Pyramids of Egypt. As you descend deeper into these unexplored catacombs and ancient tombs you must battle the forces of evil every step of the way: facing down threats such as mummies, flesh-eating scarabs, and more.

Game Pillars

  • Fast-paced hack-’n’-slash
  • Egyptian mythology
  • Powerful unique abilities
  • Challenging bosses
  • Egyptian mythology

My Roles

  • Lead Engineer
  • System Designer
  • Engineer


Complete playthrough


Tools used

The project was developed remotely due to the Corona pandemic, and it has worked better than expected. All communication during the project has been over Discord, Trello and Git where all members have been involved every weekday and some holidays. Our producer has had a standup meeting every weekday, excluding holidays. At the standup meeting, we went through what each individual had worked on the previous working day and planned for the day. For this project, we used the SCRUM framework. We divided the project into smaller goals, which has made it easier to get an overview of the production.

The game engine used was Unity and was chosen frankly because of the previous experiences. If the project scope were smaller, we would have done the project inside Unreal as it can be more performance and make the rendering look better.

For version control, we used Github because of the easy-to-use framework. I was responsible for Git, the game's file structure, and code structure during the project. So I created two files for everybody to follow: The FileConventions, which states how we store every type of file and in what format. ProgrammingConventions makes sure everybody's code is coded in the same way, so the code is more readable across authors.

I also created three different Pull Requests templates for each development group of the project Artist template, Code template, Design template to make sure nobody used Git in the wrong way. I also taught out how to use Git and how the development process works.

The Process

The development consisted of Sprints, which in our case means one or more lists of tasks that must be completed within a specific time frame. This list of tasks was divided and delegated to specific groups such as programmers, Level design and sound, etc. Each task consisted of a description of the task and its priority. If any task was a higher priority, then our producer raised this with the leader of that group who is responsible for that task. The leader then solved the task himself or delegated it further within the appropriate role in the group. When someone took a task from Trello, the group member registered themself on the card in Trello and then created a branch in Git from the Dev branch. This branch is where the task is implemented, and when the implementation is complete, a Pull Request is created where the card in Trello is moved to a list for testing. The respective leads monitor this list and the Pull Request list. Each leader is responsible for ensuring that the test list is empty; either the leader tests the implementation themself or any group member with a suitable role, but not the member who created the Pull Request. If the test goes flawlessly, the Pull Request can be accepted and merged with the Dev branch. Initially, this process was not entirely easy to understand for all members, especially those who had no experience with Git or Trello. During the project, questions were asked about Git and exactly how it worked, which I answered and helped with when needed.

After each Sprint, a game test took place where data was collected and iterated.

We had big plans for a small group

This resulted in a lot being removed during the project, but it is better to have too big plans than no plans, which resulted in a good game. We removed several bosses and enemies during development, as well as other systems. We also removed all the traps and puzzles in the game. We also simplified various combos that we wanted to implement.

The goal of the project

The group's goal was to make a captivating gaming experience and a fun game where everyone would develop as developers and individuals. We are all ambitious game developers who always want to develop and thus had big plans for the project.

My personal goal with this project was to develop as a person and programmer. Even before the project, I wanted to be a Lead Engineer, and during the project, I feel that I have grown with that role and enjoy it. The role of Lead Engineer means extremely much responsibility and the more responsibility I get, the more I develop and force myself to perform my best. I have grown in the role by delegating tasks, supporting and discussing, all for good delivery, and making smaller and larger decisive decisions, which has captured my interest further for this role.

What went well

Throughout the project, communication in the group has been better than expected, and at any time throughout the project, everyone in the group has been available. Everyone in the group has attended the morning meetings, and most have worked all day. Since everyone in the group has had this good communication, the efficiency in the group has also been above expectations. Most of the group have also taken their roles very seriously and done an excellent job. Throughout the project, the programming in the group has worked without problems. We chose to develop the game with Scriptableobjects, which has been practical interesting for the level designers and us programmers. This way of working has shown its weaknesses and strengths, such as code integrity and decoupling. The combat system was one of the most fun parts to develop, where Viktor and I were responsible for most of the system. This system was thoroughly planned and executed according to plan. The Entity system worked well right from the start, and we have only had minor adjustments during the project. One of the most useful systems I created was the Room system for the various rounds of spawn enemies. This system was developed so that the level designers could create a room and then place spawn points in specific places on the level. This system was well thought out and worked well during the project.
I also did all the cinematics in the game, which resulted in several good cutscenes. All cinematics were better than expected and were fun to develop.

Final game

When we initially had too big a vision of the idea, we had to cut back on certain parts, small and large. Much of the basic idea of ​​bosses, enemies, and traps changed, but we instead had an exciting and captivating gaming experience. We could focus on quality and not quantity. We did not achieve the vision of composing all our own music for the game in the project. However, most of the other sounds were good. As the planning with sound did not hold, I will include that experience in future projects as sound is essential to make a game.

We had big plans around the combo system and that the player should be able to perform several diffrent combos. Our game resulted in a combo and a charge-up attack. The system we implemented made it extremely easy to add new combos, but we didn't have the time to test them. The implemented system resulted in such a good condition that I will possibly reuse the system in similar game formats in the future. The game results in a fun gaming experience and arouses interest and desire to play more.

Everything Code releated resulted in excellent and functional systems, which felt good throughout the game's development.

If I redid the project, what would I have done differently?

If we had redone the project, we would have reduced the game's scope, as that was one of the biggest problems we had with the project. It might be enough to sit at least 2-3 days early in the project and discuss what should be left and removed.

As a Lead Engineer and System Designer, Flowcharts should have been made for all game parts to make the system more readable.

I would have planned the cinematics earlier in the project to develop the underlying systems under calmer conditions. I would also create a first cinematic to get an idea of ​​how time-consuming each cinematic will be.

Some engineers had little to do on two occasions during the project, as they caught up faster than calculated. I would have reacted to this earlier, so all engineers had something to do without needed to ask me for what to do.


CODE

RoomManager

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Entity.Player;
using Environment.Trigger;
using UnityEngine;
using Random = UnityEngine.Random;

namespace Environment.RoomManager
{
    /// 
    /// A Scene can have many Rooms and a Room can have many Rounds and each Round can have many EnemySpawnPoints.
    /// This is the main class for the whole RoomManager and the RoomManager is the Spawner for our Rooms.
    /// This class can spawn enemies of different types specified by the user at specific places placed by the user.
    /// SpawnLayerMask is the layer that the Entity can spawn on and the layer that the Entity can not spawn into.
    /// Spread is the distance between each Entity spawned by this system.
    /// 

    public class Room : MonoBehaviour
    {
        #region SerializeFields

        [SerializeField] 
        private AreaTrigger areaTrigger = default;
        [SerializeField] 
        private RoomKeyTrigger[] roomKeys = default;
        [SerializeField] 
        private bool spawnTest = default;
        [SerializeField] 
        private bool keyOnly = default;
        [SerializeField] 
        private LayerMask spawnLayerMask = default;
        [SerializeField, Range(0,1)]
        private float spread = default;
        [Header("Dictionary Builder")]
        [SerializeField]
        private Enemy[] enemyEnum = default;
        [SerializeField]
        private GameObject[] enemyGameObject = default;

        #endregion

        #region Events
        
        public Action OnRoomStarted;
        public Action OnRoomCompleted;
        public Action OnRoundCompleted;
        
        #endregion

        #region ListAndDictionaries

        private Dictionary _enemyEnumGameObjectPairs;
        private Dictionary _enemiesLeftToSpawn;
        private List _rounds;
        private List _chosenEnemies;
        private List _aliveEnemies;

        #endregion

        #region PrivateVariables

        private const uint Enabled = 1;
        private bool _roomActive;
        private bool _isRoundActive;
        private Round _currentRound;
        private int _currentRoundNr;
        private int _pickedUpKeys;

        #endregion

        #region Init

        private void Awake()
        {
            _rounds = new List();
            _chosenEnemies = new List();
            _aliveEnemies = new List();
            _enemyEnumGameObjectPairs = new Dictionary();
            _enemiesLeftToSpawn = new Dictionary();
            CreateDictionaries();
        }

        private void Start()
        {
            if (keyOnly && roomKeys.Length == 0)
            {
                throw new Exception("KeyOnly selected but no keys is in the list?");
            }
            PlayerController.OnPlayerDied += DestroyAllEnemies;
            CheckSpawnPoints();
            if (spawnTest)
            {
                StartNextRound();
            }

            if (areaTrigger != null)
            {
                areaTrigger.OnTrigger += _ => StartRoom();
            }
            
            Entity.AI.Enemy.OnDied += OnEnemyKilled;
        }

        private void StartRoom()
        {
            OnRoomStarted?.Invoke();
            StartNextRound();
        }

        private void DestroyAllEnemies()
        {
            _aliveEnemies.ForEach(Destroy);
        }

        /// 
        /// Unsubscribe to future EnemyClass
        /// 
        private void OnDestroy()
        {
            // TODO Unsubscribe to Static Enemy Killed event
            Entity.AI.Enemy.OnDied -= OnEnemyKilled;
        }
        

        /// 
        /// Kills all spawned enemies to test the SpawnSystem
        /// 
        private async void DebugModeKillAll()
        {
            await Task.Delay(TimeSpan.FromSeconds(5f));
            Debug.Log($"RoomManager DebugMode: Killing {_aliveEnemies.Count} spawned enemies.");
            _aliveEnemies.ForEach(Destroy);
            _aliveEnemies.Clear();
            if (_enemiesLeftToSpawn.Values.Sum() > 0)
            {
                Debug.Log($"RoomManager DebugMode: Has {_enemiesLeftToSpawn.Values.Sum()} enemies to spawn.");
                DebugModeKillAll();
            }
            OnEnemyKilled(gameObject);
        }

        /// 
        /// Checks if populated enemies has a dedicated SpawnPoint or else throw a readable Execution.
        /// 
        private void CheckSpawnPoints()
        {
            foreach (var round in _rounds)
            {
                foreach (var enemySpawnPoint in round.EnemySpawnPoints)
                {
                    if (enemySpawnPoint.Enemy.ToString().Equals("-1"))
                    {
                        continue;
                    }
                    var enumLength = enemyEnum.Length;
                    for (var i = 0; i < enumLength; i++)
                    {
                        var enemyType = (uint) enemySpawnPoint.Enemy >> (enumLength - 1 - i);
                        var result = enemyType & 1;
                        if (result == Enabled)
                        {
                            _chosenEnemies.Add((Enemy)(result << (enumLength - 1 - i)));
                        }
                    }
                }
                CheckSpawnPoint(round.name, Enemy.Jackal, round.Jackals);
                CheckSpawnPoint(round.name, Enemy.Scarabs, round.Scarabs);
                CheckSpawnPoint(round.name, Enemy.MummyGiant, round.MummyGiant);
                CheckSpawnPoint(round.name, Enemy.MummyMelee, round.MummyMelee);
                CheckSpawnPoint(round.name, Enemy.MummyRanged, round.MummyRanged);
                _chosenEnemies.Clear();
            }
        }

        /// 
        /// This is just a help Method for the CheckSpawnPoints and will throw the Execution if the EnemyType is missing SpawnPoints.
        /// 
        /// The current checked Round
        /// The EnemyType That will be checked
        /// The amount of Enemies of that Enemy Type specified by the user
        /// A error for the user to see that the room is missing a SpawnPoint
        private void CheckSpawnPoint(string round, Enemy enemyType, int wantToSpawn)
        {
            if (wantToSpawn > 0 && !_chosenEnemies.Contains(enemyType))
            {
                throw new Exception(
                    $"Missing spawnpoints for {enemyType} in {round} inside the {gameObject.name} " + 
                    $"or have selected the wrong enemy type amount in {round}!");
            }
        }

        /// 
        /// Populates all the necessary Dictionaries and adding getting the rounds in all children.
        /// 
        /// 
        private void CreateDictionaries()
        {
            if (enemyEnum.Length != enemyGameObject.Length)
            {
                throw new Exception("Enum length and GameObject length are different. Unable to build dictionary.");
            }
            for (var i = 0; i < enemyEnum.Length; i++)
            {
                _enemyEnumGameObjectPairs.Add(enemyEnum[i], enemyGameObject[i]);
            }
            foreach (var enemy in enemyEnum)
            {
                _enemiesLeftToSpawn.Add(enemy, 0);
            }
            foreach (var round in gameObject.GetComponentsInChildren())
            {
                _rounds.Add(round);
            }
            _rounds = _rounds.OrderBy(round => round.name).ToList();
            foreach (var key in roomKeys)
            {
                key.OnPickedUp += OnKeyPickedUp;
            }
        }

        private void OnKeyPickedUp()
        {
            _pickedUpKeys++;
            if (_pickedUpKeys == roomKeys.Length && (_currentRoundNr > _rounds.Count - 1 && !_roomActive || keyOnly))
            {
                OnRoomCompleted?.Invoke();   
            }
        }

        #endregion

        #region RoomLogic

        /// 
        /// To start next Round in the list
        /// 
        /// Throws a Execution if no rounds is created and the game is started.
        private void StartNextRound()
        {
            if (_rounds.Count == 0)
            {
                throw new Exception($"{gameObject.name} has no Rounds!");
            }

            if (_currentRoundNr > _rounds.Count - 1)
            {
                if (spawnTest)
                {
                    Debug.Log($"RoomManager DebugMode: {gameObject.name} Completed!");
                }

                if (_pickedUpKeys == roomKeys.Length)
                {
                    OnRoomCompleted?.Invoke();   
                }
                _roomActive = false;
            }
            else
            {
                _roomActive = true;
                _currentRound = _rounds[_currentRoundNr];
                if (spawnTest)
                {
                    Debug.Log($"RoomManager DebugMode: {_currentRound.name} started");
                    DebugModeKillAll();
                }
                _enemiesLeftToSpawn[Enemy.MummyMelee] = _currentRound.MummyMelee;
                _enemiesLeftToSpawn[Enemy.MummyRanged] = _currentRound.MummyRanged;
                _enemiesLeftToSpawn[Enemy.MummyGiant] = _currentRound.MummyGiant;
                _enemiesLeftToSpawn[Enemy.Jackal] = _currentRound.Jackals;
                _enemiesLeftToSpawn[Enemy.Scarabs] = _currentRound.Scarabs;
                _currentRoundNr++;
                _isRoundActive = true;
            }
        }

        /// 
        /// This will be called when any enemy dies.
        /// 
        /// The Entity that died.
        private void OnEnemyKilled(GameObject entity)
        {
            if (_roomActive)
            {
                _aliveEnemies.Remove(entity);
                if(_aliveEnemies.Count == 0 && _enemiesLeftToSpawn.Values.Sum() == 0)
                {
                    _isRoundActive = false;
                    OnRoundCompleted?.Invoke();
                    _rounds.ForEach(r => r.EnemySpawnPoints.ForEach(s => s.Used = false));
                    if (spawnTest)
                    {
                        Debug.Log($"RoomManager DebugMode: {_currentRound.name} Completed!");
                    }
                    StartNextRound();
                }
            }
        }

        /// 
        /// Spawn Enemies if any enemies is left to spawn.
        /// 
        private void Update()
        {
            if (_isRoundActive && _enemiesLeftToSpawn.Values.Sum() > 0)
            {
                SpawnEnemies();
            }
        }
        
        #endregion

        #region Spawn

        /// 
        /// Spawns the enemies in the spawnpoints created under the Rounds.
        /// 
        private void SpawnEnemies()
        {
            foreach (var spawn in _currentRound.EnemySpawnPoints.Where(spawn => !spawn.Used))
            {
                var enemyType = GetEnemyType(spawn.Enemy);
                if (enemyType.ToString() != "0")
                {
                    var enemyGO = _enemyEnumGameObjectPairs[enemyType];
                    var enemyCollider = enemyGO.GetComponent();
                    var entityWorldCenter = LocateSpawnLocation(spawn.transform, spawn.SpawnType, enemyCollider);
                    if ((enemyType & Enemy.Scarabs) != 0 || CanSpawn(entityWorldCenter, enemyCollider))
                    {
                        if (spawn.SpawnType == SpawnType.SingleUnit)
                        {
                            spawn.Used = true;
                        }
                        _aliveEnemies.Add(Instantiate(enemyGO, entityWorldCenter, Quaternion.identity));
                        _enemiesLeftToSpawn[enemyType]--;
                    }
                }
            }
        }

        /// 
        /// Checks if the Entity can spawn in the desired area.
        /// 
        /// The center of the wanted spawned Entity.
        /// The CapsuleCollider of the wanted to spawned Entity
        /// Returns true if the entity can Spawn at that location
        private bool CanSpawn(Vector3 entityWorldCenter, CapsuleCollider enemyCollider)
        {
            if (!HasFloor(new Vector3(entityWorldCenter.x, entityWorldCenter.y - enemyCollider.height/2, entityWorldCenter.z)))
            {
                return false;
            }
            var center = enemyCollider.center;
            var bottom = new Vector3(center.x, center.y - enemyCollider.height  / 2 + (enemyCollider.radius - spread), enemyCollider.center.z);
            var top = new Vector3(center.x, center.y + enemyCollider.height / 2 - (enemyCollider.radius - spread), enemyCollider.center.z);
            var hits = Physics.OverlapCapsule(top + entityWorldCenter + Vector3.down * spread, bottom + entityWorldCenter + Vector3.up * spread, enemyCollider.radius + spread, spawnLayerMask);
            return hits.Length == 0;
        }

        /// 
        /// Checks if spawningPosition has any floor.
        /// 
        /// 
        /// Returns true if the spawner has any floor.
        private bool HasFloor(Vector3 spawnFloorLocation)
        {
            Physics.Raycast(spawnFloorLocation, Vector3.down, out var hit, 0.2f, spawnLayerMask);
            return hit.collider;
        }

        /// 
        /// Gets a new SpawnLocation from the SpawnPoint depending on the SpawnType.
        /// 
        /// The transform of the SpawnPoint.
        /// The Type of Spawn to use.
        /// The CapsuleCollider attached to the Entity.
        /// A SpawnLocation
        internal static Vector3 LocateSpawnLocation(Transform spawnTransform, SpawnType spawnType, CapsuleCollider enemyCollider)
        {
            var spawnPosition = spawnTransform.position;
            if (spawnType == SpawnType.SingleUnit)
            {
                return new Vector3(spawnPosition.x, spawnPosition.y + enemyCollider.height/2, spawnPosition.z);
            }
            var localScale = spawnTransform.localScale;
            var radius = enemyCollider.radius;
            return new Vector3(
                Random.Range((spawnPosition.x - localScale.x / 2) + radius,
                    (spawnPosition.x + localScale.x / 2) - radius),
                spawnPosition.y + enemyCollider.height / 2,
                Random.Range((spawnPosition.z - localScale.z / 2) + radius,
                    (spawnPosition.z + localScale.z / 2) - radius));
        }
        
        /// 
        /// Gets a EnemyType based on the wanted enemy at that location.
        /// 
        /// What can be spawned on selected SpawnPoint.
        /// Returns a Enemy Type based on wanted enemy.
        private Enemy GetEnemyType(Enemy enemyOptions)
        {
            switch (enemyOptions.ToString())
            {
                case "0":
                    return 0;
                case "-1":
                    return enemyEnum[Random.Range(0, enemyEnum.Length)];
            }

            _chosenEnemies.Clear();
            var enumLength = enemyEnum.Length;
            for (var i = 0; i < enumLength; i++)
            {
                var enemyType = (uint) enemyOptions >> (enumLength - 1 - i);
                var result = enemyType & 1;
                if (result == Enabled)
                {
                    var inSelection = (Enemy)(result << (enumLength - 1 - i));
                    if (_enemiesLeftToSpawn[inSelection] > 0)
                    {
                        _chosenEnemies.Add(inSelection);   
                    }
                }
            }

            if (_chosenEnemies.Count == 0)
            {
                return 0;
            }
            return _chosenEnemies[Random.Range(0, _chosenEnemies.Count)];
        }

        #endregion

        #region Editor

        /// 
        /// Adds a Round to this GameObject
        /// 
        public void AddRound()
        {
            var id = gameObject.GetComponentsInChildren().Length + 1;
            var instance = new GameObject {name = "Round " + (id)};
            instance.AddComponent();
            instance.transform.parent = transform;
        }

        #endregion
    }
}

                    

This Tool was made with level designers in mind and the user can create spawnpoints and rounds of enemies by simple drag and drop spawnpoints to determined spawn locations, pick the type of enemies that can spawn on that spawnpoint and later select the amount of enemies to spawn. A Scene can have many Rooms and a Room can have many Rounds and each Round can have many SpawnPoints. This is the main class for the Spawner, the main purpuse for this class is to spawn enemies of diffrent types on selected positions and only spawn when nothing is in the way of the spawnpoint.


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

namespace Environment.RoomManager
{
    /// 
    /// A Room can have many Rounds and each Round can have many EnemySpawnPoints.
    /// The user can select how many enemies that will be spawned in the SpawnPoints created by this script.
    /// 
    public class Round : MonoBehaviour
    {
        #region Variables
        
        [SerializeField] 
        private int mummyMelee = default;
        [SerializeField] 
        private int mummyRanged = default;
        [SerializeField] 
        private int mummyGiant = default;
        [SerializeField] 
        private int jackals = default;
        [SerializeField] 
        private int scarabs = default;
        
        public int MummyMelee => mummyMelee;
        public int MummyRanged => mummyRanged;
        public int MummyGiant => mummyGiant;
        public int Jackals => jackals;
        public int Scarabs => scarabs;
        
        private List _enemySpawnPoints;
        public List EnemySpawnPoints => _enemySpawnPoints;

        #endregion

        #region Methods
        
        /// 
        /// Gets all the SpawnPoints that are a child to this Round
        /// 
        private void Awake()
        {
            _enemySpawnPoints = new List();
            foreach (var enemySpawnPoint in GetComponentsInChildren())
            {
                _enemySpawnPoints.Add(enemySpawnPoint);
            }
            if (_enemySpawnPoints.Count == 0)
            {
                throw new Exception("No spawnPoints in round!");
            }
        }

#if UNITY_EDITOR
        /// 
        /// Adds a SpawnPoint as a child to this Round, is called from the editor script
        /// 
        public void AddSpawnPoint()
        {
            var instance = new GameObject {name = "SpawnPoint " + (gameObject.GetComponentsInChildren().Length + 1)};
            instance.transform.localScale = new Vector3(10f,2,10f);
            var spawnInstance = instance.AddComponent();
            Selection.activeGameObject = spawnInstance.gameObject;
            instance.transform.parent = transform;
        }
#endif
#endregion
    }
}

                    

A Room can have many Rounds. The user can select how many enemies that will be spawned in this round.


using System;
using UnityEngine;

namespace Environment.RoomManager
{
    /// 
    /// A Room can have many Rounds and each Round can have many EnemySpawnPoints.
    /// A SpawnPoint for the RoomManager, can have multi EnemyTypes and has two different spawnTypes Area or single slot types. 
    /// 
    public class SpawnPoint : MonoBehaviour
    {
        [SerializeField, EnumFlag]
        private Enemy enemy = default;

        [SerializeField]
        private SpawnType spawnType = default;

        public SpawnType SpawnType => spawnType;

        public Enemy Enemy => enemy;
        public bool Used { get; set; }
        
        [HideInInspector]
        public int amountSpawned = 0;
        [SerializeField, HideInInspector]
        private bool isInitialized;

        /// 
        /// Method for updating the position of the SpawnPoint, Used by the editor script.
        /// 
        /// The new position.
        public void SetSpawnPosition(Vector3 position)
        {
            transform.position = position;
        }
        
        /// 
        /// Draw the gizmo for the SpawnPoint to make it easier for the user to determined what enemy type that will spawn and where.
        /// 
        private void OnDrawGizmosSelected()
        {
            // Color of the type of enemy that can spawn on the area
            switch (enemy)
            {
                case Enemy.MummyMelee:
                    Gizmos.color = Color.blue;
                    break;
                case Enemy.MummyRanged:
                    Gizmos.color = Color.yellow;
                    break;
                case Enemy.MummyGiant:
                    Gizmos.color = Color.red;
                    break;
                case Enemy.Jackal:
                    Gizmos.color = Color.green;
                    break;
                case Enemy.Scarabs:
                    Gizmos.color = Color.cyan;
                    break;
                default:
                    Gizmos.color = Color.yellow + Color.blue;
                    break;
            }
            
            // If nothing is selected choice white
            if (enemy.ToString().Equals("0"))
            {
                Gizmos.color = Color.white;
            }
            // If everything is selected choice magenta
            else if (enemy.ToString().Equals("-1"))
            {
                Gizmos.color = Color.magenta;
            }
            
            // Get the position where to spawn the gizmo and present the gizmo depending on the type of SpawnType;
            var spawnTransform = transform;
            var spawnScale = spawnTransform.localScale;
            switch (spawnType)
            {
                case SpawnType.SingleUnit:
                    Gizmos.DrawCube(transform.position + Vector3.up, new Vector3(1f,2f,1f));
                    break;
                case SpawnType.Area:
                    Gizmos.DrawWireCube(spawnTransform.position + Vector3.up, new Vector3(spawnScale.x, 2, spawnScale.z));
                    break;
            }
        }

        // Get the status of the Initialize
        public bool GetStatus()
        {
            return isInitialized;
        }
        
        // Set the Initialize status
        public void SetInit()
        {
            isInitialized = true;
        }
    }

    // Enum for Enemy Type
    [Flags]
    public enum Enemy{
        MummyMelee = 1,
        MummyRanged = 2,
        MummyGiant = 4,
        Jackal = 8,
        Scarabs = 16
    }
    // Enum for the Spawn Type
    public enum SpawnType{
        SingleUnit,
        Area
    }
}

                    

A Round can have many SpawnPoints. A spawnpoint can have diffrent EnemyTypes and the spawnpoint can have two diffrent types Area and singel unit where Area is a bigger area where the specific units can spawn in any part of that spawnarea. A Single unit spawn is one use only per round.


using System;
using Entity.Player;
using UnityEngine;

namespace Environment.RoomManager
{
    [RequireComponent(typeof(BoxCollider))]
    public class RoomKeyTrigger : MonoBehaviour
    {
        public Action OnPickedUp;
        private void OnTriggerEnter(Collider other)
        {
            if (other.GetComponent())
            {
                OnPickedUp?.Invoke();
                gameObject.SetActive(false);
            }
        }
    }
}

                    

Keys can be aquired by the character by just walking to them. One Key is connected to a specific Rooms and if all keys connected to that Room is aquired the Room will be completed and open any connected door if only keys are selected in the inspector. If the only key is not selected in the inspector any enemies spawned by the connected Room must die to complete the Room and open the door.


using System;
using Entity.Player;
using Framework.ScriptableObjectEvent;
using Framework.ScriptableObjectVariables;
using UnityEngine;

namespace Environment.Trigger
{
    /// 
    /// A Simple trigger.
    /// 
    internal class AreaTrigger : MonoBehaviour
    {
        [SerializeField]
        private bool isOpenTrigger = default;

        [SerializeField]
        private ScriptObjVar eyeOfHorus = default;

        [SerializeField]
        private GameEventGroup setState = default;
        
        public Action OnTrigger;
        private bool _isUsed;

        private void Start()
        {
            setState.OnEvent += SetState;
        }

        private void SetState()
        {
            if (eyeOfHorus != null && eyeOfHorus)
            {
                _isUsed = !eyeOfHorus;
            }
            else
            {
                _isUsed = false;
            }
        }

        private void OnTriggerEnter(Collider other)
        {
            if (!_isUsed && other.GetComponent() != null)
            {
                OnTrigger?.Invoke(isOpenTrigger);
                _isUsed = true;
            }
        }
    }
}

                    

This trigger is a one use trigger and will actiavte diffrent systems depends on what's connected.


using UnityEditor;
using UnityEngine;

namespace Environment.RoomManager
{
    /// 
    /// Editor Script for adding a Round to the Room gameobject
    /// 
    [CustomEditor(typeof(Room))]
    public class RoomEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            var instance = (Room)target;
            if (GUILayout.Button("Add Round"))
            {
                instance.AddRound();
            }
            DrawDefaultInspector();
        }
    }
}

                    

Editor Script for adding a Rounds to a Room.


using UnityEditor;
using UnityEngine;

namespace Environment.RoomManager
{
    /// 
    /// Editor Script for adding a SpawnPoint to the Round gameobject
    /// 
    [CustomEditor(typeof(Round))]
    public class RoundEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            var instance = (Round)target;
            if (GUILayout.Button("Add SpawnPoint"))
            {
                instance.AddSpawnPoint();
            }
            DrawDefaultInspector();
        }
    }
}

                    

Editor Script for adding a spawnpoints to a Round.


using UnityEditor;
using UnityEngine;

namespace Environment.RoomManager
{
    /// 
    /// Editor Script for adding a SpawnPoint to the Round gameobject
    /// 
    [CustomEditor(typeof(Round))]
    public class RoundEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            var instance = (Round)target;
            if (GUILayout.Button("Add SpawnPoint"))
            {
                instance.AddSpawnPoint();
            }
            DrawDefaultInspector();
        }
    }
}

                    

Editor Script for placeing spawnpoints on correct positions.



PlayerMovements

using Framework;
using Framework.ScriptableObjectVariables;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.InputSystem;

namespace Entity.Player
{
    public class PlayerMovement : MonoBehaviour
    {
        [SerializeField]
        private ScriptObjVar playerCollider = default;
        [Header("Mobility")]
        [SerializeField] 
        private ScriptObjVar playerVelocity = default;
        [SerializeField] 
        private ScriptObjVar playerPosition = default;
        [SerializeField] 
        private ScriptObjVar playerJumped;
        [SerializeField] 
        private float acceleration = default;
        [SerializeField, Range(0f, 1f)] 
        private float airControl = default;
        [SerializeField]
        private float maxSpeed = default;
        [SerializeField] 
        private float unStuckForce = default;
        
        [Header("Rotation Info")]
        [SerializeField, Range(0.01f, 1f)] 
        private float targetDirectionAlignment = 0.9f;
        
        [Header("World Info")]
        [SerializeField] 
        private ScriptObjVar gravity = default;
        [SerializeField] 
        private ScriptObjVar groundFriction = default;
        [SerializeField] 
        private ScriptObjVar airFriction = default;
        [SerializeField] 
        private ScriptObjVar slopeFriction = default;
        
        private CharacterController _controller;
        private GroundChecker _groundChecker;
        private RotateWithCamera _rotateWithCamera;
        private Vector3 _moveInput;

        public bool PlayerGrounded => _groundChecker.IsGrounded;
        public Vector3 PlayerVelocity { get => playerVelocity.value; set => playerVelocity.value = value; }
        public bool PlayerIsFalling { get; set; }

        private void Start()
        {
            _groundChecker = GetComponentInChildren();
            _rotateWithCamera = GetComponent();
            _controller = GetComponent();
            playerCollider.value = _controller;
            playerVelocity.value = Vector3.zero;
            UpdatePlayerPosition();
        }
        private void Update()
        {
            UpdatePlayerPosition();
            AddGravity();
            AddFriction();
            AddSlopeForce();
            CheckIfFalling();
            CapVelocity();
            ApplyVelocity();
            CancelGravity();
        }

        private void OnControllerColliderHit(ControllerColliderHit hit)
        {
            var dotProduct = Vector3.Dot(hit.moveDirection, Vector3.down);
            if (!_groundChecker.IsGrounded && dotProduct > 0.99f)
            {
                playerVelocity.value += (transform.position - hit.point).normalized * (unStuckForce * Time.deltaTime);
            }

            if (dotProduct < -0.99f && playerVelocity.value.y > 0f)
            {
                playerVelocity.value.y = 0;
            }
        }

        private void CapVelocity()
        {
            playerVelocity.value = Vector3.ClampMagnitude(playerVelocity.value, maxSpeed);
        }

        private void UpdatePlayerPosition()
        {
            playerPosition.value = transform.position;
        }

        private void CheckIfFalling()
        {
            if (!PlayerIsFalling && !_groundChecker.IsGrounded && playerVelocity.value.y < 0f)
            {
                PlayerIsFalling = true;
                
            }
            else if (_groundChecker.IsGrounded)
            {
                PlayerIsFalling = false;
            }
        }

        private void CancelGravity()
        {
            if (_groundChecker.IsGrounded && playerVelocity.value.y < 0f)
            {
                playerVelocity.value.y = gravity/4;
                playerJumped.value = false;
            }
        }

        private void AddSlopeForce()
        {
            if (!_groundChecker.IsGrounded && _groundChecker.HasGround) {
                playerVelocity.value.x += (1f - _groundChecker.GroundNormal.y) * _groundChecker.GroundNormal.x * (1f - slopeFriction) * Time.deltaTime;
                playerVelocity.value.z += (1f - _groundChecker.GroundNormal.y) * _groundChecker.GroundNormal.z * (1f - slopeFriction) * Time.deltaTime;
            }
        }

        private void AddFriction()
        {
            if (_groundChecker.IsGrounded)
            {
                playerVelocity.value.x /= 1 + groundFriction * Time.deltaTime;
                playerVelocity.value.z /= 1 + groundFriction * Time.deltaTime;
            }
            else
            {
                playerVelocity.value.x /= 1 + airFriction * Time.deltaTime;
                playerVelocity.value.z /= 1 + airFriction * Time.deltaTime;
            }
        }

        private void ApplyVelocity()
        {
            _controller.Move(playerVelocity.value * Time.deltaTime);
        }

        private void AddGravity()
        {
            playerVelocity.value.y += gravity * Time.deltaTime;
        }

        public void AddMovement()
        {
            if (_rotateWithCamera.WantedPercentageDirection > targetDirectionAlignment)
            {
                if (_groundChecker.IsGrounded)
                {
                    playerVelocity.value += transform.forward * (_moveInput.magnitude * (Time.deltaTime * acceleration));
                }
                else
                {
                    playerVelocity.value += transform.forward * (_moveInput.magnitude * (Time.deltaTime * acceleration * airControl));
                }
            }
        }

        public void UpdateMove(InputAction.CallbackContext obj)
        {
            var moveVector = obj.ReadValue();
            _moveInput = new Vector3(moveVector.x, 0, moveVector.y).normalized;
        }
        public Vector3 GetMoveInput()
        {
            return _moveInput;
        }
    }
}

                    

This script does all the heavy lifting regarding the movement of the character. The CharacterController Component in Unity is in use where this script is used to move the Character around from the user inputs. During the development we encounterd multiple problems that lead to new code iterations. We used RootMotion in the begining of this project but with this type of game it's hard to make it work with the systems around the player and therefore we wanted more freedom in regards to the player.


using Framework.ScriptableObjectVariables;
using UnityEngine;
using UnityEngine.InputSystem;

namespace Entity.Player
{
    public class RotateWithCamera : MonoBehaviour
    {
        [SerializeField, Range(0.01f,1f)] 
        private float turnAroundSpeed = 0.2f;
        
        [Header("Camera Info")]
        [SerializeField]
        private ScriptObjRef cameraRotation = default;

        public float WantedPercentageDirection { get; private set; }
        private Vector3 _moveInput;

        private void Update()
        {
            RotatePlayerWithCamera();
        }
        
        public void UpdateMove(InputAction.CallbackContext obj)
        {
            var moveVector = obj.ReadValue();
            _moveInput = new Vector3(moveVector.x, 0, moveVector.y);
        }

        private void RotatePlayerWithCamera()
        {
            if (_moveInput != Vector3.zero){
                var rotationVector = new Vector3(0, cameraRotation.Value.eulerAngles.y, 0);
                var adjustedRot = Quaternion.Euler(rotationVector);
                var wantedDirection = (adjustedRot * _moveInput).normalized;
                var forward = transform.forward;
                WantedPercentageDirection = Mathf.Clamp01(Vector3.Dot(wantedDirection, forward));
                forward = Vector3.Slerp(forward, adjustedRot * _moveInput * Time.deltaTime, turnAroundSpeed);
                transform.forward = forward;
            }
        }
    }
}

                    

The rotation of the character is made by this script. By rotating the Camera around the Y Axis we can change the forward vector of the character and make it possible to move in any direction the user want to go in.


using System;
using System.Threading.Tasks;
using UnityEngine;

namespace Entity.Player.MovementAbilities
{
    public class MovementAbility : MonoBehaviour
    {
        [Header("Movement Ability")]
        [SerializeField] 
        protected float cooldownMilli;
        
        protected bool notInCoolDown = true;
        protected PlayerMovement Movement;
        protected PlayerController PlayerController;

        protected virtual void Start()
        {
            Movement = GetComponent();
            PlayerController = GetComponent();        
        }

        protected async void StartCooldown()
        {
            notInCoolDown = false;
            await Task.Delay(TimeSpan.FromMilliseconds(cooldownMilli));
            notInCoolDown = true;
        }
    }
}

                    

I determind to create a superclass for all the diffrent special movements like Jumping, Dashing and more.


using Combat.ConditionSystem;
using Entity.Player.States.Physical;
using Framework;
using Framework.ScriptableObjectVariables;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

namespace Entity.Player.MovementAbilities
{
    public class PlayerJump : MovementAbility
    {
        [SerializeField]
        private float jumpHeight = default;
        [SerializeField] 
        private ScriptObjVar playerJumped;
        [Header("World Info")]
        [SerializeField] 
        private ScriptObjVar gravity = default;
        [SerializeField, Tooltip("Place conditions that would interfere with jump here")]
        private List conditions;
        [SerializeField] 
        private LayerMask enemyLayer;
        public static Action PlayerJumped;
        private PlayerController _playerController;
        private GroundChecker _groundChecker;
        private ActionGroup _group;

        protected override void Start()
        {
            base.Start();
            _playerController = GetComponentInChildren();
            _groundChecker = GetComponentInChildren();
        }
        
        public void JumpRequested(InputAction.CallbackContext obj)
        {
            if (NoEnemyOnHead() && _groundChecker.IsGrounded && obj.performed)
            {
                if (conditions != null)
                {
                    if (ConditionManager.HasAny(conditions, PlayerController))
                    {
                        _group = ConditionManager.CancelMultiple(conditions, PlayerController);
                        _group.whenAll += () => Jump(obj);
                    }
                    else
                    {
                        Jump(obj);
                    }
                }
            }
        }

        private bool NoEnemyOnHead()
        {
            return !Physics.CheckBox(transform.position, Vector3.up * _playerController.CharController.height, Quaternion.identity, enemyLayer);
        }

        private void Jump(InputAction.CallbackContext obj)
        {
            _group?.Dispose();
            _group = null;
            playerJumped.value = true;
            Movement.PlayerVelocity = new Vector3(Movement.PlayerVelocity.x,0,Movement.PlayerVelocity.z);
            Movement.PlayerVelocity += Vector3.up * Mathf.Sqrt(jumpHeight * -2f * gravity);
            PlayerJumped?.Invoke();
            _playerController.PhysicalStateMachine.TransitionTo();
        }
    }
}

                    

Jumping is inherited from the MovementAbility superclass and is a very small and compact class.


Cinematics ( All Cinematics )