Guns Guts & Glory

The Game

The arenas of the Roman Empire have all seen better days. Thankfully, the newly discovered gunpowder is here to bring the boredom to an end! Equipped with the all new arm cannon, the gladiators slash, bash and blast their foes into smithereens in an arena filled with deadly traps and hazards.

Guns Guts & Glory is a local multiplayer brawler that pits up to 4 friends against each other in chaotic and intense battles on a dynamically changing arena.

Platform

PC

Engine

Unity

Language

C#

Development Time

7 weeks

Team Size

3 x Designers

4 x 3D Artists

2 x 2D Artists

Platform

Engine

Language

Development Time

Team Size

PC

Unity

C#

7 weeks

3 x Designers

4 x 3D Artists

2 x 2D Artists

My role

  • Combat Gameplay
  • Camera-system
  • Player Input / Interaction
  • Player Setup
  • Art / Effects / Animation-implementation
  • Level Events

My main responsibilities were the combat gameplay and camera system. I worked a lot with player and weapon-interactions such as taking damage, picking up weapons/ammo and attacking. I also created an easy way to assign different armor and colors to each player at runtime.

Other responsibilities include: Level scripting, such as destruction, traps and weapon spawning.

Weapons & Damage

The game features 4 different melee-weapons. They all use the same script but behave different depending on the weapon type selected and can be tweaked further with the variables visible in the inspector.

Health

We wanted the violence to be over the top and cartoony with blood effects that would match the type of weapon used.

I created 3 different methods to handle the type of damage a player can receive. Weapons have their own damage type such as sharp and blunt and provide that info upon colliding with a player. From there the Health-script makes sure that the correct effect/damage/knockback etc is used.

Melee weapons

All the weapons in-game can be picked up and dropped at any time. I never destroy or spawn them when used/dropped. The interactions use different collision types. I switch the colliders on and off to ensure that only the right collision event is used.

All the properties such as damage and impact force can be set in the inspector to allow fast testing and tweaking. This approach really helped us improve the balance of weapons when we did playtests.

private void CheckCollision(Collider other)
{
    if(ownerCombatController == null)
        return;

    Vector3 hitDirection = (other.transform.position - ownerCombatController.transform.position).normalized;
    // We dont care about the Y in the direction
    hitDirection.y = 0;

    #region Mace ground hit
    if(ownerCombatController.isPrimaryAttacking)
    {
        if(!other.CompareTag("Player"))
        {
            if(weaponType == WeaponType.Mace)
            {
                float radius = 4f;
                Collider[] players = Physics.OverlapSphere(impactPoint.position, radius, objectsAffectedByImpact);

                for(int i = 0; i < players.Length; i++)
                {
                    Vector3 direction = (players[i].transform.position - ownerCombatController.transform.position).normalized;
                    direction.y = 0f;

                    if(players[i] != ignoreCollider)
                    {
                        Player player = players[i].GetComponent<Player>();
                        player.Knockback(direction, impactForce);
                        player.SetLastDamageDealer(ownerCombatController);
                    }
                }
            }
            SpawnGeometryHitEffect(false, impactPoint.position);
            return;
        }
    }
    #endregion

    // Check if the object hit can be damaged
    IDamagable damagable = other.GetComponent<IDamagable>();

    #region Other = IDamagable
    if(damagable != null && damagable.CanTakeDamage())
    {
        // If this is the final hit before the other Player dies
        if(damagable.IsKillingBlow(GetCurrentDamage()) && !damagable.IsDead())
        {
            // Damage the player and attach this weapon
            damagable.TakeWeaponDamage(ownerCombatController, GetCurrentDamage(), hitDirection, impactForce, weaponType, weaponDamageType);

            // STATS
            ownerCombatController.IncreaseDamageDealtStats(GetCurrentDamage());

            canDamage = false;
        }
        // or just a normal hit
        else
        {
            if(!damagable.IsDead())
            {
                damagable.TakeWeaponDamage(ownerCombatController, GetCurrentDamage(), hitDirection, impactForce, weaponType, weaponDamageType);
                ownerCombatController.IncreaseDamageDealtStats(GetCurrentDamage());

                canDamage = false;
            }
        }
    }
    #endregion

    #region Reset Damage coroutine
    if(resetCanDamage != null)
    {
        StopCoroutine(resetCanDamage);
        resetCanDamage = null;
    }

    resetCanDamage = ResetCanDamage();
    StartCoroutine(resetCanDamage);
    #endregion
}

