🕹️ Nhận input và di chuyển nhân vật

By Ngọc Vương Trương

Ở bài trước bạn đã biết vòng lặp game chạy Update mỗi frame và project Unity 2D được tổ chức ra sao. Giờ là lúc "thổi sự sống" vào nhân vật: đọc input người chơi và biến nó thành chuyển động. Đây là viên gạch đầu tiên của prototype capstone, vì va chạm và tính điểm ở bài sau đều dựa trên việc nhân vật di chuyển đúng.

Sau bài này, bạn sẽ:

  • Đọc được input từ bàn phím (và hiểu cách mở rộng sang touch/joystick trên mobile).
  • Viết được script C# di chuyển nhân vật bằng cả transform lẫn Rigidbody2D.
  • Giải thích được vì sao phải nhân với Time.deltaTime để tốc độ ổn định ở mọi frame rate.
  • Tránh được lỗi kinh điển: đi chéo nhanh hơn đi thẳng.

Từ phím bấm đến chuyển động: input đi qua đâu?

Mỗi frame, Unity hỏi hệ điều hành: "người chơi đang giữ phím nào?". Câu trả lời nằm trong lớp Input. Bạn không cần biết phím cụ thể, chỉ cần biết trục (axis):

  • Horizontal: A/D hoặc phím mũi tên trái/phải, trả về khoảng -1 đến 1.
  • Vertical: W/S hoặc mũi tên lên/xuống.

Trong Unity Editor, các phím này đã được map sẵn trong Project Settings > Input Manager. Trên mobile, cùng đoạn code đó có thể nhận giá trị từ một on-screen joystick (thường là một Image + script joystick ghi vào cùng trục), nên logic di chuyển không phải viết lại.

Hai hàm đọc trục: GetAxis và GetAxisRaw

Đây là chỗ người mới hay nhầm:

Hàm Trả về Dùng khi nào
Input.GetAxis("Horizontal") float đã làm mượt, tăng dần từ 0 đến 1 Game cần cảm giác "trượt", phù hợp joystick analog
Input.GetAxisRaw("Horizontal") -1, 0 hoặc 1, không làm mượt Game platformer, phản hồi tức thì, dễ đoán

Với game 2D nhịp nhanh, GetAxisRaw thường là lựa chọn tốt hơn vì nhân vật dừng ngay khi bạn nhả phím.

Sign in to view the full content of this lesson. Sign in

Note: Nếu project của bạn dùng package Input System mới, cú pháp là Keyboard.current.aKey.isPressed hoặc một InputAction. Cách đọc khác nhưng nguyên lý giống hệt: lấy vector input rồi nhân với tốc độ. Bài này dùng Input cổ điển vì nó chạy được ngay, không cần cấu hình.

Bài tập nhanh: Bạn muốn nhân vật di chuyển theo kiểu "trượt trên băng" (tăng tốc và giảm tốc từ từ). Chọn hàm nào?

Xem gợi ý

Dùng Input.GetAxis (có smoothing) kết hợp Rigidbody2D với drag, hoặc tự làm mượt bằng Vector2.Lerp giữa vận tốc hiện tại và vận tốc mục tiêu. GetAxisRaw sẽ cho cảm giác dừng đột ngột, không hợp với băng trượt.

Di chuyển bằng transform hay bằng Rigidbody2D?

Có hai cách, và chọn sai là nguyên nhân của phần lớn lỗi "nhân vật xuyên tường".

// Cách 1: transform, đơn giản, không có physics
public class PlayerMove : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;

    void Update()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float y = Input.GetAxisRaw("Vertical");
        Vector2 dir = new Vector2(x, y).normalized;
        transform.Translate(dir * moveSpeed * Time.deltaTime);
    }
}
// Cách 2: Rigidbody2D, tôn trọng va chạm
public class PlayerMoveRb : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    private Rigidbody2D rb;
    private Vector2 input;

    void Awake() => rb = GetComponent<Rigidbody2D>();

    void Update()
    {
        input = new Vector2(Input.GetAxisRaw("Horizontal"),
                            Input.GetAxisRaw("Vertical")).normalized;
    }

    void FixedUpdate()
    {
        rb.MovePosition(rb.position + input * moveSpeed * Time.fixedDeltaTime);
    }
}

Quy tắc chọn:

  • Chưa cần va chạm (test nhanh, camera bay, UI bay): transform trong Update.
  • Có va chạm, có physics (nhân vật capstone của bạn): Rigidbody2D, và thao tác physics đặt trong FixedUpdate.

Lý do: FixedUpdate chạy theo nhịp cố định (mặc định 0.02 giây), đúng nhịp engine mô phỏng physics. Đặt MovePosition trong Update sẽ gây giật và đôi khi xuyên vật thể.

Bài tập nhanh: Script của bạn dùng Rigidbody2D nhưng nhân vật vẫn xuyên qua tường khi đi nhanh. Kể tên hai thứ cần kiểm tra.

Xem đáp án

  1. Collider2D trên nhân vật và tường đã bật Is Trigger chưa (nếu bật thì không chặn được), và Rigidbody2D có để Body Type = Dynamic không.
  2. Có đang set transform.position trực tiếp ở đâu đó không, hoặc moveSpeed quá cao khiến mỗi bước nhảy xa hơn bề dày tường (lúc đó cần dùng Continuous collision detection).

Vì sao phải nhân với Time.deltaTime

