Unity (C#)
3 Months
3

Assemble your mighty dinosaur army and engage in thrilling battles to conquer the three lanes of battle and destroy your opponent’s castles. Your mission: Defend your castles while strategically annihilating your adversary’s army.

Technical Breakdown

The Problem

Game contains dinosaur units with differing abilities and functionality. A mutable data container is required.

The Solution

Unit Data Asset

A scriptable card data with variables that control use and functionality.

The Problem

Managing the spawning of different units and behaviours during runtime with minimum performance cost targeting mobile.

The Solution

Spawn Pooled Units

On start, spawn an estimated amount of required units and hide beneath the map.
Take from pool when in use, and recycle back into pool when done.
Spawn more as necessary.

                    
public class SpawnManager : MonoBehaviour
{
    public static SpawnManager instance;
    public CardDataListSO activeCards;
    public UnitPoolManager unitPoolManager;
    public event Action<UnitCardDataSO> UnitDragEvent;
    public event Action<UnitCardDataSO> UnitSpawnedEvent;

    // Sets the singleton instance on Awake
    private void Awake()
    {
        instance = this;
    }

    // Initializes unit pools for each active card in the list at the start of the game
    private void Start()
    {
        foreach (UnitCardDataSO unitCard in activeCards.UnitCardGroup)
        {
            unitPoolManager.InitiateUnitPool(unitCard);
        }
    }

    // Triggers the UnitDragEvent, indicating that a unit card is being dragged
    public static void DragUnit(UnitCardDataSO _unitData)
    {
        instance.UnitDragEvent?.Invoke(_unitData);

        Debug.Log($"<color=red>SpawnManager</color> detected <color=yellow>Drag Event</color> for Unit: <color=red>{_unitData.cardName}</color>");
    }

    // Triggers the UnitSpawnedEvent, indicating that a unit is being spawned
    public static void SpawnUnit(UnitCardDataSO _unitData)
    {
        instance.UnitSpawnedEvent?.Invoke(_unitData);
    }

    // Withdraws a unit from the pool based on the unit data provided
    public GameActorUnit WithdrawUnit(UnitCardDataSO _unitData)
    {
        return unitPoolManager.WithdrawUnit(_unitData);
    }

    // Returns a unit to the pool to be reused
    public void DepositUnit(GameActorUnit _unit)
    {
        unitPoolManager.DepositUnit(_unit);
    }

    // Spawns a unit in the game world, setting its position, rotation, tags, and initial state
    public void SpawnGameUnit(UnitCardDataSO _unit, Vector3 _spawnPosition, Vector3 _eulerRotation, string _unitTag, string _targetTag)
    {
        GameActorUnit gameActorUnit = WithdrawUnit(_unit);

        gameActorUnit.transform.position = _spawnPosition;
        gameActorUnit.transform.eulerAngles = _eulerRotation;
        gameActorUnit.tag = _unitTag;
        gameActorUnit.TargetTag = _targetTag;
        gameActorUnit.ChangeState(GameActorUnitState.spawning);
    }
}



public class UnitPoolManager
{
    [Header("Component References")]
    [SerializeField] private Transform spawningTransform;  // Transform for positioning pooled units
    [SerializeField] private int initialSpawnAmount = 10;  // Initial number of units to spawn per pool

    // List of all unit pools
    public List<PooledUnits> unitPoolList = new List<PooledUnits>();

    // List of currently active units
    public List<GameActorUnit> activeUnits = new List<GameActorUnit>();

    // Dictionary to manage unit pools based on the UnitCardDataSO reference
    public Dictionary<UnitCardDataSO, PooledUnits> PooledUnitsDict = new Dictionary<UnitCardDataSO, PooledUnits>();

    // Initializes a unit pool for a specific unit type, setting the initial spawn amount and storing the pool in the dictionary
    public void InitiateUnitPool(UnitCardDataSO _unitCard)
    {
        // Calculate initial position for the new pool based on the spawning transform and existing pool count
        Vector3 spawnPosition = spawningTransform.position;
        spawnPosition.x += unitPoolList.Count;

        // Create a new pool for the specified unit type
        PooledUnits unitPool = new PooledUnits(_unitCard, spawnPosition);

        // Add the pool to the list and dictionary for easy access
        unitPoolList.Add(unitPool);
        PooledUnitsDict.Add(_unitCard, unitPool);

        // Prepopulate the pool with the specified initial spawn amount
        unitPool.AddMultipleUnits(initialSpawnAmount);
    }

    // Withdraws a unit from the specified unit pool, adding it to the active units list
    public GameActorUnit WithdrawUnit(UnitCardDataSO _unitCard)
    {
        // Retrieve the pool for the specified unit card data
        if (PooledUnitsDict.TryGetValue(_unitCard, out PooledUnits unitPool))
        {
            // Withdraw a unit from the pool and add it to active units
            GameActorUnit newUnit = unitPool.WithdrawUnit();
            activeUnits.Add(newUnit);

            return newUnit;
        }

        Debug.LogWarning($"No unit pool found for {_unitCard.cardName}");
        return null;
    }

    // Returns a unit to its respective pool and removes it from the active units list
    public void DepositUnit(GameActorUnit _unit)
    {
        // Retrieve the pool associated with the unit's data
        if (PooledUnitsDict.TryGetValue(_unit.unitCardData, out PooledUnits unitPool))
        {
            // Deposit the unit back into its pool and remove it from active units
            unitPool.DepositUnit(_unit);
            activeUnits.Remove(_unit);
        }
        else
        {
            Debug.LogWarning($"No unit pool found for {_unit.unitCardData.cardName}");
        }
    }
}



public class PooledUnits
{
    private UnitCardDataSO unitCardDataSO;  // Reference to the ScriptableObject holding unit data for this pool
    private Vector3 poolPosition;  // Position in the scene where pooled units are stored when inactive

    // List of units currently inactive in the pool, ready for reuse
    public List<GameActorUnit> inactiveUnits;

    // Initializes a new instance of the PooledUnits class
    public PooledUnits(UnitCardDataSO _UnitCardDataSO, Vector3 _poolPosition)
    {
        this.unitCardDataSO = _UnitCardDataSO;
        this.inactiveUnits = new List<GameActorUnit>();
        this.poolPosition = _poolPosition;
    }

    // Adds multiple new units to the pool
    public void AddMultipleUnits(int amount)
    {
        for (int i = 0; i < amount; i++)
        {
            AddNewPooledUnit();
        }
    }

    // Adds a single new unit to the pool by instantiating it at the pool's position
    private void AddNewPooledUnit()
    {
        Vector3 spawnPosition = poolPosition;
        spawnPosition.z += inactiveUnits.Count;

        GameActorUnit gameActorUnit = GameObject.Instantiate(
            unitCardDataSO.gameActorUnit,
            spawnPosition,
            Quaternion.identity
        );

        inactiveUnits.Add(gameActorUnit);
    }

    // Withdraws a unit from the pool for active use
    public GameActorUnit WithdrawUnit()
    {
        if (inactiveUnits.Count == 0)
        {
            AddNewPooledUnit();
        }

        GameActorUnit withdrawedUnit = inactiveUnits[0];
        inactiveUnits.RemoveAt(0);

        return withdrawedUnit;
    }

    // Returns a unit back to the pool, resetting its position and rotation
    public void DepositUnit(GameActorUnit _gameActorUnit)
    {
        _gameActorUnit.transform.position = poolPosition;
        _gameActorUnit.transform.rotation = Quaternion.identity;

        inactiveUnits.Add(_gameActorUnit);
    }
}

    

The Problem

Player needs money to buy units. Money should increase passively, with changes depending on game state. This change should be represented with UI Visuals and Context

The Solution

Passively Increase Money

Passively increase money using a global variable rate.

Subtract Money on Unit Purchase

When the SpawnManager declares a player spawned unit, subtract its cost.

"Bling" Cards

When cards become purchaseable, bling them to draw players attention.

                    
public class MoneyProgressPassiveGain : MonoBehaviour
{
    const float TIME_TO_PROGRESS = 2.5f;  // Time interval for progress increment (in seconds)
    float progress = 0f;  // The current progress toward increasing money (ranges from 0 to 1)
    float progressMultiplier = 1f;  // Multiplier for progress rate, adjusted by global money multiplier

    [Header("Global Variables")]
    [SerializeField] private GlobalInt globalMoney;  // Reference to global money value
    [SerializeField] private GlobalInt MoneyMultiplier;  // Reference to the global money multiplier
    [SerializeField] private GlobalFloat globalMoneyProgress;  // Reference to the global progress value for money

    [Space(5f), Header("Game Events")]
    [SerializeField] private GameEvent moneyChangedEvent;  // Event triggered when global money is changed
    [SerializeField] private GameEvent moneyProgressChangedEvent;  // Event triggered when the money progress changes
    [SerializeField] private UnityEvent MoneyIncreasedEvent;  // Unity event invoked when money is increased

    // Initializes the progress multiplier from the MoneyMultiplier value at the start
    private void Awake()
    {
        progressMultiplier = MoneyMultiplier.GetValue();
    }

    // Starts the coroutine that continuously increases progress over time
    public void StartPassiveGain()
    {
        StartCoroutine(PassiveGainSequence());
    }

    // Coroutine that handles the passive money gain process, running continuously
    private IEnumerator PassiveGainSequence()
    {
        while (true)
        {
            IncreaseProgress();  // Continuously increases the progress
            yield return null;  // Wait for the next frame
        }
    }

    // Increases the progress toward the next money increment, considering the time to progress and the multiplier
    private void IncreaseProgress()
    {
        // If the global money has reached or exceeded its maximum value, stop progress and set progress to 1
        if (globalMoney.RuntimeValue >= globalMoney.maxValue)
        {
            globalMoneyProgress.SetValue(1f);  // Set progress to 100%
            moneyProgressChangedEvent.Raise();  // Raise event to notify progress change
            return;  // Exit the method
        }

        // Increase progress by the calculated amount based on time, multiplier, and delta time
        progress += (1f / TIME_TO_PROGRESS) * progressMultiplier * Time.deltaTime;

        // If progress reaches or exceeds 1, trigger money increase
        if (progress >= 1f)
        {
            IncreaseMoney();  // Increase global money
        }

        // Update global money progress
        globalMoneyProgress.SetValue(progress);
        moneyProgressChangedEvent.Raise();  // Raise event to notify progress change
    }

    // Increases the global money value by 1 and resets the progress
    private void IncreaseMoney()
    {
        progress -= 1f;  // Reset the progress after money increase

        globalMoney.SetValue(globalMoney.GetValue() + 1);  // Increase the global money value by 1
        moneyChangedEvent.Raise();  // Raise the event to notify that the global money value has changed

        MoneyIncreasedEvent.Invoke();  // Invoke the Unity event for any additional actions (e.g., UI update, sound effect)
    }

    // Updates the progress multiplier based on the current global money multiplier
    public void UpdateMultiplier()
    {
        progressMultiplier = MoneyMultiplier.GetValue();
    }
}



public class MoneySubtractCost : MonoBehaviour
{
    [Header("Global Variables")]
    [SerializeField] private GlobalInt playerMoney;  // Reference to the player's money value
    [SerializeField] private GameEvent moneyChangedEvent;  // Event to be raised when the player's money is changed

    // Subscribes to the UnitSpawnedEvent from the SpawnManager to trigger the cost subtraction when a unit is spawned
    void Start()
    {
        // Subscribe to the event that is raised when a unit is spawned
        SpawnManager.instance.UnitSpawnedEvent += SubtractCost;
    }

    // Subtracts the cost of the spawned unit from the player's total money and raises the money changed event
    public void SubtractCost(UnitCardDataSO _unitData)
    {
        // Subtract the cost of the unit from the player's money
        playerMoney.SetValue(playerMoney.GetValue() - _unitData.cardCost);

        // Raise the event to notify other systems that the player's money has changed
        moneyChangedEvent.Raise();
    }

    // Unsubscribes from the UnitSpawnedEvent to prevent memory leaks when the object is destroyed
    private void OnDestroy()
    {
        // Unsubscribe from the event to avoid potential memory leaks when this object is destroyed
        SpawnManager.instance.UnitSpawnedEvent -= SubtractCost;
    }
}



public class MoneyAmountVisualUpdate : MonoBehaviour
{
    [Space(5f), Header("Global Variables")]
    [SerializeField] private GlobalInt globalMoney;  // Reference to the global money variable

    [Space(5f), Header("Component References")]
    [SerializeField] private TextMeshProUGUI moneyTextMesh;  // Reference to the TextMeshProUGUI component displaying the money amount
    [SerializeField] private Image coinCounterImage;  // Reference to the Image component that visually represents the coin amount (e.g., a fillable bar)

    private float lastAmount = 0;  // Keeps track of the last money value to detect changes

    // Updates the visual representation of the player's money
    public void UpdateMoneyVisual()
    {
        // Retrieve the current amount of money from the global money variable
        int money = globalMoney.GetValue();

        // Update the TextMeshProUGUI component to display the current money value
        moneyTextMesh.text = money.ToString();

        // Calculate the fill amount for the coin counter image based on the money value (scaled to a range 0-1)
        float fillAmount = money / 10f;

        // Use LeanTween to smoothly animate the fill amount of the coin counter image
        LeanTween.value(gameObject, coinCounterImage.fillAmount, fillAmount, 0.1f)
                 .setOnUpdate((float value) => { coinCounterImage.fillAmount = value; });

        // Update the last amount for future comparisons (if needed)
        lastAmount = globalMoney.GetValue();
    }
}



public class MoneyProgressVisualUpdate : MonoBehaviour
{
    [Header("Component References")]
    [SerializeField] private Image circleFillImage;  // Reference to the Image component that visually represents the money progress (e.g., a circular progress bar)

    [Space(20f)]
    [Header("Global Variables")]
    [SerializeField] private GlobalFloat globalMoneyProgress;  // Reference to the global money progress value (ranges from 0 to 1)

    // Updates the visual representation of the money progress on the circle fill image
    public void UpdateVisualWheel()
    {
        // Update the fill amount of the circle fill image to reflect the current global money progress
        circleFillImage.fillAmount = 1f - globalMoneyProgress.GetValue();
    }
}

    

The Problem

Shop needs to randomly cycle between available cards to promote strategic play and choices.

The Solution

Assign Initial Cards

On Start, assign 3 random cards from available cards. Calculate inactive cards.

Random Inactive Card

When cards are bought, remove them from shop. After a timer, assign a new random card that isn't already in the shop.

                    
public class ShopManager : MonoBehaviour
{
    [Header("Card Groups")]
    [Tooltip("The list of available unit cards to be used in the shop.")]
    public CardDataListSO activeCardList;  // The data containing the available unit cards for the shop

    [Header("Game Object References")]
    [SerializeField, Tooltip("The holder for the shop card buttons in the UI.")]
    private Transform shopCardButtonHolder;  // The parent GameObject holding the shop card buttons in the UI

    [HideInInspector] 
    public ShopCard[] shopCards = new ShopCard[0];  // Array of ShopCards initialized at runtime

    private void Awake()
    {
        // Initialize a list to hold the ShopCard instances
        List<ShopCard> list = new List<ShopCard>();

        // Iterate over all child transforms of the shopCardButtonHolder to find ShopCard objects
        foreach (Transform transform in shopCardButtonHolder)
        {
            // Try to get the ShopCardObject component attached to each child
            ShopCardObject obj = transform.GetComponent<ShopCardObject>();

            // Skip this child if it doesn't contain a ShopCardObject component
            if (obj == null) continue;

            // Access the ShopCardComponents from the ShopCardObject
            ShopCardComponents shopCardComponents = obj.shopCardComponents;

            // Create a new ShopCard instance using the components and add it to the list
            ShopCard shopCard = new ShopCard(shopCardComponents);
            list.Add(shopCard);
        }

        // Convert the list of ShopCard instances to an array and assign it to the shopCards field
        shopCards = list.ToArray();
    }
}




[RequireComponent(typeof(ShopManager))]
public class ShopCardsInitialiser : MonoBehaviour
{
    // Reference to the ShopManager component, which manages the shop's cards
    private ShopManager shopManager;

    [Space(5f), Header("Card Data")]
    [SerializeField] private CardDataListSO activeCardList;  // List of all available unit cards to assign to the shop

    private void Awake()
    {
        // Get the ShopManager component attached to the same GameObject
        shopManager = GetComponent<ShopManager>();
    }

    private void Start()
    {
        // Assign initial cards to the shop cards at the start of the game
        AssignInitialShopCards();
    }

    // This method assigns random unit cards to the shop cards when the game starts
    private void AssignInitialShopCards()
    {
        // Get the list of shop cards from the ShopManager
        ShopCard[] shopCards = shopManager.shopCards;

        // Convert the available unit cards to a list for easier manipulation
        List<UnitCardDataSO> unitCards = new List<UnitCardDataSO>(activeCardList.UnitCardGroup);

        // Iterate through each shop card and assign a random unit card to it
        for (int i = 0; i < shopCards.Length; i++)
        {
            ShopCard shopCard = shopCards[i];

            // Randomly select an index from the remaining available unit cards
            int index = Random.Range(0, unitCards.Count);

            UnitCardDataSO unit = unitCards[index];

            // Assign the selected unit card to the shop card
            shopCard.AssignCardUnit(unit);

            // Remove the selected unit card from the list to avoid reusing it
            unitCards.RemoveAt(index);
        }
    }
}




[RequireComponent(typeof(ShopManager))]
public class ShopCardsCycler : MonoBehaviour
{
    private ShopManager shopManager;

    [Space(5f), Header("Card Data")]
    [SerializeField] private CardDataListSO activeCardList;

    [Space(5f), Header("Stored Cards")]
    public List<UnitCardDataSO> storedCards = new List<UnitCardDataSO>();

    private void Awake()
    {
        // Get the ShopManager component attached to the same GameObject
        shopManager = GetComponent<ShopManager>();
    }

    private void Start()
    {
        // Calculate the initial list of stored cards from the active card list
        CalculateStoredCards();

        // Bind the card cycling functionality to the BuyCardEvent in the shop
        BindCardCycle();
    }

    private void CalculateStoredCards()
    {
        // Initialize storedCards with the active cards
        storedCards = new List<UnitCardDataSO>(activeCardList.UnitCardGroup);

        // Remove the cards that are already being used in the shop (in the shop manager's shopCards)
        foreach (ShopCard shopCard in shopManager.shopCards)
        {
            if (storedCards.Contains(shopCard.unitData))
                storedCards.Remove(shopCard.unitData);
        }
    }

    public void AssignNewCard(ShopCard _shopCard)
    {
        // Randomly select a unit card from the stored cards list
        int index = Random.Range(0, storedCards.Count);

        UnitCardDataSO unitData = storedCards[index];

        // Assign the selected unit data to the shop card
        _shopCard.AssignCardUnit(unitData);

        // Remove the assigned card from the stored cards list to prevent duplicates
        storedCards.Remove(unitData);
    }

    private void BindCardCycle()
    {
        // Bind the event so that when a card is bought, it triggers the card cycling process
        foreach (ShopCard shopCard in shopManager.shopCards)
        {
            shopCard.BuyCardEvent += (UnitData) => { CardCycle(shopCard); };
        }
    }

    private void CardCycle(ShopCard _shopCard)
    {
        StartCoroutine(CardCycleRoutine(_shopCard));
    }

    IEnumerator CardCycleRoutine(ShopCard _shopCard)
    {
        // Hide the shop card to prepare for cycling
        _shopCard.HideCard();

        // Add the current card back to the stored cards list for re-use
        storedCards.Add(_shopCard.unitData);

        // Wait for a few seconds to simulate the cycling process
        yield return new WaitForSeconds(3f);

        // Assign a new unit card to the shop card
        AssignNewCard(_shopCard);

        // Show the updated shop card
        _shopCard.ShowCard();

        yield return null;
    }
}            
                

The Problem

Shop Cards need to be dragged to the arena and dropped to spawn units.

The Solution

Bind Drag Actions

Logic for the card to follow the mouse/touch input on button StartDrag and EndDrag

Invalid Position Logic

If the card drag ends over Shop UI, over an invalid position, or outside of the screen, then return the card to the Shop.

Purchase Card

If the EndDrag position is valid, buy the Card and Unit

                    
public class ShopCardsUnitDragging : MonoBehaviour
{
    // Reference to the ShopManager to interact with shop-related data
    ShopManager shopManager;

    // Dictionary to hold a mapping of unit data to their respective in-game units
    Dictionary<UnitCardDataSO, GameActorUnit> UnitDictionary = new Dictionary<UnitCardDataSO, GameActorUnit>();

    [Header("Layer and Material Settings")]
    [SerializeField] LayerMask groundLayer;  // Layer mask for raycasting to detect the ground
    [SerializeField] string shopUnitLayer;   // Layer for the units in the shop

    [Space(20f)]
    [SerializeField] Material validMaterial;   // Material to apply when the unit is dropped in a valid area
    [SerializeField] Material invalidMaterial; // Material to apply when the unit is dropped in an invalid area

    [Space(20f)]
    [SerializeField] GlobalInt playerMoney;  // Reference to the player's money for affordability check

    [Space(20f)]
    [SerializeField] UnityEvent DragCardEvent;    // Event triggered when the card is being dragged
    [SerializeField] UnityEvent DropCardEvent;    // Event triggered when the card is dropped
    [SerializeField] UnityEvent BuyCardEvent;     // Event triggered when the card is bought

    const float THRESHOLD_OFFSET = 200; // Offset used for scaling the card while dragging

    GameActorUnit currentUnit; // Currently dragged unit
    RaycastHit raycastHit; // Raycast hit information
    bool hit = false;  // Flag to indicate if a valid raycast hit occurred

    bool carryingUnit = false;  // Flag to indicate if a unit is being dragged
    bool doForceDrop = false;   // Flag to force a unit drop

    private void Awake()
    {
        // Get reference to ShopManager
        shopManager = GetComponent<ShopManager>();
    }

    private void Start()
    {
        // Spawn all shop holding units and initialize the dragging event
        SpawnShopHoldingUnits();
        AssignDraggingEvent();
    }

    // Spawn units for each card in the shop and add them to the UnitDictionary
    public void SpawnShopHoldingUnits()
    {
        foreach (UnitCardDataSO unit in shopManager.activeCardList.UnitCardGroup)
        {
            // Instantiate the unit and set it initially out of view
            GameActorUnit newUnit = Instantiate(unit.gameActorUnit, this.transform);
            newUnit.transform.localPosition = new Vector3(0, -20, 0);

            // Add the unit to the dictionary
            UnitDictionary.Add(unit, newUnit);

            // Set the unit to a specific layer for the shop units
            int shopUnitLayerIndex = LayerMask.NameToLayer(shopUnitLayer);
            newUnit.skinnedMeshRenderer.gameObject.layer = shopUnitLayerIndex;

            // Deactivate the unit at start (it’s hidden until dragged)
            newUnit.gameObject.SetActive(false);
        }
    }

    // Bind the dragging events to shop cards
    public void AssignDraggingEvent()
    {
        foreach (ShopCard shopCard in shopManager.shopCards)
        {
            shopCard.AssignTriggerAction(EventTriggerType.BeginDrag, () => { StartDragRoutine(shopCard); });
            shopCard.AssignTriggerAction(EventTriggerType.EndDrag, () => { StopDragRoutine(shopCard); });
        }
    }

    // Start dragging the unit when the card is dragged, if affordable
    public void StartDragRoutine(ShopCard _shopCard)
    {
        if (!CanAffordCard(_shopCard)) return;  // If the player can't afford the card, return

        carryingUnit = true;
        currentUnit = UnitDictionary[_shopCard.unitData];  // Get the corresponding unit for the card

        DragCardEvent.Invoke();  // Trigger the dragging event

        // Start the coroutine to handle the drag logic
        StartCoroutine(CardDraggingSequence(_shopCard));
    }

    // Force the unit to be dropped (in case of special logic)
    public void ForceUnitReturn()
    {
        doForceDrop = true;
    }

    // Stop dragging and handle the drop logic
    public void StopDragRoutine(ShopCard _shopCard)
    {
        if (!carryingUnit) return;  // If no unit is being dragged, return

        carryingUnit = false;  // Stop dragging
        StopAllCoroutines();   // Stop any running drag coroutine

        // Calculate raycast hit to determine drop position
        CalculateRaycast();

        float heightThreshold = Screen.height / 5;

        // Handle dropping in the shop (at the top of the screen)
        if (Input.mousePosition.y <= heightThreshold)
        {
            _shopCard.ReturnCard();  // Return the card back to its original position
            DropCardEvent.Invoke();  // Trigger the drop event
            return;
        }

        // Handle buying the unit if dropped in the attack zone
        if (raycastHit.transform.CompareTag("Attack Zone"))
        {
            _shopCard.ResetCard();  // Reset the card visual state
            _shopCard.BuyCard();    // Buy the unit card
            BuyCardEvent.Invoke();  // Trigger the buy card event
        }
        else
        {
            _shopCard.ReturnCard();  // Otherwise, return the card
        }

        // Reset the unit's position and deactivate it after dropping
        Vector3 position = transform.position;
        position.y -= 20f;
        currentUnit.transform.position = position;
        currentUnit.gameObject.SetActive(false);
    }

    // Coroutine to handle the dragging sequence (updates the drag position and visual feedback)
    IEnumerator CardDraggingSequence(ShopCard _shopCard)
    {
        while (!doForceDrop)
        {
            SetCardContentPosition(_shopCard);  // Update the card’s position based on mouse
            SetCardContentScale(_shopCard);     // Update the card’s scale based on mouse position

            CalculateRaycast();  // Cast a ray to check if the unit is in a valid drop area
            SetDragUnitPosition(currentUnit);  // Update the unit’s position based on the raycast hit
            ToggleUnitVisibility(currentUnit); // Toggle visibility based on mouse position

            SetShopUnitVisual(currentUnit);    // Update the visual feedback for the unit (valid/invalid)

            yield return null;
        }

        if (doForceDrop)
        {
            carryingUnit = false;
            _shopCard.ReturnCard();  // Return the card if forced to drop
            doForceDrop = false;

            currentUnit.gameObject.SetActive(false);  // Deactivate the unit
            DropCardEvent.Invoke();  // Trigger the drop event
        }
    }

    // Update the unit's material based on whether it's over a valid or invalid area
    private void SetShopUnitVisual(GameActorUnit _unit)
    {
        if (!hit) return;  // If no raycast hit, return early

        GameObject hitObject = raycastHit.collider.gameObject;

        // Set the material based on the raycast hit object
        if (hitObject.CompareTag("Attack Zone"))
        {
            _unit.skinnedMeshRenderer.material = validMaterial;
        }
        else
        {
            _unit.skinnedMeshRenderer.material = invalidMaterial;
        }
    }

    // Perform a raycast to detect where the mouse is in the world
    private void CalculateRaycast()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        hit = Physics.Raycast(ray, out raycastHit, Mathf.Infinity, groundLayer);
    }

    // Set the card’s position based on the mouse’s screen position
    private void SetCardContentPosition(ShopCard _shopCard)
    {
        Vector3 currentPosition = _shopCard.GetCurrentPosition();
        Vector3 targetPosition = Input.mousePosition;

        Vector3 newPosition = Vector3.Lerp(currentPosition, targetPosition, 0.1f);
        _shopCard.SetContentPosition(newPosition);
    }

    // Adjust the card's scale based on the mouse's screen position
    private void SetCardContentScale(ShopCard _shopCard)
    {
        float heightThreshold = Screen.height / 5 + 50;
        float heightDifference = heightThreshold - Input.mousePosition.y;

        float currentStep = Mathf.Clamp(heightDifference, 0, 200);
        float normalisedStep = (currentStep - 0) / (THRESHOLD_OFFSET - 0);

        _shopCard.SetContentScale(Vector3.one * normalisedStep);
    }

    // Set the dragged unit’s position based on the raycast hit location
    private void SetDragUnitPosition(GameActorUnit _unit)
    {
        if (!hit) return;

        Vector3 pos = raycastHit.point;

        // If the raycast hits the "Attack Zone", position the unit accordingly
        if (raycastHit.collider.CompareTag("Attack Zone"))
        {
            pos.x = raycastHit.transform.position.x;
            pos.z = Mathf.Clamp(pos.z, -7, -1f);
        }

        pos.y += 1f;  // Raise the unit slightly above the ground
        _unit.transform.position = pos;
    }

    // Toggle the unit's visibility based on whether it is above a certain height threshold
    private void ToggleUnitVisibility(GameActorUnit _unit)
    {
        float heightThreshold = Screen.height / 5;

        if (Input.mousePosition.y > heightThreshold)
        {
            _unit.gameObject.SetActive(true);  // Show the unit if the mouse is above the threshold
        }
        else
        {
            _unit.gameObject.SetActive(false); // Hide the unit if the mouse is below the threshold
        }
    }

    // Check if the player can afford the card based on the player's current money
    private bool CanAffordCard(ShopCard _shopCard)
    {
        return playerMoney.GetValue() >= _shopCard.unitData.cardCost;
    }
}
              
                