private void WorldCollision(Collision collision)
{
    // Get the position of the first collision
    ContactPoint contactPoint = collision.contacts[0];

    SpawnGeometryHitEffect(contactPoint.point);

    // Reset throwAttack when weapon collides with the level 
    ResetThrowAttack();

    // Disable spear trail effect
    if(weaponType == WeaponType.Spear && weaponTrail.enabled)
        weaponTrail.enabled = false;
}

public void TakeWeaponDamage(CombatController damageDealer, int damage, Vector3 hitDirection, float forceToAddToPlayer, WeaponType weaponType, WeaponDamageType damageType)
{
    // Who dealt the damage
    HandleLastDamageDealer(damageDealer);

    if(forceToAddToPlayer > 0)
        player.Knockback(hitDirection, forceToAddToPlayer);

    LoseHealth(damage, hitDirection, damageType);
    SpawnWeaponHitEffect(hitDirection, weaponType);

    OnTakeDamageAudio();
}

public void TakeProjectileDamage(CombatController damageDealer, int damage, Vector3 hitDirection, float forceToAddToPlayer, WeaponDamageType damageType)
{
    // Who dealt the damage
    HandleLastDamageDealer(damageDealer);

    if(forceToAddToPlayer > 0)
        player.Knockback(hitDirection, forceToAddToPlayer);

    LoseHealth(damage, hitDirection, damageType);
    SpawnTrapHitEffect();

    OnTakeDamageAudio();
}

public void TakeOtherDamage(int damage, Vector3 hitDirection, float forceToAddToPlayer, WeaponDamageType damageType)
{
    if(forceToAddToPlayer > 0)
        player.Knockback(hitDirection, forceToAddToPlayer);

    LoseHealth(damage, hitDirection, damageType);
        
    // Spawn blood effect if the Damage type is not Water
    if(damageType != WeaponDamageType.Water)
        SpawnTrapHitEffect();

    OnTakeDamageAudio();
}

Arm Cannon

Ammo must be picked up from the level and is thrown in to the arena by the crowd whenever a player gets a kill.

There are two type of projectiles: an explosive cannonball and a net. The cannonball is a guaranteed kill on hit but also explodes and can damage additional players depending on their distance to it. The net slows players to a crawl which allows you to stop them right on top of a deadly trap or to get in close with a melee weapon.

public void Shoot(CombatController ownerCombatController)
{
    if(!CanShoot())
        return;

    // Instantiate the projectile with the rotation of the Player
    GameObject projectile = Instantiate(currentProjectile, projectileSpawnPoint.position, Quaternion.LookRotation(ownerCombatController.transform.forward, Vector3.up));

    Vector3 euler = projectile.transform.eulerAngles;
    // Zero out the X-axis (Up) to avoid shooting in to the ground or above other Players
    euler.x = 0f;
    projectile.transform.eulerAngles = euler;

    projectile.GetComponent<Projectile>().Shoot(ownerCombatController);

    // Small delay before the shoot effect, to make it follow the arm cannon better
    StartCoroutine(SpawnShootEffect());

    gunAudio.clip = gunSounds[Random.Range(0, gunSounds.Count)];
    gunAudio.Play();

    RemoveAmmo();
}

private void DoDamage(Transform player)
{
    IDamagable damagable = player.GetComponent<IDamagable>();

    // Get direction between player and this projectile
    Vector3 direction = (player.position - transform.position).normalized;

    if(damagable != null && damagable.CanTakeDamage())
    {
        // Check if the distance is within lethal range and apply max damage
        if(Vector3.Distance(transform.position, player.transform.position) < (explosionRadius * 0.5f))
        {
            // Check if this damage will kill the Player
            if(damagable.IsKillingBlow(maxDamage))
            {
                ownerCombatController.IncreaseDamageDealtStats(maxDamage);
            }

            damagable.TakeProjectileDamage(ownerCombatController, maxDamage, direction, impactForce, weaponDamageType);
        }
        // Otherwise, normal (non-lethal) damage
        else
        {
            // Check if this damage will kill the Player
            if(damagable.IsKillingBlow(damage))
            {
                ownerCombatController.IncreaseDamageDealtStats(damage);
            }

            damagable.TakeProjectileDamage(ownerCombatController, damage, direction, impactForce, weaponDamageType);
        }
    }
}

