U
05 / 22 · 12 phút
Hỏi đáp

CharacterController không di chuyển được: bảy phép kiểm tra

câu trả lời ngắn

Vì sao Unity CharacterController không di chuyển được?

Gần như mọi lý do nhân vật không đi được đều không báo lỗi, nên hãy dò theo thứ tự: input có tới script không, component và capsule có thật sự tồn tại, và vector có bằng 0 không. Kế đó là những chỗ chính Move() từ chối — Step Offset, Slope Limit, mặt sàn để nhầm Is Trigger — và cuối cùng là Time.deltaTime, scale của Transform, cùng một Rigidbody cũng giành Transform đó.

Bạn bấm W. Nhân vật đứng im, hoạt ảnh đi có khi vẫn chạy, còn Console thì trống trơn. Chuyện đó bình thường: gần như mọi lý do khiến CharacterController trong Unity không di chuyển được đều không báo lỗi gì. Bảy nhóm nguyên nhân, xếp theo thứ tự kiểm tra từ rẻ nhất — ba nhóm đầu đã chiếm phần lớn các ca người ta đi hỏi.

1. Không có input nào tới script

Mọi script di chuyển mở đầu giống nhau: đọc một con số, khác 0 thì đi. Trước khi sửa gì, hãy chứng minh con số đó có tới thật. Bốn chuyện bình thường chặn được nó, và không chuyện nào ghi lỗi.

  1. 1

    Play mode, object đang bật. Script chỉ chạy khi game chạy, và tên bị xám trong Hierarchy thì không làm gì cả. Cái ô cạnh tên object ở đầu Inspector là công tắc khác với ô trên từng component.

  2. 2

    Script đã compile. Chỉ cần một dòng đỏ trong Console từ script bất kỳ trong dự án, Unity vẫn đang chạy bản code cũ: hàm Update mới không có trong đó. File chưa lưu cũng y hệt.

  3. 3

    Script nằm trên đúng object. Code di chuyển chỉ đọc và ghi một Transform. Gắn lên object cha, lên model con hay lên camera thì nó lái sai object, và gần như lúc nào cũng trông như không có gì nhúc nhích.

  4. 4

    Update, không phải FixedUpdate, và đúng từng chữ. Unity gọi callback theo tên, nên void update() viết hoa sai vẫn biên dịch và không bao giờ được gọi. Đọc input để di chuyển ở Update; chỉ phần vật lý ở FixedUpdate.

MoveProbe.cs — temporary proof that input arrived
using UnityEngine;

// Gắn vào ĐÙNG object với script di chuyển, bấm Play rồi giữ W.
public class MoveProbe : MonoBehaviour
{
    CharacterController cc;

    void Awake()
    {
        cc = GetComponent<CharacterController>();
    }

    void Update()
    {
        Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));

        // Tia xanh mọc từ đầu nhân vật, bấm càng mạnh càng dài.
        // Nó hiện trong Scene view, không phải Game view.
        if (input.sqrMagnitude > 0.001f)
            Debug.DrawRay(transform.position + Vector3.up, input * 2f, Color.green);

        // Một lần mỗi giây cho đỡ ngập Console. Bấm vào dòng log sẽ chọn đúng
        // object đã viết ra nó: hãy tin output này, đừng tin trí nhớ.
        if (Time.frameCount % 60 == 0)
            Debug.Log(name + " | input " + input
                      + " | deltaTime " + Time.deltaTime
                      + " | controller " + (cc != null)
                      + " | grounded " + (cc != null && cc.isGrounded), this);
    }
}

Có số trong Console nghĩa là input ổn và vấn đề ở phía sau. Không có gì nghĩa là script không chạy, và sửa bao nhiêu thuật toán chuyển động cũng vô ích.

2. Script bị tắt, hoặc component thiếu

