U
05 / 22 · 12 分钟
常见问题

CharacterController 不动:按成本从低到高的七项检查

简短回答

Unity 里 CharacterController 为什么不动?

角色不走路的原因几乎都不报错,所以按成本从低到高排查才是捷径:输入有没有传到脚本、组件和它的胶囊是不是真的存在、移动向量是不是 0。接着查 Move() 自己拒绝的情况——Step Offset、Slope Limit、地板被勾成 Is Trigger——最后看 Time.deltaTime、Transform 缩放,以及在争同一个 Transform 的 Rigidbody。

你按下 W,角色一动不动,走路动画说不定还在播,而 Console 里什么都没有——这很正常。Unity 里 CharacterController 不移动的原因,几乎每一个都不报任何错。原因分成七组,按检查成本从低到高排列,光前三组就占去一大半。

1. 输入根本没传到脚本

每个移动脚本的开头都一样:读一个数值,不是 0 就走。在改任何东西之前,先证明这个数值真的到了。挡在路上的有四件稀松平常的事,而且都不报错。

  1. 1

    Play 模式、以及启用中的 object。脚本只在游戏运行时执行,Hierarchy 里名字发灰的物体什么都不做。Inspector 最上方名字旁边的勾选框,和每个 component 上的是两个独立开关。

  2. 2

    脚本编译过了没有。只要 Console 里有任意一条来自项目里任何脚本的红字,Unity 跑的就还是上一版代码:新的 Update 不在里面。文件没保存也是一样的表现。

  3. 3

    脚本挂在正确的 object 上。移动代码只读写一个 Transform。挂在父级、子模型或相机上,它驱动的就是错的对象,看起来往往就是纹丝不动。

  4. 4

    是 Update 不是 FixedUpdate,而且名字分毫不差。Unity 按名字调用回调,所以小写开头的 void update() 编译能过却永远不被调用。移动用的输入在 Update,只有物理部分在 FixedUpdate。

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

// Attach it to the SAME object as your movement script, press Play, hold 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"));

        // A green ray out of the character's head, growing as you press.
        // It draws in the Scene view, not the Game view.
        if (input.sqrMagnitude > 0.001f)
            Debug.DrawRay(transform.position + Vector3.up, input * 2f, Color.green);

        // Once a second so the Console does not flood. Clicking a line selects
        // the object that wrote it: trust this output, not your memory.
        if (Time.frameCount % 60 == 0)
            Debug.Log(name + " | input " + input
                      + " | deltaTime " + Time.deltaTime
                      + " | controller " + (cc != null)
                      + " | grounded " + (cc != null && cc.isGrounded), this);
    }
}

Console 里有数值,说明输入没问题,问题在后头。什么都没有,说明脚本压根没跑,再改移动算法也没用。

2. 脚本被关掉,或者缺少组件

探针在一个开着的 object 上仍然沉默。剩下的查找都在一个 Inspector 里,顺便也确认到底哪个 object 持有 controller:Play 模式里选中角色,在 Scene 视图上按 F。绿色胶囊出现的位置和模型站着的地方不同,那就是两个 object。

  • component 上的勾选框。取消勾选的 MonoBehaviour 永远不会被 update——不读 input,也不调 Move——尽管 OnTriggerEnter 这类物理消息照样能送到它手上。点一下就全好了,所以先看这里。

  • 这个 object 上没有 CharacterController,所以代码拿着 null 站着不动。CharacterController 本身就是 collider,永远不需要第二个,但它必须和调用 Move 的脚本在同一个 object 上。给类加上 [RequireComponent(typeof(CharacterController))],Unity 会自动挂上,并在脚本还在时拒绝移除。

  • 胶囊没有体积。瞎调的话 Radius 或 Height 变成 0,或者整个 capsule 到了地板下面。那样 Move 在世界里没有可推的东西,而 Unity 一声不吭。课程里适合人形模型的数值是 Center 0, 1, 0、Radius 0.3、Height 2。

3. 算出来的移动向量是 0

