1. 动手前,先把这三个底层概念理清楚
很多刚接触Unity的初学者,第一反应是找个角色模型,拖进场景,然后就开始写移动脚本。这个流程我太熟悉了,因为我自己也是从这一步过来的。但问题在于,网上的教程五花八门,有人用Transform移动,有人用Rigidbody,还有人用CharacterController,每个看上去都能跑,你抄下来之后发现自己的角色要么穿墙、要么抖成帕金森、要么原地打转。为什么?因为不同的移动方式背后是完全不同的运行机制,搞不清楚这些,你抄来的代码就只是个没有灵魂的壳。
这篇文章要讲的,就是在C#脚本里实现角色移动的几种主流方式,以及我在真实项目里摸爬滚打得到的经验。我会把每种方案的底层原理说透,再配上可以直接抄走的代码,最后告诉你什么场景该用哪一种。无论你是做2D平台跳跃、3D俯视角射击,还是第三人称RPG,这篇文章都能让你少走很多弯路。
1.1 坐标系统:先搞懂Unity让角色动起来的本质
在Unity里,任何物体的移动说白了就是修改它的Transform组件的数值。Transform是每个游戏物体(GameObject)都有的组件,它记录了物体在世界中的位置(Position)、旋转(Rotation)和缩放(Scale)。
你可以把Unity的场景想象成一个巨大的三维坐标系,X轴左右、Y轴上下、Z轴前后。角色要移动,本质上就是要改变它在世界坐标(World Space)或者局部坐标(Local Space)中的值。世界坐标是以原点为基准的绝对坐标,而局部坐标是以父物体为基准的相对坐标。通俗点说,你在房间里走了一步,世界坐标记录的是你在这个城市中的绝对位置变化,而局部坐标记录的是你相对于书桌的位置变化。
对于移动逻辑来说,我们关心的是两个系统:一个是不经过物理引擎的纯坐标变换,一个是经过物理引擎的刚体运动。前者直来直去,简单可控;后者会自动处理碰撞、摩擦和受力,真实感更强。理解了这两套系统的差别,你才能明白为什么有的移动脚本要在Update里写、有的要写在FixedUpdate里。
1.2 物理系统:Rigidbody、Collider 和 Update 方法的三角关系
Unity的物理模拟是基于Rigidbody(刚体)来完成的。当一个物体挂上Rigidbody组件后,它就纳入了物理引擎的管理范围,会受到重力、摩擦力、碰撞力的影响。与此同时,你还需要一个Collider(碰撞器)来定义物体的物理边界,比如BoxCollider(盒形)、SphereCollider(球形)或CapsuleCollider(胶囊形)。
这里有个初学者经常会掉进去的陷阱:如果在Update里直接修改挂有Rigidbody的物体的Transform.position,物理引擎下一帧计算碰撞时就会懵掉——它以为物体还在按照自己的模拟规则运动,结果你突然篡改了位置,最后就会出现穿模、抖动、甚至物体直接飞出去的诡异现象。
正确的做法很简单:如果你在用物理方式移动角色,相关代码请写在FixedUpdate里。这个方法的执行频率是固定的,默认每秒50次,专门配合物理引擎的计算节奏。而如果你用纯Transform方式移动,那就写在Update里,因为Update的调用频率和帧率一致,画面表现会更顺滑。简单记住一个原则:凡涉及刚体,一律FixedUpdate;只是改位置,用Update。
1.3 输入系统:旧Input与新Input的取舍
在C#脚本中,移动逻辑的第一步是获取玩家输入。Unity有两种输入系统,老牌的是Input.GetAxis和Input.GetKey,新的是可编程的输入系统包(Input System Package)。新手入门阶段用旧Input完全没问题,API简单,类型提示明确,几行代码就能拿到方向值。我自己的建议是,先别纠结,教程里给什么用什么,把核心移动逻辑跑通再说。
但你要有心理准备:Input System是未来趋势,它支持手柄、触屏、多设备映射、UI事件绑定,在复杂项目里优势明显。后面我会专门写一节关于输入封装的建议,那是真正能提升开发效率和工作幸福感的东西,但那是后话。现在先把移动方案的原理讲透,这比什么都重要。
2. 方案一:Transform直接修改位置的移动方案
这是最直观、最容易理解的做法,也是新手第一次让物体“动起来”时最常用的方式。它的核心就是一个概念:在每一帧,我给物体的位置加上一个增量,时间长了,位置自然就变了。
2.1 最基础的Transform.Translate写法
看下面这段代码,它是我见过最精简的移动脚本之一:
using UnityEngine; public class TransformMove : MonoBehaviour { public float moveSpeed = 5f; void Update() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; transform.Translate(direction * moveSpeed * Time.deltaTime); } }这段代码的逻辑很直白:Input.GetAxis("Horizontal")读取键盘A/D和方向键的左右输入,返回-1到1之间的值;GetAxis("Vertical")读取W/S和上下方向键。把两个值拼成三维向量后,调用normalized归一化,防止斜着走时速度变成根号2倍。最后用Translate把方向乘以速度再乘以Time.deltaTime,这样移动速度就变成每秒多少单位,而不是每帧多少单位。
这里我特别强调一下Time.deltaTime的作用。它表示从上帧到本帧所花费的秒数,帧率高时这个值小,帧率低时这个值大。如果不乘它,在帧率60的环境下角色飞得跟跑车一样,帧率20的环境下角色慢得像蜗牛。乘了它之后,同样一个速度值,在任何帧率下每秒移动的距离都是一样的。凡是持续性的数值变化,都要乘Time.deltaTime,这是所有C#脚本里最值得养成的习惯。
2.2 平滑移动的进阶姿势:MoveTowards和Lerp
上面的写法在没障碍物的场景里没问题,但有个小毛病:角色速度是恒定的,起停都很生硬。如果你想让角色像现实生活中的人一样,起步有加速、停下有减速,可以改用Vector3.MoveTowards:
using UnityEngine; public class SmoothTransformMove : MonoBehaviour { public Transform target; public float moveSpeed = 3f; void Update() { // 每帧朝目标位置靠近,速度固定为 moveSpeed * deltaTime transform.position = Vector3.MoveTowards( transform.position, target.position, moveSpeed * Time.deltaTime ); } }MoveTowards的特点是:它会以你指定的最大速度向目标点移动,到达后自动停在目标位置,不会超出。这种特性特别适合寻路、小兵追击、自动门等场合。它的表现更像AI控制,而不像玩家手动操作的角色。
另一种是Vector3.Lerp(线性插值),它的写法是这样的:
Vector3 newPosition = Vector3.Lerp(transform.position, targetPosition, 0.1f);Lerp的作用是在两个点之间按比例取一个中间点。第三个参数是0到1之间的数,0.1表示“每帧向目标靠近10%”。注意这是个非常迷惑人的陷阱——它不是匀速运动,而是前快后慢的指数衰减运动。如果你是第一次用,很容易觉得这没毛病啊,但真做竞技类游戏时就会发现手感怪怪的。很多新手以为Lerp是匀速,其实只有MoveTowards才是匀速。
2.3 这套方案适合用在什么场景
说了这么多,你肯定想问:直接改Transform位置的方法到底能不能用于正式项目?我的回答是:能用,但要看场景。
如果你在做一款俯视角的休闲游戏、机关门、开关面板、移动平台,或者角色本身只是场景中的一颗棋子(比如炉石传说的棋子),直接用Transform移动是最高效的选择,代码简单、易于控制、不需要关心物理系统带来的各种奇怪交互。另外在制作UI动画、技能特效位移、机关触发方面,它同样表现优秀。
但如果是ARPG、MOBA、动作游戏里玩家真正操控的角色,我强烈不建议你用纯Transform移动,因为它天生没有碰撞响应。你虽然可能给角色加了Collider,但在Update里强行改位置会让碰撞系统没办法准确反应,最后结果是角色要么压过障碍物、要么被卡得住。所以当你需要和世界中的其他物体交互时,请跳到下一节用刚体方案。
3. 方案二:Rigidbody物理驱动的移动方案
物理驱动的核心是让Unity物理引擎来管理角色的运动过程。你不直接设置位置,而是给刚体施加速度或力,引擎会处理碰撞、反弹、摩擦等一切细节。这是做3D动作游戏最常用的方案,也是我实际项目里用得最多的方式。
3.1 用velocity控制速度,手感像开遥控车
最简单的刚体移动写法是直接改Rigidbody.velocity。这个属性表示刚体的线速度,单位是米每秒。你赋值给它的值会覆盖物理引擎原本计算出的速度。看代码:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class RigidbodyMove : MonoBehaviour { public float moveSpeed = 8f; private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; // 把方向乘以速度,得到目标速度向量 Vector3 targetVelocity = direction * moveSpeed; // 保留原有y轴速度(重力影响),只替换水平方向的速度 targetVelocity.y = rb.velocity.y; rb.velocity = targetVelocity; } }这段代码看着简单,但里面藏着一个小技巧:赋值速度之前先把原来的y轴速度保存下来再放回去。为什么?因为重力每帧都在修改刚体的y轴速度,如果你直接把整个velocity覆盖成一个只有水平方向的向量,重力效果就消失了,角色的跳跃和下坠都会变得极其诡异。保持y轴速度不动,只在水平面上动手,这是新手最容易漏掉的一行代码。
把速度设成每秒8米,在默认物理设置下,角色的移动非常跟手。但要注意,直接改velocity意味着你剥夺了物理引擎对水平速度的累积计算,角色会有一种开遥控车的手感:上半身没有惯性,按下按键瞬间速度拉满,松开按键瞬间速度归零。这在很多快节奏游戏里是优点,但在模拟类项目里会显得假。
3.2 用AddForce推角色,手感像推一块冰
和直接改速度不同,AddForce是给刚体施加力,物理引擎会按照牛顿第二定律去计算加速度,进而改变速度。这意味着角色有惯性:起步慢、加速渐显、松开后还会滑行一段距离。看示例:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class RigidbodyForceMove : MonoBehaviour { public float forceMagnitude = 12f; public float damping = 5f; // 速度衰减系数 private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); rb.drag = damping; // 让刚体自带阻力,模拟地面的摩擦力 } void FixedUpdate() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; // 沿输入方向施加力 rb.AddForce(direction * forceMagnitude, ForceMode.Acceleration); } }ForceMode.Acceleration表示这个力忽略物体的质量,直接产生加速度。如果你用ForceMode.Force,那么就取决于物体本身的质量,质量大的加速慢、质量小的加速快。实际手感上,AddForce方案会有明显的滑动感,起步那一瞬间人物像站在冰面上。为了保证可控性,我会在Rigidbody的Drag(阻力)属性上加上一个值,来模拟地面摩擦带来的减速效果。Drag值调大一些,手感和速度控制的折中才合理。
3.3 搭配Collider才能真正和场景碰撞
用Rigidbody移动时,碰撞器的作用不可忽视。你在Rigidbody方案里使用物理引擎来处理碰撞,场景里的墙壁、障碍物只要有Collider,角色就会自动被挡住。这也是Rigidbody方案和Transform方案最本质的区别:物理引擎会在FixedUpdate里精确模拟碰撞,把你的角色从穿透状态中推挤回来。
我会在角色身上挂一个CapsuleCollider(胶囊碰撞器),因为它最接近人体轮廓,足够圆润、不容易卡墙角。同时也给场景里的墙壁挂BoxCollider。这样角色撞墙时会被引擎推回来,不会穿模。这是整个物理移动方案能成立的关键所在。
3.4 刚体方案的两大坑:抖抖病和旋转大战
刚体方案用久了你会遇到两个比较典型的毛病。第一个是抖动。如果场景里有移动平台,角色站在上面,或者地面物体发生了形变,刚体在每一帧尝试保持静止和引擎碰撞推挤的作用会反复冲突,角色就会肉眼可见地“抖动”。这个问题的倍数方案是让角色对移动平台使用Transform.SetParent,或者干脆用Rigidbody的关节组件。第二个是旋转冲突。当你想让角色朝移动方向转向时,如果你直接修改transform.rotation,而这个物体又受物理引擎的其他作用力影响,就会产生旋转争夺。解决办法是在使用旋转时使用rb.MoveRotation,或者锁死刚体的FreezeRotation选项,让它只跟着代码走,不参与物理旋转模拟。
4. 方案三:CharacterController,专门给人形角色设计的移动方案
CharacterController 是Unity内置的一个专用组件,它本质上不是刚体,也能被它巨大的性能优势惊艳到。官方对它的定位是:用于“人形角色”在复杂地形上的移动控制,比如斜坡、楼梯、走廊转角。
4.1 CharacterController 到底帮你解决了什么问题
用CharacterController写移动脚本,最直观的感受是:角色有“脚”去踩地面了。它会自动处理重力带来的下压、自动限制不能进入斜坡的大角度倾斜面、自动在台阶高度允许的情况下跨过小台阶。这些能力是Rigidbody方案默认不具备的,用刚体你需要自己写一大坨地面检测逻辑,而CharacterController内置帮你处理好了。
它的实现原理说穿了也不复杂:它内部是一个胶囊形的物理检测体,每一帧通过Move或SimpleMove方法来检测路径上的碰撞,然后贴碰撞面滑动。所以它不会穿透墙体,同时又能平滑地爬坡。这天生适合“人形角色”的需求,所以它在FPS、TPS、俯视角ARPG里被广泛使用。
4.2 写出带重力和地面检测的Controller移动脚本
下面这段脚本是我个人最常用的CharacterController移动模板,添加了重力、基础的转向逻辑,并兼顾了斜坡倾斜方向的动态调整:
using UnityEngine; [RequireComponent(typeof(CharacterController))] public class CharacterControllerMove : MonoBehaviour { public float moveSpeed = 6f; public float gravity = -9.81f; public float jumpHeight = 1.2f; private CharacterController controller; private Vector3 velocity; private bool isGrounded; void Start() { controller = GetComponent<CharacterController>(); } void Update() { // 地面检测 isGrounded = controller.isGrounded; if (isGrounded && velocity.y < 0f) { velocity.y = -2f; // 让角色稳定贴地 } // 获取输入 float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 move = transform.right * horizontal + transform.forward * vertical; // 这里移动使用Move方法,传入方向与速度 controller.Move(move * moveSpeed * Time.deltaTime); // 跳跃判断 if (Input.GetButtonDown("Jump") && isGrounded) { velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } // 重力累积 velocity.y += gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }细心的读者会发现,我在角度朝向里利用了transform.right和transform.forward,这意味着角色的移动方向会相对于它当前的朝向,这种写法非常适合第三人称操作。如果希望像第一人称那样,输入方向始终是世界方向,直接改用Vector3控制即可。
controller.isGrounded是CharacterController提供的真实地面检测,它会监测胶囊底部是否接触可站立层,避免你写一堆射线检测代码。跳跃初速度我直接用了Mathf.Sqrt(jumpHeight * -2f * gravity),这是公式推导出来的:当你设置跳起高度为1.2米时,这个初速度恰好能让角色在重力作用下达到那个最高点。
4.3 CharacterController和Rigidbody的本质区别
很多人第一次接触CharacterController时会疑惑:它不是刚体,那它能碰撞吗?答案是能,但它只做碰撞检测,不模拟物理响应。也就是说,你的角色推不动场景里的箱子,受力不会倒地、不会弹飞,表现更靠近“幽灵”而不是“物理实体”。所以凡是需要角色和场景进行物理交互(比如踢飞一个花瓶、被爆炸击退)的游戏,要么混合方案、要么干脆用刚体。CharacterController的好处是稳定、可控、不会瞎崩;缺点是“它只会走,不会撞”。
我实际做项目时的经验是:在RPG里用CharacterController加Rigidbody做混合方案,角色主体跑在Controller上,受击击退、爆炸震开则直接操作刚体,让物理引擎接管一小段运动。这样一来,平时移动手感稳定,特殊事件又有物理反馈,两全其美。
5. 三种方案横向对比与选型建议
学完了三种写法,选型就成了下一个绕不开的问题。每种方案没有绝对的好坏,只有合不合适的区别。我做一个比较直观的表格,方便你参考:
| 对比项 | Transform移动 | Rigidbody移动 | CharacterController移动 |
|---|---|---|---|
| 核心组件 | 无 | Rigidbody + Collider | CharacterController |
| 是否参与物理模拟 | 否 | 是 | 否 |
| 碰撞检测能力 | 弱,靠碰撞器但易穿模 | 强,物理引擎准确处理 | 强,胶囊体滑动式碰撞 |
| 斜坡与台阶处理 | 无 | 需自行实现重力与斜坡逻辑 | 内置支持,效果好 |
| 常见代码位置 | Update | FixedUpdate | Update(内部检测) |
| 可控性 | 极高 | 中等,受物理影响 | 高,稳定可控 |
| 适用场景 | 机关移动、UI动画、平台移动 | ARPG受击、物理交互物、追求真实感的项目 | FPS/TPS移动、人形角色、稳定碰撞 |
| 缺点 | 不真实、撞墙易穿透 | 调参成本高、可能抖动 | 无法主动推箱子和物理交互 |
从这张表能清晰看出,如果你的项目只需要一个“会移动的方块”,用Transform;如果你需要“被物理世界影响的有质量物体”,用Rigidbody;如果你需要“一个像人一样走路和碰撞的角色”,首选CharacterController。
5.1 我的一般选型逻辑:先问需求,再选方案
每次动手写移动脚本之前,我会先问自己三个问题。第一,角色会撞到东西吗?第二,东西会撞角色吗?第三,移动手感偏“人”还是偏“物体”?第三个问题尤其关键。举个实际例子:做恐怖游戏里的怪物追踪,怪物需要绕过障碍物追玩家,但它不需要推动任何箱子,也不需要被子弹击退(没有受击系统),选CharacterController就很合适。但如果做开放世界沙盒游戏,角色可以推箱子、捡起物品、被爆炸掀飞,那就必须用Rigidbody来保证交互效果。
5.2 前期用最简单的方式把核心玩法跑通
对于纯新手,我的建议是:第一版用Transform移动也行,因为你好调试、好理解。核心玩法验证通过后再按需要切换方案。我遇到很多同行把大量时间花在纠结移动方案上,结果原型跑不起来。先把能动的版本做出来,后面再优化,这个思路适合所有Unity项目。
6. 实战实录:一个融合重力、转向、物理交互的通用移动控制器
方案各有优劣,但在真实项目里,我们经常需要组合使用。下面这个例子是我在开发一个俯视角ARPG原型时实际用过的方案,它融合了CharacterController、重力、跳跃、鼠标控制转向和受击击退效果。作为最终演示,我会把它拆解到可以直接抄走的颗粒度。
6.1 需求清单与脚本结构设计
在做任何代码之前先列需求。我的需求很简单:玩家用WASD控制角色前后左右移动,鼠标控制角色面向,空格跳跃,当角色被怪物攻击时,会弹出一小段受击位移。由于涉及受击物理反馈,我选择CharacterController+刚体混合方案。
整个脚本的划分是:一个角色移动控制脚本PlayerMotor,负责处理平时的移动、跳跃、转向;另一个受击反馈脚本PlayerImpactReceiver,负责处理瞬时的物理震动。两者通过公共方法或事件通信。下面给出主控制器代码:
using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerMotor : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 5f; public float turnSpeed = 540f; // 每秒旋转角度 public float jumpHeight = 1.2f; public float gravity = -9.81f; [Header("组件引用")] public Camera cam; private CharacterController controller; private Vector3 verticalVelocity; private bool isGrounded; void Start() { controller = GetComponent<CharacterController>(); if (cam == null) { cam = Camera.main; } } void Update() { HandleGroundCheck(); HandleMove(); HandleJump(); HandleRotation(); } void HandleGroundCheck() { isGrounded = controller.isGrounded; if (isGrounded && verticalVelocity.y < 0f) { verticalVelocity.y = -2f; } } void HandleMove() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 moveCamForward = cam.transform.forward; Vector3 moveCamRight = cam.transform.right; moveCamForward.y = 0f; moveCamRight.y = 0f; moveCamForward.Normalize(); moveCamRight.Normalize(); Vector3 direction = (moveCamForward * vertical + moveCamRight * horizontal).normalized; controller.Move(direction * moveSpeed * Time.deltaTime); } void HandleJump() { if (Input.GetButtonDown("Jump") && isGrounded) { verticalVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } verticalVelocity.y += gravity * Time.deltaTime; controller.Move(verticalVelocity * Time.deltaTime); } void HandleRotation() { Ray ray = cam.ScreenPointToRay(Input.mousePosition); Plane groundPlane = new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(ray, out float enter)) { Vector3 hitPoint = ray.GetPoint(enter); Vector3 direction = hitPoint - transform.position; direction.y = 0f; if (direction.sqrMagnitude > 0.001f) { Quaternion targetRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.RotateTowards( transform.rotation, targetRotation, turnSpeed * Time.deltaTime ); } } } public void ApplyImpact(Vector3 impactForce, float duration) { // 通过协程或者给Rigidbody加力模拟冲击 // 实际项目中可以调用刚体组件实现 } }6.2 代码中的几个关键细节解读
这段代码至少隐藏了三个值得学习的细节。第一,移动方向不是直接取键盘输入的世界向量,而是用相机的前向和右向作为基准,这样无论相机朝向哪里,按W总是“往屏幕深处走”。这是第三人称相机的标准做法。第二,旋转用了Quaternion.RotateTowards而不是瞬间Lerp,它让转身速度更接近人的自然动作,尤其适合做需要精细操控的动作游戏。第三,通过射线与地面平面的交点计算目标朝向,这样鼠标点哪里角色就转到哪,适配俯视角的瞄准需求。
6.3 参数调试的方法论
很多新手看到一推public浮点参数就头晕,不知道该怎么调。我的调试思路是这样的:先把moveSpeed调到8左右,感受一下基础速度;然后调turnSpeed,先设360度每秒,再上下浮动看手感;接着调jumpHeight,从0.8开始,每加0.2看一次跳起来的幅度是否合适;最后调gravity,如果觉得跳跃滞空感太假,就把重力调大些。Unity的Inspector面板支持运行时实时修改,所以你可以一边运行一边拖参数,看到效果就记录下数值。不要靠脑补,直接跑起来调才是最高效的。
7. 新手常踩的坑与排查技巧
写移动脚本最容易出问题的地方,往往不是脚本本身,而是组件配置和Unity的工作模式。我整理了四个出现频率极高的坑,附上排查思路。
7.1 角色一按方向键就穿墙,是代码问题吗
很多人第一个反应是脚本写错了,但绝大多数穿墙案例其实是碰撞器配置问题。排查步骤应该是:第一,确认角色身上有Collider;第二,确认墙壁有Collider;第三,确认其中一个物体(通常是角色)身上有Rigidbody(或者你用CharacterController,它自带碰撞逻辑);第四,检查两者是否在同一Layer并且启用了碰撞检测。另外值得一提的是,如果角色确实挂了刚体和碰撞器,但在Update里直接改transform.position,依然可能穿墙,原因就是我开头讲到的物理模拟顺序问题——请改用rb.MovePosition或者把代码挪到FixedUpdate。
7.2 角色上下楼梯过快导致飞起来
CharacterController能自动跨越台阶,但遇到太高的台阶或陡坡也会弹飞。这时候可以检查Step Offset(台阶偏移值)和Slope Limit(斜坡限制角)这两个参数。Step Offset默认是0.3米,如果你的台阶高度接近或略大于这个值,角色就会“爬”不上去,表现为在原地抖动。解决方法是把Step Offset设到合理范围——不能无限大,过大角色容易直接从矮墙顶上踩过去。
7.3 角色跑起来身体乱晃、卡在墙角
这个情况最常见的原因是多个Collider互相挤压。当你的角色碰撞器和场景里的碰撞器边界出现长时间的接触时,Unity会不断尝试把角色推回正确位置,结果就是疯狂抖动。我的排查经验是:先把角色的CapsuleCollider半径调小一点,再把中心的Y轴位置抬高,让胶囊体的下端刚好贴合脚底。另外,在墙体转角处引入小的“倒角”或使用半圆滑的墙角模型,也能有效减少卡墙角的问题。
7.4 键盘输入时灵时不灵,断触感极强
这种情况大概率不是键盘坏了,而是输入响应逻辑写到了某些会被跳过的生命周期函数里。比如把移动代码写在OnTriggerStay里,触发器不持续触发时就拿不到输入。还有一种是你在Update里拿输入再去操作Rigidbody,因为物理世界和逻辑帧不同步,角色的响应会有半帧延迟,手感就很“肉”。正确的排查顺序是:检查输入逻辑是不是在Update里,检查物理操作是不是在FixedUpdate里,检查两者的数据交换是不是用了临时变量,而非直接读输入值。
8. 写在最后的一些工程级建议
代码能跑起来和能在项目里稳定工作,是两码事。下面这些经验是我在做了多个完整Unity项目后才总结出来的,分享给走到这一步的你。
第一,尽量封装你的输入层,不要直接在移动逻辑里散落一堆Input.GetAxis。哪怕你现在只做PC端,以后要加手柄、要加移动端虚拟摇杆,如果输入代码和移动逻辑混在一起,改动成本会直线上升。合理做法是定义自己的MoveInput数据结构,把所有输入转换成一个统一的向量,供移动逻辑调用。
第二,注意帧率对移动手感的影响。虽然Time.deltaTime消除了大部分帧率差异,但角色转动、相机跟随这类持续性操作,在帧率剧烈波动时依旧会让人觉得不流畅。可以给相机Follow和角色转向加入平滑阻尼,比如Quaternion.Slerp配合固定速度,避免帧率抖动带来的劣质感。
第三,永远不要迷信教程代码。任何移动方案都有其隐含的前提条件——有些教程默认角色朝向等于相机朝向,有些默认物理步长恒定,这些前提在你的项目里不一定成立。把代码抄过来之后,先用最简单的空场景验证基础行为,再逐步加入自己的实际逻辑,这样出了错也容易定位。
最后说一句体己话:角色移动是游戏的手感根源,也是编程逻辑和游戏设计碰撞最密集的地方。一开始写得难看没关系,代码是迭代出来的。每换一次方案,你对Unity运行机制的理解就更深一层。希望这篇文章能让你少踩几个坑,多享受几次“跑通那一刻”的爽感。