The Problem

Dragging a card should spawn a player unit.

The Solution

Spawn Unit

Call SpawnManager to Spawn a Player Unit.

Configure Unit

Configure Unit with correct directions, targetting parameters, and behavioural state.

                    
public class ShopCardsBuyCard : MonoBehaviour
{
    // Reference to the ShopManager for accessing shop-related data
    private ShopManager shopManager;

    // LayerMask to specify the ground layer for raycasting
    [SerializeField] private LayerMask groundLayer;

    // UnityEvent that gets invoked when a card is bought
    [SerializeField] private UnityEvent CardBuyEvent;

    private void Awake()
    {
        // Initialize the shopManager reference
        shopManager = GetComponent<ShopManager>();
    }

    private void Start()
    {
        // Bind the BuyCard function to the BuyCardEvent for all shop cards
        BindBuyToCards();
    }

    // Binds the BuyCard method to the BuyCardEvent of each shop card
    private void BindBuyToCards()
    {
        foreach (ShopCard shopCard in shopManager.shopCards)
        {
            shopCard.BuyCardEvent += BuyCard;  // Subscribe to the BuyCardEvent of each shop card
        }
    }

    // This method handles the buying process of a card
    private void BuyCard(UnitCardDataSO _cardData)
    {
        // Create a ray from the camera to the mouse position
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        // Perform a raycast to detect where the mouse is in the game world
        Physics.Raycast(ray, out hit, Mathf.Infinity, groundLayer);

        // If no valid hit, return early (don't process)
        if (hit.collider == null) return;

        // Get the position where the ray hit
        Vector3 pos = hit.point;
        pos.y += 1f; // Raise the position a little bit to avoid collision with the ground

        // If the hit is in the "Attack Zone", we want to adjust the position slightly
        if (hit.collider.CompareTag("Attack Zone"))
        {
            pos.x = hit.transform.position.x; // Align with the X position of the attack zone
            pos.z = Mathf.Clamp(pos.z, -7f, -1f); // Constrain Z position to valid range
        }

        // Spawn the unit at the calculated position
        SpawnManager.instance.SpawnGameUnit(_cardData, pos, Vector3.zero, "PlayerActor", "EnemyActor");

        // You can also use this if you have a static SpawnUnit method in the SpawnManager
        SpawnManager.SpawnUnit(_cardData);

        // Invoke the CardBuyEvent to notify any listeners
        CardBuyEvent?.Invoke();
    }
}
           
                