Probe im lặng trên một object vẫn đang bật. Phần tìm kiếm còn lại nằm gọn trong một Inspector, và tiện thể kiểm luôn object nào đang giữ controller: chọn nhân vật lúc đang Play rồi bấm F ở Scene view. Capsule màu xanh xuất hiện chỗ khác chỗ model đứng là hai object khác nhau đấy.

  • Ô tick trên component. MonoBehaviour bị bỏ tick thì không bao giờ được update — không đọc input, không gọi Move — dù các thông điệp vật lý như OnTriggerEnter vẫn tới được nó. Một cái click là xong hết, nên nhìn vào đây trước.

  • Object này không có CharacterController nên code giữ null và đứng im. Bản thân CharacterController đã là một collider và không bao giờ cần cái thứ hai, nhưng nó phải ở cùng object với script gọi Move. Thêm [RequireComponent(typeof(CharacterController))] vào class, Unity sẽ tự gắn và không cho gỡ khi script còn ở đó.

  • Capsule không có thể tích. Chỉnh ẩu thì Radius hoặc Height thành 0, hoặc cả capsule nằm dưới sàn. Khi đó Move không còn gì để đẩy trong thế giới, còn Unity thì không nói gì. Bộ giá trị hợp với model cỡ người trong khoá học là Center 0, 1, 0, Radius 0.3, Height 2.

3. Vector tính ra 0

Input có tới, component đang bật, và Move chạy mỗi khung hình — với Vector3.zero. Đây là nguyên nhân số một và khó thấy nhất, vì 0 là giá trị hoàn toàn hợp lệ và không có cảnh báo nào cho nó.

  • Field speed chưa được gán. public float speed; khởi đầu là 0 — Unity không tự cho con số nào giá trị mặc định, mà Inspector hiện 0 thì cũng không phải lỗi, nên script chạy hoàn hảo ở không mét mỗi giây. Riêng ca này chiếm quá nửa số câu hỏi 'CharacterController không di chuyển được'.

  • Vector3.forward không phải transform.forward. Cái đầu là hằng số (0, 0, 1): hướng bắc thế giới, mãi mãi, nhân vật quay đi đâu cũng vậy. Cái sau là hướng nhân vật này đang thực sự nhìn. Dùng hướng bắc thế giới sau khi quay vào hành lang là bạn đang tì vào bức tường cạnh mình, tức mục 4 đội lốt.

  • Move muốn hướng trong không gian world, còn transform.Translate mặc định là local. Hãy dựng vector từ trục của chính nhân vật bằng transform.TransformDirection(input); cùng một dòng nhưng hai hàm hiểu khác nhau, đó là lý do chép script qua lại thì hết hiểu nổi.

  • Chốt không bao giờ mở: Vector3.zero để nguyên từ lúc dựng khung, canMove vẫn false vì animation event không bao giờ chạy, cờ bắt đầu level còn hạ, phép thử stamina so với field đang 0. Mọi thứ dạng if (!ready) return đặt trên dòng Move đều chặn chuyển động mà không để lại bằng chứng nào.

PlayerMove.cs — the shape that fails loudly
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerMove : MonoBehaviour
{
    // Float không gán thì mãi mãi là 0, và chuyện đó chẳng có màu đỏ nào.
    // [SerializeField] giữ nó hiện ra để còn nhìn thấy trong lúc Play.
    [SerializeField] private float speed = 6f;
    [SerializeField] private float gravity = -20f;

    CharacterController cc;
    float verticalSpeed;

    void Awake()
    {
        cc = GetComponent<CharacterController>();
        if (cc == null)
            Debug.LogError(name + " không có CharacterController trên object này", this);
    }

    void Update()
    {
        Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
        if (input.sqrMagnitude > 1f) input.Normalize();

        // Hướng local đổi ra hướng world, theo đúng hướng nhân vật đang nhìn.
        // Dùng Vector3.forward ở đây thì thành hướng bắc của thế giới.
        Vector3 planar = transform.TransformDirection(input) * speed;

        // CharacterController không chịu tác động vật lý, nên trọng lực phải tự cộng.
        if (cc.isGrounded && verticalSpeed < 0f) verticalSpeed = -2f;
        verticalSpeed += gravity * Time.deltaTime;

        // Move nhận quãng đường CỦA KHUNG HÌNH NÀY, nên phải nhân deltaTime.
        // Giá trị trả về cho biết cái gì chặn bạn: Sides là tường, Below là sàn.
        CollisionFlags hit = cc.Move((planar + Vector3.up * verticalSpeed) * Time.deltaTime);

        if (input.sqrMagnitude > 0.001f && (hit & CollisionFlags.Sides) != 0)
            Debug.Log(name + " đang tì vào thứ gì đó: " + hit, this);
    }
}

