VR Interactivity Unleashed: From Gourmet to Keys to Unlock Teleportation Gates

Thomas Mauro
7 min readMar 1, 2024

--

In the world of virtual reality (VR) development, creating immersive and interactive environments is key to engaging users. One way to enhance immersion is through the use of interactive objects that users can manipulate in various ways, such as eating food or drinking beverages. This article delves into the implementation of such interactions using Unity and the XR Interaction Toolkit, specifically through the development of scripts for eating food (FoodEater) and drinking beverages (DrinkInteractor). Additionally, we'll explore how to set up socket interactions for wearing items like hats and helmets without writing any code.

Understanding the FoodEater and DrinkInteractor Scripts

The FoodEater and DrinkInteractor scripts are designed to simulate the actions of eating food and drinking beverages within a VR environment. These scripts leverage Unity's XR Interaction Toolkit to detect when a user interacts with objects designated as food or drink, play corresponding sound effects (like eating, drinking, and burping), and visually represent these actions with effects (such as particles). Furthermore, these scripts handle the respawning of food or drink items, returning them to their original positions after a set time, allowing for repeated interactions.

using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
using System.Collections;
using System.Collections.Generic;

public class FoodEater : MonoBehaviour
{
[SerializeField] private XRSocketInteractor socketInteractor;
[SerializeField] private AudioClip eatingSound;
[SerializeField] private AudioClip burpSound;
[SerializeField] private AudioSource audioSource;
[SerializeField] private GameObject eatingEffectPrefab;
[SerializeField] private float respawnTime = 5f;

private Dictionary<GameObject, Vector3> originalPositions = new Dictionary<GameObject, Vector3>();

private void Start()
{
if (audioSource == null)
{
audioSource = gameObject.AddComponent<AudioSource>();
}

// Initialize the original positions of interactable food items
// If the interactables are already spawned and not instantiated at runtime
var allFoodItems = FindObjectsOfType<XRGrabInteractable>();
foreach (var foodItem in allFoodItems)
{
originalPositions[foodItem.gameObject] = foodItem.transform.position;
}
}
private void Awake()
{
if (audioSource == null)
{
audioSource = gameObject.AddComponent<AudioSource>();
}
}

private void OnEnable()
{
// Subscribe to the hover event
socketInteractor.hoverEntered.AddListener(RegisterOriginalPosition);
}

private void OnDisable()
{
// Unsubscribe from the hover event
socketInteractor.hoverEntered.RemoveListener(RegisterOriginalPosition);
}

private void RegisterOriginalPosition(HoverEnterEventArgs args)
{
var foodGameObject = args.interactable.transform.gameObject;
// If this food has not been hovered before, store its position
if (!originalPositions.ContainsKey(foodGameObject))
{
originalPositions[foodGameObject] = foodGameObject.transform.position;
}
}

public void EatFood()
{
if (socketInteractor.interactablesHovered.Count > 0)
{
var currentFood = socketInteractor.interactablesHovered[0];
var foodGameObject = currentFood.transform.gameObject;

PlayEatingEffect(foodGameObject.transform.position);
PlaySound(eatingSound);

foodGameObject.SetActive(false);

StartCoroutine(RespawnFood(foodGameObject));

Invoke(nameof(PlayBurpSound), 1.5f);
}
}

private IEnumerator RespawnFood(GameObject foodGameObject)
{
yield return new WaitForSeconds(respawnTime);

if (foodGameObject != null && originalPositions.TryGetValue(foodGameObject, out var originalPos))
{
// Disable collider
var collider = foodGameObject.GetComponent<Collider>();
if (collider != null)
collider.enabled = false;

// Reset the object's position
foodGameObject.transform.position = originalPos;

// Reset the Rigidbody's velocity if it has one
var rb = foodGameObject.GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}

// Reactivate the food item
foodGameObject.SetActive(true);

// Re-enable the collider after a frame to avoid physics interaction
yield return null;
if (collider != null)
collider.enabled = true;
}
}