input 到了、组件开着、Move 每帧都在跑——传进去的是 Vector3.zero。这是最多也最难看见的原因,因为 0 是完全合法的值,没有任何警告会为它响起。

  • speed 没被赋值。public float speed; 起始是 0——Unity 不会给数字默认值,而 Inspector 显示 0 又不是报错,于是脚本以每秒零米完美运行。单这一种就占了 CharacterController 不动类提问的一大半。

  • Vector3.forward 不是 transform.forward。前者是常量 (0, 0, 1):不管角色朝哪,都是世界的正北。后者才是这个角色真正指向的方向。转身进走廊后还用前者,你只是在往旁边的墙上顶,那是第 4 节换了件外衣。

  • Move 要的是世界空间方向,而 transform.Translate 默认 local。用 transform.TransformDirection(input) 按角色自己的轴搭出向量;同一行代码在两个函数里含义不同,这就是脚本抄来抄去之后看不懂的原因。

  • 永远放不开的门卫:搭框架留下的 Vector3.zero、因为动画事件不触发而仍是 false 的 canMove、没立起的开始标记、拿 0 值字段来比的体力判定。任何 if (!ready) return 形状的东西放在 Move 那行上面,都会不留证据地停掉移动。

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

[RequireComponent(typeof(CharacterController))]
public class PlayerMove : MonoBehaviour
{
    // A float with no value stays 0 forever, and nothing about that is red.
    // [SerializeField] keeps it visible so you can watch it during 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 + " has no CharacterController on this object", this);
    }

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

        // Local input to world direction, along THIS character's facing.
        // Vector3.forward here would be world north instead.
        Vector3 planar = transform.TransformDirection(input) * speed;

        // A CharacterController ignores physics, so gravity is manual.
        if (cc.isGrounded && verticalSpeed < 0f) verticalSpeed = -2f;
        verticalSpeed += gravity * Time.deltaTime;

        // Move takes metres for THIS frame, so deltaTime is multiplied on.
        // The return value is what stopped you: Sides is a wall, Below the floor.
        CollisionFlags hit = cc.Move((planar + Vector3.up * verticalSpeed) * Time.deltaTime);

        if (input.sqrMagnitude > 0.001f && (hit & CollisionFlags.Sides) != 0)
            Debug.Log(name + " is pressing into something: " + hit, this);
    }
}

4. Move 调用了,却被碰撞挡住

CharacterController 只去它的胶囊挤得进去的地方。挤不进时它把原因以 CollisionFlags 返回,而几乎没人读那个值。接一次,很多谜就散了:Sides 是墙,Below 是地板,Above 是天花板。

  • Step Offset,默认 0.3。比它高的就是墙,静悄悄完全挡住——0.35 米的木箱就能让角色定住,唯一痕迹是 Sides 标志。调高一点或加段坡,并且记住上台阶需要头顶留空:横梁低就迈不上去。

  • Slope Limit,默认 45 度。比这更陡的坡直接不被接受:input 照读、Move 照跑、什么也不发生。元凶通常是画得太陡的 terrain 边缘。

  • Skin Width,默认 0.08——胶囊外面的一圈余量。太大时,比约两倍 skin width 更窄的门进不去,而且悬在地板上方不落地,isGrounded 就一直闪。太小时又会卡在 mesh 之间的接缝上。

  • 站在 trigger 上。CharacterController 直接穿过任何勾了 Is Trigger 的 collider,所以那样的地板什么都兜不住:isGrounded 永远 false,被 if (!cc.isGrounded) return 拦着的脚本一次也不会调 Move。

  • 在几何体内部出生。开局 capsule 就和墙重叠的话,Move 每一帧都用来把你从困住你的那个面里推出去,角色像在跟空气扭动。在它动之前,先在 Scene 视图看出生点。

5. Time.deltaTime 乘除弄反了

速度是每秒多少米,Move 要的是这一帧多少米。deltaTime 就是两者之间的换算,而这个错的两个方向在画面上完全不像一回事。

Three lines on one field — only one of them is a walkable character
// speed = 6, running at 60 fps, so Time.deltaTime is about 0.0166.
cc.Move(input * speed);                   // 6 metres EVERY frame: 360 m/s
cc.Move(input * speed / Time.deltaTime);  // 360 metres per frame: gone by frame 2
cc.Move(input * speed * Time.deltaTime);  // 0.1 m this frame, which is 6 m/s — correct
  • 速度太小不是卡住,只是慢——而在游戏尺度下这两者分不出来。speed = 0.001 是一毫米每秒,走完六米的房间大约要一百分钟。在 Unity 的单位里,2 是步行,6 是跑步。

  • 该乘却去除,角色就会被以每秒数千米发射出去,通常还拖着相机一路跟着。整个去掉 deltaTime 就变成依赖帧率:60 Hz 下正确,120 Hz 的手机上快一倍。

  • Time.timeScale = 0 让 Time.deltaTime 精确为 0,于是乘它的结果也全是 0。暂停菜单设了却不还原,就会出现动画照播、角色冻住,因为用 unscaled time 的 Animator 不看 timeScale。

