Interactions

com.zalinteractive.interactions

Make your first object usable

Interactions finds the object the player is looking at, tells your UI what pressing interact would do, and runs the tap or hold when the player presses the button. It has no input, UI or networking code of its own, so it fits any input setup and any netcode.

  1. Create a script for the object, such as a lever, and implement IInteractable. Return a prompt from GetInteraction and do the work in Interact. Prompts are Unity Localization LocalizedString values.

    using UnityEngine;
    using UnityEngine.Localization;
    using Zal.Interactions;
    
    public sealed class Lever : MonoBehaviour, IInteractable
    {
        [SerializeField] LocalizedString _prompt;
    
        public Interaction GetInteraction(Interactor interactor) => Interaction.Tap(_prompt);
    
        public void Interact(Interactor interactor)
        {
            // Play the lever animation and apply its effect.
        }
    }
  2. Add the script to a GameObject that has a collider, or whose child has one, and assign a localized string to Prompt.

  3. Add Interactor to your player and assign the player’s camera to Origin. The Interactor aims along the camera’s forward direction.

    The Interactor component outlined on the Player GameObject, with Main Camera assigned to Origin

    Figure 1

  4. Call BeginInteract() when your interact button is pressed and EndInteract() when it’s released. With the Input System, call them from the action’s started and canceled callbacks.

Enter Play Mode and look at the lever from closer than 2.5 units. Interactor.Focused now returns the lever, and pressing the button calls its Interact. To show the prompt on screen, see Show prompts and progress.

Choose what an object offers

The Interactor calls GetInteraction every frame while the player aims at the object, so return what the object offers right now. The object takes focus when it returns anything other than Interaction.None.

  • Interaction.Tap(prompt) runs Interact as soon as the player presses the button.
  • Interaction.Hold(prompt, seconds) runs Interact after the player keeps the button pressed for that many seconds. Releasing the button or looking away cancels the hold and resets its progress.
  • Interaction.Unavailable(prompt) shows a prompt but does nothing when pressed. Use it to explain why an object can’t be used yet, such as a locked door.
  • Interaction.None makes the Interactor ignore the object, as if it weren’t interactable.
public Interaction GetInteraction(Interactor interactor)
{
    if (_isOpen)
    {
        return Interaction.None;
    }

    return _isLocked ? Interaction.Unavailable(_lockedPrompt) : Interaction.Hold(_openPrompt, 1.2f);
}

Note: Return the same LocalizedString instance every frame. Interactions compare prompts by reference, so a new instance every frame raises FocusChanged every frame and cancels any hold.

Several interactables on one object

One GameObject can carry several IInteractable components, such as a character to talk to and a body to search. The Interactor tries them in component order and focuses the first one that returns an interaction. Move a component up in the Inspector to give it priority.

Tune how the player aims

Select the player and adjust the Interactor settings.

Origin, Range, Cast Radius, Proximity Range and Mask outlined in the Interactor Inspector

Figure 2

Origin: Where the aim starts. Its forward direction is the aiming direction, so assign the player’s camera. Required.

Range: How far, in world units, the player can reach an object. The default is 2.5.

Cast Radius: The thickness of the aim. The Interactor casts a sphere of this radius instead of a thin ray. The default is 0.05. Larger values make small objects easier to hit, but can pick an object beside the one at the center of the screen.

Proximity Range: How far away objects start receiving proximity feedback. The default is 5. Set it to 0 to turn proximity off. See Fade markers in as the player approaches.

Mask: The layers the aim and proximity checks see. Trigger colliders are included. Leave out layers with invisible volumes that would block the aim.

The nearest collider the aim hits decides focus, so walls and objects in front block the ones behind them. Colliders in the player’s own hierarchy are skipped.

Fade markers in as the player approaches

Implement IProximityReceiver on an interactable to react before the player can use it, such as fading in a world-space marker.

public sealed class Crate : MonoBehaviour, IInteractable, IProximityReceiver
{
    [SerializeField] CanvasGroup _marker;

    // GetInteraction and Interact as in the Quick Start.

    public void OnProximityChanged(float proximity01) => _marker.alpha = proximity01;
}

The value is 0 at Proximity Range and rises to 1 at Range, where the player can interact. It’s measured from the Interactor’s Origin to the receiver’s transform. The Interactor updates proximity ten times per second and only calls a receiver when its value changes. Leaving the range or disabling the Interactor reports 0.

The receiver must sit on the same GameObject as an IInteractable that has a collider within Proximity Range.

Show hold progress on the object

Implement IHoldProgressReceiver to show hold progress in the world, such as a valve turning while the player holds the button. OnHoldProgress receives a value from 0 to 1. It reaches 1 when the hold completes, then returns to 0. It also returns to 0 when the hold is cancelled or focus moves away.

Show prompts and progress

Your HUD reads the Interactor’s state and listens to its events. Interactions doesn’t draw any UI, so show the prompt and the input glyph for the player’s active device yourself.

  • FocusChanged is raised when the focused object or its interaction changes. Read Focused and FocusedInteraction for the new state.
  • HoldProgressChanged is raised with the hold progress from 0 to 1. HoldProgress returns the current value.
  • Interacted is raised after an object’s Interact runs. Use it for player-side reactions, such as a hand animation.
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Zal.Interactions;

public sealed class InteractionPrompt : MonoBehaviour
{
    [SerializeField] Interactor _interactor;
    [SerializeField] TMP_Text _label;
    [SerializeField] Image _holdFill;

    void OnEnable()
    {
        _interactor.FocusChanged += Refresh;
        _interactor.HoldProgressChanged += ShowProgress;
        Refresh();
    }

    void OnDisable()
    {
        _interactor.FocusChanged -= Refresh;
        _interactor.HoldProgressChanged -= ShowProgress;
    }

    void Refresh()
    {
        var interaction = _interactor.FocusedInteraction;
        _label.gameObject.SetActive(interaction.IsAvailable);
        if (interaction.IsAvailable)
        {
            _label.text = interaction.Prompt.GetLocalizedString();
        }

        _holdFill.gameObject.SetActive(interaction.IsHold);
    }

    void ShowProgress(float progress) => _holdFill.fillAmount = progress;
}

Check FocusedInteraction.IsActionable to hide the button glyph for Interaction.Unavailable prompts, which can’t be pressed.

Use interactions in a networked game

The Interactor only runs on the local machine. Enable it only on the player object the local client owns, and disable it on other players’ copies.

Interact runs on that client. To change shared state, send your own request to the server from Interact, such as a server RPC. The server must check the request again, including the player’s distance and the object’s current state, because it can’t trust the client’s focus result.

public void Interact(Interactor interactor)
{
    PlayOpenAnimation();
    OpenServerRpc();
}

Fix common problems

Nothing gets focused

Check these in order:

  • Select the player and confirm Origin is assigned on the Interactor. Without it, the Console shows an error every frame.
  • Confirm the object has a collider on itself or a child, and that the collider’s layer is included in Mask.
  • Move closer than Range. The distance is measured from Origin, not from the player’s feet.
  • Check that GetInteraction doesn’t return Interaction.None in the object’s current state.

A different object is focused

The aim focuses the nearest collider it hits. An invisible collider, a trigger volume or a larger collider in front of the object wins. Move that collider to a layer outside Mask.

A hold never completes

The hold restarts whenever focus changes. Return the same LocalizedString instance from GetInteraction every frame, and call EndInteract() only when the button is released.

Proximity never reaches the object

IProximityReceiver must be on the same GameObject as an IInteractable, with a collider on a layer in Mask within Proximity Range. Confirm Proximity Range is greater than 0.