private void PlaySound(AudioClip clip)
{
if (clip != null && audioSource != null)
{
audioSource.PlayOneShot(clip);
}
}

private void PlayBurpSound()
{
PlaySound(burpSound);
}

private void PlayEatingEffect(Vector3 position)
{
if (eatingEffectPrefab != null)
{
Instantiate(eatingEffectPrefab, position, Quaternion.identity);
}
}
}
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
using System.Collections; // Required for coroutines
public class DrinkInteractor : MonoBehaviour
{
[SerializeField] private XRSocketInteractor socketInteractor;
[SerializeField] private AudioClip drinkingSound;
[SerializeField] private AudioClip burpSound;
[SerializeField] private AudioSource audioSource;
[SerializeField] private GameObject drinkingEffectPrefab;
[SerializeField] private float respawnTime = 5f;

private Vector3 originalPosition; // Store the original position

private void Awake()
{
if (audioSource == null)
{
audioSource = gameObject.AddComponent<AudioSource>();
}
}

public void Drink()
{
if (socketInteractor.interactablesHovered.Count > 0)
{
var currentDrink = socketInteractor.interactablesHovered[0];
var drinkGameObject = currentDrink.transform.gameObject;

originalPosition = drinkGameObject.transform.position; // Save the original position

if (drinkingEffectPrefab != null)
{
Instantiate(drinkingEffectPrefab, originalPosition, Quaternion.identity);
}

PlaySound(drinkingSound);
drinkGameObject.SetActive(false);
StartCoroutine(RespawnDrink(drinkGameObject));
Invoke(nameof(PlayBurpSound), 1.5f);
}
}

private IEnumerator RespawnDrink(GameObject drinkGameObject)
{
yield return new WaitForSeconds(respawnTime);

if (drinkGameObject != null)
{
drinkGameObject.SetActive(true);
drinkGameObject.transform.position = originalPosition; // Reset to the original position
}
}

private void PlaySound(AudioClip clip)
{
if (clip != null && audioSource != null)
{
audioSource.PlayOneShot(clip);
}
}

private void PlayBurpSound()
{
PlaySound(burpSound);
}
}

Key Components and Their Functions

  • XRSocketInteractor: Acts as a placeholder or socket for interactive objects. When an object is placed within its vicinity, it can trigger specific actions.
  • AudioClip (eatingSound, burpSound, drinkingSound): Stores the sound effects to be played during the eating or drinking actions.
  • AudioSource: The component responsible for playing the sound effects in the scene.
  • GameObject (eatingEffectPrefab, drinkingEffectPrefab): Prefabs instantiated to visually represent the eating or drinking actions, such as particle effects.
  • respawnTime: The time in seconds before a consumed item reappears in its original location.

Setting Up for Food and Drink Interactions

  1. Creating Interactable Layers: Assign your food and drink objects to custom layers named “Food” and “Drink,” respectively. This categorization helps in managing interactions specific to each type of object.
  2. Configuring Grab Interactables: For each food or drink item, add the XRGrabInteractable component. This component allows users to grab and manipulate these items within the VR environment.
  3. Setting Up Socket Interactors: Create empty GameObjects to serve as sockets for the food and drink items. Attach an XRSocketInteractor component to each socket and configure them to only accept objects from their respective layers (Food or Drink).
  4. Implementing Hover Events: The FoodEater and DrinkInteractor scripts subscribe to the hoverEntered event of their respective XRSocketInteractor to trigger actions like playing sound effects or effects when the user performs an eating or drinking motion.

The SocketObjectActivator Script Explained

Purpose: The SocketObjectActivator script is designed to toggle the activation state of a GameObject, in this case, a teleporter effect, when a specific object (an orb) is inserted or removed from an XR socket. This effectively creates an "unlocking" mechanism where the orb acts as a key.

using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;