The Problem

Dinosaur units need logic to control their behaviour, movement, and combat logic.

The Solution

Stored

When not in use, Units should not take up processing power with detection, movement, or animation.

Hovering

When hovering while card is dragged, be visually distinct from active units, to clarify spawn position.

Spawning

"Drop" unit onto board with tweening animation, and active health visual

Moving

When no enemy units are in front, move ahead.

Combat

When an enemy unit is in front, attack it using Unit Data for damage and health.

Dying

When Health reaches zero, shrink beneath the map. Then return to object pool for recycling.

                    
public class GameActorUnit : GameActor<GameActorUnitState>
{
    public UnitCardDataSO unitCardData; // Unit's data (e.g., damage, health)
    public Animator animator;           // Animator for controlling animations
    public SkinnedMeshRenderer skinnedMeshRenderer; // Mesh renderer for visuals

    // Override the base class's Damage property to return unit's damage
    public override int Damage
    {
        get { return unitCardData.unitDamage; }
        set { } // Damage is read-only
    }

    private void Awake()
    {
        Health = unitCardData.unitHealth;
        Damage = unitCardData.unitDamage;
    }

    public override void Alive()
    {
        base.Alive();
        Health = unitCardData.unitHealth;
    }
}

// Enum defining possible states for a GameActorUnit
public enum GameActorUnitState
{
    stored,
    hovering,
    spawning,
    idle,
    moving,
    combat,
    dying
}       
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitBehaviour : MonoBehaviour
{
    [SerializeField] GameActorDetection gameActorDetection;

    GameActorUnit gameActorUnit;
    public event Action<IDamageable> EnemyFoundEvent;

    bool inCombat = false;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnit.StateChangeEvent += StateChanged;
        gameActorDetection.EnemyTargetsChangedEvent += BehaviourLogic;
        gameActorUnit.DeathEvent += Dying;
    }

    private void Dying(IDamageable damageable)
    {
        gameActorUnit.ChangeState(GameActorUnitState.dying);
    }

    private void StateChanged(GameActorUnitState _gameActorUnitState)
    {
        // Activate detection based on state
        if (_gameActorUnitState != GameActorUnitState.stored && _gameActorUnitState != GameActorUnitState.hovering)
        {
            gameActorDetection.gameObject.SetActive(true);
        }
        else
        {
            gameActorDetection.gameObject.SetActive(false);
        }

        if (_gameActorUnitState == GameActorUnitState.idle)
        {
            BehaviourLogic();
        }
    }

    private void BehaviourLogic()
    {
        // No enemies detected, move the unit
        if (gameActorDetection.targetableEnemies.Count == 0)
        {
            gameActorUnit.ChangeState(GameActorUnitState.moving);
            gameActorUnit.animator.SetBool("isMoving", true);
            return;
        }

        // Engage combat if no ongoing combat
        if (!inCombat)
        {
            EnemyFoundEvent?.Invoke(gameActorDetection.targetableEnemies[0]);
            gameActorUnit.ChangeState(GameActorUnitState.combat);
            gameActorUnit.animator.SetBool("isMoving", false);
        }
    }
}  
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitStored : MonoBehaviour
{
    GameActorUnit gameActorUnit;
    Vector3 startPosition;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnit.StateChangeEvent += StateChanged;
        startPosition = transform.position;
    }

    private void StateChanged(GameActorUnitState _gameActorUnitState)
    {
        // Store the unit if the state is 'stored'
        if (_gameActorUnitState == GameActorUnitState.stored)
            StoreUnit();
    }

    private void StoreUnit()
    {
        // Reset the unit's position and trigger the stored animation
        transform.position = startPosition;
        gameActorUnit.animator.SetTrigger("isStored");
    }
}                    
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitHovering : MonoBehaviour
{
    GameActorUnit gameActorUnit;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnit.StateChangeEvent += Hovering;
    }

    private void Hovering(GameActorUnitState _gameActorUnitState)
    {
        // Trigger hovering animation if the unit's state is 'hovering'
        if (_gameActorUnitState == GameActorUnitState.hovering)
            HoverUnit();
    }

    private void HoverUnit()
    {
        // Play the hovering animation
        gameActorUnit.animator.SetTrigger("isHovering");
    }
}                    
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitSpawning : MonoBehaviour
{
    GameActorUnit gameActorUnit;

    [SerializeField] GameObject modelObject;
    [SerializeField] Vector3 fallingScale;

    [Space(20)]
    [SerializeField] UnityEvent spawnEvent;
    [SerializeField] UnityEvent landEvent;

    private void Awake()
    {
        // Get reference to Game Actor Unit and bind the StateChangeEvent
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnit.StateChangeEvent += StateChanged;
    }

    private void StateChanged(GameActorUnitState _gameActorUnitState)
    {
        // If the unit is spawning, start the spawn process
        if (_gameActorUnitState == GameActorUnitState.spawning)
            SpawnUnit();
    }

    private void SpawnUnit()
    {
        // Trigger spawning animation and set the unit as alive
        gameActorUnit.animator.SetTrigger("isSpawning");
        gameActorUnit.Alive();

        // Drop the unit into the scene
        DropUnit();
    }

    private void DropUnit()
    {
        spawnEvent?.Invoke();

        // Calculate and tween the unit's position to ground level
        Vector3 targetPosition = gameActorUnit.transform.position;
        targetPosition.y = 0;
        LeanTween.move(gameObject, targetPosition, 0.25f).setEaseInExpo();

        // Scale the unit and animate it dropping to the ground
        LeanTween.scale(modelObject.gameObject, new Vector3(0.5f, 2f, 0.5f), 0.25f)
                 .setEaseInCubic()
                 .setOnComplete(Splat);
    }

    private void Splat()
    {
        // Animate the unit's scale back to normal with an elastic ease
        LeanTween.scale(modelObject.gameObject, Vector3.one, 0.25f).setEaseOutElastic();

        // Set the unit to idle state after splat animation
        Invoke("SetStateIdle", 0.5f);
    }

    private void SetStateIdle()
    {
        // Set the unit's state to idle
        gameActorUnit.ChangeState(GameActorUnitState.idle);
    }
}                    
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitMovement : MonoBehaviour
{
    GameActorUnit gameActorUnit;

    float moveSpeed;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();

        gameActorUnit.StateChangeEvent += StateChanged;

        moveSpeed = gameActorUnit.unitCardData.unitSpeed;
    }

    public void StateChanged(GameActorUnitState _gameActorUnitState)
    {

        if (_gameActorUnitState == GameActorUnitState.moving)
        {
            StartCoroutine(MovementSequence());

            return;
        }
        else
        {
            StopAllCoroutines();
        }   
    }

    IEnumerator MovementSequence()
    {
        gameActorUnit.animator.SetBool("isMoving", true);


        while (gameObject.activeSelf)
        {
            Vector3 position = transform.position;

            position.z += transform.forward.z * moveSpeed * Time.deltaTime;

            transform.position = position;

            yield return null;
        }

        

        yield return null;
    }
}                    
                
                    
[RequireComponent(typeof(GameActorUnit), typeof(GameActorUnitBehaviour))]
public class GameActorUnitCombat : MonoBehaviour
{
    GameActorUnit gameActorUnit;
    GameActorUnitBehaviour gameActorUnitBehaviour;
    IDamageable targetEnemy;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnitBehaviour = GetComponent<GameActorUnitBehaviour>();