The Camera

Intermission & Round Start

Between each round there is a small break were the players get to choose a spawn position and see the current score displayed with UI and a 2D representation of the level. During this time the camera positions itself with a nice overview of the whole arena and changes the depth of field to achieve a unfocused effect.

When the countdown before the round starts, the camera starts to move down closer to the players and gradually change the depth of field back to normal. This was accomplished by using Animation Curves and using the value of the curve in a lerp of the position and rotation.

Movement & Zoom

This was my first time making a system that had to work dynamically based on how many players it currently needs to track. I ended up using a list to store all coordinates of the active players and sorting the min and max-values. Then basing the camera position in the middle of these values.

The zoom was handled by parenting the camera object to an empty object acting as the rig, which was tilted to the desired angle. The camera then moved along its local Forward-axis to zoom in and out based on the distance between the two players furthest away from each other.

// Find the min and max position of Players in the level
private void GetMinMaxPositions()
{
    // Add all player position
    for(int i = 0; i < players.Count; i++)
    {
        xPositions.Add(players[i].position.x);
        yPositions.Add(players[i].position.z);
    }

    // Find the min and max positions on X & Y
    maxX = Mathf.Max(xPositions.ToArray());
    maxY = Mathf.Max(yPositions.ToArray());
    minX = Mathf.Min(xPositions.ToArray());
    minY = Mathf.Min(yPositions.ToArray());

    // Make vectors for min and max
    min = new Vector3(minX, 0f, minY);
    max = new Vector3(maxX, 0f, maxY);
}

// Set the new position for the camrea to move towards
private void SetTargetPosition()
{
    // Get middle between min and max
    targetPos = (min + max) * 0.5f;
    // Make the target position vector (for the Camera rig transform)
    newRigPosition = new Vector3(targetPos.x, 2f, targetPos.z);
}

private void UpdatePositionAndZoom()
{
    if(currentMode != CameraMode.Normal)
    {
        return;
    }
    else
    {
        // Get distance between min and max
        distance = Vector3.Distance(min, max);
                
        SetSizeOfPlayerCanvasBasedOnDistanceFromCamera();

        targetZPos = Mathf.Clamp(distance, minZoom, maxZoom);
        // Get the direction from the target position to the camera
        newCameraPosition = camTransform.TransformDirection(-tf.up * targetZPos);
        // Set the destination position of the camera
        newCameraPosition = (Vector3.forward * newCameraPosition.z);
    }
}

private void SetSizeOfPlayerCanvasBasedOnDistanceFromCamera()
{
    for(int i = 0; i < playerCanvas.Count; i++)
    {
        if(playerCanvas[i] != null)
        {
            float size = Vector3.Distance(camTransform.position, playerCanvas[i].parent.position);
            playerCanvas[i].localScale = Vector3.one * (size * 0.08f);
        }
    }
}

private void MoveCameraAndRig()
{
    // Lerp the camera z-position (Zoom) to the desired position
    camTransform.localPosition = Vector3.Lerp(camTransform.localPosition, newCameraPosition, lerpSpeedY * Time.deltaTime);
    // Lerp the Camera Rig to the desired position
    tf.position = Vector3.Lerp(tf.position, newRigPosition, lerpSpeedXZ * Time.deltaTime);
}

The Player

Armor & Color

We started out by working with one player prefab for each player that was set up with its unique armor and color etc. We realized quite fast that each time we needed to change something that applied to all of them such as the character model, it took forever to have to do that 4 times.

Instead I created a scriptable object class to store all relevant info in. Then I only needed to create 4 of those and set them up for use with a generic player prefab that all players share. When the players are spawned at the start of a match they are assigned the info relevant to the ID of their controller, which then sets up armor and color.

This ended up saving us a lot of time since we were constantly iterating and improving parts of the prefabs hierarchy. 

public class PlayerInfo : ScriptableObject
{
    public int PlayerID;

