top of page

SOLID Character Control in Unity

Updated: Oct 11, 2022

My solution to make Character Control more scalable and easier to maintain 😊


Almost all the tutorials for Character Controllers found online are just one simple Monobehaviour, like this one:

using UnityEngine;

namespace ExampleFromInternet.Scripts
{
    public class CharacterController2D : MonoBehaviour
    {
        [SerializeField] private float moveSpeed = 30f;
        
        private Rigidbody2D _rigidbody2D;
        private Vector3 _moveDir;

        private void Awake() => _rigidbody2D = GetComponent<Rigidbody2D>();

        private void Update()
        {
            float moveX = 0f;
            float moveY = 0f;

            if (Input.GetKey(KeyCode.W)) moveY = 1f;
            if (Input.GetKey(KeyCode.S)) moveY = -1f;
            if (Input.GetKey(KeyCode.A)) moveX = -1f;
            if (Input.GetKey(KeyCode.D)) moveX = 1f;

            _moveDir = new Vector3(moveX, moveY).normalized;
        }

        private void FixedUpdate() => _rigidbody2D.velocity = _moveDir * moveSpeed;
    }
}

They are fast to implement and get the job done. However, it is troublesome to scale them, and adding new features will make them really hard to maintain. These are some problems with this approach:



One of my first school projects was to make a Old-school GTA clone, the character was able to drive cars, shoot and you can imagine how messy the Character Controllers ended up. So this is my solution to solve the problem.


Player Input


The idea here is to keep every feature that requires player inputs in separate classes so other classes can get just the information they need. To keep it nice and clean, there is an Event that fires up every time the input changes (Observer Pattern). The movement input broadcasts a Vector2:


public class PlayerMovInput : MonoBehaviour
    {
        [SerializeField] private UnityEvent<Vector2> onMovInputUpdates;

        private Vector2 _inputRecord;
        private Vector2 _currentInput;
        private void Update()
        {
            _currentInput.x = UpdateHorizontal(); 
            _currentInput.y = UpdateVertical();

            if (_currentInput != _inputRecord)
            {
                onMovInputUpdates.Invoke(_currentInput);
                _inputRecord = _currentInput;
            }
        }

        private sbyte UpdateHorizontal()
        {
            if (Input.GetKey(KeyCode.A)) return -1;
            if (Input.GetKey(KeyCode.D)) return 1;
            return 0;
        }

        private sbyte UpdateVertical()
        {
            if (Input.GetKey(KeyCode.W)) return 1;
            if (Input.GetKey(KeyCode.S)) return -1;
            return 0;
        }
    }
}

So it is easy to add new classes like ShootInput, and change each one of them without having to scroll through a huge list of input.



Pawn Controller


The controller is a system. The first class is responsible for holding the data of the Character (RigidBody2D in this case) to be controlled and broadcast every time the character changes (Observer Pattern again!). Making it easy to change which character to control, from a human to a car or a spaceship, for example.


public class ControlledPawn : MonoBehaviour
{
    [SerializeField] private Rigidbody2D pawn;

    [SerializeField] public UnityEvent<Rigidbody2D> onPawnChanges;

    private void Awake() => ChangePawn(pawn);

    public void ChangePawn(Rigidbody2D newPawn) 
        => onPawnChanges.Invoke(newPawn);
}

The other classes will be responsible to execute each one of the features depending on the player input. In this example we have a PawnMover, this class is listening to the PlayerInput class to know what is the direction of the velocity and listening to the Controlled Pawn to know which character is the player. To make sure the class can move any character that can be moved, an Interface was added (Liskov Substitution Principle - LSP and Interface Segregation Principle - ISP).


public class PawnMover : MonoBehaviour
{
    private IMovable _movable;
    private Rigidbody2D _pawn;
    private Vector2 _direction;

    private void FixedUpdate() => _movable?.Move(_direction);

    public void UpdatePawn(Rigidbody2D newPawn)
    {
        _pawn = newPawn;
        _movable = _pawn.GetComponent(typeof(IMovable)) as IMovable;
    }

    public void UpdateDirection(Vector2 newDirection) => _direction = newDirection;
}


Customizing GameObjects


Now it is time to add specific features to the GameObject. In this example the movement has been implemented exactly the same way as in the first example, using velocity from rigidbodies. The beauty here is the interface, as long the class implements the IMovable interface, it will move. Doesn't matter if it is moving through Velocity, or Forces, or just changing positions, etc.


public interface IMovable
{
    public void Move(Vector2 direction);
}
public class Movement : MonoBehaviour, IMovable
{
    [SerializeField] private float speed;

    private Rigidbody2D _rigidbody2D;

    private void Awake() => _rigidbody2D = GetComponent<Rigidbody2D>();

    public void Move(Vector2 direction) 
        => _rigidbody2D.velocity = direction.normalized * speed;
}


Final thoughts


The example that opened this post is a very good way to go for a quick prototype or in a Game Jam situation, however more thought is necessary if the goal is to deal with the controllers for a long time. The time “wasted” in the beginning will pay off later, with systems that are easier to maintain and expand.


I don’t think this is a final solution for this problem, and I would love to be challenged to improve it even more. Here is the repository, if you want to explore it by yourself.


🖖

122 views0 comments

Opmerkingen


bottom of page