        gameActorUnit.StateChangeEvent += StateChanged;
        gameActorUnitBehaviour.EnemyFoundEvent += AssignEnemy;
    }

    private void AssignEnemy(IDamageable _targetEnemy)
    {
        targetEnemy = _targetEnemy;
    }

    private void StateChanged(GameActorUnitState _gameActorUnitState)
    {
        // Start attacking when the unit enters combat state
        if (_gameActorUnitState == GameActorUnitState.combat)
        {
            StartCoroutine(AttackEnemySequence(targetEnemy));
        }
        else
        {
            StopAllCoroutines(); // Stop attacking when not in combat
        }
    }

    IEnumerator AttackEnemySequence(IDamageable _targetEnemy)
    {
        while (!targetEnemy.IsDead)
        {
            yield return new WaitForSeconds(1f);

            // Trigger attack animation
            gameActorUnit.animator.SetTrigger("isAttacking");

            yield return new WaitForSeconds(0.2f);

            // Wait for the current attack animation to complete
            yield return new WaitForSeconds(gameActorUnit.animator.GetCurrentAnimatorClipInfo(0)[0].clip.length);
        }

        targetEnemy = null;
        yield return null;
    }

    public void DamageEnemy()
    {
        // Apply damage to the target if it's not dead
        if (!targetEnemy.IsDead) gameActorUnit.AttackTarget(targetEnemy);
    }
}                  
                
                    
[RequireComponent(typeof(GameActorUnit))]
public class GameActorUnitDeath : MonoBehaviour
{
    GameActorUnit gameActorUnit;