    public Texture2D SkinTexture;
    public Color PlayerColor;

    // Armor parts
    public GameObject Helmet;
    public Vector2 CapeUV;
}

// Set the ID and get the Player color
protected void InitPlayerInfo()
{
    // Get the Player ID
    playerID = playerInfo.PlayerID;

    // Enable the specific armor for the Player
    SetPlayerArmor();
    SetPlayerArmCannon();
    SetPlayerHelmet();
    SetPlayerColorOnMaterials();

    meshes = new GameObject[4];
    meshes[0] = bodyMesh;
    meshes[1] = hipsJNT;
    meshes[2] = pants;
    meshes[3] = capeConnector;
}

/// <summary>
/// Gets the player info
/// </summary>
/// <returns>The player info</returns>
public PlayerInfo GetPlayerInfo()
{
    return playerInfo;
}

// Activates the armor parts for this Player based on Player ID
protected void SetPlayerArmor()
{
    Transform playerMesh = transform.GetChild(0);

    for(int i = 0; i < 4; i++)
    {
        // Disable all the pants that do not match the Player ID
        if(i != playerID)
        {
            playerMesh.Find("SM_Pants" + (i + 1)).gameObject.SetActive(false);
        }
        else
        {
            pants = playerMesh.Find("SM_Pants" + (i + 1)).gameObject;
        }
    }
}

protected void SetPlayerArmCannon()
{
    Transform armCannon = GetComponentInChildren<Gun>().transform;

    GameObject[] armCannonMeshes = new GameObject[2];

    int index = 0;

    for(int i = 0; i < armCannon.childCount; i++)
    {
        if(armCannon.GetChild(i).CompareTag("ArmCannon"))
        {
            armCannonMeshes[index] = armCannon.GetChild(i).gameObject;
            index++;
        }
    }

    // Give Player 2 & 4 the second arm cannon
    if(PlayerID == 0 || PlayerID == 2)
    {
        if(armCannonMeshes[1].gameObject.activeInHierarchy)
            armCannonMeshes[1].SetActive(false);
        if(!armCannonMeshes[0].gameObject.activeInHierarchy)
            armCannonMeshes[0].SetActive(true);
    }
    else
    {
        if(armCannonMeshes[0].gameObject.activeInHierarchy)
            armCannonMeshes[0].SetActive(false);
        if(!armCannonMeshes[1].gameObject.activeInHierarchy)
            armCannonMeshes[1].SetActive(true);
    }
}

// Create a helmet for the Player
protected void SetPlayerHelmet()
{
    // Fix for helmet mesh position offset
    playerInfo.Helmet.transform.position = Vector3.zero;

    helmet = Instantiate(playerInfo.Helmet, helmetPosition, false);

    foreach(MeshRenderer item in helmet.GetComponents<MeshRenderer>())
    {
        Material[] mats = item.materials;

        foreach(Material mat in mats)
        {
            if(mat.HasProperty("_PlayerColor"))
            {
                mat.SetColor("_PlayerColor", playerInfo.PlayerColor);
            }
        }
    }
}

/// <summary>
/// Call after setting Armor & Helmet 
/// </summary>
protected void SetPlayerColorOnMaterials()
{
    string colorProperty = "_PlayerColor";
    Color playerColor = playerInfo.PlayerColor;

    if (!inCharacterSelection)
    {
        // Make new color to allow setting the alpha
        Color col = playerColor;
        col.a = health.healthBarFill.color.a;
        // Set color of the health bar;

        health.InitHealthUI(colorProperty, col);
        health.HideLeaderFrame();

        // Set color of the CombatControllers "aimLine" (LineRenderer)
        combat.InitAimLineMaterials(colorProperty, playerColor);
    }
    // Set color of all armor
    foreach(SkinnedMeshRenderer smr in GetComponentsInChildren<SkinnedMeshRenderer>())
    {
        if(smr.name == "SK_hero")
            smr.material.SetTexture("_MainTex", playerInfo.SkinTexture);

        // Only set the color of materials that contain the "_PlayerColor" property
        if(smr.material.HasProperty(colorProperty))
        {
            if(smr.name == "SM_Cape_Connector1")
            {
                capeConnector = smr.gameObject;
            }

            smr.material.SetColor(colorProperty, playerColor);

            // Get the correct shape and number on the cape
            if(smr.material.HasProperty("_UVX"))
                smr.material.SetFloat("_UVX", playerInfo.CapeUV.x);
            if(smr.material.HasProperty("_UVY"))
                smr.material.SetFloat("_UVY", playerInfo.CapeUV.y);
        }
    }
}

Input

Since the game is a fast and chaotic multiplayer game we wanted the controls to be easy to learn. At first we had one button each for attack, throw and shoot but we learned that it was too many buttons for our type of gameplay. I removed the need for a third button by having both throw and shoot on the same button. This also worked in favor of the gameplay we desired, the button is used to shoot whenever the arm cannon is loaded to prioritize the explosive side of combat and to throw otherwise.

Movement

The final version of the movement system came quite late thanks to the very late addition of a knockback-effect, which was a much needed addition to the combat gameplay. I had to re-write most of it to not allow the movement input to affect the velocity while being in the knockback-state.

// Normal melee attack
if(InputManager.GetAttackInputDown(player.PlayerID) && !isPrimaryAttacking && !isSecondaryAttacking)
{
    StartPrimaryAttack();
}
else
{
    if(canSecondaryAttack)
    {
        if(armCannon.CanShoot() && !isThrowing)
        {
            // Arm cannon attack
            HandleShootInputDownAndHeld(InputManager.GetThrowInputDown(player.PlayerID), InputManager.GetThrowInput(player.PlayerID), out isShootInputHeld, out wasShootInputReleased, wasShootInputHeldLastFrame);

            // Only update wasShootInputHeldLastFrame if the game is not paused
            if(!GameController.gamePaused && !isPrimaryAttacking)
                wasShootInputHeldLastFrame = isShootInputHeld;

            // Must shoot when there is ammo loaded
            if(armCannon.CanShoot())
                return;
        }
        else if(!isShooting && hasWeapon)
        {
            // Throw weapon attack
            HandleThrowInputDownAndHeld(InputManager.GetThrowInputDown(player.PlayerID), InputManager.GetThrowInput(player.PlayerID), out isThrowInputHeld, out wasThrowInputReleased, wasThrowInputHeldLastFrame);

            // Only update wasThrowInputHeldLastFrame if the game is not paused
            if(!GameController.gamePaused && !isPrimaryAttacking)
                wasThrowInputHeldLastFrame = isThrowInputHeld;
        }
    }
}

// Checks for input for Throwing
private void HandleThrowInputDownAndHeld(bool inputDown, bool inputHeld, out bool isInputHeld, out bool wasInputReleased, bool wasInputReleasedLastFrame)
{
    // Start throw animation
    if(inputDown && !isPrimaryAttacking && !isSecondaryAttacking)
    {
        isSecondaryAttacking = true;
        isThrowing = true;

        StartChargeThrow();

        // Set a different Movement speed based on what the Player is doing
        UpdateMovementModifier();

        // Enable the line
        EnableAimLine(true, true);
    }

    // Set is throw held
    isInputHeld = inputHeld;
    // Check if it was held last frame and not this one to determine of it was released
    wasInputReleased = wasInputReleasedLastFrame && !isInputHeld;

    // Charge if the button is held
    if(isInputHeld && !isPrimaryAttacking && isSecondaryAttacking)
    {
        // Increase the modifier while the button is held
        throwForceModifier += (weaponThrowChargeSpeedModifier * Time.deltaTime);

        currentThrowForce = ((normalThrowForce * weaponThrowLengthModifier) * throwForceModifier);
        currentThrowForce = Mathf.Clamp(currentThrowForce, normalThrowForce * weaponThrowLengthModifier, maxThrowForce * weaponThrowLengthModifier);
        // Set the position of the line
        SetAimLine(meleeWeaponHold, false);
    }
    // Only release the throw if the game is not paused
    else if(wasInputReleased && !isInputHeld && !isPrimaryAttacking && isSecondaryAttacking && !GameController.gamePaused)
    {
        StartSecondaryAttack();
        // Set a different Movement speed based on what the Player is doing
        UpdateMovementModifier();

        // Disable the line
        EnableAimLine(false, true);

        canSecondaryAttack = false;
    }
}

