Best practices for decoupling input from other scripts in Unity

Viewed 1365

I have an input script that translates touches to directions (Left, Right, Up, Down) with a magnitude (0-1) in the Update loop, and when an input is detected this script fires a UnityEvent:

public class TouchAnalogStickInput : MonoBehaviour
{
    [System.Serializable]
    public class AnalogStickInputEvent : UnityEvent<Direction, float> { }

    [Header("Events")]
    [Space]
    [Tooltip("Fired when a successful swipe occurs. Event Args: Swipe Direction, A normalized input magnitude between 0 and 1.")]
    public AnalogStickInputEvent OnAnalogStickInput;

    void Update()
    {
        ...
        if (successfulInputDetected)
        {
            OnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag);
        }
    }
}

I'm subscribing to this OnAnalogStickInput event from the Unity Inspector to call my CharacterController2D.Move method, which takes a Direction and a magnitude:

public class CharacterController2D : MonoBehaviour
{
    public void Move(Direction movementDirection, float normalizedInputMagnitude)
    {
        // Move the character.
    } 
}

Then I have another GameObject that I wish to rotate using the same input script. so I've attached TouchAnalogStickInput and SnapRotator to this GameObject, subscribing the OnAnalogStickInput event to call SnapRotator.Rotate:

public class SnapRotator : MonoBehaviour
{
    public void Rotate(Direction movementDirection, float normalizedInputMagnitude)
    {
        // Rotate object.
    }
}

At this point I've come to realize I'm no longer in charge of which game loops these methods are called from, e.g. I should be detecting input in Update, using this input in FixedUpdate for movement, and perhaps in my case I'd like to do the rotation last in LateUpdate. Instead both CharacterController2D.Move and SnapRotator.Rotate are being fired from the Update loop that the input code runs in.

The only other option I can think of is perhaps refactoring the Input script's code into a method call. Then having CharacterController2D and SnapRotator call this method in the Update loop, executing movement/rotation in the FixedUpdate or LateUpdate loop as required, e.g.:

public class CharacterController2D : MonoBehaviour
{
    public TouchAnalogStickInput Input;

    private var mMovementInfo;

    void Update()
    {
        // Contains a Direction and a Normalized Input Magnitude 
        mMovementInfo = Input.DetectInput();
    }

    void FixedUpdate()
    {
        if (mMovementInfo == Moved)
            // Move the character.
    } 
}

My question is: What's the best practice for decoupling scripts like these in Unity? Or am I taking re-usability too far/being overly afraid of coupling sub classes/components in game development?

Solution

In case it's helpful for anyone else here's my final solution, in semi-sudocode, credit to Ruzihm:

Utility class to store information about a detected input:

public class InputInfo
{
    public Direction Direction { get; set; } = Direction.None;
    public float NormalizedMagnitude { get; set; } = 0f;
    public TouchPhase? CurrentTouchPhase { get; set; } = null;

    public InputInfo(Direction direction, float normalizedMagnitude, TouchPhase currentTouchPhase)
    {
        Direction = direction;
        NormalizedMagnitude = normalizedMagnitude;
        CurrentTouchPhase = currentTouchPhase;
    }

    public InputInfo()
    {

    }
}

Input class:

public class TouchAnalogStickInput : MonoBehaviour
{
    [System.Serializable]
    public class AnalogStickInputEvent : UnityEvent<InputInfo> { }

    [Header("Events")]
    [Space]
    [Tooltip("Fired from the Update loop when virtual stick movement occurs. Event Args: Swipe Direction, A normalized input magnitude between 0 and 1.")]
    public AnalogStickInputEvent OnUpdateOnAnalogStickInput;

    [Tooltip("Fired from the FixedUpdate loop when virtual stick movement occurs. Event Args: Swipe Direction, A normalized input magnitude between 0 and 1.")]
    public AnalogStickInputEvent OnFixedUpdateOnAnalogStickInput;

    [Tooltip("Fired from the LateUpdate loop when virtual stick movement occurs. Event Args: Swipe Direction, A normalized input magnitude between 0 and 1.")]
    public AnalogStickInputEvent OnLateUpdateOnAnalogStickInput;

    private bool mInputFlag;
    private InputInfo mInputInfo;

    void Update()
    {
        // Important - No input until proven otherwise, reset all members.
        mInputFlag = false;
        mInputInfo = new InputInfo();

        // Logic to detect input
        ...
        if (inputDetected)
        {
            mInputInfo.Direction = direction;
            mInputInfo.NormalizedMagnitude = magnitude;
            mInputInfo.CurrentTouchPhase = touch.phase;

            // Now that the Input Info has been fully populated set the input detection flag.
            mInputFlag = true;

            // Fire Input Event to listeners
            OnUpdateOnAnalogStickInput.Invoke(mInputInfo);
        }

        void FixedUpdate()
        {
            if (mInputFlag)
            {
                OnFixedUpdateOnAnalogStickInput.Invoke(mInputInfo);
            }
        }

        void LateUpdate()
        {
            OnLateUpdateOnAnalogStickInput.Invoke(mInputInfo);
        }
    }
}

Character controller that subscribes to the OnFixedUpdateOnAnalogStickInput event.

public class CharacterController2D : MonoBehaviour
{
    public void Move(InputInfo inputInfo)
    {
        // Use inputInfo to decide how to move.
    }
}

Rotation class is much the same but subscribes to the OnLateUpdateOnAnalogStickInput event.

2 Answers

Here is one alternative, which mostly requires changes in your TouchAnalogStickInput class.

Set input state flags in Update and fire any relevant Events. Only this time, you fire an "OnUpdate" event (which in your specific case would not have anything registered to it):

void Update()
{
    ...
    inputFlag_AnalogStickInput = false;
    ...

    if (successfulInputDetected)
    {
        inputFlag_AnalogStickInput = true;
        OnUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag);
    }
}

And in TouchAnalogStickInput.FixedUpdate, call OnFixedUpdateOnAnalogStickInput, which would be registered with your Move

void FixedUpdate()
{
    ...
    if (inputFlag_AnalogStickInput)
    {
        OnFixedUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag);
    }
}

And so on with LateUpdate, which fires an event that your Rotate is registered with.

void LateUpdate()
{
    ...
    if (inputFlag_AnalogStickInput)
    {
        OnLateUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag);
    }
}

These OnFixedUpdateOn... events, of course, fire on every FixedUpdate where that flag is true. For most cases--including for Move--that is probably appropriate, but in other situations that may not be desirable. So, you can add additional events which fire on only the first FixedUpdate occurrence after an update. e.g.:

void Update()
{
    ...
    firstFixedUpdateAfterUpdate = true;
    inputFlag_AnalogStickInput = false;
    ...

    if (successfulInputDetected)
    {
        inputFlag_AnalogStickInput = true;
        OnUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag);
    }
}



void FixedUpdate()
{
    ...
    if (inputFlag_AnalogStickInput)
    {
        OnFixedUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag); 

    }
    if (inputFlag_AnalogStickInput && firstFixedUpdateAfterUpdate)  
    {
        OnFirstFixedUpdateOnAnalogStickInput.Invoke(mInputDirection, normalizedInputMag); 
    }
    ...
    firstFixedUpdateAfterUpdate = false;
}

Hopefully that makes sense.

You may wish to look into customising Script Execution Order and having your gesture processor run before everything else.

Hope that helps. =)

Related