+1

Xử lý input cho multiplatform trong Unity

Tiêu đề nghe thực sự khá nguy hiểm nhưng đây là một kinh nghiệm mình học hỏi được qua quá trình thực hiện các project cần support nhiều platform (PC, WebGL, Mobile). Việc handle input của các platform có thể khác nhau ví dụ như Mobile (touch screen, sensor), PC (keyboard, mouse), console (gamepad).

Để lấy ví dụ cho việc xử lý input cho multiplatform, mình lấy một ví dụ khá tiêu biểu: hệ thống điều khiển nhân vật. Hệ thống bao gồm các hành động như: di chuyển, chạy, bắn, nhảy, ngồi, hệ thống cần chơi được cho cả PC, Mobile. Ví dụ cho hệ thống này có thể kể đến như Genshin Impact, PUBG, Leage of Legend mobile,...

  • PC: sử dụng keyboard với các phím A,W,D,S để di chuyển, Space nhảy, Shift chạy, Chuột trái để tấn công
  • Mobile: Các phím trên UI

Hướng giải quyết:

Bắt đầu với cách tiếp cận mà chúng ta thường xuyên sử dụng, bên dưới là đoạn code mà trước đây mình hay sử dụng để implement chức năng này:

public class CharacterMoveController: MonoBehaviour {
        private void Update()
        {
            if (Input.GetKey(KeyCode.A)) { MoveLeft(); }
            if (Input.GetKey(KeyCode.D)) { MoveRight(); }
            if (Input.GetKey(KeyCode.W)) { MoveForward(); }
            if (Input.GetKey(KeyCode.S)) { MoveBackward(); }
            if (Input.GetKey(KeyCode.RightShift)) { Run(); }
            if (Input.GetKeyDown(KeyCode.Space)) { Jump(); }
            if (Input.GetMouseButton(0)) { Shoot(); }
        }
        public void MoveLeft() { }
        public void MoveRight() { }
        public void MoveForward() { }
        public void MoveBackward() { }
        public void Run() { }
        public void Shoot() { }
        public void Jump() { }
        public void Attack() { }
}

Dựa vào đoạn code trên thì chúng ta nhận ra được có 2 bước trong việc xử lý input là lắng nghe các Input mà player nhập vào và thực thi hành động đúng với Input đó.

Vậy cách giải quyết là gì? Chúng ta tách chúng ra thành 2 thành phần riêng biệt là Input HandlerInput Executor

  • Input Handler: là object đảm nhận nhiệm vụ lắng nghe các input của player và kiểm tra các input đó thỏa mãn các điều kiện để thực thi các hành động tương ứng. Với vị dụ trên thì đoạn code ở hàm Update() đang hoặt động như một Input Handler và đây cũng là nơi để kiểm tra xem người chơi có đang nhấn phím di chuyển, nhảy, hay click chuột để bắn hay không. Nếu ở trên console thì có thể là kiểm tra cần analog hoặc nút trigger trên tay cầm.
  • Input Executor: là object nhận các sự kiện được raise lên từ Input Handler để thực thi hành động nào đó.

Việc tách rời 2 thực thể này giúp code của chúng ta bảo toàn được nguyên lý Single Responsibility trong SOLID.

Input Handler chỉ đảm nhiệm lắng nghe input từ player, không cần quan tâm đến các logic được thực thi bởi các input đó và chỉ bắn ra các sự kiện để những Input Executor lắng nghe và thực thi logic.

Input Executor không cần quan tâm đến Input của player là loại gì, Keyboard, Mouse, Touch Screen hay Gamepad. Chỉ cần output các function hay interface để thực hiện các hành động nhằm phản hồi lại các Input mà player nhập vào. image.png

Vì vậy khi có sự thay đổi từ Input ta chỉ việc xử lý các thay đổi đó ở phía Input Handler cũng như các thay đổi logic ở phía Input Executor mà không làm ảnh hưởng hoặc bị phụ thuộc lẫn nhau.

Cơ bản là vậy, ý tưởng rất hay nhưng show code please !!

public static class InputEvents
{
    public static UnityEvent moveLeftEvent;
    public static UnityEvent moveRightEvent;
    public static UnityEvent moveForwardEvent;
    public static UnityEvent moveBackwardEvent;
    public static UnityEvent runEvent;
    public static UnityEvent jumpEvent;
    public static UnityEvent shootEvent;
}

public class PCInputHandler : MonoBehaviour
{
    [SerializeField] private KeyCode moveLeftKey;
    [SerializeField] private KeyCode moveRightKey;
    [SerializeField] private KeyCode moveForwardKey;
    [SerializeField] private KeyCode moveBackwardKey;
    [SerializeField] private KeyCode runKey;
    [SerializeField] private KeyCode jumpKey;
    [SerializeField] private int shootMouseButton;