Giả sử bạn viết transform.Translate(dir * 5f). Ở máy chạy 60 FPS, mỗi giây bạn cộng 300 đơn vị. Ở máy 120 FPS, bạn cộng 600 đơn vị. Nhân vật chạy nhanh gấp đôi chỉ vì máy khỏe hơn. Đó là lỗi frame-rate dependent.

Time.deltaTime là số giây của frame vừa qua. Nhân vào, phép tính trở thành "5 đơn vị mỗi giây", không phải "5 đơn vị mỗi frame".

$$ v = \text{moveSpeed} \times \Delta t $$

Trong FixedUpdate, dùng Time.fixedDeltaTime (hằng số 0.02) cho khớp nhịp, hoặc Time.deltaTime cũng chấp nhận được vì Unity trả về đúng bước cố định trong ngữ cảnh này. Đừng trộn lẫn hai hàm giữa hai chỗ.

Đi chéo không được nhanh hơn: phép normalized

Nếu bạn cộng thẳng x và y, vector (1, 1) có độ dài √2 ≈ 1.41. Nghĩa là đi chéo nhanh hơn 41%. .normalized kéo vector về độ dài 1, giữ nguyên hướng. Đây là lý do dòng new Vector2(x, y).normalized xuất hiện trong hầu hết script di chuyển 2D.

Tip: Nhớ gọi .normalized sau khi ghép vector, không phải trên từng trục riêng lẻ. Nếu x và y đều bằng 0, normalized trả về (0,0), nhân vật đứng yên, đúng như mong muốn.

Những lỗi hay gặp khi cho nhân vật di chuyển

  • Quên Time.deltaTime: nhân vật chạy như tên lửa trên máy khỏe. Kiểm tra đầu tiên khi test trên nhiều thiết bị.
  • Gọi GetComponent trong Update: tốn hiệu năng vô ích. Cache trong Awake hoặc Start.
  • Trộn transform.position với Rigidbody2D: physics sẽ "đánh nhau" với bạn, gây rung và xuyên tường. Chọn một cách và đi theo nó.
  • Đặt moveSpeed quá lớn: mỗi frame nhảy xa hơn bề dày collider, dẫn đến xuyên tường dù code đúng. Với game mobile, 4 đến 8 là khoảng hợp lý cho bắt đầu.
  • Dùng Input.GetKey khi cần "nhấn một lần": GetKeyDown chỉ đúng ở frame đầu tiên, phù hợp cho nhảy hoặc bắn.
  • Quên [SerializeField]: biến private sẽ không hiện trong Inspector, bạn không tinh chỉnh được tốc độ khi chơi thử. Mẹo: vừa chạy game vừa kéo thanh moveSpeed để cảm nhận độ nhanh chậm.

Ghép lại: checklist trước khi sang bài capstone

Bạn đã có đủ mảnh ghép để cho nhân vật chạy. Kiểm tra nhanh bằng cloze dưới đây:

Trong Unity, _____ trả về -1, 0 hoặc 1 mà không làm mượt giá trị. Nhân tốc độ với _____ để chuyển động không phụ thuộc frame rate. Di chuyển bằng Rigidbody2D nên đặt trong _____. Gọi _____ trên vector input để đi chéo không nhanh hơn đi thẳng. Khi cần phát hiện "vừa nhấn xuống", dùng _____ thay vì Input.GetKey.

Và đây là bộ thẻ để ôn lại:

Front: Input.GetAxis("Horizontal") trả về gì? Back: Một float đã làm mượt, chạy từ -1 đến 1.

Front: GetAxisRaw khác GetAxis ở điểm nào? Back: Không làm mượt, trả về đúng -1, 0 hoặc 1.

Front: Time.deltaTime là gì? Back: Số giây đã trôi qua kể từ frame trước.

Front: Vì sao phải nhân tốc độ với Time.deltaTime? Back: Để tốc độ tính theo giây, không phụ thuộc frame rate.

Front: Nên đặt di chuyển Rigidbody2D trong hàm nào? Back: FixedUpdate, vì đây là nhịp cố định của physics.

Front: Time.fixedDeltaTime dùng ở đâu? Back: Trong FixedUpdate, khi tính quãng đường di chuyển.

Front: Vector2.normalized làm gì? Back: Giữ nguyên hướng và kéo độ dài vector về 1.

Front: Vì sao cần normalized khi đi chéo? Back: Để đi chéo không nhanh hơn √2 lần so với đi thẳng.

Front: transform.Translate phù hợp khi nào? Back: Khi cần di chuyển đơn giản, không cần physics hay va chạm.

Front: Rigidbody2D.MovePosition khác gì set transform.position? Back: MovePosition tôn trọng physics và va chạm, set trực tiếp thì không.

Front: GetKeyDown khác GetKey thế nào? Back: GetKeyDown chỉ true ở frame đầu tiên của lần nhấn.

Front: Hai trục input mặc định cho WASD và phím mũi tên? Back: Horizontal và Vertical.

Front: Nên cache Rigidbody2D ở đâu? Back: Trong Awake, để không gọi GetComponent mỗi frame.

Tóm lại: input chỉ là một vector hai chiều, việc của bạn là chuẩn hóa nó, nhân với tốc độ và với Time.deltaTime, rồi đưa vào đúng hàm (Update cho transform, FixedUpdate cho Rigidbody2D). Bài tiếp theo, bạn sẽ dùng chính chuyển động này để va vào vật thể, tính điểm và hoàn thiện prototype capstone.