    [SerializeField] UnityEvent deathEvent;

    private void Awake()
    {
        gameActorUnit = GetComponent<GameActorUnit>();
        gameActorUnit.StateChangeEvent += StateChanged;
    }

    private void StateChanged(GameActorUnitState _gameActorUnitState)
    {
        // Trigger dying process when the unit's state is 'dying'
        if (_gameActorUnitState == GameActorUnitState.dying)
            DyingUnit();
    }

    private void DyingUnit()
    {
        // Move the unit downward and invoke the death event
        Vector3 targetPosition = transform.position;
        targetPosition.y -= 4;
        LeanTween.move(gameObject, targetPosition, 0.25f).setOnComplete(StoreUnit);

        deathEvent?.Invoke();
    }

    private void StoreUnit()
    {
        // Change the unit's state to 'stored' and deposit it
        gameActorUnit.ChangeState(GameActorUnitState.stored);
        SpawnManager.instance.DepositUnit(gameActorUnit);
    }
}       
                

The Problem

Enemy AI needs to spawn dinosaur units, at a rate compareable to the Player.

The Solution

Enemy Economy

Enemy builds own economy at the same rate as player

Random Dino, Random Lane

Randomly select a unit to save for, and once affordable, spawn in a random lane.

                    
public class EnemyManager : MonoBehaviour
{
    public float money = 0f;
    public float moneyProgress = 0f;

