Bloonz

This is a tower defense game I created. The idea for this game was based on the Bloons TD6 game. I always thought that bloons TD 6 mechanics where pretty interesting so in this project I tried to recreate them.

Project details:

  • code language : C#

  • Software : Unity engine, GitHub an Trello

  • Team size : 1

  • My role : Solo

  • Date : 12 September 2022 - 15 November 2022


My Contribution

This was the first time I created a game by myself. I made the features, UI and the models.


Balloon system

Balloon layer

I aimed to create a flexible system for easily adding new balloons and modifying the properties of existing ones. To achieve this, I used a layer system.

Each layer has his own attributes, including health, speed, color, and more. By using layers I could seamlessly switch between them. For instance, when a balloon is damaged by a tower, the balloon will swap layers (For example 2 damage results in a switch of 2 layers).

Set up the balloon with the layer

                    
public void SetUpBalloon(BalloonLayer balloonLayer)
{
    currentBalloonLayer = balloonLayer;

    if (balloonLayer.MOABBalloon)
    {
        GetComponent<MeshFilter>().mesh = balloonLayer.balloonMesh;
        GetComponent<Renderer>().material = balloonLayer.balloonMaterial;
    }
    else
    {
        GetComponent<MeshFilter>().mesh = balloonLayer.balloonMesh;
        GetComponent<Renderer>().material = balloonLayer.balloonMaterial;
        GetComponent<Renderer>().material.color = balloonLayer.balloonColor;
    }

    CamoBalloon = balloonLayer.camoBalloon;
    this.balloonHealth = balloonLayer.BalloonHealth;
    transform.localScale = balloonLayer.balloonScale;
    followWaypoint.ChangeBalloonSpeed(balloonLayer.BalloonSpeed);
}
                                  
                

Balloon

                    
private void BalloonGotHit()
{

    if (!balloonDictionary.ContainsKey(balloonHealth)) { return; }
    if(currentBalloonLayer.MOABBalloon)
    {
        GetComponent<MeshFilter>().mesh = balloonDictionary[balloonHealth].balloonMesh;
        GetComponent<Renderer>().material = balloonDictionary[balloonHealth].balloonMaterial;
    }
    transform.localScale = balloonDictionary[balloonHealth].balloonScale;
    GetComponent<Renderer>().material.color = balloonDictionary[balloonHealth].balloonColor;
    followWaypoint.ChangeBalloonSpeed(balloonDictionary[balloonHealth].BalloonSpeed);
    currentBalloonLayer = balloonDictionary[balloonHealth];
}
                                  
                

Wave system

Wave creation

This system is created by using scriptable objects and the balloon layers.

Within every wave you have a list where you have a couple of option. You have the option to choose the balloon layer, the amount of balloons that are spawned in for that layer and the time between spawns.

By doing it like this the system is flexible to use and to adjust.

Go to the next wave

                    
public void GoToNextWave()
{
    if (wave < maxWave)
    {
        wave++;
        waveCounter.text = $"{wave}/{maxWave}";
        bank.IncreaseBankAmount(moneyIncreasePerWave);
        EnemySpawner.StartNextWave(waves[wave - 1]);
    }
    else if(wave >= maxWave)
    {
        PlayerWonTheGame();
    }
}
                                  
                

Start a new wave

                    
public void StartNextWave(WaveScriptableObject wave)
{
    enemiesInWave = 0;
    for (int i = 0;i < wave.balloons.Count;i++)
    {
        enemiesInWave += wave.balloons[i].amount;
    }
    canSpawn = true;
    currentWave = wave;
    betweenSpawnTime = currentWave.timeBetweenSpawn;
    EnemyCounter.GetAmountOfEnemys(enemiesInWave);
}
                                  
                

Object pool

Because you can freely choose how many balloons you want to spawn in each round, I decided to created my own object pool. I wanted to make sure the game would not lag or freeze. In the inspector you can choose how many balloons the object pool holds.

When a balloon needs to be spawned the system looks which balloon is disabled in the hierarchy. The first one he can find will be selected. The balloon layer is getting applied to the selected balloon, after that the balloon will be enabled. When the balloon has died it disable itself and will be put back into the object pool.

Enable Balloon

                    
private void EnableBalloons()
{
    canSpawn = true;

    for (int i = 0;i < balloonPool.Count;i++)
    {
        if (!balloonPool[i].activeInHierarchy && currentWave.balloons[waveBalloonsIndex].amount > enemiesHaveSpawned)
        {
            enemiesHaveSpawned++;
            enemiesLeft++;

            balloonPool[i].GetComponent<Enemy>().SetUpBalloon(currentWave.balloons[waveBalloonsIndex].balloonLayer);
            balloonPool[i].SetActive(true);
            balloonPool[i].GetComponent<EnemyFollowWaypoint>().enemySpawnedIn = true;
            return;
        }
    }

    if (currentWave.balloons[waveBalloonsIndex].amount == enemiesHaveSpawned&&waveBalloonsIndex < currentWave.balloons.Count - 1)
    {
        enemiesHaveSpawned = 0;
        waveBalloonsIndex++;
    }
    else if (waveBalloonsIndex == currentWave.balloons.Count - 1 && enemiesLeft <= 0)
    {          
        AllBalloonsDied();
    }
}
                                  
                

Fill the object pool

                    
private void PopulatePool()
{
    for (int i = 0; i < enemyPoolSize; i++) 
    {
        GameObject newBalloon = Instantiate(balloonPrefab, transform);
        newBalloon.SetActive(false);
        balloonPool.Add(newBalloon);
    }
} 
                                
                

Towers

Tower placement

When you buy a tower from the shop you can place the tower on the grass in the level.

When you try to place the tower you see its range so you know what the range is of the tower. When the tower is placed you can click on it to see the range, the upgrades and the targeting preference.

Get the tower to the mouse position

                    
private void TowerToMousePos()
{
    if (isSelected)
    {
        if (EventSystem.current.IsPointerOverGameObject()) 
        {
            mouseIsInUI = true;
            return; 
        }

        mousePos = Input.mousePosition;
        Ray ray = Camera.main.ScreenPointToRay(mousePos);
        towerRangeTransform.GetComponent<MeshRenderer>().enabled = true;

        if (Physics.Raycast(ray,out RaycastHit hitInfo, 100f, groundLayer))
        {
            mouseIsInUI = false;
            hit = hitInfo;
            worldPos = hitInfo.point;
        }

        transform.position = new Vector3(worldPos.x,worldPos.y + (transform.localScale.y / 2),worldPos.z);
    }
}
                                  
                

Change the color of the range

                    
private void ChangeRangeColor()
{
    if (hit.transform == null) { return; }

    if (hit.transform.tag == "Path" || isInTowerCollider || mouseIsInUI)
    {
        rangeColor = Color.red;
        rangeColor.a = towerRangeOpacity;
        towerRangeTransform.GetComponent<Renderer>().material.color = rangeColor;
    }
    else
    {
        rangeColor = Color.grey;
        rangeColor.a = towerRangeOpacity;
        towerRangeTransform.GetComponent<Renderer>().material.color = rangeColor;
    }
}
                                  
                

Place the tower on the map

                    
private void PlaceTheTower()
{
    if (Input.GetMouseButtonDown(0) && isSelected && hit.transform.tag != "Path" 
        && !isInTowerCollider && bank.bankBalance >= towerCost && !mouseIsInUI)
    {
        if (transform.parent.transform.childCount > 0)
        {
            transform.parent.BroadcastMessage("CanSelectTower");
        }
        GetComponentInChildren<SaveTowerPosition>().SaveCurrentPosition();
        bank.DecreaseBankAmount(towerCost);
        towerShop.TowerHasBeenPlaced();
        towerRangeTransform.GetComponent<MeshRenderer>().enabled = false;
        isSelected = false;

        tower.enabled = true;
        PlaceTower placeTower = this;
        placeTower.enabled = false;
    }
}
                                  
                

Cancel the tower placement

                    
private void CancelTowerPlacement()
{
    if (Input.GetMouseButtonDown (1))
    {
        towerShop.TowerHasBeenPlaced();
        if (transform.parent.transform.childCount > 0)
        {
            transform.parent.BroadcastMessage("CanSelectTower");
        }
        Destroy(gameObject);
    }
}
                                  
                

Tower attack

Every tower has his own attack speed and range for example the sniper is shooting slow but he can shoot from everywhere on the map.

You can also switch up the targeting preference of the tower. Depending on the target preference the tower calculate who is the first balloon on the track or the last balloon on the track.

Find a target

                    
private void FindTarget()
{
    if (balloonList.Count == 0) { return; }

    switch (targetStyle)
    {
        case TargetStyle.first:
            GetFirstTarget();
            break;
        case TargetStyle.last:
            GetLastTarget();
            break;
        default:
            Debug.Log("Tower has no target style");
            break;
    }
}
                                  
                

Select the target depending on the targeting preference

                    
private void GetFirstTarget()
{
    EnemyFollowWaypoint target = balloonList[0];
    for (int i = 0; i < balloonList.Count; i++)
    {
        if (balloonList[i].GetComponent<Enemy>().CamoBalloon && !canSeeCamo) { return; }
        if (balloonList[i].totalDistanceTraveled > target.totalDistanceTraveled)
        {
            target = balloonList[i];
        }
        currentTarget = target.transform;
    }
}

