Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 36

Contextual Radial Menus

Hello! Welcome to the next lesson in our Player Interaction Framework module.

In the previous lesson, we made a significant architectural improvement by creating a data-driven system for interactions. Your radial menu is now dynamically populated from a central interactions.json file, which is a fantastic step towards making our game scalable and moddable.

However, the menu currently shows every interaction an object offers, regardless of the situation. This can lead to illogical choices, like trying to sit in a chair that's already occupied. Today, we'll fix that.

Our goal is to filter the radial menu to show only contextually relevant interactions. This will make the game feel smarter and more polished by ensuring that the player is only ever presented with valid options.


1. The Concept: Interaction Prerequisites

Right now, our InteractableObject script simply lists all possible interactions. To make the system contextual, we need a way to test if an interaction is valid before we show it to the player. Think of it like a disabled button in a user interface—the functionality exists, but it's not available under the current conditions.

Our system will perform a check for each potential interaction. For a "Sit" interaction on a chair, the check would be: "Is this chair currently empty?" If yes, we show the "Sit" option. If no, we hide it.

Dynamic Radial Menu Item Editing at Runtime
This image illustrates our goal. At runtime, we will dynamically hide or disable menu options based on the game's state, ensuring only valid actions are available.

To implement this cleanly, we'll adopt a design pattern where each interactable object is responsible for its own state and validation logic. The selection manager won't need to know why an interaction is invalid; it will simply ask the object, "Is this action valid right now?" and trust the object's response.

This diagram illustrates a professional-grade architecture for such a system.

UML Class Diagram for Agent Interaction State Machine
This UML diagram shows how an 'Agent' can use a detector to find objects that implement an 'IInteractable' interface. Each interactable object (like a switch or a weapon) has its own `Interact` logic. We will implement a similar pattern where each of our objects can test the validity of its own interactions.

We will create a base class for all interactable objects and use polymorphism to allow each specific type of object (a chair, a fridge, a TV) to provide its own custom validation rules.


2. Evolving the InteractableObject Script

Our current InteractableObject script is just a simple data container. To support contextual logic, we need to upgrade it into a more robust system using inheritance.

First, rename InteractableObject.cs to BaseInteractable.cs. This will serve as the foundation for all objects the player can interact with.

Update the code in BaseInteractable.cs to the following:

using UnityEngine;
using System.Collections.Generic;

public class BaseInteractable : MonoBehaviour
{
    // This list still holds all theoretically possible interactions for this object.
    public List<string> availableInteractionIDs;

    /// <summary>
    /// Checks if a specific interaction is currently valid.
    /// This is a 'virtual' method, meaning child classes can override it with their own logic.
    /// By default, all interactions are considered valid.
    /// </summary>
    /// <param name="interactionID">The ID of the interaction to test.</param>
    /// <returns>True if the interaction is valid, false otherwise.</returns>
    public virtual bool IsInteractionValid(string interactionID)
    {
        // Base implementation assumes the interaction is always valid.
        return true;
    }
}

Now, let's create a specific interactable for a chair. Create a new C# script named ChairInteractable.cs:

using UnityEngine;

public class ChairInteractable : BaseInteractable
{
    // This private field holds the state of this specific chair.
    private bool isOccupied = false;

    // This 'override' replaces the base class's method with custom logic for chairs.
    public override bool IsInteractionValid(string interactionID)
    {
        // We only care about the "Sit" interaction.
        if (interactionID == "interaction_sit")
        {
            // Only allow sitting if the chair is not occupied.
            return !isOccupied;
        }

        // For any other interaction IDs this object might have,
        // we fall back to the default behavior.
        return base.IsInteractionValid(interactionID);
    }

    // Public method to allow other systems to change the chair's state.
    public void SetOccupied(bool occupied)
    {
        isOccupied = occupied;
        Debug.Log($"Chair '{gameObject.name}' occupied state set to: {occupied}");
    }
}