// Checks for input for shooting
private void HandleShootInputDownAndHeld(bool inputDown, bool inputHeld, out bool isInputHeld, out bool wasInputReleased, bool wasInputReleasedLastFrame)
{
    // Start throw animation
    if(inputDown && !isPrimaryAttacking && !isSecondaryAttacking)
    {
        isSecondaryAttacking = true;
        isShooting = true;
        StartRaiseArmCannon();

        // Set a different Movement speed based on what the Player is doing
        UpdateMovementModifier();

        // Enable the line
        EnableAimLine(true, false);
    }

    // Set is throw held
    isInputHeld = inputHeld;
    // Check if it was held last frame and not this one to determine of it was released
    wasInputReleased = wasInputReleasedLastFrame && !isInputHeld;

    // Charge if the button is held
    if(isInputHeld && !isPrimaryAttacking && isSecondaryAttacking)
    {
        // Set the position of the line and set length
        if(armCannon.currentProjectile.GetComponent<Net>())
            SetAimLine(armCannon.projectileSpawnPoint, true, 9f);
        // Set the position of the line getting default length (13f)
        else
            SetAimLine(armCannon.projectileSpawnPoint, true);
    }
    // Only release the throw if the game is not paused
    else if(wasInputReleased && !isInputHeld && !isPrimaryAttacking && isSecondaryAttacking && !GameController.gamePaused)
    {
        StartArmCannonShoot();
        // Set a different Movement speed based on what the Player is doing
        UpdateMovementModifier();

        // Disable the line
        EnableAimLine(false, false);

        canSecondaryAttack = false;
    }
}

Player Animations

I set up the animations for the Player. The player is controlled with “twin-stick” controls and needed to be able to change the walk animation depending on the facing direction in relation to the movement direction.

 

 

Everything except for the legs of the player needs to be able to move independent of the legs so I created a separate layer for the upper body where all of the attacking takes place. This layer overrides the other layer whenever the player does more than movement.

 

private void MovementAnimation(Vector3 moveInput)
{
    // Choose animation based on the direction the player is facing
    float dirX = Vector3.Dot(directionLeftStick, tf.right);
    float dirY = Vector3.Dot(directionLeftStick, tf.forward);

    if(moveInput != Vector3.zero)
        SetMovementBlendTree(dirX, dirY);
    else
        SetMovementBlendTree(0f, 0f);
}

public void SetMovementBlendTree(float velocityX, float velocityY)
{
    // Blend smoother to prevent cape from going crazy
    velocityX = Mathf.MoveTowards(anim.GetFloat("VelocityX"), velocityX, speed * Time.deltaTime);
    velocityY = Mathf.MoveTowards(anim.GetFloat("VelocityY"), velocityY, speed * Time.deltaTime);

    anim.SetFloat("VelocityX", Mathf.Clamp(velocityX, -1f, 1f));
    anim.SetFloat("VelocityY", Mathf.Clamp(velocityY, -1f, 1f));
}

Level

Destruction

During the second out of 3 rounds of a match the outer ring of the arena will start exploding one piece at a time until the arena has shrunk by one ring at the end of the round. This makes the combat of the third round very close quarters which makes certain weapons and traps even deadlier.

2D Map

The spawn select-map contains information about where weapons and traps are located. I made a method for converting their world position to positions on the 2D canvas. This way we could spawn traps and weapons in any position and get the correct information without moving icons on the map with each change.

private IEnumerator Destruction()
{
    while(true)
    {
        bool done = false;
        Transform currentPart;

        if(!outerRingsDestroyed)
        {
            currentPart = outerRings[currentPartIndex];
                
            // If currentPartIndex is equal to the length of the list we are done with the outer ring
            if(++currentPartIndex == outerRings.Count)
            {
                currentPartIndex = 0;
                outerRingsDestroyed = true;
            }
        }
        else
        {
            if(!innerRingsStarted)
                innerRingsStarted = true;

            currentPart = innerRings[currentPartIndex];
                
            if(++currentPartIndex == innerRings.Count)
            {
                done = true;
            }
        }
            
        yield return StartCoroutine(Shake(currentPart));

        if(done)
        {
            EndLevelDestruction();
            yield break;
        }
    }
}

