跳转至

14 Advanced Scriptable Objects

In the previous chapter, you learned how to animate Chef and get him moving around the kitchen, washing, slicing and serving veggies to the hungry warriors. So far, you’re only serving single-ingredient plates. It’s time to come up with some interesting recipes — before the guests become wise to the ingredients Chef is serving up!

But how can Chef prepare a meal without a recipe? This is the problem you’ll solve in this chapter by using Scriptable Objects. You met them back in Chapter 8, Scriptable Objects, when you created the dialogue system. In this chapter, you’ll look at a few more techniques that you can use scriptable objects for. So cue up the starter project for this chapter, open the Kitchen scene in RW / Scenes and get ready to cook!

Scriptable objects as data containers

The key property about scriptable objects is that they can be created as serialized objects and stored in your project folders. Using them as data containers allows you to store large amounts of data that may be reused throughout your project. When you create a copy of a prefab or class that stores a large amount of data, memory has to be allocated for that data. Create a whole load of these objects, and you’ve used a lot of memory.

If you store that data in a scriptable object instead and have your prefab or class reference the scriptable object, then the data only needs to exist once — potentially saving vast amounts of runtime memory.

Beyond the potential memory-saving superpowers, scriptable objects can also help you increase your workflow - you can save data changes to them while playing in the Editor - but they can also be used to decouple your code architecture. Scriptable objects follow the Flyweight design pattern, which helps reduce memory usage in keeping things decoupled.

For your Chef, you’ll use scriptable objects to first set up what you need to define a recipe. If you consider a game like you’re creating here, in the full version, there could be many different recipes designed for the game. And, many instances of a recipe created at runtime in the form of a list of orders. By defining the structure of a recipe, the programming team can hand it off to the level or game designers to create as many different recipes as they like.

Defining the recipe scriptable object

You’ll find the foundations of the recipe inside RW / Scripts / ScriptableObjects. Open the Recipe script inside your code editor.

At the top, notice the class currently inherits from MonoBehaviour.

public class Recipe : MonoBehaviour

The first step is to change this to a ScriptableObject class:

public class Recipe : ScriptableObject

In order to create new recipes, you need to add a menu item option to the class. Above the class definition, add the following line:

[CreateAssetMenu(fileName = "New Recipe", menuName = "Scriptable Objects/New Recipe", order = 51)]

This instruction allows you to create recipe assets in your project. Save the class and head back into the Unity Editor. Then, right-click the Assets / RW / Recipes folder and select CreateScriptable ObjectsNew Recipe. Name the new recipe asset Peas&Carrots.

img

Select your newly created recipe in the Inspector and you’ll see it’s already expecting a few pieces of data.

img

Take a look back at the Recipe script again to see what was already provided.

public List<IngredientObject.IngredientType> ingredients; // 1
public GameObject prefab; // 2

public Sprite thumbnail; // 3
public int score; // 4
  1. A List of ingredients (or IngredientTypes) that make up the recipe.
  2. A Prefab that will be used to store the model of the prepared dish.
  3. A thumbnail image of the dish that will be used by the UI when a dish is added to the order list.
  4. A score for the dish, for when Chef gets it over ThePass.

You can see how this list matches what you see in the Inspector for the recipe.

Fortunately, everything you need to set up the Peas & Carrots dish is already in the project.

  1. Add two ingredients to the list. From the drop-downs, select — you guessed it — Carrot and Pea.
  2. For the Prefab, add the Dish_Carrot prefab from the Assets / RW / Prefabs / Dishesfolder.
  3. For the Thumbnail, add the Peas&Carrots image from Assets / RW / UI folder.
  4. For the score, well that’s up to you! By default, the player gets 5 points for serving anything, so 20 seems like a reasonable score for a requested dish.

img

There you have it — the first recipe is in the book!

Only, it’s not in the book yet. To start getting orders for your new dish, find the Managers / RecipeBook in the Hierarchy and add the Peas&Carrots recipe to the list of Recipes on the Order Book component.

img

Save the scene and enter Play mode. Notice that orders start popping onto the screen. Grab yourself some peas from the barrel in the corner (you can put them straight onto the plate), then wash and chop a carrot to add to the same plate. As a recap, you can use Space to pick up and drop objects and the Control key for washing and chopping (in that order).

The ingredients change to the prefab you assigned earlier and can serve up that ordered dish!

img

But wait, 0 points? You were going to score generously for this exquisite dish of raw vegetables.

Scriptable Objects as events

The logic to handle scoring is inside the Plate script. Open it from Assets / RW / Scripts in your code editor, and navigate down to the last method: Serve.

public void Serve()
{
    // Check for a recipe
    if (Recipe != null)
    {

    }
    else
    {
        // Player served a non-recipe dish, award some points
        OrderBook.instance.Service();
    }
    // Return ingredients to the pool
    foreach (IngredientObject ingredient in 
        transform.GetComponentsInChildren<IngredientObject>(true))
    {
        IngredientPool.Instance.Add(ingredient);
    }

    OnPlateServed?.Invoke();
    Destroy(gameObject, 1f);
}

As you can see, the opening if statement checks to see if there’s a recipe on the plate (this has been worked out in the method above, CheckRecipes). Currently, though, it does nothing. If no match is found it calls Service in the OrderBook. That’s where the 5 points are awarded for a non-recipe dish.

We need a new way to award points for when the player serves a requested dish. Not only that, but we’ll also need to take that order out of the queue once it’s been fulfilled.

This is the perfect opportunity to look at one of the other great uses of scriptable objects - as an event system. As you know, scriptable objects are defined objects which allow you to create multiple variants fairly easily. This is also an ideal situation for when you want to have multiple variants of a specific type of event. By using scriptable objects as an event, you can decouple your code further. In the code above, you could add a bunch of code to the inside of that if statement to do all the things you needed, but instead we’ll create two new classes.

Scriptable event raiser

Inside the Assets / RW / Scripts / ScriptableObjects folder, create a new script called ServeEvent. Open it in your code editor.

First, make sure this class inherits from ScriptableObject and add the CreateAssetMenuattribute to it.

[CreateAssetMenu(fileName = "New Serve Event", menuName = "Scriptable Objects/Serve Event", order = 52)]
public class ServeEvent : ScriptableObject

Now add this to the top of the class:

private List<ServeEventListener> listeners
    = new List<ServeEventListener>();

This declares a List of ServeEventListener. ServeEventListener doesn’t exist yet, but we’ll get to that next.

The ServeEvent class exists for one purpose (as most classes should!), and that is to raise an event when a recipe is served. ServeEventListener objects will serve one purpose — to listen for the serve events and pass instruction on to other parts of the code. Listeners will be able to register and unregister themselves, so that you don’t try to pass an event to a listener that’s not active — or worse, doesn’t exist anymore!

With all that in mind, you need to create three methods. First add this in ServeEvent:

public void Raise()
{
    for(int i = listeners.Count -1; i>=0 ; i--)
    {
        listeners[i].OnEventRaised();
    }
}

The Raise method runs through the list of listeners and calls a method called OnEventRaised that you’ll add later.

Now add this method under the last:

public void RegisterListener(ServeEventListener listener)
{
    listeners.Add(listener);
}

RegisterListener will add the passed listener to the list of listeners.

Finally, continue by adding this:

public void UnregisterListener(ServeEventListener listener)
{
    listeners.Remove(listener);
}

UnregisterListener will remove the passed listener from the list.

That’s it for the SeverEvent script. Save it and head back to the Unity editor.

Note: You’ll see a couple of errors in the Console window at this point. That’s OK — they relate to the fact that you haven’t created the ServeEventListener class yet.

Event listener

Now that you have the event class, it’s time to create the listener class. Create another script in the Assets / RW / Scripts / ScriptableObjects folder called ServeEventListener. You’re putting it in the same folder as the event, however this class is not going to be a scriptable object. Instead, it stays as a MonoBehaviour because you’ll attach it as a component to objects in the scene.

Open the ServeEventListener script. At the top, add the following using statement below the other directives already there:

using UnityEngine.Events;

This library allows us to use UnityEvents. This is Unity’s own inbuilt event system that allows you to hook events up in the editor in the same way that you connected the animation events in the last chapter. The ServeEventListener will translate your own custom event into a Unity event, so that you can use it in the same way.

Now right inside the ServeEventListener class, add the following fields at the top of the class:

[SerializeField]
private ServeEvent serveEvent;
[SerializeField]
private UnityEvent response;

These fields are private, so they can’t be accessed by code outside this class. But you also mark them as Serialized so you can access them as fields of the component inside the Unity editor. This will allow you to assign values to them later.

Remember what the listener class has to do?

  1. Register itself as a listener.
  2. Unregister itself as a listener.
  3. Respond to the OnEventRaised call from ServeEvent.

For the first two items, you can make use of some Monobehaviour methods. OnEnable and OnDisable are called when a GameObject becomes active or inactive, respectively. The power of these methods is that they can be called from a number of different actions:

  • When a GameObject is created or destroyed.
  • When a GameObject is activated or deactivated (either in the editor or by calling .SetActive(bool)).
  • When the component is enabled or disabled (again, either in the editor or by using the .enabled variable).

Add the following to ServeEventListener:

private void OnEnable()
{
    serveEvent.RegisterListener(this);
}

private void OnDisable()
{
    serveEvent.UnregisterListener(this);
}

These get your event listener to register and unregister itself in the situations noted above.

Now add the following:

public void OnEventRaised()
{
    response.Invoke();
}

As discussed, the OnEventRaised method is required by the ServeEvent. It passes the instruction through to the UnityEvent by calling its Invoke method.

Save the script and head back to the Unity editor. Right-click in the Assets / RW / Scripts / ScriptableObjects folder and select CreateScriptable ObjectsServe Event. Name your new event Peas&Carrots.

img

Now select the Managers / RecipeBook in the Hierarchy once more. Add a ServeEventListener component to it, and set it up like this:

  • Add the new Peas&Carrots event as the Serve Event.
  • For the Response, add an item, drag the RecipeBook into the Object field and choose OrderBookService (Recipe) in the drop-down.
  • Finally, add the Peas&Carrots recipe as the passed recipe.

img

There’s one final thing to set up before the new event system is working. You need to be able to raise the events when a recipe is served. Open the Recipe script from RW / Scripts / ScriptableObjects. Add a ServeEvent field to the scriptable object:

public ServeEvent serveEvent;

Save the script and head back to the Unity editor. Select your Peas&Carrots recipe in Assets/ RW / Recipes and add the Peas&Carrots event to the new Serve Event field.

img

Remember where this whole thing started? Open the Plate script once more, and navigate back down to the Serve method. Inside the empty if statement that you saw earlier, add the following statement:

Recipe.serveEvent?.Raise();

Now, when a plate is served across ThePass, if there is a known recipe on the plate, that recipes serve event will get raised. Save the script and head back into the editor for the last time. Click Play and test it out! Not only will you get 20 points (or however many you defined in the recipe), but the order will come off the list of current orders.

img

Expanding Chef’s repertoire

It may have felt like a lot of work to get the scriptable objects and event system set up, but you are about to see the power of having done so. Sure, Peas and Carrots is a fine dish to be serving, but Chef is too talented for just one signature dish! It’s time to mix things up by adding in another dish — Potatoes and Zucchini. (Or Courgette for your European friends.)

However, there’s no Zucchini to be found currently, so first you do need to prepare the scene and project a little. To begin with, you need a new prefab for the ingredient.

Navigate to Assets / RW / Prefabs, right-click and select CreatePrefab. Name it Zucchini.

img

