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