    public event Action MoneyUpdated;

    public CardDataListSO availableCards;

    private void Start()
    {
        // Trigger MoneyUpdated event on start
        MoneyUpdated?.Invoke();
    }

    public void ChangeMoney(float _newMoney)
    {
        money = _newMoney;
        MoneyUpdated?.Invoke();
    }

    public void AddProgress(float amount)
    {
        moneyProgress += amount;

        // If progress reaches 1, update money and reset progress
        if (moneyProgress >= 1f)
        {
            moneyProgress -= 1f;
            ChangeMoney(money + 1);
        }
    }
}



[RequireComponent(typeof(EnemyManager))]
public class EnemyMoneyPassiveProgress : MonoBehaviour
{
    EnemyManager enemyManager;
    const float TIME_TO_PROGRESS = 2.5f;

    float moneyMultiplier = 1.0f;

    [SerializeField] GlobalInt MoneyMultiplier;

    private void Awake()
    {
        enemyManager = GetComponent<EnemyManager>();
    }

    public void UpdateMultiplier()
    {
        // Update money multiplier value from GlobalInt
        moneyMultiplier = MoneyMultiplier.GetValue();
    }

    IEnumerator PassiveMoneyGain()
    {
        while (true)
        {
            IncreaseProgress();
            yield return null;
        }
    }