private void GetLastTarget()
{
    EnemyFollowWaypoint target = balloonList[0];
    for (int i = 0; i < balloonList.Count; i++)
    {
        if (balloonList[i].totalDistanceTraveled < target.totalDistanceTraveled)
        {
            target = balloonList[i];
        }
        currentTarget = target.transform;
    }
}
                                  
                

Attack the target

                    
private void AttackWithDarts()
{
    currentFireRate -= Time.deltaTime;

    foreach (var dart in dartPool)
    {
        if (!dart.activeInHierarchy && currentFireRate <= 0)
        {
            transform.LookAt(new Vector3(currentTarget.position.x, transform.position.y, currentTarget.position.z));
            dart.GetComponent<Dart>().SetUpDart(currentTarget.transform, transform, towerDamage);
            currentFireRate = fireRate;
        }
    }
}

private void AttackWithRay()
{
    currentFireRate -= Time.deltaTime;
    ray.origin = transform.position;
    ray.direction = transform.forward;

    transform.LookAt(new Vector3(currentTarget.position.x, transform.position.y, currentTarget.position.z));

    if (Physics.Raycast(ray, out RaycastHit hitInfo) && currentFireRate <= 0)
    {
        sniperMuzzleEffect.Play();
        Debug.DrawLine(transform.position, currentTarget.position, Color.red, 1f);
        currentTarget.GetComponent<Enemy>().DecreaseHealth(towerDamage, this.transform);
        currentFireRate = fireRate;
    }
}
                                  
                

Tower upgrades

Every tower has his own upgrades. The upgrades can different between upgrading the attack speed, The range or the damage the tower does per shot. With this system you have a lot of choice of how your tower needs to behave, so you have a lot of replay ability.

On every tower there is a script with two upgrade paths. You can add new upgrades to one of the path. When the player is on the last upgrade for one of the paths and the player choose that upgrade the last upgrade of the other path will be locked.

Upgrade tower depending on the upgrade type

                    
public void HasBeenUpgraded(UpgradeType type, float value, int cost)
{
    UpgradeType upgradeType = type;
    switch (upgradeType)
    {
        case UpgradeType.Range:
            towerValue += cost;
            towerRangeColliderTrans = value;
            towerRangeSphere = value;
            towerRangeTransform.transform.localScale = new Vector3(towerRangeSphere, 0, towerRangeSphere);
            towerRangeCollider.transform.localScale = new Vector3(towerRangeColliderTrans, 0, towerRangeColliderTrans);
            break;
        case UpgradeType.Damage:
            towerValue += cost;
            towerDamage = (int)value;
            break;
        case UpgradeType.AttackSpeed:
            towerValue += cost;
            fireRate = value;
            break;
        case UpgradeType.CanSeeCamo:
            canSeeCamo = true;
            break;
        default:
            break;
    }
}
                                  
                

Upgrade one of the paths

                    
public void UpgradePath1()
{
    if (!path1Done)
    {
        UpgradeScript upgradeScript = Path1[path1Index];
        UpgradeType upgradeType;
        float upgradeValue;
        int upgradeCost;
        for (int i = 0; i < upgradeScript.UpgradeValueInfo.Count; i++)
        {
            upgradeCost = upgradeScript.upgradeCost;
            bank.DecreaseBankAmount(upgradeScript.upgradeCost);
            upgradeType = upgradeScript.UpgradeValueInfo[i].upgradeType;
            upgradeValue = upgradeScript.UpgradeValueInfo[i].upgradeValue;
            TellTowerToUpgrade(upgradeType, upgradeValue, upgradeCost);
        }

        path1Index++;
    }
}

public void UpgradePath2()
{
    if (!path2Done)
    {
        UpgradeScript upgradeScript = Path2[path2Index];
        UpgradeType upgradeType;
        float upgradeValue;
        int upgradeCost;
        for (int i = 0; i < upgradeScript.UpgradeValueInfo.Count; i++)
        {
            upgradeCost = upgradeScript.upgradeCost;
            upgradeType = upgradeScript.UpgradeValueInfo[i].upgradeType;
            upgradeValue = upgradeScript.UpgradeValueInfo[i].upgradeValue;
            TellTowerToUpgrade(upgradeType, upgradeValue, upgradeCost);
        }
        bank.DecreaseBankAmount(upgradeScript.upgradeCost);
        path2Index++;
    }
}
                                  
                

Check if one of the paths is on the max upgrades

                    
private void CheckIfAPathIsDone()
{

    if (path1Index >= Path1.Count && !path1Done)
    {
        path1Done = true;
    }
    if (path2Index >= Path2.Count && !path2Done)
    {
        path2Done = true;
    }

    if (path1Done && path2Index == Path2.Count - 1)
    {
        path2Done = true;
        path1Max = true;
    }
    if (path2Done && path1Index == Path1.Count - 1)
    {
        path1Done = true;
        path2Max = true;
    }
}