Double-click the prefab to open it in the prefab editor. You currently have an empty GameObject, so add the IngredientObject component to it.

IngredientObject has a type drop-down that doesn’t currently have Zucchini on the list, so open the script and add it to the IngredientType enum:

public enum IngredientType { Carrot, Pepper, Potato, Pea, Zucchini }

Save the script and go back to the Unity editor. You can now select Zucchini from the dropdown for Type. There are no Zucchini warriors dropping down, so Chef doesn’t need to wash them. You can set State to Clean.

Next, navigate to the Assets / RW / Prefabs / Dishes folder and you’ll see two Zucchini models in there: Ingredient_Zucchini and Ingredient_ZucchiniChopped. Drag both in as children of the root GameObject, and make sure their positions are set to {0, 0, 0}. Then, assign them as the Clean Ingredient Model and Chopped Ingredient Model, respectively.

img

Save the prefab and go back into the scene. In the Hierarchy, find Interactables / Barrels and select Barrel (3). Rename it to Barrel (Zucchini) and add the Supply Barrel component. Assign your new Zucchini prefab as the Ingredient Prefab.

img

For aesthetics, add the Ingredient_Zucchini model from the Assets / RW / Prefabs / Dishesfolder (not your prefab!) as the child of the Barrels Barrel model, and set its position to {0, 0.75, 0}.

img

Finally, select the Player GameObject in the Hierarchy and add the new supply barrel to the list of Pick Up Zones.

img

That’s all the set up needed. From here on, you already know what you’re doing!

  1. Add a new Recipe (CreateScriptable ObjectsNew Recipe) in the Assets / RW / Recipes folder called PotatoDish (because Zucchini is tiresome to spell).
  2. Add a new Serve Event (CreateScriptable ObjectsServe Event) in the Assets / RW/ Scripts / ScriptableObjects folder called PotatoDish.
  3. Set up the PotatoDish recipe:
    • Ingredients should be Potato and Zucchini.
    • Prefab should be set to Dish_Potato from the Assets / RW / Prefabs / Dishes folder.
    • Serve Event is your new PotatoDish event.
    • Thumbnail is PotatoDish from the Assets / RW / UI folder.
    • Score again is up to you, but 20 sounds fair.

img

Finally, in the Hierarchy select the Managers / RecipeBook and add the PotatoDish to the list of recipes.

Add another Serve Event Listener component for the new event, passing the new recipe to the same method as before, OrderBook.Service(Recipe).

img

Save the scene, click Play and start serving some potato dishes!

img

Challenge

Got one more in you? All the materials you need to create a third recipe are in the project. The final dish involves Peppers, Onion and Sausage. You’ve already learned everything you need to know to complete the steps for this final recipe, but the tasks you need to complete are:

  1. Add Onion and Sausage to the list of ingredients in IngredientObject.
  2. Create new IngredientObject prefabs for Onion and Sausage. (You can find the models you need in the Assets / RW / Prefabs / Dishes folder.)
  3. Create a new Recipe
  4. Create a new ServeEvent.
  5. Set up Supply Barrels for the ingredients. There’s two more barrels already in the scene, beside the sink.
  6. Add the Supply Barrels to the Player Controllers list of Pick Up Zones.
  7. Add the new Recipe to the list on Managers / RecipeBook.
  8. Add a new ServeEventListener on Managers / RecipeBook to listen for your new serve event, and pass the new recipe to the same OrderBook.Service(Recipe) event.

And with those steps, you’ve added yet another recipe for Chef to serve up to the hungry warriors. Hopefully you can see now how easy it would be to continue to expand this game with new levels, new ingredients and new recipes.

Key points

  • Scriptable objects can be used as data containers — allowing you to reuse data throughout instances of objects — without assigning additional memory for the data.
  • Scriptable objects can also be used to represent events — allowing you to decouple your code and make a system that’s easy for a level designer to come in and expand your game without extra programming.
  • Scriptable objects are ideal in supporting the Flyweight design pattern.