    public void StartPassiveGain()
    {
        // Start the passive money gain coroutine
        StartCoroutine(PassiveMoneyGain());
    }

    private void IncreaseProgress()
    {
        // Calculate and add progress based on multiplier
        float progressAmount = (1f / TIME_TO_PROGRESS) * moneyMultiplier * Time.deltaTime;
        enemyManager.AddProgress(progressAmount);
    }
}



[RequireComponent(typeof(EnemyManager))]
public class EnemyBrain : MonoBehaviour
{
    EnemyManager enemyManager;
    public UnitCardDataSO currentCard;

    private void Awake()
    {
        enemyManager = GetComponent<EnemyManager>();
        DecideUnit();
        enemyManager.MoneyUpdated += CheckUnitCost;
    }

    private void DecideUnit()
    {
        // Randomly select a unit card from the available cards
        int randomValue = Random.Range(0, enemyManager.availableCards.UnitCardGroup.Count);
        currentCard = enemyManager.availableCards.UnitCardGroup[randomValue];
    }

    private void CheckUnitCost()
    {
        // If the enemy has enough money, spawn the unit and update the money
        if (enemyManager.money >= currentCard.cardCost)
        {
            SpawnCard();
            enemyManager.ChangeMoney(enemyManager.money - currentCard.cardCost);
            DecideUnit();
        }
    }

