Heartburn
- Category: Virtual Reality
- Project date: 06 May, 2025
- Time: 8 weeks
- Made with: Unity, C#, MetaXR SDK
Don't stop moving!
In this game you have to keep moving to stay alive. While doing that you also have to avoid the enemies that are flying toward you. After you've defeated a wave you can choose an upgrade. The goal is to survive as long as possible.
You can use your rocket arms to fly around in the map, and dodge the enemies. This allows you to do some really cool tricks.
I mainly focused on making the enemies is this project. I also made the main menu and in-game menu. However I fixed a lot of bugs and helped my team a lot. I led the stand-ups every day and I did the sprint plannings. I also led the retrospectives and I made sure that everyone was on the same page.
Trailer
Flying Enemies
I'm most proud of the flying enemies. I use a steering behavior to navigate them around the map without any Unity Nav-Mesh. They will try to avoid each other and obstacles, while also trying to get to the player. This makes them very dynamic and unpredictable. They also have movement prediction and a dash when they get close to the player. I made 3 different variants: fast/weak, normal, and slow/tank.
Player Detection
For the player detection I use the OverlapSphere function to see if the player is in range. If so I send a raycast to the player's position to see if its in line of sight.
Movement Prediction
Here I calculate the future position of the player based on their current velocity. This makes the enemies more challenging to deal with.
private Vector3 Pursue(StateController controller, Vector3 velocity)
{
// Predict future target position with velocity
Vector3 prediction = controller.Target.transform.position + velocity * predictionTime;
return Seek(controller, prediction);
}
Enemy steering
This is the main code for the steering behavior. It calculates the desired velocity based on the player's position and the enemy's current position. This makes it move towards the player.
private Vector3 Seek(StateController controller, Vector3 prediction)
{
// Desired = targetPos - currentPos
Vector3 desired = prediction - controller.transform.position;
float speed = desired.magnitude;
// Caps speed when bigger than maxSpeed
if (speed > controller.maxSpeed)
desired = desired.normalized * controller.maxSpeed;
if (speed < controller.minSpeed)
desired = desired.normalized * controller.maxSpeed * 2;
// Calculate steeringforce
Vector3 steering = desired - controller.rb.linearVelocity;
Vector3 force = controller.maxSpeed * steering;
// Rotate to face movement direction
if (desired.sqrMagnitude > 0.1f)
{
Quaternion targetRotation = Quaternion.LookRotation(desired, Vector3.up);
controller.graphics.transform.rotation = Quaternion.Slerp(
controller.graphics.transform.rotation,
targetRotation,
Time.deltaTime * 10f
);
}
return force;
}
Obstacle Avoidance
This function will calculate the 'best' avoidance direction. It uses the RaysOnPlane function to calculate this for each of the planes (XZ, YZ, XY).
private Vector3 GetAvoidanceVector(StateController controller)
{
Vector3 weightedAverageFreeRay = Vector3.zero;
// Direction to target
Vector3 startVector = (controller.Target.transform.position - controller.transform.position);
weightedAverageFreeRay += RaysOnPlane(controller, weightedAverageFreeRay, new Vector3(startVector.x, 0f, startVector.z).normalized, Vector3.up);
weightedAverageFreeRay += RaysOnPlane(controller, weightedAverageFreeRay, new Vector3(0f, startVector.y, startVector.z).normalized, Vector3.right);
weightedAverageFreeRay += RaysOnPlane(controller, weightedAverageFreeRay, new Vector3(startVector.x, startVector.y, 0f).normalized, Vector3.forward);
weightedAverageFreeRay = weightedAverageFreeRay.normalized;
Debug.DrawRay(controller.transform.position, weightedAverageFreeRay * 5f, Color.blue);
return weightedAverageFreeRay;
}
Obstacle Detection
In this function I sent out rays around the enemy on a given plane. If it detects an obstacle it will calculate how much it weights based on the distance to the obstacle. If not it adds the direction of the ray times its weight to the total.
private Vector3 RaysOnPlane(StateController controller, Vector3 weightedAverageFreeRay, Vector3 startVector, Vector3 rotationAxis)
{
int rayCount = 8;
float rayDistance = 3f;
float minimalRayWeight = 0.5f;
// Shoots rays around enemy and when it hits an object returns force in opposite direction
for (int i = 0; i < rayCount; i++)
{
float angle = i * (360f / rayCount);
// 0 = 1
// 180 = 0.5
// 360 = 1
float weight = minimalRayWeight + // Elke ray telt een beetje mee!
Mathf.Abs(180f - angle) / 180f; // Hoever zit de ray qua graden van onze "main" ray af? (getal tussen 0 en 1)
// Rotation on the correct axis (up, right or forward) * the start vector, rotates the start vector the angle along the axis.
Vector3 direction = Quaternion.Euler(angle * rotationAxis) * startVector;
RaycastHit hit;
bool blocked = Physics.Raycast(controller.transform.position, direction, out hit, rayDistance);
Debug.DrawRay(controller.transform.position, direction * (blocked ? (hit.distance) : rayDistance), blocked ? Color.red : Color.green);
if (blocked)
{
// The higher the distance, the lower the weight should be because we are still far away from collision
// The lower the distance, the higher the weight because we are almost hitting something.
float distanceWeight = 1.0f - hit.distance / (float)rayDistance;
weightedAverageFreeRay -= direction * weight * distanceWeight;
}
else
{
weightedAverageFreeRay += direction * weight;
}
}
return weightedAverageFreeRay;
}