Made in Jauary 2024 using Unity
On this page
Highlights
- Developed solo in 4 weeks using Unity 2022.11.
- This was an assignment submission for a module in my Master's course. The brief asked us to develop a game combining at least two of the following game mechanics: Co-operative, Protect the Target, Teleportation, Timed Tasks and Bouncing Object.
- 'Army of Two' cleverly combines all but the Bouncing Object mechanic.
- A couch co-op that can be played by two players on the same PC, with one player attacking the enemies and the other defending and repairing towers.
-
To reinforce the co-op elements, the defender needs to be close to the
attacker or their health depletes continuously.
- Features a bomb defusal mechanism, which needs the player to teleport over the differently colored pedestals in a certain order.
- I chose to use Unity for this game to refresh my knowledge of Unity, having worked on Unreal Engine for my last 4 games.
Description
'Army of Two' is a 2-player, co-operative game where both players work together to protect a research facility from the minions of a rival corporation. One player attacks, while the other defends. Both players need to communicate and strategize their movements together in order to beat the level.Technical Walkthrough
-
Since Army of Two had numerous multiple systems working together such as:
- Player 1 rotation, shooting, and teleportation
- Player 2 movement
- Bomb defusal
- Enemy AI
- Enemy spawning with progressive difficulty
- Pickups
- Tower repair
- Health and damage
- Best practices in programming played a key role here. All systems were segregated and had their own scripts, with clear boundaries between each script in terms of variable visiblity and access.
- Circular dependencies between different scripts were avoided to eliminate any race conditions. Where necessary, dependency was injected as a function parameter to limit its scope in the script.
Bomb Defusal
-
The game features bombs which can be defused if the player teleports
over the towers in a particular sequence.
-
Each bomb's defusal sequence is constructed whenever it is instantiated:
private void Start() { Init(); ConstructBombDefusalSequence(); _timer.SetInitialTime(6 * _defusalSequence.Count); } -
The bomb first creates a master list of towers depending on how many are
currently alive in the level. Then it initialises a map that stores
sprites for each tower. These sprites are used later to display in the
defusal sequence.
private void Init() { _defusalSequence = new List<Towers>(); _defusalSeqInstdSprites = new List<GameObject>(); _playerTeleportHistory = new List<Towers>(); _towerToSpriteMap = new Dictionary<Towers, Sprite>(); Tower[] towersInScene = GameObject.FindObjectsOfType<Tower>(); if (towersInScene.Length <2) { _towersMasterList = FindObjectOfType<LevelController>().GetTowersAtBeginning(); } else { _towersMasterList = new(); foreach (var towerInScene in towersInScene) { _towersMasterList.Add(towerInScene.GetThisTower()); } } foreach (TowerSpriteInfo towerSpriteInfo in _towerSprites) { _towerToSpriteMap[towerSpriteInfo.towerEnum] = towerSpriteInfo.towerSprite; } } - Admittedly, I used 'FindObjectOfType' more than I would have liked, and I am trying to improve upon it in my recent projects by caching the objects in the calling class and sending them as function parameters.
-
In 'ConstructBombDefusalSequence()' all data structures are cleared, and
the defusal sequence is constructed, taking care not to repeat any tower
consecutively, and not to include the tower on which the player is
currently standing.
private void ConstructBombDefusalSequence() { _playerTeleportHistory.Clear(); _defusalSequence.Clear(); foreach (GameObject child in _defusalSeqInstdSprites) { Destroy(child); } _defusalSeqInstdSprites.Clear(); int seqLength = UnityEngine.Random.Range(2, 6); RectTransform defusalSeqPanelRect = _defusalSeqPanel.GetComponent<RectTransform>(); defusalSeqPanelRect.sizeDelta = new Vector2(seqLength + 1, defusalSeqPanelRect.sizeDelta.y); List<Towers> towerListForNextPass = new(_towersMasterList); GameObject player1 = GameObject.FindGameObjectWithTag("Player1"); if (player1 != null ) { Towers p1CurrentTower = player1.GetComponent<Teleporter>().GetCurrentTower(); towerListForNextPass.Remove( p1CurrentTower ); } for (int i = 0; i < seqLength; i++) { Towers randomTower = towerListForNextPass[UnityEngine.Random.Range(0, towerListForNextPass.Count)]; _defusalSequence.Add(randomTower); GameObject randomTowerSprite = Instantiate(_defusalSeqSpriteObject, _defusalSeqPanel.transform); randomTowerSprite.GetComponent<Image>().sprite = _towerToSpriteMap[randomTower]; _defusalSeqInstdSprites.Add(randomTowerSprite); towerListForNextPass = new List<Towers>(_towersMasterList); towerListForNextPass.Remove(randomTower); } } -
Whenever the player teleports, a broadcast is sent to all bombs in the
level to check if the player's teleportation history matches the defusal
sequence partially or completely. In case of complete match, the bomb is
defused, while for a partial match the defusal sequence panel is updated
on the UI:
public void OnPlayerTeleport(Towers towerTeleportedTo) { if (_isBombDefused) { return; } _playerTeleportHistory.Add(towerTeleportedTo); for (int i = _defusalSequence.Count; i > 0; i--) { if (!AreListsEqualBackwards(_defusalSequence.GetRange(0, i), GetN_ElementsFromBack(i, _playerTeleportHistory))) { continue; } if (i == _defusalSequence.Count) { DefuseBomb(); return; } else { UpdateDefusalSeqSpritesOnSubseqMatch(i); return; } } //No subsequence in teleport history matched any subsequence in defusal sequence. //So reset the sprites. ResetDefusalSeqSpritesOpacity(); } private void UpdateDefusalSeqSpritesOnSubseqMatch(int i) { //fade i-1th child as all children before this will have already been faded. Color translucent = new(1, 1, 1, 0.5f); _defusalSeqInstdSprites[i - 1].GetComponent<Image>().color = translucent; //Also reset opacity of all children after this one. for (int j = i; j < _defusalSequence.Count; j++) { _defusalSeqInstdSprites[j].GetComponent<Image>().color = Color.white; } } - To check player's teleportation history against the defusal sequence, I chose to extract the last 'n' elements from the telportation history and compare it with the defusal sequence backwards i.e. starting with the last element. This is because I wanted to fetch the longest teleportation subsequence from the end that matched the defusal sequence from the beginning.
-
For example, if the defusal sequence were
Yellow-Blue-Yellow-Redand the player's telportation history wereYellow-Blue-Yellow-Blue,then the last 'Yellow-Blue' subsequence in the history would match the 'Yellow-Blue' at the beginning of the defusal sequence.
Enemy AI Behaviour
- For enemy pathfinding, h8man's NavMeshPlus was used.
-
Enemy's behaviour was essentially "If current target is dead, find a new
target, go to it, and start shooting.":
void Update() { if (LevelController.isGamePaused) return; if (_target == null || !_target.GetComponent<Health>().enabled) { FindNewTarget(); } if(_target == null) { return; } _navMeshAgent.SetDestination(_target.position); if (_isPlayerWithinShootingRange && _player2.GetComponent<Health>().enabled) { RotateTowardsTarget(_player2); TriggerShooting(); } else if(!_navMeshAgent.pathPending && _navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance) { RotateTowardsTarget(_target); TriggerShooting(); } else { RotateTowardsTarget(_target); StopShooting(); } }
Enemy Spawning with Progressive Difficulty
-
I aimed to create levels that got more difficult and chaotic with time,
and enemy spawn rate seemed an ideal candidate which could be tweaked
over time to introduce a difficulty curve.
- I also considered increasing the probability of spawning a bomb over time, but playtesting suggested that it would end up making the game too difficult, so this idea was dropped.
-
The enemy spawn rate was controlled using a spawn delay variable which
was lerped from a maximum to minimum value as the level progressed. The
duration between spawning enemies was a random value between a minimum
and this lerped value:
void Update() { LerpSpawnDelay(); _timeElapsedSinceLastSpawn += Time.deltaTime; if (_timeElapsedSinceLastSpawn >= _nextEnemySpawnDelay) { _nextEnemySpawnDelay = SpawnEnemy(); _timeElapsedSinceLastSpawn = 0f; } } private void LerpSpawnDelay() { _timeElapsedSinceStart += Time.deltaTime; _currentSpawnDelay = Mathf.Lerp(_maxSpawnDelay, _minSpawnDelay+2, (_timeElapsedSinceStart / _timeFromMaxToMin)); } private float SpawnEnemy() { Transform randomSpawnPoint = _spawnPts[UnityEngine.Random.Range(0, _spawnPts.Length)]; //Choose whether to spawn bomber or shooter enemy if (UnityEngine.Random.Range(1, 101) <= _bomberSpawnPercent && _activeBomb == null) { Transform target = _enemyTargets.ElementAt(UnityEngine.Random.Range(0, _enemyTargets.Count)).transform; GameObject spawnedBomber = Instantiate(_bomberToSpawn, randomSpawnPoint.position, Quaternion.identity); spawnedBomber.GetComponent<BomberBehavior>().SetTarget(target); } else { Transform target = _allTargets.ElementAt(UnityEngine.Random.Range(0, _allTargets.Count)).transform; GameObject spawnedEnemy = Instantiate(_enemyToSpawn, randomSpawnPoint.position, Quaternion.identity); spawnedEnemy.GetComponent<EnemyBehavior>().SetTarget(target); } return UnityEngine.Random.Range(_minSpawnDelay, _currentSpawnDelay); } - As the lerped value became smaller, so did the wait between spawning enemies, resulting in enemies being spawned faster at the end of the level.
Player Interaction with Game Entities (Pickups, Repair)
- Player-game interaction was programmed using an 'Administrable-Administrator' relationship, where the administrator performs some action on the administrable object.
- The administrable objects were implemented as interfaces, with each object implementing the interface and defining its own behaviour when administered.
- Two such relationships existed in the game - Interactable-Interactor, and Pickable-Picker.
-
The Interactor kept track of any Interactables within its interaction
range, and called its 'Interact' method whenever the player interacted
with it:
[SerializeField] private InputActionReference _interactAction; private void OnCollisionEnter2D(Collision2D collision) { if (collision != null && collision.gameObject.GetComponent<IInteractable>() != null) { _isInteractableWithinRange = true; _interactableWithinRange = collision.gameObject.GetComponent<IInteractable>(); } } private void OnCollisionExit2D(Collision2D collision) { if (collision != null && collision.gameObject.GetComponent<IInteractable>() != null) { if (_interactableWithinRange != null) { _interactableWithinRange.HideInteractMsg(); } _isInteractableWithinRange = false; _interactableWithinRange = null; } } void Update() { if (!_isInteractableWithinRange || _interactableWithinRange == null) { return; } if (_interactAction.action.IsPressed()) { _interactableWithinRange.Interact(); } else { _interactableWithinRange.DisplayInteractMsg(); } } -
The 'Repairable' component of the Tower objects implements the
IInteractable interface and is programmed to increase the tower's health
when interacted with:
public class Repairable : MonoBehaviour, IInteractable { public void Interact() { _health.SetCurrentHealth(_health.GetCurrentHealth() + (repairSpeed * Time.deltaTime)); } public void DisplayInteractMsg() { if (_interactMsgObject != null) { _interactMsgObject.SetActive(true); } } public void HideInteractMsg() { if (_interactMsgObject != null) { _interactMsgObject.SetActive(false); } } } -
The 'Picker', 'IPickable', and 'HealthPickup' follow the same pattern,
where the Picker calls the IPickable's Pick() function when the two
collide:
public class Picker : MonoBehaviour { private void OnTriggerEnter2D(Collider2D collision) { if (collision == null) { return; } IPickable pickable = collision.gameObject.GetComponent<IPickable>(); if (pickable == null) { Debug.Log("No IPickable component on collided trigger."); return; } pickable.Pick(gameObject); } }public class HealthPickup : MonoBehaviour, IPickable { public void Pick(GameObject picker) { Health pickerHealth = picker.GetComponent<Health>(); if (pickerHealth == null) { return; } try { Player2Health p2Health = (Player2Health)pickerHealth; } catch (InvalidCastException) { Debug.Log("Invalid cast exception : Object that picked pickup is not Player ."); } float amountToHeal = ((float)_healAmountPercentage / 100) * pickerHealth.GetMaxHealth(); pickerHealth.SetCurrentHealth(pickerHealth.GetCurrentHealth() + amountToHeal); _audioPlayer.PlaySFX(_pickupSound); DestroyPickup(); } }
Health and Damage
public class TowerHealth : Health
{
[SerializeField]
private Sprite _damagedTower;
[SerializeField]
private Sprite _healthyTower;
public override void ReduceHealth(float damage)
{
base.ReduceHealth(damage);
if (_currentHealth / _maxHealth < 0.5f)
{
_spriteRenderer.sprite = _damagedTower;
}
}
public override void SetCurrentHealth(float health)
{
base.SetCurrentHealth(health);
if (_currentHealth / _maxHealth >= 0.5f)
{
_spriteRenderer.sprite = _healthyTower;
}
}
}
public class BulletHitHandler : MonoBehaviour
{
[SerializeField]
private float damage = 1f;
private void OnCollisionEnter2D(Collision2D collision)
{
Health otherObjectHealth = collision.gameObject.GetComponent<Health>();
if (otherObjectHealth != null && otherObjectHealth.enabled)
{
otherObjectHealth.OnBulletHit(damage);
}
Destroy(gameObject);
}
}
public class Bomb : MonoBehaviour, IInteractable
private void OnTimerFinished()
{
foreach (var colliderInDamageRadius in Physics2D.OverlapCircleAll(transform.position, _damageRadius))
{
Health bombVictim = colliderInDamageRadius.GetComponent<Health>();
if (bombVictim != null && bombVictim.enabled)
{
bombVictim.ReduceHealth(_damage);
}
}
GameObject explosionObject = Instantiate(_explosionEffect, transform.position, Quaternion.identity);
_audioPlayer.PlaySFX(_explosionSound);
Destroy(explosionObject, 2f);
Destroy(transform.root.gameObject);
}
.png)