6. 角色会转身,却不往前走

能转身本身就是一个答案:input 到了、脚本在跑,缺的只是往前那一步。这个组合指向 Transform 的缩放。

  • CharacterController 不是按缩放设计的;Unity 期望它的 Transform 是 1, 1, 1。缩放根节点或任何父级,capsule 就不再符合移动数学的假设——角色像被焊在地板或墙上,而不受影响的旋转却照常运行。

  • 任意轴为 0 的 localScale,或镜像模型的负值,会让 capsule 彻底失去体积。这多半是 import 带进来的——按厘米的文件留着 Scale Factor 0.01,或为翻正 mesh 把父级设成 (-1, 1, 1)。改到 importer 的 Model 页里设置,或者把控制器留在未缩放的根上,让缩放过的 mesh 当它的子物体。

  • 父级被旋转、角色又是带自己 local rotation 的子物体,于是 transform.forward 返回父级朝向,前方成了没人看的方向。把层级拉平:挂 CharacterController 的 object 和被渲染的 object 应共用一个旋转,而不是两个相乘。

7. 有 Rigidbody 或第二个控制器在争同一个 Transform

CharacterController 的碰撞和位移都是自己算的。任何同样写这个 Transform 的东西都在跟它争,而角色最后谁都不听。

  • 同一个 object 上既有 Rigidbody 又有 CharacterController。物理按自己的节奏把你往下按进地板,脚本又在 Update 里用自制的重力推,角色就下沉、发抖或焊死在地上,W 按破天也没用。留一个;玩家角色留 CharacterController。

  • 控制器旁边的 Kinematic Rigidbody 是为了推箱子而有意为之——但一旦另一个脚本也来开它,就变成两个所有权的问题。只要有谁往这个 body 写 rb.velocity 或调 AddForce,或在 Move 跑着的同时调 Rigidbody.MovePosition,位置就又有了两个作者。

  • 第二个 CharacterController 不会和你的碰撞——它们互相穿过——所以 NPC 挡不住你,但静态的墙、关上的门、挂了 Rigidbody 的木箱能,而且拦住你的东西也许就在相机背后。在 Scene 视图里飞到角色跟前,看胶囊正贴着什么。

  • isGrounded 永远不通过。一个 if (!cc.isGrounded) return 形状的门卫会不留痕迹地拒掉所有指令:地板是 trigger、改过 Center 或 Height 后底部悬在地板上方、或地板所在 layer 在 Edit > Project Settings > Physics 里与角色那格没勾选。边按 W 边 log cc.isGrounded——一行,一个答案。

90 秒定位原因的顺序

  • ✓Console 里没有红色报错——有的话 Unity 还在跑上一版代码,你改什么都不作数。
  • ✓Play 在跑、object 启用、脚本 component 勾着。
  • ✓按住 W 时 MoveProbe 打出数值。沉默就回到第 1、2 节。
  • ✓Inspector 里的 speed 是你认识的非零数字,且在 Play 中确认。
  • ✓绿色胶囊包着角色并坐在地板上——不埋在里面,也不悬着。
  • ✓Time.deltaTime 是乘,不是除。
  • ✓这个 object 和每层父级的缩放都是 (1, 1, 1)——没有 0,也没有负值。
  • ✓移动这个 Transform 的只有一个:CharacterController 或 Rigidbody,不是两个。
  • ✓站着时 cc.isGrounded 为 true,前方也没有高于 Step Offset 或陡过 Slope Limit 的东西。

角色重新走起来之后,你下一个在意的就是接触那一刻的行为,那在 碰撞与 trigger 里。至于运动本身该有的样子——按相机输入、重力、平滑转身——看 如何让角色移动。

做出这个的那一课

移动、跳跃并落到方块上

这篇指南单独成篇,是一份可以直接照着做的配方。在课程里,同样的东西会作为贯穿全部五个章的那个项目的一部分来搭建 —— 角色与战斗,第 02 课。