This project was developed using the original Oculus Rift CV1 through Unity 2020. After development, it has been successfully tested with the Oculus Quest and Oculus Quest 2. The game may work with other headsets, but full compatibility is not guaranteed.
Roadmap

Downloads
EntireProject.7z will have both my project folder from Unity and the folder containing the game. Next, HowTo.pdf explains how to play the game, as well as how to compile it. Lastly, CapstonePresentation.pptx is the presentation I used to present my project on May 1st, 2021.
Source Code
GameScript.cs
//Author: Derek Huber (May, 2021)
//Filename: GameScript.cs
//Purpose: Controls game loop, UI navigation, and VR controller vibration
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR;
using UnityEngine.XR.Interaction.Toolkit;
using System.IO;
using TMPro;
public class GameScript : MonoBehaviour
{
//Public objects viewable in the Inspector
public GameObject mainMenu;
public GameObject endMenu;
public GameObject inGameMenu;
public GameObject pauseMenu;
public GameObject leftController;
public GameObject rightController;
public TMP_Dropdown songDropdown;
public TMP_Text BPMTB;
public TMP_Text FirstBeatDelayTB;
public SoundManagerScript soundManagerScript;
//Public variables available to other scripts
public static float gameStartCubeSpawnDelay = 2.0f;
public static int score = 0;
public static int cubeSpeed = -3;
public static bool bpmMode = false;
public static bool gameBeingPlayed = false;
public static string selectedSongFullPath;
//Private variables
private bool isPaused = false;
private string[] filePaths;
private List<string> songList;
private List<UnityEngine.XR.InputDevice> controllers;
private bool primaryButtonValue = false;
private GameObject[] hideUIObjects;
private GameObject[] frontObjects;
void Start()
{
//Get the path of the game .exe folder, and then move into Songs folder
string songsPath = Directory.GetCurrentDirectory();
songsPath += "\\Songs\\";
//Obtain full file path for songs in all audio formats supported by Unity
string[] mp3Files = Directory.GetFiles(songsPath, "*.mp3");
string[] wavFiles = Directory.GetFiles(songsPath, "*.wav");
string[] oggFiles = Directory.GetFiles(songsPath, "*.ogg");
string[] aiffFiles = Directory.GetFiles(songsPath, "*.aiff");
//Convert to list, sort alphabetically, then convert back to array of full file paths for all songs
List<string> filesList = new List<string>();
filesList.AddRange(mp3Files);
filesList.AddRange(wavFiles);
filesList.AddRange(oggFiles);
filesList.AddRange(aiffFiles);
filesList.Sort();
filePaths = filesList.ToArray();
//List of song names only (exclude full path and extension) for dropdown menu
List<string> songList = new List<string>();
//Parse full song paths and add only song names to songList
foreach(string file in filePaths)
{
string[] temp = file.Split('\\');
string extension = System.IO.Path.GetExtension(file);
string result = temp[temp.Length - 1].Substring(0, temp[temp.Length - 1].Length - extension.Length);
songList.Add(result);
}
//Add song names to dropdown and select and load first song
songDropdown.AddOptions(songList);
selectedSongFullPath = filePaths[0];
soundManagerScript.SetAudioClip("file:///" + selectedSongFullPath);
//Get VR controllers
controllers = GetControllers();
//Get UI elements to be hidden/unhidden between mode switches and immediately hide BPM mode UI elements
hideUIObjects = GameObject.FindGameObjectsWithTag("HideUI");
for (int i = 0; i < hideUIObjects.Length; i++)
hideUIObjects[i].SetActive(false);
//Get Front Ropes of Boxing Ring and Front Punching Bags and immediately hide them
frontObjects = GameObject.FindGameObjectsWithTag("Front Object");
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(false);
}
void Update()
{
if (controllers.Count == 0)
controllers = GetControllers();
if (gameBeingPlayed == true)
{
UpdateInGameScore();
CheckForPause();
}
}
//Song dropdown listener - loads audio file of song selected
public void SetSelectedSong(int songIndex)
{
selectedSongFullPath = filePaths[songIndex];
soundManagerScript.SetAudioClip("file:///" + selectedSongFullPath);
}
//Toggle group listener - changes game mode and hides/uhides respective UI elements
public void SetGameMode(bool newToggleStatus)
{
//This listener function is called whenever the user hits the BPM Mode Toggle. We only care if the toggle state changes
if (newToggleStatus != bpmMode)
{
//Set game mode
bpmMode = newToggleStatus;
//Hide/unhide respective UI elements
if (bpmMode == true) //BPM Mode
{
for (int i = 0; i < hideUIObjects.Length; i++)
{
hideUIObjects[i].SetActive(true);
//Preserve beat delay slider value (round to 2 decimal places)
if(hideUIObjects[i].name == "FirstBeatDelaySlider")
SpawnScript.firstBeatSpawnDelay = Mathf.Round(hideUIObjects[i].GetComponent<Slider>().value * 100f) / 100f;
}
}
else //Beat Detection Mode
{
//No delay since beat detection algorithm will take care potential delays
SpawnScript.firstBeatSpawnDelay = 0f;
for (int i = 0; i < hideUIObjects.Length; i++)
hideUIObjects[i].SetActive(false);
}
}
}
//Spawn range slider listener - sets cube spawn range
public void SetSpawnRange(float newRange)
{
float floatValue = 0.75f; //Need to initialize floatValue to something
switch(newRange)
{
case 1: floatValue = 0.75f; break;
case 2: floatValue = 1.00f; break;
case 3: floatValue = 1.25f; break;
}
SpawnScript.spawnRange = floatValue;
}
//Cube speed slider listener - sets cube speed
public void SetCubeSpeed(float newSpeed)
{
cubeSpeed = (int) newSpeed * -1;
}
//Song BPM slider listener - sets song BPM
public void SetSongBPM(float newBPM)
{
BPMTB.text = newBPM + " BPM";
SpawnScript.spawnTime = 60 / newBPM;
}
//First Beat Delay slider listener - sets first beat delay
public void SetFirstBeatDelay(float newDelay)
{
newDelay = Mathf.Round(newDelay * 100f) / 100f; //Round to 2 decimal places
FirstBeatDelayTB.text = newDelay + "s";
SpawnScript.firstBeatSpawnDelay = newDelay;
}
//Start button listener - starts the game
public void StartGame()
{
gameBeingPlayed = true;
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(true);
leftController.GetComponent<XRInteractorLineVisual>().enabled = false;
rightController.GetComponent<XRInteractorLineVisual>().enabled = false;
mainMenu.SetActive(false);
inGameMenu.SetActive(true);
}
//Exit button listener - quits the application, returning to the desktop
public void ExitGame()
{
Application.Quit();
}
//Resume button listener - resumes game
public void ResumeGame()
{
Time.timeScale = 1;
AudioListener.pause = false;
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(true);
leftController.GetComponent<XRInteractorLineVisual>().enabled = false;
rightController.GetComponent<XRInteractorLineVisual>().enabled = false;
pauseMenu.SetActive(false);
isPaused = false;
}
//Restart button listener - restarts song from beginning
public void RestartGame()
{
//Destroy all spawned boxes
GameObject[] objects = GameObject.FindGameObjectsWithTag("BoxTag");
for (int i = 0; i < objects.Length; i++)
Destroy(objects[i]);
//Restart game
score = 0;
Time.timeScale = 1;
AudioListener.pause = false;
soundManagerScript.StopSong();
SpawnScript.ResetTimer();
SpawnScript.firstBlockPlaced = false;
SpawnScript.ok2spawn = true;
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(true);
leftController.GetComponent<XRInteractorLineVisual>().enabled = false;
rightController.GetComponent<XRInteractorLineVisual>().enabled = false;
endMenu.SetActive(false);
pauseMenu.SetActive(false);
if (gameBeingPlayed == false) //For Play Again Button
{
gameBeingPlayed = true;
inGameMenu.SetActive(true);
}
isPaused = false;
}
//Quit button listener - quits the game, returns user to main menu
public void QuitGame()
{
//Destroy all spawned boxes
GameObject[] objects = GameObject.FindGameObjectsWithTag("BoxTag");
for (int i = 0; i < objects.Length; i++)
Destroy(objects[i]);
//Quit game
score = 0;
Time.timeScale = 1;
gameBeingPlayed = false;
soundManagerScript.StopSong();
SpawnScript.ResetTimer();
SpawnScript.firstBlockPlaced = false;
SpawnScript.ok2spawn = true;
AudioListener.pause = false;
leftController.GetComponent<XRInteractorLineVisual>().enabled = true;
rightController.GetComponent<XRInteractorLineVisual>().enabled = true;
inGameMenu.SetActive(false);
pauseMenu.SetActive(false);
mainMenu.SetActive(true);
isPaused = false;
}
//Displays the end of song menu when the song is over
public void EndOfSong()
{
GameObject textBox = GameObject.Find("FinalScoreDisplayTB");
textBox.GetComponent<TMP_Text>().text = "Final Score: " + score;
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(false);
leftController.GetComponent<XRInteractorLineVisual>().enabled = true;
rightController.GetComponent<XRInteractorLineVisual>().enabled = true;
inGameMenu.SetActive(false);
endMenu.SetActive(true);
gameBeingPlayed = false;
}
//Back to main menu button listener - brings user back to main menu
public void BacktoMainMenu()
{
score = 0;
endMenu.SetActive(false);
mainMenu.SetActive(true);
}
//Updates text box that displays the user's score during gameplay
void UpdateInGameScore()
{
GameObject textBox = GameObject.Find("InGameScoreDisplayTB");
textBox.GetComponent<TMP_Text>().text = "Score: " + score;
}
//Check primary button on either VR controller - if pressed during gameplay, pause game
void CheckForPause()
{
foreach (var controller in controllers)
{
if (controller.TryGetFeatureValue(UnityEngine.XR.CommonUsages.primaryButton, out primaryButtonValue) && primaryButtonValue)
{
if (!isPaused)
{
VibrateController(controller);
PauseGame();
}
}
}
}
//Pauses the game
void PauseGame()
{
Time.timeScale = 0;
AudioListener.pause = true;
//Destroy existing particle effects
GameObject[] objects = GameObject.FindGameObjectsWithTag("Particles");
for (int i = 0; i < objects.Length; i++)
Destroy(objects[i]);
//Stop wrong arm sound if it is playing
AudioSource[] audioSources = soundManagerScript.GetComponents<AudioSource>();
if(audioSources[1].isPlaying)
audioSources[1].Stop();
//Hide punching bags + ropes
for (int i = 0; i < frontObjects.Length; i++)
frontObjects[i].SetActive(false);
leftController.GetComponent<XRInteractorLineVisual>().enabled = true;
rightController.GetComponent<XRInteractorLineVisual>().enabled = true;
pauseMenu.SetActive(true);
isPaused = true;
}
//Find available VR controllers
List<UnityEngine.XR.InputDevice> GetControllers()
{
List<UnityEngine.XR.InputDevice> controllers = new List<UnityEngine.XR.InputDevice>();
UnityEngine.XR.InputDevices.GetDevicesWithRole(UnityEngine.XR.InputDeviceRole.LeftHanded, controllers);
List<UnityEngine.XR.InputDevice> temp = new List<UnityEngine.XR.InputDevice>();
UnityEngine.XR.InputDevices.GetDevicesWithRole(UnityEngine.XR.InputDeviceRole.RightHanded, temp);
controllers.AddRange(temp);
return controllers;
}
//Since controller vibration isn't a necessary feature for the game, a try catch is utilized with an empty catch
public void VibrateController(UnityEngine.XR.InputDevice controller)
{
try
{
uint channel = 1;
float amplitude = 1.0f;
float duration = 0.1f;
controller.SendHapticImpulse(channel, amplitude, duration);
}
catch (System.Exception){}
}
}
SoundManagerScript.cs
//Author: Derek Huber (May, 2021)
//Filename: SoundManagerScript.cs
//Purpose: Loading, playing, and stopping songs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
public class SoundManagerScript : MonoBehaviour
{
public static AudioSource songSelectedAudioSource;
public static AudioSource beatDetectionAudioSource;
private float timer;
private bool beatDetectionSourcePlayed = false;
private bool isPlaying;
void Start()
{
AudioSource[] audioSources = GetComponents<AudioSource>();
songSelectedAudioSource = audioSources[0];
beatDetectionAudioSource = audioSources[2];
isPlaying = false;
timer = 0;
}
void Update()
{
if(GameScript.gameBeingPlayed == true)
{
timer += Time.deltaTime;
if (GameScript.bpmMode == false && beatDetectionAudioSource.isPlaying == false && timer > GameScript.gameStartCubeSpawnDelay
&& beatDetectionSourcePlayed == false) //Play silent beat detection audio ahead of time
{
beatDetectionAudioSource.Play();
beatDetectionSourcePlayed = true;
}
if (timer >= (4.35f / GameScript.cubeSpeed * -1) + //Time taken to be 0.65 units (arm's length) in front of player after delay
GameScript.gameStartCubeSpawnDelay && isPlaying == false)
{
songSelectedAudioSource.Play();
isPlaying = true;
}
}
else //Not in play
{
timer = 0;
isPlaying = false;
beatDetectionSourcePlayed = false;
}
}
//Starts the Coroutine to load selected audio file
public void SetAudioClip(string selectedSongFullPath)
{
StartCoroutine(LoadSongCoroutine(selectedSongFullPath));
}
//Coroutine that does the loading of the audio file
IEnumerator LoadSongCoroutine(string selectedSongFullPath)
{
UnityWebRequest www = UnityWebRequestMultimedia.GetAudioClip(selectedSongFullPath, AudioType.UNKNOWN); //Could be mp3, wav, ogg, aiff
yield return www.SendWebRequest();
songSelectedAudioSource.clip = DownloadHandlerAudioClip.GetContent(www);
beatDetectionAudioSource.clip = DownloadHandlerAudioClip.GetContent(www);
GetComponent<MyAudioProcessor>().SetSong();
}
//Stops audio sources and resets variables to default state
public void StopSong()
{
timer = 0;
isPlaying = false;
beatDetectionSourcePlayed = false;
songSelectedAudioSource.Stop();
beatDetectionAudioSource.Stop();
}
}
SpawnScript.cs
//Author: Derek Huber (May, 2021)
//Filename: SpawnScript.cs
//Purpose: Spawns cubes to the beat (whether in BPM mode or Beat Detection mode)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SpawnScript : MonoBehaviour
{
public Transform spawnPosition;
public GameObject redCube;
public GameObject blueCube;
public static float timer = 0f;
public static double spawnTime = 0.5d; //Time between each beat (60 / BPM)
public static float firstBeatSpawnDelay = 0f; //Time before first beat in audio file
public static float spawnRange = 0.75f; //Where in space cubes spawn (higher is farther apart)
public static bool firstBlockPlaced = false;
public static bool ok2spawn = true;
public GameScript gameScript;
//Timer-dependent so FixedUpdate is preferred to stay in sync with music
void FixedUpdate()
{
if (GameScript.gameBeingPlayed == true)
{
timer += Time.deltaTime;
if (firstBlockPlaced == false)
{
if (timer >= firstBeatSpawnDelay + GameScript.gameStartCubeSpawnDelay)
{
if (GameScript.bpmMode == true)
SpawnObject();
ResetTimer();
firstBlockPlaced = true;
}
}
else //first block has been placed
{
if (timer >= spawnTime && ok2spawn == true)
{
if (GameScript.bpmMode == true)
SpawnObject();
ResetTimer();
if (SoundManagerScript.songSelectedAudioSource.time >
SoundManagerScript.songSelectedAudioSource.clip.length - (4.35f / GameScript.cubeSpeed * -1))
ok2spawn = false;
}
else if (timer >= 3 + (4.35f / GameScript.cubeSpeed * -1)) //Game done!
{
ResetTimer();
gameScript.EndOfSong();
firstBlockPlaced = false;
ok2spawn = true;
}
}
}
}
//Spawns the cube randomly (random color, side, height, and distance left/right of player (dependent on cube spawn range value from main menu))
public void SpawnObject()
{
//Left or right side (0 == left, 1 == right)
int side = Random.Range(0, 2);
float x = Random.Range(0.25f, spawnRange);
if (side == 0)
x *= -1;
//Height and distance away from player
float y = Random.Range(0.75f, 1.75f);
float z = spawnPosition.position.z;
spawnPosition.position = new Vector3(x, y, z);
//Red or blue cube (0 == red, 1 == blue)
int redOrBlue = Random.Range(0, 2);
if (redOrBlue == 0)
Instantiate(redCube, spawnPosition.position, spawnPosition.rotation);
else
Instantiate(blueCube, spawnPosition.position, spawnPosition.rotation);
}
public static void ResetTimer()
{
timer = 0;
}
}
MoveCube.cs
//Author: Derek Huber (May, 2021)
//Filename: MoveCube.cs
//Purpose: Attached to each cube when they spawn in - moves cubes, tests for collision with VR controller, adjusts score accordingly
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MoveCube : MonoBehaviour
{
public GameObject particles;
private Vector3 cubeVector;
private bool pastHitZone = false;
void Start()
{
cubeVector = new Vector3(0, 0, GameScript.cubeSpeed);
}
void FixedUpdate()
{
//Move cube
transform.Translate(cubeVector * Time.deltaTime);
//If behind player (1 meter), leave behind particle effect, destroy cube, and reduce score
if (transform.position.z < -1)
{
GameScript.score -= 25;
Instantiate(particles, transform.position, transform.rotation);
Destroy(this.gameObject);
}
}
void OnTriggerEnter(Collider triggerObject)
{
if (triggerObject.gameObject.name == "Fist")
{
//Find out which controller hit the cube, adjust score accordingly
List<UnityEngine.XR.InputDevice> controllers = new List<UnityEngine.XR.InputDevice>();
if (triggerObject.transform.parent.parent.parent.gameObject.name == "Left Controller")
{
UnityEngine.XR.InputDevices.GetDevicesWithRole(UnityEngine.XR.InputDeviceRole.LeftHanded, controllers);
if (gameObject.name.Contains("Red Cube"))
GoodHit(); //Red arm on red cube
else
BadHit(); //Red arm on blue cube
}
else
{
UnityEngine.XR.InputDevices.GetDevicesWithRole(UnityEngine.XR.InputDeviceRole.RightHanded, controllers);
if (gameObject.name.Contains("Blue Cube"))
GoodHit(); //Blue arm on blue cube
else
BadHit(); //Blue arm on red cube
}
//Make the controller that hit the cube vibrate
foreach (var controller in controllers)
GameObject.Find("GameController").GetComponent<GameScript>().VibrateController(controller);
//Leave behind particle effect
Instantiate(particles, transform.position, transform.rotation);
//Destroy cube
Destroy(this.gameObject);
}
}
//Increases score and applies force to punching bags
void GoodHit()
{
GameScript.score += 10;
GameObject[] punchingBags = GameObject.FindGameObjectsWithTag("PunchingBagTag");
Rigidbody rb;
float force = 0.20f;
for (int i = 0; i < punchingBags.Length; i++)
{
rb = punchingBags[i].GetComponent<Rigidbody>();
if (punchingBags[i].name == "Punching BagL")
rb.AddForce(-1f * force, 0, 0, ForceMode.Impulse);
else if (punchingBags[i].name == "Punching BagR")
rb.AddForce(force, 0, 0, ForceMode.Impulse);
else
rb.AddForce(0, 0, force, ForceMode.Impulse);
}
}
//Plays error sound reduces score
void BadHit()
{
GameObject.Find("Sound Manager").GetComponents<AudioSource>()[1].Play();
GameScript.score -= 25;
}
}
MyAudioProcessor.cs
//Author: Derek Huber (May, 2021)
//Filename: MyAudioProcessor.cs
//Purpose: Used for Beat Detection (based on sample data)
// Based on the "Simple sound energy algorithm #2" found on http://archive.gamedev.net/archive/reference/programming/features/beatdetection/index.html
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MyAudioProcessor : MonoBehaviour
{
//AudioSource attached to GameObject
public AudioSource audioSource;
//Sampling variables
//~0.02 seconds of sound
private int sampleSize = 1024;
//Sample rate of audio file (prefer 44100 Hz)
private int samplingRate;
//Will be current 1024 samples (stereo)
private float[] currentSampleSpectrumL;
private float[] currentSampleSpectrumR;
//Energy variables
//Will be how many sample groups in ~1 second (should be 43 for 44100 Hz)
private int energyGroupAmount;
//Energy record of last second
private float[] energyHistory;
//Average of energyHistory
private float pastEnergyAvg;
//Energy from current sample group
private float instantEnergy;
//Formula variables
private float constantC = 1.4f;
//My other script to finally spawn the objects
public SpawnScript spawner;
//Timer variable for beat separation
private float lastBeatTimer;
private float MIN_BEAT_SEPARATION = 0.25f;
void Start()
{
lastBeatTimer = Time.time;
}
void Update()
{
if(audioSource.isPlaying && GameScript.bpmMode == false)
{
//Get left and right channel data
audioSource.GetOutputData(currentSampleSpectrumL, 0);
audioSource.GetOutputData(currentSampleSpectrumR, 1);
//Calculate instant energy
instantEnergy = 0f;
for(int i = 0; i < sampleSize; i++)
instantEnergy += (currentSampleSpectrumL[i] * currentSampleSpectrumL[i]) + (currentSampleSpectrumR[i] * currentSampleSpectrumR[i]);
//Compute average energy of the past
pastEnergyAvg = 0f;
for(int i = 0; i < energyGroupAmount; i++)
pastEnergyAvg += energyHistory[i];
//Total past energy becomes average past energy
pastEnergyAvg /= (float)energyGroupAmount;
//Out with the old!
for(int i = energyGroupAmount - 1; i > 0; i--)
energyHistory[i] = energyHistory[i-1];
//In with the new!
energyHistory[0] = instantEnergy;
//Beat!
if (instantEnergy > constantC * pastEnergyAvg && Time.time - lastBeatTimer > MIN_BEAT_SEPARATION)
{
lastBeatTimer = Time.time;
spawner.SpawnObject();
}
}
}
//Set relevant variables for beat detection according to selected audio file
public void SetSong()
{
samplingRate = audioSource.clip.frequency;
energyGroupAmount = samplingRate / sampleSize;
energyHistory = new float[energyGroupAmount];
currentSampleSpectrumL = new float[sampleSize];
currentSampleSpectrumR = new float[sampleSize];
lastBeatTimer = Time.time;
}
}
DropDownRayCast.cs
//Author: Derek Huber (May, 2021)
//Filename: DropDownRayCast.cs
//Purpose: Rearranges UI sorting order so VR controller raycasts hit the song dropdown menu
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class DropDownRayCast : MonoBehaviour
{
void Awake()
{
Canvas canvas = gameObject.GetComponent<Canvas>();
if (canvas != null)
canvas.sortingOrder = 3;
}
}