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;
}
}
}