    private void SpawnCard()
    {
        // Calculate spawn position and rotation, then spawn the unit
        Vector3 spawnPosition = GetRandomLaneSpawn();
        Vector3 eulerRotation = new Vector3(0, -180f, 0);
        SpawnUnit(currentCard, spawnPosition, eulerRotation);
    }

    private void SpawnUnit(UnitCardDataSO _cardData, Vector3 _spawnPosition, Vector3 _eulerRotation)
    {
        // Call the SpawnManager to spawn the unit
        SpawnManager.instance.SpawnGameUnit(_cardData, _spawnPosition, _eulerRotation, "EnemyActor", "PlayerActor");
    }

    private Vector3 GetRandomLaneSpawn()
    {
        // Generate a random spawn position along one of the three lanes
        Vector3 position = Vector3.zero;
        int randomLane = Random.Range(0, 3);

        switch (randomLane)
        {
            case 0: position.x = -4f; break;
            case 1: position.x = 0f; break;
            case 2: position.x = 4f; break;
        }

        position.z = Random.Range(0.75f, 7.75f);
        return position;
    }

    public void StopBrain()
    {
        // Unsubscribe from MoneyUpdated event when stopping the brain
        enemyManager.MoneyUpdated -= CheckUnitCost;
    }
}
                

The Problem

Game timer must decrease from 3 minutes, so that games have a definitive ending. Last 30 seconds should initiate a 'last rush' phase.

The Solution

Update Timer

Coroutine to decrease remaining time, formatted into minutes and seconds.

Last Rush

During last 30 seconds, initate 'last rush' phase.

                    
public class GameTimer : MonoBehaviour
{
    [Header("Variables")]
    [SerializeField] CountdownTimer countdownTimer;
    [SerializeField] float lastRushStartSeconds = 30.0f;

    [Header("References")]
    [SerializeField] TextMeshProUGUI countdownTextMesh;

    [Header("Game Events")]
    [SerializeField] GameEvent LastRushEvent;
    [SerializeField] GameEvent TimeOutEvent;

    private void Start()
    {
        // Initialize the display to show the starting countdown time
        UpdateDisplay(countdownTimer.countdownSeconds);
    }

    public void InitiateCountdownTimer()
    {
        // Bind the timer's update events and start the countdown
        countdownTimer.UpdateCountdown += UpdateDisplay;
        countdownTimer.UpdateCountdown += CheckForLastRush;
        countdownTimer.UpdateCountdown += CheckForTimeOut;

        StartCoroutine(countdownTimer.CountdownSequence());
    }

    private void UpdateDisplay(float _secondsRemaining)
    {
        // Update countdown text in "MM:SS" format
        int minutes = Mathf.FloorToInt(_secondsRemaining / 60);
        int seconds = Mathf.FloorToInt(_secondsRemaining % 60);
        countdownTextMesh.text = string.Format("{0:00}:{1:00}", minutes, seconds);
    }

    private void CheckForLastRush(float _secondsRemaining)
    {
        // Trigger Last Rush event when the countdown reaches the set threshold
        if (_secondsRemaining <= lastRushStartSeconds)
        {
            countdownTimer.UpdateCountdown -= CheckForLastRush;
            LastRush();
        }
    }

    private void CheckForTimeOut(float _secondsRemaining)
    {
        // Trigger Time Out event when the countdown reaches zero
        if (_secondsRemaining <= 0f)
        {
            countdownTimer.UpdateCountdown -= CheckForTimeOut;
            TimeOutEvent.Raise();
        }
    }

    private void LastRush()
    {
        // Raise the Last Rush event
        LastRushEvent.Raise();
    }
}



[Serializable]
public class CountdownTimer
{
    public float countdownSeconds = 180.0f;       // Initial countdown time in seconds
    float remainingSeconds;

    public event Action<float> UpdateCountdown;   // Event triggered each update with remaining time

    public IEnumerator CountdownSequence()
    {
        remainingSeconds = countdownSeconds;

        // Decrement time until the countdown reaches zero
        while (remainingSeconds > 0)
        {
            remainingSeconds -= Time.deltaTime;
            remainingSeconds = Mathf.Clamp(remainingSeconds, 0, countdownSeconds);

            UpdateCountdown?.Invoke(remainingSeconds);  // Notify listeners with the current time

            yield return null;
        }
    }
}
                
Destroy Enemy Towers
Drag Dinosaurs
Spend Gold