-
Notifications
You must be signed in to change notification settings - Fork 3
Alchemy Game Tutorial
This tutorial is for VSDK Unity. For VSDK Unreal documentation, see the VSDK Unreal wiki.
In this tutorial, we will dive deeper into VSDK, focusing more on how we can leverage VSDK’s framework with custom code in order to maximize flexibility. This section will introduce coding concepts including custom reactions, custom interaction areas, and custom reactors.
From a content perspective, we will be building an Alchemy Simulator. In this game, the user will mix ingredients in a cauldron to change the color and makeup of the potion within. They will then be able to fill a bottle to preserve the generated color and pour that potion into a miniature cauldron to receive points.
If you haven't completed First-Time Setup and Chapter 1: VSDK for Prototyping yet, we recommend completing those first.
Before we dive into the code portions, we will set up the game level to add a little flavor and make the player feel more like they are in a secret alchemy lab and less like they are floating in the void.
- Create a new scene and set up the SDK Manager and Scripts as described in the Setup Tutorial.
- Add the main level geometry: find the Cave prefab in Assets/Tutorial Assets/Prefabs/Alchemy folder and drag it into the scene. Reset the location to 0, 0, 0.
- Find the Cauldron prefab in the Assets/Tutorial Assets/Prefabs/Alchemy folder and drag it into the scene. Position it so that it is in front of the player but comfortably reachable (and test it in VR to make sure!).
- Drag in two copies of the Table prefab in the same folder and position them to either side of the player start point. These tables will hold Ingredients and Potion Bottles in later sections, so make sure they are at comfortable heights. If not, you can scale them along the Y axis accordingly.
- Drag in two copies of the Torch prefab in the same folder and position them behind the cauldron. Finally, delete the directional light to give the level a cave-like feel.
We are going to start by implementing a reusable item respawner prefab. In addition to introducing custom reactions, this prefab is exceedingly useful—often you will want more than one of an item, so having a quick and easy way to spawn more objects on the fly is essential.
All reactions extend a base class called GenericReaction, which has two special methods: StartReaction and StopReaction. Each reaction method accepts a few event arguments as parameters, including a reference to the game object triggering the reaction. As a rule, try to keep reactions as generic as possible; by doing so, you will both keep your Reactions reusable and easy to maintain. If a reaction requires a lot of unique processing and/or state data, you may want to consider a custom reactor instead which can more easily deal with complex interactions.
For our RespawnItemReaction, we want to spawn an object based on a prefab when the reaction is triggered (i.e., when StartReaction is called). From a user-facing perspective, we want this to happen when the user removes the previously spawned object from the spawn point (and during startup). In order to prevent accidental object spawning, we will only allow the most recently spawned object to trigger the respawn of the next item. Additionally, we want to limit the number of objects spawned to prevent the user from spawning too many—this is crucial as large numbers of unexpected objects can lead to performance problems.
As a final consideration, we also do not want the objects to respawn instantly, as this can also lead to some problems and unnecessary collisions. Logic and considerations like this will largely be left up to the developer.
From a high level, we will need to do the following to build an item respawner:
- Create a RespawnItemReaction
- Tie the RespawnItemReaction to an Interaction Area via the ObjectExitedInteractionArea event
- Turn the completed object into a prefab for reuse
First, set up a blank script and a few game objects:
- Create a new script, called
RespawnItemReaction.cs
and in Visual Studio (double click by default):- Add
using CharlesRiverAnalytics.Virtuoso.Reaction;
andusing System;
to the top of the file - Change the base class of
RespawnItemReaction
fromMonobehaviour
toGenericReaction
- If you are a programmer, you may be wondering why GenericReaction is not an abstract class. Unity has issues serializing classes with abstract base classes, which is why we chose not to make GenericReaction abstract.
- Add
- Create an empty game object in the scene and name it ‘Respawner’. Then:
- Add a Box Collider, an Interaction Area, a RespawnItemReaction, and a Reactor
- Set the Event Sender field in the Reactor to the Interaction Area by dragging the Interaction Area component into the field on the Reactor
- On the Reactor, expand the ObjectExitedInteractionArea section and add the RespawnItemReaction to the list
- On the Box Collider, set the Is Trigger field to true; resize the Box Collider to be 0.2 in each axis
We’ll also want an item prefab to test our respawner: - Create a an empty object and name it ‘RespawnTestPrefab’ - Add a Cube as a child of the Test Item Prefab and adjust its scale to 0.1 in each axis - Add a VRTK_InteractableObject script to Test Item Prefab and **check ** 'Is Grabbable'. - Add a RigidBody and **check ** ‘Use Gravity’ - Reset the RespawnTestPrefab’s transform to 0,0,0 - Drag Test Item Prefab into the Asset view to create a prefab - Delete the original Test Item from the scene
Now that we have everything set up, let’s add some variables to the RespawnItemReaction
script. We’ll need a public field for the prefab we want to spawn and a public integer for the maximum number of items to spawn. We’ll also want a private game object field for the most recent game object spawned and an integer to count how many items have spawned so far. Update your code to include the following variables:
#region Public Variables
[Tooltip("The prefab of the object to spawn")]
public GameObject itemToSpawn;
[Tooltip("How many items this point can spawn in total")]
public int maxNumberOfItems = 5;
#endregion
#region Control Variables
// tracks the last object spawned
private GameObject lastItemSpawned;
// how many objects have been spawned so far
private int numberOfItemsSpawned = 0;
#endregion
Note that we have used region tags; this helps keep code organized and in some editors (e.g., Visual Studio) you can collapse regions to hide them when not in use.
Next, let us start working on the StartReaction
behavior (we will not need a StopReaction
behavior). First, we’ll want to check the event args to see if the game object in the event is the last spawned item. This will require adding: using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
to the top of the script.
The method signature for StartReaction should seem familiar if you’ve worked with event-driven systems in the past. The method is driven by C#’s event system.
public override void StartReaction(object o, EventArgs e)
{
InteractionAreaEventArgs eventArgs = (InteractionAreaEventArgs)e;
if(eventArgs != null)
{
if(lastItemSpawned != null && lastItemSpawned == eventArgs.interactionObject)
{
// TODO
}
}
}
Finally, let us add a coroutine to spawn the item. We will use a coroutine so that we can implement a delay in respawn time, in order to prevent collision between spawned objects. We will also need to add another variable to control the delay time:
[Tooltip("How long to wait before respawning the item")]
public float respawnDelay = 1.0f;
public IEnumerator SpawnItem()
{
//set to null so we don't queue up more spawns
lastItemSpawned = null;
if (numberOfItemsSpawned < maxNumberOfItems)
{
numberOfItemsSpawned++;
yield return new WaitForSeconds(respawnDelay);
lastItemSpawned = Instantiate(itemToSpawn, transform);
}
}
The final version of the code, with the coroutine calls, is below.
We are attempting to demonstrate some best practices for code documentation as well. Even if you are writing code only for yourself, comments on classes, fields, and methods as well as good organization through regions will increase readability and maintainability. Refer to our contributor guidelines for more information.
using System.Collections;
using UnityEngine;
using CharlesRiverAnalytics.Virtuoso.Reaction;
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
using System;
/// <summary>
/// This Reaction respawns an item when StartReaction is called.
/// It has no StopReaction behavior (GenericReaction is not abstract, so it does not need an override)
/// Updated: May 2019
/// </summary>
public class RespawnItemReaction : GenericReaction
{
#region Public Variables
[Tooltip("The prefab of the object to continuously respawn")]
public GameObject itemToSpawn;
[Tooltip("How many items this point can spawn in total")]
public int maxNumberOfItems = 5;
[Tooltip("How long to wait before respawning the item")]
public float respawnDelaySec = 1.0f;
#endregion
#region Control Variables
// tracks the last object spawned
private GameObject lastItemSpawned;
// how many objects have been spawned so far
private int numberOfItemsSpawned = 0;
#endregion
#region Reaction Methods
/// <summary>
/// If the Reaction is called by an Interaction Area Event and the InteractableObject
/// involved in the event is the object spawned last by this script, then spawn a new item.
/// </summary>
public override void StartReaction(object o, EventArgs e)
{
InteractionAreaEventArgs eventArgs = (InteractionAreaEventArgs)e;
if (eventArgs != null)
{
if (lastItemSpawned != null
&& lastItemSpawned == eventArgs.interactionObject)
{
StartCoroutine(SpawnItem());
}
}
}
/// <summary>
/// This coroutine spawns a new item after a delay, unless this script
/// has already spawned its maximum number of items.
/// </summary>
public IEnumerator SpawnItem()
{
//set to null so we don't queue up more spawns
lastItemSpawned = null;
if (numberOfItemsSpawned < maxNumberOfItems)
{
numberOfItemsSpawned++;
yield return new WaitForSeconds(respawnDelaySec);
lastItemSpawned = Instantiate(itemToSpawn, transform);
}
}
#endregion
#region Unity Methods
void Start()
{
// spawn the first item
StartCoroutine(SpawnItem());
}
#endregion
}
We can now test the Respawner:
- Position the Respawner on top of one of the Tables in the scene
- Drag the Test Item Prefab into the Item To Spawn field in Respawn Item Reaction
Enter playmode; the first item should spawn after a second of delay time and you should be able to grab and move more objects until you’ve reached the pool maximum. We’ll reuse this object to spawn in objects in the following sections. To prepare it for reuse:
- Drag the Respawner into the asset tab to create a prefab
When reusing, we’ll want to rescale the box collider to fit the item it is supposed to spawn.
The core concept of alchemy—whether historically or in fiction—revolves around the combination of ingredients to create new substances. In our Alchemy game, players will be adding Ingredients to a Cauldron to create potions. Ingredients will have the immediate effect of changing the color of the potion, but success or failure will be determined by whether or not they add the correct ingredients in the correct order (i.e., how well they can follow a recipe). The recipe portion will be covered in a later section. For this section, we will focus on creating Ingredients and a custom Reactor to change the potion’s color based on the ingredients added.
A custom Reactor is not a specific type in VSDK, but rather a design pattern we use to create effects tailored to a specific use-case. Custom Reactors trade flexibility and reuse for depth.
We will start by creating a class for Ingredient objects. We only have two models for ingredients—an Antler and an Onion—so to create variety between the ingredients we will change the color of the model and add a label for the name. Since we only need to set the color and text once, we will do so in the Start method which is called when the object is created by Unity.
- Create a new script called
Ingredient.cs
, and add the following code to it:
using UnityEngine;
using TMPro;
/// <summary>
/// Controls the data and appearance of an ingredient.
/// Updated: May 2019
/// </summary>
public class Ingredient : MonoBehaviour
{
#region Public Variables
[Tooltip("The primary color of the ingredient, both for appearance and how it affects potion color")]
public Color ingredientColor = Color.blue;
[Tooltip("The name of the ingredient for both the label and use in recipes")]
public string ingredientName = "Ingredient";
#endregion
#region Unity Methods
public void Start()
{
// find the main mesh of this ingredient and recolor it; the label also has a mesh renderer so we need to ignore the TMP mesh
foreach(MeshRenderer potentialMeshObject in GetComponentsInChildren<MeshRenderer>())
{
if(potentialMeshObject.gameObject.GetComponent<TextMeshPro>() == null)
{
potentialMeshObject.material.color = ingredientColor;
}
}
// find and update the label text
TextMeshPro ingredientText = GetComponentInChildren<TextMeshPro>();
if(ingredientText != null)
{
ingredientText.text = ingredientName;
}
}
#endregion
}
Next, we will build an ingredient object:
- Create an empty object and name it after the ingredient (see list below for inspiration)
- Reset the transform to 0,0,0
- Navigate to the tutorial prefabs: Assets/Tutorial Assets/Prefabs/Alchemy and select either the Antler or Onion prefab and add it as a child of the new ingredient object
- Add a Box Collider to the Empty GameObject you created, and scale it to fit the mesh. This will look like the below:
- Add a Rigidbody component and ensure Use Gravity is **checked ** (we’re no longer in space, and players need to be able to throw ingredients!)
- Add a VRTK_InteractableObject component and **check ** ‘Is Grabbable’. For this example, leave the “Hold Button to Grab” option enabled. This will produce a more natural grab and drop mechanic.
- Add the Ingredient script and pick a name and color for the ingredient
Next, let’s create a label for our ingredients, so users can tell what the name of the ingredient is:
- Import TextMeshPro if you haven’t already
- In Unity 2018.3, this is done by clicking Window > Package Manager, searching for TextMeshPro and clicking ‘Install’
- Navigate to Window > TextMesh Pro > Install TMP Essentials to finish TextMeshPro setup
- On the ingredient’s root object, right click and add 3D Object > Text Mesh Pro Text
- Change the text to ‘Default’ and rescale until the text is the desired size (we found 0.05 scale to be readable with a font size of 36); center the text and reposition the label as desired. We also recommend adding an outline to the text to make it more readable. Our final settings are shown below.
You may see errors from TextMeshPro during this process while in the prefab view; this is normal.
Finally, create a prefab of the Ingredient:
- Save this new Interactable Onion (or Antler) as a prefab within your Assets by dragging it from the hierarchy to the Assets folder
- Create a second prefab for the alternate model by right clicking on the prefab in the scene view and selecting ‘Unpack Prefab’. Remove the model and replace it with the other ingredient prefab. Rescale the box collider and adjust the text position as needed before creating a new prefab.
If you need some inspiration, here are some name-mesh-color combinations to try:
- Yellow Onion – Onion – Yellow
- Shallot – Onion – Purple
- Chives – Onion – Green
- Raddish – Onion – Red
- Antler – Antler – Yellow/White
- Ox Horn – Antler – Red
- Giant Thorn – Antler – Green
- Garlic – Antler – Yellow/Orange
Once you’ve made a prefab using each of the models (i.e., Antler or Onion),, you can then use prefab variants to quickly create new ingredients.
- To create a prefab variant, right click on the prefab and click Create > Prefab Variant.
Next, let us create Respawner objects in the scene and change their item to spawn fields to the different ingredients you’d like to have in the scene:
- Drag the Respawner prefab into your scene and position it on top of one of the tables in the scene
- Drag the prefab you'd like to respawn into the Item to Spawn field
- Resize the collider on the respawner to fit the new item to spawn.
- Rotate the Respawners so that new ingredient labels point towards the player. You’ll likely want a single respawner for each ingredient type.
This is the beginnings of your ingredient toolbench!
Next, we are going to create a script that acts as a custom reactor and controls the color of the eventual potion we are creating that will be located in a cauldron. The driving logic is simple; any time an interactable object enters the interaction area, if it is an ingredient then the object will interpolate between the current color of the liquid and the color of the Ingredient and assign the new value to the Cauldron’s mesh.
We will be interpolating over the HSV color space rather than over RGB as RGB interpolation can look very unnatural—we recommend reading Alan Zucconi’s article on color interpolation if you are interested in learning more.
Create a new script called CauldronManager.cs
. Below is the complete code listing for the Potion Manager class. We will discuss what’s happening below.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
/// <summary>
/// Reacts to Interaction Area events to drive the color of the potion.
/// </summary>
[RequireComponent(typeof(InteractionArea), typeof(BoxCollider))]
public class CauldronManager : MonoBehaviour
{
#region Public Variables
[Tooltip("The default color for the liquid in the Cauldron. By default, this is a translucent blue.")]
public Color startingColor = new Color(0, 0, 1, 0.5f);
#endregion
#region Control Variables
// The interaction area on this object that interacts with ingredients
protected InteractionArea cauldronInteractionArea;
// the material on the objectMesh that maps to the surface of the liquid in the cauldron
protected Material liquidSurfaceMaterial;
// the current color of the liquid in the cauldron
protected Color currentColor;
// the cauldron's model
protected MeshRenderer objectMesh;
#endregion
#region Color Management
/// <summary>
/// Handles the Object Entered event sent by the interaction area
/// </summary>
public void OnObjectEntered(object o, InteractionAreaEventArgs args)
{
Ingredient ingredient = args.interactionObject.GetComponent<Ingredient>();
if (ingredient != null)
{
AdjustColor(ingredient.ingredientColor);
}
}
/// <summary>
/// Changes the color of the potion
/// </summary>
/// <param name="color"></param>
public void AdjustColor(Color color)
{
float ingredientH, ingredientS, ingredientV;
float currentH, currentS, currentV;
Color.RGBToHSV(color, out ingredientH, out ingredientS, out ingredientV);
Color.RGBToHSV(currentColor, out currentH, out currentS, out currentV);
Color newColor = MixHSV(currentH, currentS, currentV, ingredientH, ingredientS, ingredientV);
// preserve transparency, since the interpolation function doesn't include alpha
newColor.a = currentColor.a;
currentColor = newColor;
liquidSurfaceMaterial.color = newColor;
}
/// <summary>
/// Interpolates between two HSV values and returns an RGB color
/// </summary>
protected Color MixHSV(float h1, float s1, float v1, float h2, float s2, float v2)
{
float hueDistance = h1 - h2;
float mixedHue = 0.0f;
if (h1 > h2)
{
float hTemp = h2;
h2 = h1;
h1 = hTemp;
hueDistance = -1 * hueDistance;
}
if (hueDistance > 0.5)
{
h1 += 1;
mixedHue = (h1 + 0.5f * (h2 - h1)) % 1;
}
else
{
mixedHue = (h1 + 0.5f * (h2 - h1));
}
return Color.HSVToRGB(mixedHue, (v1 + v2) / 2.0f, (s1 + s2) / 2.0f);
}
#endregion
#region Unity Methods
public void Start()
{
// find the components needed for this script
cauldronInteractionArea = GetComponent<InteractionArea>();
objectMesh = GetComponentInChildren<MeshRenderer>();
// grabs the liquid surface material; this is specific to the model in the Tutorial Assets
liquidSurfaceMaterial = objectMesh.materials[1];
liquidSurfaceMaterial.color = startingColor;
currentColor = startingColor;
// subscribe to the interaction area events
cauldronInteractionArea.ObjectEnteredInteractionArea += OnObjectEntered;
}
#endregion
}
Starting at the top of the script, you’ll notice we used an RequireComponent statement:
[RequireComponent(typeof(InteractionArea), typeof(BoxCollider))]
This tells Unity that if we put this script on an object without an Interaction Area and/or a Box Collider, it should add whichever are missing. While not essential, this will save a few clicks when setting up the Cauldron object.
The only public variable in this script is the Starting Color of the liquid. We set the default to be a translucent blue to represent water.
The OnObjectEntered
method is used to respond when an Interactable Object enters the area. If the Interactable Object is an Ingredient, it updates the color of the potion.
public void OnObjectEntered(object o, InteractionAreaEventArgs args)
{
Ingredient ingredient = args.interactionObject.GetComponent<Ingredient>();
if (ingredient != null)
{
AdjustColor(ingredient.ingredientColor);
}
}
What you can take away from this sample is that custom Reactors enable developers to drive effects based on data tied to the objects. However, there is a tradeoff because the final result is less reusable—the decision on when to use a generic Reactor versus a custom Reactor is up to the developer.
After the event handling code, we have a few methods for interpolating colors and setting the color of the main mesh. At the bottom of the file is the Unity Start method which sets up the control variables and sets the OnObjectEntered
method to be an event listener:
cauldronInteractionArea.ObjectEnteredInteractionArea += OnObjectEntered;
Now we can set up the Cauldron object:
- Find the Cauldron object in your scene
- Add the Cauldron Manager script (which will automatically add an Interaction Area and a Box Collider) to the Cauldron
- Scale the box collider to roughly fill the Cauldron (we are using the box collider because it is the only collider with a flat surface, even if it doesn’t fit the Cauldron well). This should look like the following when complete:
- Mark the box collider as a trigger
At this point, you should be able to toss your ingredients into the Cauldron. The Cauldron’s brew will change color based on the color of the ingredient.
This has been quite the section! After all that work, we have something that is almost a game! Go ahead and try it out. In the next section, we are going to introduce a few more elements that will turn this sandbox experience into a game, including a scoring system.
In this section, we will start by creating a custom Interactable Object for a Potion Bottle that can be filled using the Cauldron. We will also add a fun particle effect to simulate pouring the bottle out.
First, let us set up the prefab for the Potion Bottle:
- Create an Empty GameObject called Potion Bottle
- Find the Bottle prefab in the Tutorial Assets/Prefabs/Alchemy folder and drag it into the hierarchy as child of the Potion Bottle
Next, we will build a particle effect using Unity’s particle system to represent a liquid flow:
- Rotate the Potion Bottle object by 180 degrees on either the X or Z axis to flip it over (this way we can tell what it will look like later)
- Right click on the root of the Potion Bottle and select Add Effect > Particle System
- Under the Shape tab of the particle system editor, change the cone arc to 5 and radius to 0.1 to reduce the particle spread
- Move the particle system so it aligns with the opening at the top of the bottle and rotate it -90 degrees in the X axis so it appears to be pouring out of the bottle
- Make the particles smaller so they appear more physical by clicking on the down arrow next to the Start Size parameter in the particle system and selecting ‘random between two constants’. Set one box to 0.1 and the other to 0.01
- Next, since the particles should appear to be falling out of the bottle, set the Gravity Modifier to 0.2 and the Simulation Space to World. This ensures that the particles fall naturally.
- The particles are flowing too fast; change the Start Speed parameter by clicking the down arrow to the right of the entry box and selecting ‘Random Between Two Constants’ and set the resulting boxes to 0.1 and 0.2 (this will start the particles off with a randomized speed so that they fall at slightly different rates and appear less uniform).
- At this point, the particles look like liquid dripping from the bottle but the number of particles don’t really seem like a flow. To fix this, let us increase the Rate over Time parameter in the Emission tab to 100
- The particle flow may now appear too long, so adjust the Start Lifetime variable to 0.75 to reduce how long the particles stay on screen (resulting in a shorter flow)
- In order to stop the particles from popping out at the end of the flow, enable the Color over Lifetime tab by ticking the box and open the gradient within; click the top right hand marker and set the Alpha value to 0—this will smoothly transition the particles from fully visible when they leave the bottle to completely invisible at the bottom of the flow
Your finished particle effect should look like this:
Just a few more steps to finish setup for the particle system and potion bottle:
- Uncheck the box labeled ‘play on awake’ so we can control through scripts when the particle system starts and stops
- Reset the orientation of the Potion Bottle so it is upright again
- Add a Text Mesh Pro Text object as a child of the bottle to act as a label for the contents (you can use the same settings as on the ingredients)
- Drag the bottle into the Asset window to create a new prefab
- Create a Respawner object for the Bottles and place it on one of the tables in the scene
Next, we’ll create a PotionBottleInteractableObject
script to control the behavior of the bottle. When the bottle is filled, we will update the color of the liquid plane and the particle system to reflect the new liquid. We will then use the existing events within the VRTK_InteractableObject
to indicate what is happening with the Potion Bottle, according to the following table.
State | Event |
---|---|
Bottle flipped upside down and has liquid | InteractableObjectUsed |
Bottle flipped right side up and has liquid | InteractableObjectUnUsed |
Bottle runs out of liquid while upside down | InteractableObjectUseFinished |
We will then use UnityEventReactions to turn the particle systems on and off appropriately. Create a script called PotionBottleInteractableObject.cs
with the following:
using UnityEngine;
using VRTK;
using TMPro;
/// <summary>
/// Controls the state of a Potion Bottle to simulate pouring and sends out
/// Interactable Object events based on state.
/// InteractableObjectUsed -> bottle tipped over and has liquid inside
/// InteractableObjectUnUsed -> bottle righted with liquid inside
/// InteractableObjectFinished -> all liquid is poured out
/// Updated: May 2019
/// </summary>
public class PotionBottleInteractableObject : VRTK_InteractableObject
{
#region Public Variables
[Tooltip("The maximum volume in relative units that this potion bottle can hold")]
public float maxVolume = 5.0f;
[Tooltip("How much volume is lost per second when the potion bottle is being poured")]
public float flowSpeed = 1.0f;
// name of the potion
public string potionName = "Unknown";
#endregion
#region Control Variables
// how much liquid is in the bottle
protected float currentVolume = 0.0f;
// tracks whether the bottle was empty in the previous frame
protected bool wasEmpty = false;
// tracks whether the bottle was pouring the previous frame
protected bool wasPouring = false;
// the potion label, updated when new liquid is added or if the bottle is emptied
protected TextMeshPro potionLabelText;
// material on the liquid plane so we can update the color
protected Material liquidPlaneMaterial;
// particle system so we can update the color
protected ParticleSystem liquidParticleSystem;
#endregion
#region Bottle Control Methods
/// <summary>
/// Adds liquid to the bottle and sets the name of the potion
/// </summary>
public void FillBottle(Color liquidColor, string liquidName, float amount)
{
potionName = liquidName;
currentVolume = Mathf.Clamp(amount + currentVolume, 0, maxVolume);
if (potionLabelText != null)
{
potionLabelText.text = liquidName;
}
if(liquidParticleSystem != null)
{
ParticleSystem.MainModule mainModule = liquidParticleSystem.main;
mainModule.startColor = liquidColor;
}
if(liquidPlaneMaterial != null)
{
liquidPlaneMaterial.color = liquidColor;
}
wasEmpty = false;
}
/// <summary>
/// Checks if the object is pointing towards the -Z direction
/// </summary>
protected bool IsUpsideDown()
{
return Vector3.Dot(transform.up, Vector3.down) > 0;
}
#endregion
#region Unity Methods
public void Start()
{
// initialize settings so that user cannot override tipping behavior with the trigger on the controller
isUsable = false;
isGrabbable = true;
// set control variables to defaults
currentVolume = 0.0f;
wasEmpty = true;
wasPouring = false;
potionLabelText = GetComponentInChildren<TMPro.TextMeshPro>();
if (potionLabelText != null)
{
potionLabelText.text = "";
}
// since the label also has a mesh renderer, we need to make sure we are using the correct mesh
foreach(MeshRenderer potentialBottleMesh in GetComponentsInChildren<MeshRenderer>())
{
if(potentialBottleMesh.gameObject.GetComponent<TMPro.TextMeshPro>() == null)
{
liquidPlaneMaterial = potentialBottleMesh.materials[1];
}
}
liquidParticleSystem = GetComponentInChildren<ParticleSystem>();
}
// Must be marked as override because the base class has an update method
protected override void Update()
{
base.Update();
// if it is already empty, no need to fire new events
if (IsUpsideDown() && !wasEmpty)
{
if (!wasPouring)
{
//send the using event when started pouring
wasPouring = true;
base.OnInteractableObjectUsed(SetInteractableObjectEvent(gameObject));
}
currentVolume -= flowSpeed * Time.deltaTime;
if (currentVolume <= 0)
{
wasEmpty = true;
base.OnInteractableObjectUseFinish(SetInteractableObjectEvent(gameObject));
if (potionLabelText != null)
{
potionLabelText.text = "";
}
}
}
else if (!wasEmpty)
{
if (wasPouring)
{
// stopped pouring; set to unuse
wasPouring = false;
base.OnInteractableObjectUnused(SetInteractableObjectEvent(gameObject));
}
}
}
#endregion
}
While it initially looks intimidating, most of this code is actually simple. First, the PotionBottleInteractableObject
class extends the VRTK_InteractableObject
class, granting it all of the features provided by that class (i.e., grab-based interactions and built-in events), making it easy to interact with. Next, we have a few methods for controlling the color and contents of the bottle. Finally, in the update method we modify how much liquid is in the bottle and trigger events based on the quantity of the liquid and the angle of the bottle.
Note the calls like OnInteractableObjectUsed
—these send events through the normal VRTK_InteractableObject
channels. This lets users code custom interactions for specific objects that we still want to interact normally with interaction areas and reactors. In this case, we are sending an Object Used event when the bottle is first flipped over, followed by an Object Use Finished event if the bottle is completely drained. We also call Object Unused if the bottle is flipped back upright. We will use the Object Used event later when scoring potions.
Now we can finish Potion Bottle prefab setup. Double click on the Potion Bottle prefab and:
- Attach the new script to the bottle and **check ** IsGrabbable.
- Add a box collider and scale appropriately
- Add a Rigidbody, with Use Gravity checked
Next, we need to set up a Reactor with Unity Event Reactions to control the particle system:
- Create 3 Empty GameObject under the Potion Bottle and name them: Object Used, Object UnUsed, and Object Finished
- Add a UnityEventReaction to each of the 3 new objects
- On Object Used, add a Unity Event with the plus sign and select the particle system object as a target. Then, from the dropdown, choose Particle System > Play
- On Object UnUsed and Object Finished, similarly add an event targeting the particle system, but choose Particle System > Stop instead
- Add a Reactor to the root Potion Bottle object:
- Drag the PotionBottleInteractableObject component into the Event Sender field of the Reactor
- For InteractableObjectUsed, add the reaction on the Object Used game object
- For InteractableObjectUnUsed, add the reaction on the Object UnUsed game object
- For InteractableObjectUseFinished, add the reaction on the Object Finished game object
At this point, the bottle is fully interactable but it starts out empty so we can’t really tell what it’s up to. Let’s add to the CauldronManager
script so that when a bottle is placed in the Cauldron the bottle will be filled with whatever is inside the Cauldron.
All we need to do is add the following code to the OnObjectEntered
method in the CauldronManager.cs
script:
PotionBottleInteractableObject potion =
args.interactionObject.GetComponent<PotionBottleInteractableObject>();
if(potion != null)
{
potion.FillBottle(currentColor, "Default", 5.0f);
}
Go ahead and try it out—put the potion bottle into the cauldron, and then pour it out.
We are already getting some cool behavior by combining otherwise simple interactions—at a basic level, all we are doing is changing color values and turning particle systems on and off but we are managing to simulate some amount of liquid physics!
In this section, we are going to build a Recipe System that will prompt the player with a list of ingredients. If the user places the ingredients into the Cauldron in the correct order, it will produce a new potion that the player can then bottle and place in a Scoring Area which will trigger the next recipe to appear. If the player puts the ingredients in the Cauldron in the wrong order, they will need to start again.
We will start by creating a data structure to hold a Recipe. A Recipe needs a list of ingredients, a name, and a final color (once the ingredients are all added, the potion with turn the final color no matter what the ingredients were, as if by magic). Create a script named Recipe.cs
with the following code:
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Data container for a single potion recipe
/// Updated: May 2019
/// </summary>
// setting this as serializeable allows us to edit it in the Unity Editor
[System.Serializable]
public class Recipe
{
public string name = "Unknown";
public List<string> ingredients = new List<string>();
public Color finalColor = Color.clear;
}
We will then need an AlchemyGameManager to keep a list of Recipes. The AlchemyGameManager will tell the PotionGrader and the Cauldron what Recipe the player should be working on, and update text with an Ingredient list. As the player completes each Recipe, the AlchemyGameManager will supply the next one. If the player can complete all of the Recipes, they win!
The AlchemyGameManager will be implemented as an Interaction Area so that we can easily tie Reactions to player progress. Note: this code will have compilation errors until we update the CauldronManager and create the PotionGrader.
Create a script called AlchemyGameManager.cs
:
using System.Collections.Generic;
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
using UnityEngine;
using System;
using TMPro;
/// <summary>
/// Decides which potion the player should be making and updates interaction areas accordingly
/// Updated: May 2019
/// </summary>
public class AlchemyGameManager : InteractionArea
{
#region Public Variables
public List<Recipe> recipeList = new List<Recipe>();
#endregion
#region Control Variables
// start at -1 so calling NextRecipe the first time will set the index to 0
protected int currentRecipeIndex = -1;
protected CauldronManager cauldronManager;
protected PotionGrader potionGrader;
protected TextMeshPro recipeDisplayAndScoreboardText;
#endregion
#region Alchemy Game Manager Logic
/// <summary>
/// Advances progress to the next recipe and signals with ObjectFinishedInteractionArea event if the recipes are all complete.
/// </summary>
protected void NextRecipe()
{
currentRecipeIndex++;
if(currentRecipeIndex >= recipeList.Count)
{
// player has completed all of the recipes, or there are none
base.OnObjectFinishedInteractionArea(SetInteractionAreaEvent(gameObject));
if (recipeDisplayAndScoreboardText != null)
{
recipeDisplayAndScoreboardText.text = "Congratulations! You win!";
}
}
else
{
if (cauldronManager != null)
{
cauldronManager.SetRecipe(recipeList[currentRecipeIndex]);
}
if (potionGrader != null)
{
potionGrader.SetRecipe(recipeList[currentRecipeIndex]);
}
if (recipeDisplayAndScoreboardText != null)
{
string ingredientListString = String.Join(", \n", recipeList[currentRecipeIndex].ingredients);
recipeDisplayAndScoreboardText.text = recipeList[currentRecipeIndex].name + ":\n" + ingredientListString;
}
}
}
/// <summary>
/// Handles the callback from the PotionGrader
/// </summary>
protected void HandlePotionCompleted(object obj, EventArgs args)
{
// send out an event indicating progress
base.OnObjectUsedInteractionArea(SetInteractionAreaEvent(gameObject));
NextRecipe();
}
#endregion
#region Unity Methods
private void Start()
{
potionGrader = FindObjectOfType<PotionGrader>();
cauldronManager = FindObjectOfType<CauldronManager>();
recipeDisplayAndScoreboardText = GetComponentInChildren<TextMeshPro>();
if (potionGrader != null)
{
potionGrader.ObjectFinishedInteractionArea += HandlePotionCompleted;
}
// update everything
NextRecipe();
}
#endregion
}
Next, let’s write the script for the PotionGrader. The PotionGrader will be an Interaction Area and have a variable to track the current Recipe. When a Potion enters the PotionGrader, it will emit a Finished event if the Potion name matches the desired Recipe, or an Interrupted event if the Recipe is incorrect.
Create a script called PotionGrader.cs
:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
/// <summary>
/// Fires a Finished event if a Potion with a name matching the desired Recipe,
/// and an Interrupted event if a Potion with the wrong name is entered.
/// Updated: May 2019
/// </summary>
public class PotionGrader : InteractionArea
{
#region Control Variables
protected Recipe currentRecipe;
#endregion
#region Potion Grader Logic
/// <summary>
/// Updates the current recipe
/// </summary>
public void SetRecipe(Recipe newRecipe)
{
currentRecipe = newRecipe;
}
/// <summary>
/// Uses the base ObjectEntered behavior from Interaction Area and additionally
/// checks to see if a potion matching the recipe has been added to the area
/// </summary>
public override void OnObjectEnteredInteractionArea(InteractionAreaEventArgs interactionArgs)
{
base.OnObjectEnteredInteractionArea(interactionArgs);
PotionBottleInteractableObject potion
= interactionArgs.interactionObject.GetComponent<PotionBottleInteractableObject>();
if(potion != null)
{
// check if it is the correct potion
if(potion.potionName == currentRecipe.name)
{
base.OnObjectFinishedInteractionArea(interactionArgs);
}
else
{
base.OnObjectInterruptInteractionArea(interactionArgs);
}
}
}
#endregion
}
Before we can update the CauldronManager to handle a recipe, we first need to make a custom Interaction Area to handle an Ingredients list. We will call this a RecipeInteractionArea; it will take a Recipe and fire events based on what Ingredients the player adds. If they add the correct Ingredient, it will emit the Object Used event and start looking for the next Ingredient. If the user adds the wrong Ingredient, it will emit the Interrupted event and restart with the first Ingredient. If the user adds all of the Ingredients successfully, it will emit the Finished event.
The CauldronManager will then use the RecipeInteractionArea to monitor player progress on a potion; once it receives the Finished event, it will rename and recolor the contents to match the current recipe, allowing the player to bottle it normally.
Create a new script called RecipeInteractionArea.cs
:
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
/// <summary>
/// When it has a Recipe, this interaction area will emit events based on
/// what ingredients are added to it.
/// Updated: May 2019
/// </summary>
public class RecipeInteractionArea : InteractionArea
{
#region Control Variables
// indicates what recipe the interaction area is currently checking for
protected Recipe currentRecipe;
// indicates which ingredient in the list the interaction area is expecting
protected int ingredientIndex = 0;
#endregion
#region Recipe Logic
/// <summary>
/// Updates the recipe and resets the ingredient number
/// </summary>
public void SetRecipe(Recipe newRecipe)
{
currentRecipe = newRecipe;
ingredientIndex = 0;
}
/// <summary>
/// Monitors incoming objects and updates recipe progress accordingly
/// </summary>
/// <param name="interactionArgs"></param>
public override void OnObjectEnteredInteractionArea(InteractionAreaEventArgs interactionArgs)
{
base.OnObjectEnteredInteractionArea(interactionArgs);
if(currentRecipe != null)
{
Ingredient ingredientAdded = interactionArgs.interactionObject.GetComponent<Ingredient>();
if(ingredientAdded != null)
{
if(ingredientIndex >= currentRecipe.ingredients.Count)
{
// already has enough ingredients
base.OnObjectInterruptInteractionArea(interactionArgs);
ingredientIndex = 0;
}
else if(currentRecipe.ingredients[ingredientIndex] == ingredientAdded.ingredientName)
{
// correct ingredient added
base.OnObjectUsedInteractionArea(interactionArgs);
ingredientIndex++;
if(ingredientIndex == currentRecipe.ingredients.Count)
{
base.OnObjectFinishedInteractionArea(interactionArgs);
}
}
else
{
// incorrect ingredient added
base.OnObjectInterruptInteractionArea(interactionArgs);
ingredientIndex = 0;
}
}
}
}
#endregion
}
Next, we can update the CauldronManager to use the new RecipeInteractionArea and deal with Recipes appropriately (by updating the potion name and color when successful):
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CharlesRiverAnalytics.Virtuoso.InteractionAreas;
/// <summary>
/// Controls color and name of potion in Cauldron based on interaction area events
/// Fills potion bottles based on interaction area events
/// Updated: May 2019
/// </summary>
[RequireComponent(typeof(RecipeInteractionArea), typeof(BoxCollider))]
public class CauldronManager : MonoBehaviour
{
#region Public Variables
[Tooltip("The default color for the liquid in the Cauldron. By default, this is a translucent blue.")]
public Color startingColor = new Color(0, 0, 1, 0.5f);
#endregion
#region Control Variables
// The interaction area on this object that interacts with ingredients
protected RecipeInteractionArea cauldronInteractionArea;
// the material on the objectMesh that maps to the surface of the liquid in the cauldron
protected Material liquidSurfaceMaterial;
// the current color of the liquid in the cauldron
protected Color currentColor;
// the cauldron's model
protected MeshRenderer objectMesh;
// current recipe we are making
protected Recipe currentRecipe;
// name of the current contents
protected string currentPotionName = "Unknown";
#endregion
#region Recipe Management
public void SetRecipe(Recipe newRecipe)
{
currentRecipe = newRecipe;
cauldronInteractionArea.SetRecipe(newRecipe);
}
#endregion
#region Color Management
/// <summary>
/// Handles the Object Entered event sent by the interaction area
/// </summary>
public void OnObjectEntered(object o, InteractionAreaEventArgs args)
{
Ingredient ingredient = args.interactionObject.GetComponent<Ingredient>();
if (ingredient != null)
{
AdjustColor(ingredient.ingredientColor);
}
PotionBottleInteractableObject potion =
args.interactionObject.GetComponent<PotionBottleInteractableObject>();
if(potion != null)
{
potion.FillBottle(currentColor, currentPotionName, 5.0f);
}
}
/// <summary>
/// Renames the potion if an incorrect ingredient was added
/// </summary>
public void OnPotionInterrupted(object o, InteractionAreaEventArgs interactionArgs)
{
currentPotionName = "Unknown";
}
/// <summary>
/// Renames and recolors the potion when it is successfully completed
/// </summary>
public void OnPotionCompleted(object o, InteractionAreaEventArgs interactionArgs)
{
Color adjustFinalColor = currentRecipe.finalColor;
adjustFinalColor.a = currentColor.a;
currentColor = adjustFinalColor;
liquidSurfaceMaterial.color = currentColor;
currentPotionName = currentRecipe.name;
}
/// <summary>
/// Changes the color of the potion by interpolating between the current
/// and new color using the HSV colorspace for smoother blending.
/// </summary>
public void AdjustColor(Color color)
{
float ingredientH, ingredientS, ingredientV;
float currentH, currentS, currentV;
Color.RGBToHSV(color, out ingredientH, out ingredientS, out ingredientV);
Color.RGBToHSV(currentColor, out currentH, out currentS, out currentV);
Color newColor = MixHSV(currentH, currentS, currentV, ingredientH, ingredientS, ingredientV);
// preserve transparency, since the interpolation function doesn't include alpha
newColor.a = currentColor.a;
currentColor = newColor;
liquidSurfaceMaterial.color = newColor;
}
/// <summary>
/// Interpolates between two HSV values and returns an RGB color
/// </summary>
protected Color MixHSV(float h1, float s1, float v1, float h2, float s2, float v2)
{
float hueDistance = h1 - h2;
float mixedHue = 0.0f;
if (h1 > h2)
{
float hTemp = h2;
h2 = h1;
h1 = hTemp;
hueDistance = -1 * hueDistance;
}
if (hueDistance > 0.5)
{
h1 += 1;
mixedHue = (h1 + 0.5f * (h2 - h1)) % 1;
}
else
{
mixedHue = (h1 + 0.5f * (h2 - h1));
}
return Color.HSVToRGB(mixedHue, (v1 + v2) / 2.0f, (s1 + s2) / 2.0f);
}
#endregion
#region Unity Methods
public void OnEnable()
{
// find the components needed for this script
cauldronInteractionArea = GetComponent<RecipeInteractionArea>();
objectMesh = GetComponentInChildren<MeshRenderer>();
// grabs the liquid surface material; this is specific to the model in the Tutorial Assets
liquidSurfaceMaterial = objectMesh.materials[1];
liquidSurfaceMaterial.color = startingColor;
currentColor = startingColor;
// subscribe to the interaction area events
cauldronInteractionArea.ObjectEnteredInteractionArea += OnObjectEntered;
cauldronInteractionArea.ObjectInterruptInteractionArea += OnPotionInterrupted;
cauldronInteractionArea.ObjectFinishedInteractionArea += OnPotionCompleted;
}
public void OnDisable()
{
cauldronInteractionArea.ObjectEnteredInteractionArea -= OnObjectEntered;
cauldronInteractionArea.ObjectInterruptInteractionArea -= OnPotionInterrupted;
cauldronInteractionArea.ObjectFinishedInteractionArea -= OnPotionCompleted;
}
#endregion
}
We are finally ready to set up game objects with the new scripts. Let us start by updating the Cauldron game object:
- Remove the InteractionArea on the Cauldron
- Add a RecipeInteractionArea to the Cauldron
Next, create the Alchemy Game Manager:
- Create an Empty Game Object and name it Alchemy Game Manager
- Add the Alchemy Game Manager component to the new object
- Create a new Text Mesh Pro object as a child of the Alchemy Game Manager object to act as a scoreboard
- Make sure the text is placed a good distance from the player so that it is readable (i.e., against the far wall of the Cave)
- Create a few recipes. Here are some samples if you are stuck (you may need to create or rename ingredients to match):
- Healing Potion; Red; Red Onion, Red Horn
- Mana Potion; Blue; Purple Onion, Green Horn, Yellow Onion
- Elixir of Life; Gold; Shallot (Purple Onion); Garlic (Yellow Horn); Garlic; Garlic
Next, set up the Potion Grader:
- Create an empty object and name it ‘Potion Grader’
- Add the PotionGrader component
- Add a Box Collider and scale it to 0.2 in each direction
- Add a Cube object as a child of the Potion Grader to create a pedestal for players to put the potion on:
- Scale the cube to 0.2, 0.05, and 0.2 in X, Y, Z respectively
- Move the Cube to the base of the Potion Grader Box Collider Now, time to test out the grading system. Verify that you have enough ingredients of the correct types, and then attempt to follow the instructions on the Alchemy Game Manager billboard.
Let us add a finish touch to the Potion Grader. We need live feedback so we can tell whether or not we have made the correct potion without looking at the scoreboard to see if the current recipe has changed.
- Add a Color Change Reaction to the Cube under the Potion Grader
- Set the Start color to bright green
- Set the End color to a dimmer red
- Add a Reactor to the Potion Grader
- Drag the Potion Grader component into the Event Sender field of the Reactor
- Set the ObjectFinishedInteractionArea to call the StartReaction on the Color Change Reaction
- Set the ObjectInterruptedInteractionArea and ObjectExitedInteractionArea to call the StopReaction on the Color Change Reaction
Now, if you put the correct potion in the Potion Grader, the pedestal will turn green. If you put the wrong potion in or remove a potion, the pedestal will turn red.
Congratulations! At this point, you have created a potion mini-game using VSDK! We hope you have leaned a ton about how to use Interaction Areas and Interactable Objects in this chapter, and also had some fun!