4. Move được gọi, nhưng va chạm chặn lại

CharacterController chỉ đi tới nơi capsule của nó chui vừa. Khi không đi được, nó trả về lý do dạng CollisionFlags, mà gần như không ai đọc giá trị đó. Bắt lấy một lần là nhiều bí ẩn biến mất: Sides là tường, Below là sàn, Above là trần.

  • Step Offset, mặc định 0.3. Cao hơn là thành tường, chặn tuyệt đối và im lặng — cái thùng 0.35 mét làm nhân vật chết đứng, dấu duy nhất là cờ Sides. Nhích lên một chút hoặc thêm dốc, và nhớ bậc cần khoảng trống phía trên: beam thấp là leo không được.

  • Slope Limit, mặc định 45 độ. Dốc hơn mức đó là bị từ chối thẳng: input vẫn đọc, Move vẫn chạy, không có gì xảy ra. Thủ phạm thường là edge của terrain bị vẽ quá dốc.

  • Skin Width, mặc định 0.08 — khoảng đệm quanh capsule. Quá lớn thì không vào được khung cửa hẹp hơn cỡ hai lần skin width, và lơ lửng trên sàn không chạm, làm isGrounded nhấp nháy. Quá nhỏ thì mắc vào đường nối giữa các mesh.

  • Đứng trên trigger. CharacterController đi xuyên mọi collider có tick Is Trigger, nên sàn làm kiểu đó không giữ được gì: isGrounded false mãi và script có chốt if (!cc.isGrounded) return thì không bao giờ gọi Move.

  • Sinh ra bên trong hình học. Nếu capsule bắt đầu đã chồng lên tường, Move dành mọi khung hình đẩy bạn ra khỏi chính mặt đang giữ bạn, và nhân vật bò y như chỗ không có gì. Hãy nhìn điểm xuất phát trong Scene view trước khi nó đi.

5. Time.deltaTime bị nhân/chia sai

Tốc độ tính bằng mét trên giây; Move lại muốn mét của khung hình này. deltaTime là phép đổi giữa hai cái, và hai chiều của lỗi trên màn hình trông không giống nhau chút nào.

Three lines on one field — only one of them is a walkable character
// speed = 6, chạy ở 60 fps, nên Time.deltaTime cỡ 0.0166.
cc.Move(input * speed);                   // 6 MÉT mỗi khung hình: 360 m/s
cc.Move(input * speed / Time.deltaTime);  // 360 mét mỗi khung hình: bay biến từ khung 2
cc.Move(input * speed * Time.deltaTime);  // khung này 0.1 m, tức 6 m/s — đúng
  • Tốc độ quá nhỏ không phải kẹt mà là chậm — và ở tỷ lệ game thì không phân biệt được. speed = 0.001 là một milimét mỗi giây, độ trăm phút mới đi hết căn phòng sáu mét. Trong đơn vị của Unity, 2 là đi bộ, 6 là chạy.

  • Đáng ra nhân lại chia thì nhân vật bị phóng đi với tốc độ hàng nghìn mét mỗi giây, thường kèm camera bị kéo giật. Mà bỏ hẳn deltaTime thì chuyển động phụ thuộc số khung hình: đúng trên màn hình 60 Hz, nhanh gấp đôi trên điện thoại 120 Hz.

  • Time.timeScale = 0 khiến Time.deltaTime bằng 0 tuyệt đối, nên mọi phép nhân với nó cũng 0. Menu pause đặt timeScale mà không trả lại sẽ đóng băng nhân vật trong khi hoạt ảnh vẫn chạy, vì Animator dùng unscaled time không đọc timeScale.

6. Nhân vật quay nhưng không tiến