To apply this:

  1. Find a chair object in your scene.
  2. Remove the old InteractableObject (or now BaseInteractable) component.
  3. Add the new ChairInteractable component.
  4. In the ChairInteractable component's "Available Interaction IDs" list, add the ID for sitting. Let's create a new, more generic one. Open your interactions.json file and add:
{
  "id": "interaction_sit",
  "displayName": "Sit",
  "effects": [] // No effects for now
}
  1. Set the interaction ID in the ChairInteractable component to interaction_sit.

3. Updating the SelectionManager to Filter Interactions

Now we modify the SelectionManager to use our new validation logic. The change is small but powerful. Instead of blindly adding every interaction from the list, we'll first ask the object if the interaction is valid.

Open SelectionManager.cs and find the section in your Update() method where you populate the radial menu.

// In SelectionManager.cs -> Update() -> inside the Raycast check

// ...
if (Physics.Raycast(ray, out hit, 100f, interactableLayer))
{
    _currentSelection = hit.transform;

    // IMPORTANT: We now get the BaseInteractable component.
    // This works for ChairInteractable too, because it inherits from BaseInteractable.
    var interactable = _currentSelection.GetComponent<BaseInteractable>();

    if (interactable != null && interactable.availableInteractionIDs.Count > 0)
    {
        _radialMenu.ClearEntries();

        foreach (string id in interactable.availableInteractionIDs)
        {
            // THIS IS THE NEW VALIDATION STEP!
            if (interactable.IsInteractionValid(id))
            {
                InteractionData data = InteractionDataManager.GetInteraction(id);
                if (data != null)
                {
                    // This callback logic will be expanded in the next lesson.
                    // For now, let's make it change the chair's state to test our system.
                    System.Action callback = () => {
                        Debug.Log($"Executing interaction: {data.id}");
                        // If this is a chair, tell it it's now occupied.
                        if (interactable is ChairInteractable chair)
                        {
                            chair.SetOccupied(true);
                        }
                    };

                    _radialMenu.AddEntry(data.displayName, null, callback);
                }
            }
        }
        
        // Only open the menu if it has any valid entries.
        if (_radialMenu.EntryCount > 0)
        {
             _radialMenu.Open(Input.mousePosition);
        }
        else
        {
            // If there are no valid interactions, make sure the menu is closed.
            _radialMenu.Close();
        }
    }
}
//...

Note: You may need to add a public property EntryCount to your RadialMenu.cs script that returns the number of currently active entries.


4. Testing the Contextual Logic

Run the scene and test the complete flow:

  1. Click on the chair. The radial menu should appear with the "Sit" option.
  2. Click "Sit". The callback will fire, printing to the console and calling chair.SetOccupied(true).
  3. Click on the same chair again. Because isOccupied is now true, the IsInteractionValid check will fail. The radial menu should not open, or it will be empty. The system correctly recognizes that the context has changed.

To make this fully interactive, you could add an "Stand" interaction that sets isOccupied to false. This reinforces the pattern: each action can modify the state of objects, which in turn affects which future actions are available.

This object-oriented approach is incredibly powerful. You can now create FridgeInteractable that checks if there is food, or a TVInteractable that shows a "Turn Off" interaction only if isTurnedOn is true, all without adding a single if statement to your SelectionManager.



Conclusion

Congratulations! You have just implemented a core feature of intelligent simulation games. Your interaction system is no longer just a simple list; it's a dynamic, context-aware system that responds to the state of the game world.

Key Takeaways:

  • Contextual Filtering: By adding a validation step before displaying an interaction, we prevent the player from performing invalid actions.
  • Polymorphism for Scalability: Using a virtual method (IsInteractionValid) in a base class (BaseInteractable) allows each type of object to define its own specific rules in a clean, encapsulated way.
  • Decoupled Logic: The SelectionManager remains simple. It doesn't need to know about chairs, fridges, or TVs; it just needs to know about BaseInteractable objects and trust them to manage their own state.

In our final lesson for this module, Send an interaction command to Rust to update the character's needs and mood upon completion, we will take the interactionID from the selected menu option and send it across the FFI to the Rust backend. This will finally connect the player's choice in the UI to a meaningful change in the character's simulation state.