private IEnumerator Shake(Transform currentPart)
{
    float timer = 0f;

    // Spawn destruction effect
    #region Effect position, rotation and size
    // Instantiate the effect at the position of the current part and correct y-position
    Transform effect = Instantiate(destructionEffect, transform.position + (Vector3.up * 2f), Quaternion.identity);
    // Find all the Particle systems in the instantiated effect
    ParticleSystem[] ps = effect.GetComponentsInChildren<ParticleSystem>();
    // Default offset for effect rotation
    float offset = 30f;
    // Set the arc of all ParticleSystem to match the current part
    for(int i = 0; i < ps.Length; i++)
    {
        ParticleSystem.ShapeModule shape = ps[i].shape;

        if(!innerRingsStarted)
        {
            if(currentPart.name.Contains("0.5"))
            {
                if(ps[i].name == "PS_Rocks")
                {
                    Transform rockEffect = ps[i].transform;

                    rockEffect.gameObject.SetActive(false);
                }
            }
        }
        else if(innerRingsStarted)
        {
            if(ps[i].name == "PS_Rocks Copy")
            {
                Transform rockEffect = ps[i].transform;

                Vector3 pos = (currentPart.position - rockEffect.localPosition).normalized;
                ps[i].transform.localPosition += pos * 4f;
            }
            else if(ps[i].name == "PS_Rocks")
            {
                Transform rockEffect = ps[i].transform;

                if(currentPart.name.Contains("0.5"))
                {
                    rockEffect.gameObject.SetActive(false);
                }
                else
                {
                    Vector3 pos = (currentPart.position - rockEffect.localPosition).normalized;
                    ps[i].transform.localPosition += pos * 4f;
                }
            }
            else
            {
                shape.radius -= 2.3f;
            }
        }

        // Parts with names containing "0.5" are half the size and the effect arc needs adjusting
        if(currentPart.name.Contains("0.5"))
        {
            // Default arc is 60 so this one needs to be half
            shape.arc = 30f;
            // and half the offset
            offset = 15f;
        }
    }
    // Set the rotation of the effect
    Vector3 rotation = new Vector3(-90f, 0f, currentPart.eulerAngles.y - offset);
    effect.eulerAngles = rotation;
    #endregion

    // Play the effect
    effect.GetComponent<ParticleSystem>().Play();
    CameraShake.Shake(0.2f, 0.3f);

    while(timer <= shakeTime)
    {
        Vector3 endPos = currentPart.right * shakeCurve.Evaluate(timer) + Vector3.up * fallCurve.Evaluate(timer);
        currentPart.position = Vector3.Lerp(currentPart.position, endPos, shakeSpeed);
        timer += Time.deltaTime;
        yield return null;
    }

    yield return StartCoroutine(Fall(currentPart));
}

private IEnumerator Fall(Transform currentPart)
{
    float timer = 0.0f;

    while(timer <= fallTime)
    {
        currentPart.position = Vector3.Lerp(currentPart.position, Vector3.down * 10f, fallSpeed * Time.deltaTime);
        timer += Time.deltaTime;
        yield return null;
    }

    currentPart.parent.gameObject.SetActive(false);
}

public static class ConvertWorldToScreenPosition
{
    /// <summary>
    /// Get a screen position from a world position relative to the parent
    /// </summary>
    /// <param name="parent"> RectTransform that the UI element should be displayed within. </param>
    /// <param name="worldPosition"> The position that should be converted. </param>
    /// <param name="min"> Bottom Left of the level. </param>
    /// <param name="max"> Top Right of the level. </param>
    /// <returns> The position converted. </returns>
    public static Vector2 GetPosition(RectTransform parent, Vector3 worldPosition, Vector3 min, Vector3 max)
    {
        Vector2 pos;
        pos.x = GetPos(worldPosition.x, parent.sizeDelta.x, GetLevelSize(min, max).x);
        pos.y = GetPos(worldPosition.z, parent.sizeDelta.y, GetLevelSize(min, max).y);

        return pos;
    }

    private static float GetPos(float pos, float mapSize, float levelSize)
    {
        return pos * (mapSize / levelSize);
    }

    private static Vector2 GetLevelSize(Vector3 min, Vector3 max)
    {
        return new Vector2(max.x - min.x, max.z - min.z);
    }
}