Quay được đã là một câu trả lời: input có tới, script có chạy, chỉ mất bước tiến. Tổ hợp đó chỉ về scale của Transform.

  • CharacterController không được thiết kế để scale; Unity mong Transform của nó là 1, 1, 1. Scale object gốc hoặc object cha phía trên thì capsule không còn khớp giả định của phép tính chuyển động — nhân vật bị hàn xuống sàn hoặc vào tường, trong khi quay, thứ không bị ảnh hưởng, vẫn chạy.

  • localScale có 0 ở bất kỳ trục nào, hoặc giá trị âm từ model lật gương, khiến capsule không còn thể tích. Thường nó vào qua import — file theo centimét để nguyên Scale Factor 0.01, hoặc object cha đặt (-1, 1, 1) để lật mesh. Hãy đặt ở tab Model của importer, hoặc giữ controller trên root không scale và coi model đã scale là con.

  • Object cha đang quay còn nhân vật là con với local rotation riêng, nên transform.forward trả về hướng của cha và 'phía trước' là hướng không ai nhìn. Làm phẳng hierarchy: object mang CharacterController và object được vẽ nên dùng chung một phép quay, không phải hai phép nhân.

7. Rigidbody hoặc controller khác giành cùng Transform

CharacterController tự làm va chạm và tự tính chuyển động. Bất kỳ thứ gì khác cũng ghi vào cùng Transform đó đều thi đua với nó, và nhân vật cuối cùng không nghe ai cả.

  • Rigidbody và CharacterController trên cùng object. Vật lý kéo bạn lún xuống sàn theo nhịp riêng trong khi script cũng đẩy bằng trọng lực riêng ở Update, và nhân vật bị lún, run, hoặc hàn chặt dù bấm W mạnh tới đâu. Giữ một cái; với nhân vật của người chơi là CharacterController.

  • Rigidbody Kinematic cạnh controller là lựa chọn có chủ đích để đẩy thùng — và thành vấn đề hai chủ nhân ngay khi script khác cũng lái nó. Nếu có thứ gì ghi rb.velocity hay AddForce lên body đó, hoặc gọi Rigidbody.MovePosition khi Move cũng đang chạy, thì vị trí lại có hai tác giả.

  • CharacterController thứ hai không va chạm với controller của bạn — chúng đi xuyên nhau — nên NPC không chặn được bạn, nhưng tường tĩnh, cửa đóng hay cái thùng có Rigidbody thì có, và thứ chặn bạn có thể đang nằm sau camera. Hãy bay tới nhân vật trong Scene view và xem capsule đang chạm gì.

  • isGrounded không bao giờ true. Chốt dạng if (!cc.isGrounded) return từ chối mọi lệnh mà không để lại dấu vết: sàn là trigger, đáy capsule lơ lửng sau khi sửa Center hoặc Height, hoặc sàn ở layer bị bỏ tick so với layer nhân vật trong Edit > Project Settings > Physics. Log cc.isGrounded lúc bấm W — một dòng, một câu trả lời.

Thứ tự 90 giây tìm ra nguyên nhân

  • ✓Console không còn lỗi đỏ — nếu còn thì Unity vẫn chạy bản code cũ và bạn sửa gì cũng chưa ăn thua.
  • ✓Đang Play, object đang bật, component script có tick.
  • ✓MoveProbe in số khi giữ W. Im lặng thì mục 1 và 2.
  • ✓speed trong Inspector là số khác 0 mà bạn nhận ra, kiểm lúc đang Play.
  • ✓Capsule xanh ôm nhân vật và nằm trên sàn — không nằm trong sàn, không lơ lửng.
  • ✓Time.deltaTime được nhân, không bao giờ chia.
  • ✓Scale của object và mọi object cha là (1, 1, 1) — không 0, không số âm lật gương.
  • ✓Đúng một thứ di chuyển Transform này: CharacterController hoặc Rigidbody — không phải cả hai.
  • ✓cc.isGrounded đứng yên mà ra true, và phía trước không có gì cao hơn Step Offset hay dốc hơn Slope Limit.

Nhân vật đi lại được rồi thì điều kế tiếp bạn để ý là chuyện lúc nó chạm vào thứ gì, và đó là bài va chạm và trigger. Còn hình hài đúng của chuyển động — input theo camera, trọng lực, quay người mượt — ở bài cách di chuyển nhân vật.

bài học làm ra thứ này

Di chuyển, nhảy và đáp lên khối

Bài này là một công thức đứng riêng. Trong khoá học, cùng thứ đó được dựng như một phần của dự án xuyên suốt năm tab — Nhân vật & Chiến đấu, bài 02.