public class SocketObjectActivator : MonoBehaviour
{
public XRBaseInteractor socketInteractor; // Assign your Socket Interactor here
public GameObject objectToActivate; // Assign the GameObject you want to activate or deactivate

void OnEnable()
{
// Subscribe to the events
socketInteractor.selectEntered.AddListener(OnObjectInserted);
socketInteractor.selectExited.AddListener(OnObjectRemoved);
}

void OnDisable()
{
// Unsubscribe to prevent memory leaks
socketInteractor.selectEntered.RemoveListener(OnObjectInserted);
socketInteractor.selectExited.RemoveListener(OnObjectRemoved);
}

private void OnObjectInserted(SelectEnterEventArgs arg)
{
// Activate the GameObject when an object is inserted into the socket
objectToActivate.SetActive(true);
}

private void OnObjectRemoved(SelectExitEventArgs arg)
{
// Deactivate the GameObject when an object is removed from the socket
objectToActivate.SetActive(false);
}
}

How It Works:

  • OnObjectInserted: When the orb is placed into the socket, this method is invoked, and the specified teleporter effect GameObject is activated.
  • OnObjectRemoved: Conversely, when the orb is removed from the socket, this method is called, and the teleporter effect GameObject is deactivated.

Setting Up the Orb and Teleporter Interaction

  1. Assign the Socket Interactor: You need an XRSocketInteractor component in your scene. This will be the socket that detects the presence of the orb.
  2. Create the Teleporter Effect: Develop a GameObject that represents the teleporter effect. It could be a visual effect, a trigger for a new scene, or any other teleportation representation.
  3. Prepare the Orb: The orb-like object (as seen in the image you provided) should have an XRGrabInteractable component, allowing it to be grabbed and placed by the user.
  4. Configure the SocketObjectActivator Script:
  • Attach this script to the GameObject that has the XRSocketInteractor.
  • Assign the socket interactor to the socketInteractor field.
  • Assign the teleporter effect GameObject to the objectToActivate field.

5. Test the Interaction: When the orb is placed into the socket, the teleporter effect should activate, visualizing the unlocking and activation of a teleporter. Removing the orb should deactivate the effect, indicating that the teleporter is locked again.

Practical Application in a VR Scene

  • Teleporter Mechanism: This setup is ideal for puzzle games or escape room scenarios where a player must find the orb and place it in the correct location to unlock a portal or move to the next stage.
  • Visual Feedback: The orb can be designed with glowing effects or other visual cues to suggest its importance and interaction possibilities.

Visualizing the Process

To showcase this interaction:

  • In-Scene Setup: Provide a video demonstrating the orb being picked up and placed into the socket, followed by the activation of the teleporter effect.
  • Script Highlight: Show the SocketObjectActivator script in your Unity editor and explain the connection between the script, the orb, and the teleporter effect.
  • Step-by-Step Instructions: Create a tutorial explaining how to set up the socket and grab interactables to work together with the SocketObjectActivator script.

The Orb as the Key

The orb acts as a physical key to the teleporter effect in the virtual environment. It’s a tangible object that players can interact with, providing a clear action-reaction mechanic that can enhance the player’s sense of presence and accomplishment within the game.

Implementing No-Code Socket Interactions for Hats and Helmets

Unity’s XR Interaction Toolkit also allows for straightforward, no-code solutions for interactions like wearing hats or helmets through socket interactions. Here’s how to set it up:

  1. Prepare Your Items and Sockets: Create your hat and helmet objects along with their corresponding sockets, named “HatSocket” and “HelmetSocket.”
  2. Assign Correct Layers: Ensure your hat and helmet objects are on distinct layers, named “Hat” and “Helmet,” to differentiate their interactions.
  3. Configure the Sockets: Add an XRSocketInteractor component to each socket. In the component's settings, filter the interactions to only accept objects from their corresponding layers. This ensures that each socket only interacts with the appropriate item (hat with HatSocket, helmet with HelmetSocket).
  4. Adjust Attachment Points: If your hat and helmet have different attachment points or orientations when worn, adjust the attachTransform property of each XRSocketInteractor to ensure they align correctly when placed by the user.

By following these guidelines, developers can create engaging and interactive VR experiences that allow users to perform intuitive actions like eating, drinking, and wearing accessories, thereby enhancing the overall immersion and enjoyment of the virtual world.

--

--