    private void Update()
    {
        if (Input.GetKey(moveLeftKey))
        {
            InputEvents.moveLeftEvent.Invoke();
        }
        if (Input.GetKey(moveRightKey))
        {
            InputEvents.moveRightEvent.Invoke();
        }
        if (Input.GetKey(moveForwardKey))
        {
            InputEvents.moveForwardEvent.Invoke();
        }
        if (Input.GetKey(moveBackwardKey))
        {
            InputEvents.moveBackwardEvent.Invoke();
        }
        if (Input.GetKeyDown(runKey))
        {
            InputEvents.runEvent.Invoke();
        }
        if (Input.GetKeyDown(jumpKey))
        {
            InputEvents.jumpEvent.Invoke();
        }
        if (Input.GetMouseButton(shootMouseButton))
        {
            InputEvents.shootEvent.Invoke();
        }
    }
}

public class CharacterMoveController : MonoBehaviour
{
  public class CharacterMoveController : MonoBehaviour
{
    private void Start()
    {
        InputEvents.moveLeftEvent.AddListener(OnMoveLeftEvent);
        InputEvents.moveRightEvent.AddListener(OnMoveRightEvent);
        InputEvents.moveForwardEvent.AddListener(OnMoveForwardEvent);
        InputEvents.moveForwardEvent.AddListener(OnMoveBackwardEvent);
        InputEvents.runEvent.AddListener(OnMoveLeftEvent);
        InputEvents.jumpEvent.AddListener(OnJumpEvent);
        InputEvents.shootEvent.AddListener(OnShootEvent);
    }
    private void OnDestroy()
    {
        InputEvents.moveLeftEvent.RemoveListener(OnMoveLeftEvent);
        InputEvents.moveRightEvent.RemoveListener(OnMoveRightEvent);
        InputEvents.moveForwardEvent.RemoveListener(OnMoveForwardEvent);
        InputEvents.moveForwardEvent.RemoveListener(OnMoveBackwardEvent);
        InputEvents.runEvent.RemoveListener(OnRunEvent);
        InputEvents.jumpEvent.RemoveListener(OnJumpEvent);
        InputEvents.shootEvent.RemoveListener(OnShootEvent);
    }


    private void OnMoveRightEvent()
    {
        MoveRight();
    }

    private void OnMoveForwardEvent()
    {
        MoveForward();
    }

    private void OnMoveBackwardEvent()
    {
        MoveBackward();
    }

    private void OnMoveLeftEvent()
    {
        MoveLeft();
    }

    private void OnRunEvent()
    {
        Run();
    }

    private void OnJumpEvent()
    {
        Jump();
    }

    private void OnShootEvent()
    {
        Shoot();
    }

    public void MoveLeft() { }
    public void MoveRight() { }
    public void MoveForward() { }
    public void MoveBackward() { }
    public void Run() { }
    public void Shoot() { }
    public void Jump() { }
    public void Attack() { }
}

Ở trên là code của mình sau khi phân tách Input HandlerInput Executor. Ở đây mình phân tách đống code kiểm tra player input trong hàm Update ở class CharacterMoveControl cũ ra class mới tên là PCInputHandler và xem class này như là một Input Handler cho PC.

Tại sao lại có sự xuất hiện của class static InputEvents? class này mình sử dụng như một version thu gọn của Observer Pattern đóng vai trò như cây cầu kết nối giữa Input HandlerInput Executor.

Vậy giờ muốn thêm chức năng nhận Input từ GamePad thì sao ? Có ngay thưa sếp.


public class GamePadInputHandler : MonoBehaviour
{
    [SerializeField] private GamePad gamePad;
    [SerializeField] private int moveAnalog;
    [SerializeField] private int runButton;
    [SerializeField] private int jumButton;
    [SerializeField] private int shootButton;

    private void Update()
    {
        if (gamePad.GetAnalog(moveAnalog).x < 0)
        {
            InputEvents.moveLeftEvent.Invoke();
        }
        if (gamePad.GetAnalog(moveAnalog).x > 0)
        {
            InputEvents.moveLeftEvent.Invoke();
        }
        if (gamePad.GetAnalog(moveAnalog).y < 0)
        {
            InputEvents.moveBackwardEvent.Invoke();
        }
        if (gamePad.GetAnalog(moveAnalog).y > 0)
        {
            InputEvents.moveForwardEvent.Invoke();
        }
        if (gamePad.GetButton(runButton))
        {
            InputEvents.runEvent.Invoke();
        }
        if (gamePad.GetButton(jumButton))
        {
            InputEvents.jumpEvent.Invoke();
        }
        if (gamePad.GetButton(shootButton))
        {
            InputEvents.shootEvent.Invoke();
        }
    }
}

Vậy là mình đã giải thích và hướng dẫn giải quyết cách xử lý input hỗ trợ multiplatform. Với cách này các bạn có thể áp dụng để xử lý các dự án cần yêu cầu xử lý nhiều loại input mà không phải đắn đo về khả năng mở rộng hay khó khăn trong việc bảo trì code. Ngoài ra với hướng giải quyết này cũng có nhiều ứng dụng hay mà mình sẽ đề cập trong bài sau.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí