About
Dual Dynamite is a top-down, single-player game set in a post-apocalyptic world. Players must scavenge a bunker for ammunition while fending off other scavengers. The game offers a variety of weapons to choose from, which players can use to gather as many points as possible.
Project Info
Group Size: 4
Project Length: 8 Weeks
Project Date: May-June 2023
Engine & Tools: Unity, Visual Studio Code & Trello
Summary
World Generation
Player Controller & Hud
Interactive Props & Pickups
Enemies
World Generation
For the world generation, we aimed for something infinite, allowing players to run in one direction without ever encountering a world border. This presented a challenge for me. After theory crafting for a while, I decided to use a dictionary with Vector2Int
as the key and GameObject
as the value, representing the location of the room and the prefab to spawn. The nine rooms surrounding the player are always rendered, and once the player enters a room, enemies begin to spawn. When a room is no longer rendered, it unloads itself and saves its state, so when the player returns, the room remains unchanged.
World Generation Code
public class RoomManager : MonoBehaviour
{
[Header("RoomGeneration")]
[SerializeField] private List<GameObject> prefabs;
private Dictionary<Vector2Int, GameObject> rooms;
[Header("RoomSettings")]
[SerializeField] private Transform worldRoot;
[SerializeField] private Vector2 roomSize;
[SerializeField] private Transform player;
private Vector2Int playerRoomLocation;
private Vector2Int previousPlayerRoomLocation;
private void Start()
{
ActivateSurroundingRooms();
}
private void Update()
{
UpdatePlayerRoom();
}
/// <summary>
/// Updates the player's room location and activates the surrounding rooms
/// </summary>
private void UpdatePlayerRoom()
{
Vector2Int currentPlayerRoomLocation = GetPlayerGridCoordinate(player);
if (currentPlayerRoomLocation != previousPlayerRoomLocation)
{
ActivateSurroundingRooms();
DeactivateNotSurroundingRooms();
previousPlayerRoomLocation = currentPlayerRoomLocation;
}
}
/// <summary>
/// Activates the rooms surrounding the player
/// </summary>
private void ActivateSurroundingRooms()
{
GetPlayerGridCoordinate(player);
int x = playerRoomLocation.x;
int y = playerRoomLocation.y;
for (int i = -1; i <= 1; i++)
{
for (int j = -1; j <= 1; j++)
{
Activate(new Vector2Int(x + i, y + j));
}
}
}
/// <summary>
/// Deactivates the rooms that are not surrounding the player
/// </summary>
private void DeactivateNotSurroundingRooms()
{
// Create a copy of the keys to avoid modifying the dictionary while iterating
List<Vector2Int> roomCoordinates = new List<Vector2Int>(rooms.Keys);
foreach (Vector2Int coordinate in roomCoordinates)
{
// Check if the coordinate is within the desired range around the player
if (!IsCoordinateWithinRange(coordinate, playerRoomLocation))
{
Deactivate(coordinate);
}
}
}
/// <summary>
/// Checks if a coordinate is within the desired range around the player
/// </summary>
private bool IsCoordinateWithinRange(Vector2Int coordinate, Vector2Int centerCoordinate)
{
// Define the range by adding/subtracting 1 from the center coordinate
int minX = centerCoordinate.x - 1;
int maxX = centerCoordinate.x + 1;
int minY = centerCoordinate.y - 1;
int maxY = centerCoordinate.y + 1;
// Check if the coordinate is within the range
return coordinate.x >= minX && coordinate.x <= maxX && coordinate.y >= minY && coordinate.y <= maxY;
}
/// <summary>
/// Activates a room at the given grid coordinate
/// </summary>
public void Activate(Vector2Int coordinate)
{
// Create a new dictionary if it doesn't exist
if (rooms == null)
rooms = new Dictionary<Vector2Int, GameObject>();
// Does the coordinate already have a room registered?
if (rooms.ContainsKey(coordinate))
{
rooms[coordinate].SetActive(true);
return;
}
// Create a new room
GameObject prefab = prefabs[Random.Range(0, prefabs.Count)];
if (prefab == null)
throw new System.NullReferenceException("<color=#FF8888>There is an empty spot in the prefabs list of the RoomManager</color>");
GameObject newRoom = Instantiate(prefab, worldRoot);
// Set the name
newRoom.transform.name = $"{prefab.transform.name} {coordinate}";
newRoom.transform.localPosition = new Vector3(roomSize.x * coordinate.x, 0, roomSize.y * coordinate.y);
// Add to the dictionary
rooms.Add(coordinate, newRoom);
}
/// <summary>
/// Deactivates a room at the given grid coordinate
/// </summary>
public void Deactivate(Vector2Int coordinate)
{
// Create a new dictionary if it doesn't exist
if (rooms == null)
rooms = new Dictionary<Vector2Int, GameObject>();
// Does the coordinate already have a room registered?
if (rooms.ContainsKey(coordinate))
{
rooms[coordinate].SetActive(false);
}
}
/// <summary>
/// Clears all rooms and deletes the game objects parented to them
/// </summary>
public void Clear()
{
// If no dictionary exists, there's nothing to clear
if (rooms == null)
return;
// Go through every entry in the dictionary and destroy the game object
foreach (KeyValuePair<Vector2Int, GameObject> kvp in rooms)
{
Destroy(kvp.Value);
}
// Clear the dictionary
rooms.Clear();
}
/// <summary>
/// Gets the grid coordinate of the player
/// </summary>
public Vector2Int GetPlayerGridCoordinate(Transform playerTransform)
{
// Calculate the player's position relative to the worldRoot
Vector3 localPosition = playerTransform.position - worldRoot.position;
// Calculate the grid coordinates based on the roomSize
playerRoomLocation.x = Mathf.RoundToInt(localPosition.x / roomSize.x);
playerRoomLocation.y = Mathf.RoundToInt(localPosition.z / roomSize.y);
return playerRoomLocation;
}
}
Player Controller & Hud
For the Player Controller, we wanted something simple. I created a class that allows the player to move and dash in all directions. I added extras like animations and particle effects, and I ensured that all important statistics were easily accessible through the hierarchy.
I decided to create something special for the HUD by loading a spinning 3D object using a RenderCam. While it didn’t require much code, setting it up in Unity took a bit of work.
I also worked on things like:
– Highscores
– Keybinds
– Scene Transistions
– Player HP
– Player Combat
Player Controller & Hud Code
public class PlayerController : MonoBehaviour
{
[Header("PlayerMovement")]
[SerializeField] private float moveSpeed;
[SerializeField] private float dashSpeed;
Vector3 moveDir;
[Header("DashSettings")]
[SerializeField] private float cooldown;
[SerializeField] private bool isDashing = false;
[SerializeField] private Image dash;
private float timer;
private float dashDuration = 0.4f;
private float dashTimer = 0f;
[Header("SpeedControl")]
[SerializeField] private float groundDrag;
[SerializeField] private float maxVelocity;
[SerializeField] private float resetMaxVelocity;
[Header("References")]
[SerializeField] private Transform orientation;
[SerializeField] private Transform getForward;
[SerializeField] private Animator animator;
[SerializeField] private ParticleSystem walkParticle;
[SerializeField] private ParticleSystem dashParticle;
private Rigidbody rb;
private PauseMenu pauseMenu;
private static PlayerController _instance;
public static PlayerController Instance
{
get
{
if (_instance == null)
_instance = FindObjectOfType<PlayerController>();
return _instance;
}
}
private void Start()
{
rb = GetComponent<Rigidbody>();
pauseMenu = FindObjectOfType<PauseMenu>();
dash.enabled = false;
}
private void Update()
{
Vector3 currentVelocity = new Vector3(rb.velocity.x, 0f, rb.velocity.z);
if (currentVelocity.magnitude > maxVelocity)
{
Vector3 horizontalVelocityDir = currentVelocity.normalized;
rb.velocity = horizontalVelocityDir * maxVelocity + Vector3.up * rb.velocity.y;
}
if (Input.GetKeyDown(Keybind.Instance.GetMoveForwardKey()) ||
Input.GetKeyDown(Keybind.Instance.GetMoveBackwardKey()) ||
Input.GetKeyDown(Keybind.Instance.GetMoveLeftKey()) ||
Input.GetKeyDown(Keybind.Instance.GetMoveRightKey()))
{
animator.SetTrigger("isWalking");
animator.ResetTrigger("isIdle");
walkParticle.Play();
}
else if (rb.velocity.magnitude < 0.01f)
{
animator.SetTrigger("isIdle");
animator.ResetTrigger("isWalking");
walkParticle.Stop();
}
if (cooldown < timer)
{
dash.enabled = true;
}
else
{
dash.enabled = false;
}
}
private void FixedUpdate()
{
if (pauseMenu.isPaused)
{
return;
}
float horizontal = 0f;
float vertical = 0f;
if (Input.GetKey(Keybind.Instance.GetMoveLeftKey()))
{
horizontal -= 1f;
}
else if (Input.GetKey(Keybind.Instance.GetMoveRightKey()))
{
horizontal += 1f;
}
else if (Input.GetKey(Keybind.Instance.GetMoveForwardKey()))
{
vertical += 1f;
}
else if(Input.GetKey(Keybind.Instance.GetMoveBackwardKey()))
{
vertical -= 1f;
}
// Setting the MoveDirection
moveDir = getForward.forward * vertical + getForward.right * horizontal;
// Adding the force
rb.AddForce(moveDir.normalized * moveSpeed, ForceMode.Impulse);
rb.drag = groundDrag;
timer += Time.fixedDeltaTime;
if (isDashing)
{
dashTimer += Time.deltaTime;
if (dashTimer >= dashDuration)
{
// Dash duration has been reached, stop dashing
isDashing = false;
dashTimer = 0f;
maxVelocity = resetMaxVelocity;
dashParticle.Stop();
}
}
else
{
if (Input.GetKey(Keybind.Instance.GetDashKey()) && cooldown < timer)
{
Dash();
timer = 0;
dashParticle.Play();
}
}
}
private void Dash()
{
isDashing = true;
maxVelocity = dashSpeed;
rb.AddForce(moveDir.normalized * dashSpeed, ForceMode.Impulse);
}
}
Interactive Props & Pickups
We wanted to hide the pickups in destroyable props, I came up with the idea to give all the props 3 health stages, after breaking the first 2 it will become clear if there is a pickup and which one it is. When breaking the prop the player will be able to pick it up.
Interactive Props & Pickups Code
public class BreakableBox : MonoBehaviour
{
[Header("BoxStages")]
[SerializeField] private GameObject boxStage1;
[SerializeField] private GameObject boxStage2;
[SerializeField] private GameObject boxStage3;
[Header("Animations & Sounds")]
[SerializeField] private Animation boxHit;
[SerializeField] private AudioSource boxHitSound;
[Header("Settings")]
[SerializeField] private float health = 80;
[SerializeField] private float stage1 = 60;
[SerializeField] private float stage2 = 30;
[SerializeField] private float stage3 = 0;
private void Start()
{
SetBoxStage();
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Bullet"))
{
health -= 10;
boxHit.Play();
boxHitSound.Play();
SetBoxStage();
}
}
/// <summary>
/// Sets the box stage when the amount of damage is reached
/// </summary>
private void SetBoxStage()
{
if (health > stage1)
{
// Box is in stage 1
boxStage1.SetActive(true);
boxStage2.SetActive(false);
boxStage3.SetActive(false);
}
else if (health > stage2)
{
// Box is in stage 2
boxStage1.SetActive(false);
boxStage2.SetActive(true);
boxStage3.SetActive(false);
}
else if (health > stage3)
{
// Box is in stage 3
boxStage1.SetActive(false);
boxStage2.SetActive(false);
boxStage3.SetActive(true);
}
else
{
// Box is destroyed
ScoreManager.Instance.GainScore(1);
this.gameObject.SetActive(false);
}
}
}
Enemies
When we initially started this project i did not plan on making the enemies, but 7 weeks in we had reached a roadblock. The code for the enemies stopped worked so in the last week of the project i picked up the task of making enemies. I did not have time to figure out how behaviour trees work so i decided on hard coding my enemies. I made 2 scripts for the AI, one for walking, and one for attacking. The enemies walk a straight path towards the player and when they reach their attack range, they attack.
Enemies Code
public class EnemyAI : MonoBehaviour
{
private NavMeshAgent agent;
private float timeSinceDestinationUpdate;
private GameObject player;
private float originalSpeed;
[SerializeField] private ParticleSystem walkParticle;
private void Start()
{
agent = GetComponent<NavMeshAgent>();
player = PlayerController.Instance.gameObject;
originalSpeed = agent.speed;
CheckIfOnNavMesh();
timeSinceDestinationUpdate = 0f;
}
private void Update()
{
timeSinceDestinationUpdate += Time.deltaTime;
if (player != null && timeSinceDestinationUpdate > 0.25f)
{
agent.SetDestination(player.transform.position);
timeSinceDestinationUpdate = 0;
}
if (agent != null)
{
if (agent.velocity.magnitude > 0f && !agent.isStopped)
{
// Play the walk particle
if (!walkParticle.isPlaying)
{
walkParticle.Play();
}
}
else
{
// Stop or don't play the particle
if (walkParticle.isPlaying)
{
walkParticle.Stop();
}
}
}
}
private void CheckIfOnNavMesh()
{
NavMeshHit hit;
if (!NavMesh.SamplePosition(transform.position, out hit, 0.1f, NavMesh.AllAreas))
{
Debug.LogWarning("Enemy is not on the navmesh. Adjust the position.");
}
}