说个最常见的场景:你在Unity3D里拖了一个Cube进场景,然后想把它的位置改一下让它“动起来”,于是选中物体后在Inspector面板里拖动Position数值,或者直接用手把物体拽到别处。结果一按Play,物体瞬间回到了原来位置。很多人会愣一下,然后开始在菜单里找“运动”相关的按钮,找半天发现根本没有。Unity的移动不是靠“记录坐标”实现的,它需要在运行时每帧执行逻辑来改变物体位置,而这段逻辑,就放在一个代码文件里,标题里说的就是这个事。
这篇博文的目标很明确:带你从零创建一个C#脚本文件,把它挂到某个物体上,让物体在游戏运行时动起来。先做最简单的匀速直线移动,再做键盘控制移动,最后聊物理刚体方案的区别。适合刚学Unity3D、对脚本还没有建立起模型的同学,也适合那些能照着教程把脚本拖进物体但不知道“为什么这样就能动”的人。我尽量把原理和踩坑一起讲了。
1. 先把问题讲清楚:为什么“运动”必须交给代码文件
1.1 手动改坐标与代码控制的本质区别
手动拖动Position其实也是“移动”,只是它发生在编辑阶段的静态场景里。一旦进入Play模式,Unity会重新从场景初始状态开始运行,你手动改过的坐标如果不被任何逻辑记录,就等于没改过。换句话说,编辑器里的拖拽是给美术摆场景用的,不是给游戏运行时用的。
代码控制则完全不同。脚本挂在物体上之后,引擎会在每一帧调用脚本里的方法,方法里面写一句“把当前坐标加上一小段距离”,这时候物体就真的开始动了。所以很多新手会觉得“为什么移动这么麻烦”,这不是麻烦,而是Unity对运行时行为的基本约束:一切动态行为必须有逻辑驱动,美术摆不动,动画扳不动,只有代码最自由。
1.2 三种驱动方式的定位
实际项目中,让物体“动起来”不只代码一种方式,但适合的场景差别很大。我整理了一张对比表,你先感受一下:
| 驱动方式 | 适用场景 | 控制精度 | 学习成本 | 性能特点 |
|---|---|---|---|---|
| 动画(Animation/Animator) | 角色待机、走路、攻击等固定表演 | 中等,靠关键帧和曲线 | 中等 | 模型带动画时效率高 |
| 物理引擎(Rigidbody) | 碰撞、掉落、投掷、推挤效果 | 高,但受物理参数影响 | 较高 | 计算量较大 |
| C#代码文件 | 玩家控制、简单巡逻、机关移动 | 最高,可完全定制 | 低 | 逻辑简单时开销极小 |
大多数玩法上的“功能性移动”,比如玩家按方向键控制角色走动、子弹向前飞、敌人追踪目标,其实都适合用代码文件驱动。动画适合表现动作,物理适合表现“被别的东西推动”的自然效果,而代码可以做到精准、可控、无意外碰撞的纯逻辑移动。
1.3 一个代码文件究竟做了什么
C#脚本挂到物体上后,本质上是一个组件。它继承了MonoBehaviour这个基类,从而被Unity的组件系统识别。它并不神秘,就像给物体装了一个会定时发指令的小控制器。
脚本最常见的两个方法是Start和Update。Start会在物体激活后的第一帧更新前调用一次,适合做“初始化”操作;Update则会在每一次画面渲染更新前被调用,游戏跑60帧,它就调用60次。你要做移动,就是利用了Update的高频执行特性:每次把一个极小的位移量加到物体坐标上,连续调用下来,物体自然就走起来了。
2. 创建脚本这个动作:比新建文档有更多的规矩
2.1 场景、物体与脚本的命名规范
很多人第一步就翻车,不是因为代码写错,而是因为命名不对。在Unity里创建C#脚本,文件名必须和类名完全一致,例如新建一个脚本叫MoveCube.cs,那里面默认生成的类名也必须是MoveCube,一个字母都不能差,否则编译直接报错。
我的建议是从一开始就养成习惯:脚本名首字母大写,用驼峰命名;不要用中文名;不要用“Script1”、“NewBehaviourScript”这种默认名。因为一个项目做到后面脚本几十上百个,光靠名字根本无法维护。
2.2 在Project面板里创建C# Script的正确路径
在Project面板中找到你想存放脚本的文件夹,右键,选择Create -> C# Script,然后重命名。这里的文件夹位置不影响功能,但是建议放到单独的“Scripts”文件夹里,方便之后管理。
创建完成后双击打开,默认内容是这样的:
using System.Collections; using System.Collections.Generic; using UnityEngine; public class MoveCube : MonoBehaviour { // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } }这几行自带的注释和空方法就是Unity给你留好的“运动接口”。Start写初始化逻辑,Update写每帧执行的逻辑。
2.3 把脚本挂到物体上的两种方式
脚本创建出来只是一个文件,还没有和被控制的物体产生关系。把脚本拖到Hierarchy里的Cube物体上,或者选中Cube后在Inspector面板点击Add Component,搜索“MoveCube”并添加。这两种方式等价。
这里有个关键点需要搞明白:脚本里的this.transform并不是“随便某个物体”,而是挂载这个脚本的物体的Transform组件。你把这个脚本挂到Cube上,它控制的就是Cube;挂到Sphere上,控制的就是Sphere。换句话说,脚本本身不知道自己叫“MoveCube”,它只知道“谁挂了我,我就控制谁”。
2.4 默认代码的三大关键成员
如果你是第一次接触C#脚本,可以先把脚本理解为三段:MonoBehaviour基类提供生命周期方法;Start是做一次性准备工作的;Update是做每帧重复工作的。
移动这种操作要不要放在Start里?不行,因为Start只在开始执行一次,放进去变成瞬移一次。常用习惯是:Update里写持续移动逻辑,Start里获取组件引用或者给初值。到后面你会发现,有些与物理相关的逻辑会放到FixedUpdate里,那是固定频率调用的方法,第三节再讲。
3. 第一段能跑的移动代码:从位置累加理解运动本质
3.1 核心API:Transform的position、Translate和localPosition
物体在3D空间里的位置,核心数据就是Transform组件。它有三个互补的概念:position是物体在世界坐标系下的位置;localPosition是相对于父物体坐标系下的位置;Translate则是一个方法,表示“让物体沿着某个方向移动一段距离”。
新手一上来容易混淆position和localPosition,因为这个Demo里物体没有父物体,两者数值一样。一旦你创建了几个空物体做父子层级,就会发现子物体的localPosition永远参考父物体的坐标原点,想在做可嵌套物体时理解坐标,最好从一开始就把这两个概念分开。
3.2 最简单版本:每帧给position叠加速度
你要让物体匀速移动,物理直觉告诉我们:位移等于速度乘以时间。所以在Update里,你只需要把当前位置加上“速度乘以每帧消耗的时间”:
using UnityEngine; public class MoveCube : MonoBehaviour { public float speed = 5f; void Update() { transform.position += Vector3.forward * speed * Time.deltaTime; } }这里Vector3.forward就是世界坐标的Z轴正方向,也就是静态场景里蓝色箭头指向的方向。运行后,物体会自动沿Z轴匀速前进,你的第一个代码驱动的运动就完成了。
3.3 不要忘记Time.deltaTime:帧率对移动的直接影响
很多第一次运行的人会做两个动作:把speed调到20、去掉Time.deltaTime。然后发现物体“嗖”一下飞出屏幕,或者在不同电脑上速度差很多,原因就在这里。
我算笔账给你看:假设speed等于5,游戏每秒渲染60帧,每帧耗时约0.0167秒,那么每帧位移量就是5乘以0.0167,约0.083米,一秒总位移约5米。如果游戏只有20帧,每帧耗时0.05秒,每帧位移0.25米,一秒总位移还是5米。这就是Time.deltaTime的作用:把“每帧”位移换算成“每秒”位移,让速度在不同帧率下保持一致。
如果不乘它,60帧时一秒走300米,如果哪台机器跑到了120帧,一秒就变成600米。同一个游戏在不同设备上会出现完全不同的体验。所以从写第一行移动代码开始,就要习惯性地带上这个参数。
3.4 Transform.Translate 和直接赋值到底选哪个
上面的代码用到了transform.position +=,这不是唯一方式。还有另一个更直观的方法:
transform.Translate(Vector3.forward * speed * Time.deltaTime);Translate的意思是“在当前物体的坐标系(或世界坐标系)中移动指定距离”,写起来更像日常说话。直接赋值则更像“先算出终点,再把位置放过去”。
我的习惯是:Demo阶段用Translate减少出错,发现它内部还会帮你处理一些旋转后的方向换算;高级阶段用直接赋值,因为你可以先计算位移向量,再考虑是否要做边界检测、是否要跟动画混合。两者本身没有优劣,但搞清楚差异可以让你以后读别人代码时不犯迷糊。
3.5 先跑通:往目标点直线移动的最短验证
写完后别急着写复杂交互,先把这几步做通:创建一个Cube,创建脚本挂上去,填好speed,运行,看到物体沿Z轴移动。整个链路通了,再往下加键盘控制。
我见过太多人连最基本的“挂载后编译成功”都没验证,就开始写一整套复杂的角色控制系统。结果出错后不知道是脚本没挂上,还是输入方向读错了,还是坐标系用反了。调试往往是从最原始的一步开始的。
4. 键盘控制:让物体跟你的手指走
4.1 用Input.GetAxis读取方向轴
单纯的自动移动没什么交互感,真正有价值的是“按键控制移动”。Unity内置的输入系统给了一套标准映射:Horizontal轴对应A/D键和左右方向键,Vertical轴对应W/S键和上下方向键。
读取方式是用Input.GetAxis,它会返回一个-1到1之间的平滑值。按D键时数值逐渐向1靠近,按A键时向-1靠近,松开后向0回落。这个“平滑”是内置的渐变设置,适合需要柔和转向的游戏;如果你希望按键瞬间响应、毫无缓冲,可以用Input.GetAxisRaw,它只返回-1、0、1三个值,非常适合刚体移动和像素级操作。
4.2 完整键盘控制脚本与逐行解释
直接看代码,这是我通常推荐给新手的版本:
using UnityEngine; public class MoveCube : MonoBehaviour { public float moveSpeed = 5f; public float rotateSpeed = 100f; void Update() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical); if (direction.sqrMagnitude > 1f) { direction.Normalize(); } transform.Translate(direction * moveSpeed * Time.deltaTime, Space.World); } }前两行从输入系统读取横轴和竖轴数值。第三行把两个数值拼成一个三维向量,因为物体主要在XZ平面上移动,所以Y轴填0。if (direction.sqrMagnitude > 1f)判断的是“斜向按两个键时,向量长度是不是超过了1”。比如只按D键时,向量是(1,0,0),长度为1;同时按D和W时,向量是(1,0,1),长度约1.414,如果不处理,斜着走会比直着走快41%。
Normalize()就是把向量长度缩回1,但方向不变。最后用Translate让物体往方向向量所指的方向移动,Space.World表示这是按世界坐标方向移动,而不是物体旋转后的局部方向。
4.3 为什么斜方向会变快:向量归一化
如果你不想用sqrMagnitude,也可以先判断长度后再归一化,效果一样。很多人不理解为什么键盘控制这么简单的事还要处理斜向问题,但你想象一下:直走每秒5米,斜走每秒却变成7.07米,这在MOBA类游戏或者俯视角游戏里会严重影响操作手感。
这里顺带提一个实际项目里常见的坑:如果用GetAxisRaw,按两个方向键向量长度就正好是根号2,所以归一化非常关键;如果用GetAxis,因为数值是平滑变化的,偶尔你会得到长度介于1和1.414之间的向量,同样需要归一化。无论哪种读取方式,处理方向向量归一化都是标准动作。
4.4 补充:相机视角下的前后左右
用上面的代码跑起来后,你会发现移动方向完全和世界坐标对齐:按W是沿Z轴正方向,按S是沿Z轴负方向,按A/D是沿X轴负/正方向。如果相机固定在这个轴上,看起来没问题;如果相机旋转到45度,画面里的“向上”就不再是世界Z轴了。
控制方向跟随相机需要把方向向量旋转到相机坐标系里,核心做法是用Camera.main.transform的朝向和右向构建一个新的方向向量。这个内容稍微进阶,但你现在心里留个概念就好:键盘方向永远只代表“输入值”,最终移动向量怎么映射到世界,取决于你的相机坐标系。
5. 进阶:让移动受到物理约束
5.1 为什么要碰刚体
一直用Transform移动,物体会无视碰撞体直接穿过墙壁,因为position +=本质上是“瞬移一小段”,不等同于“推动一个物理物体”。假如你在做推箱子游戏,或者想让物体受重力掉到地上,就必须引入Rigidbody组件,这是Unity物理引擎的核心载体。
Rigidbody负责重力、摩擦力、碰撞响应。物体一旦挂上Rigidbody并开启重力,播放模式下它就会往地面掉。如果此时你还在Update里给它用Transform改坐标,就会产生严重冲突:物理引擎算出物体在这个位置,你下一秒又把它瞬移到另一个位置,结果物体要么抖动,要么穿模。
5.2 AddForce方案:给物体一个力
物理方案的第一种常见做法是AddForce,也就是给物体施加一个持续的力:
public class PhysicMove : MonoBehaviour { public float force = 10f; private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 dir = new Vector3(h, 0f, v).normalized; rb.AddForce(dir * force); } }这串代码把GetComponent<Rigidbody>()放在了Start里初始化,每次利用FixedUpdate的稳定帧率来施力。注意物理相关代码不要放进Update,因为物理计算有自己的时间步长,Update的调用频率可能忽高忽低,放进FixedUpdate才能和其他物理插值匹配。
5.3 Transform方案与刚体方案怎么选
我用一张表把关键差异列出来:
| 对比项 | Transform.Translate | Rigidbody.AddForce/Velocity |
|---|---|---|
| 受重力影响 | 不受 | 受,会掉落 |
| 与碰撞体交互 | 基本无视碰撞 | 会碰撞、反弹 |
| 对帧率依赖 | 依赖Time.deltaTime | 依赖FixedUpdate固定步长 |
| 控制手感 | 硬、直接、响应快 | 有惯性、需要调参数 |
| 典型场景 | UI动画、机关移动、视觉表现 | 角色控制、投掷物、可破坏物 |
如果你是做一个纯粹的视觉展示,比如旋转的齿轮、来回移动的平台,用Transform就够了。如果你做的是玩家角色、敌人角色、或者任何需要和场景里的墙壁门框发生实际交互的物体,那必须考虑刚体。
5.4 配合速度参数:Rigidbody.velocity更可控
AddForce的问题在于它会累积力,角色容易越跑越快然后像个冰面一样刹不住。很多动作游戏使用的是更“硬”的做法:直接设置刚体速度,让速度严格等于方向和速度的乘积:
void FixedUpdate() { Vector3 dir = new Vector3(h, 0f, v).normalized; rb.velocity = new Vector3(dir.x * moveSpeed, rb.velocity.y, dir.z * moveSpeed); }这个做法的巧妙之处在于:它只修改X和Z方向的速度,保留Y方向速度,这样重力仍然能正常把人往下拉,同时地面移动又非常跟手。我接触过的很多Demo项目都是这样控制角色的,虽然不是最精细的物理模拟,但做大作业、做原型验证非常够用。
6. 我实际跑这个Demo时踩过的坑与排查思路
6.1 物体完全不动:脚本没挂上或编译失败
最常见的情况是:写完代码,挂上组件,按Play,物体纹丝不动。这时候不要怀疑代码逻辑,先按下面顺序排查:
第一,看Hierarchy里选中的物体在Inspector里有没有MoveCube这个组件,没有就说明根本没挂成功。第二,看Console窗口有没有红色报错,只要有报错,旧脚本默认不会运行。第三,双击查看到底是哪个文件报错,最常见的错误是类名和文件名不一致。
一个很有用的调试动作是在Update里加一行Debug.Log("running");,如果运行时Console有输出,说明脚本确实在跑,问题在移动逻辑本身;如果完全没有输出,说明脚本执行入口受阻,问题在挂载或编译。
6.2 “Input.GetAxis找不到”:新旧输入系统冲突
很多新版本Unity3D创建项目时默认开启了新版输入系统。在这个模式下,旧版的Input.GetAxis是不会被编译通过的,Console会报类似“Input.GetAxis is not defined”的错误。这时候你有两个选择:改成新版输入系统API,或者干脆在Player Settings里把Active Input Handling设为“Input Manager (Old)”或“Both”。
个人建议:只是学这个Demo的话,切回旧输入管理器最省事,因为教程里大部分传统移动例子都基于老API;如果项目已经全面用了新输入系统,那就借这个机会学一下新版输入事件,但不要混着用。
6.3 物体运动时抖动或穿透
用Transform移动时,物体速度过快会直接穿过薄墙或触发频繁的碰撞抖动。这是因为Transform移动类似“跳跃式插值”,物理引擎来不及响应。如果你是物理方案,注意调高Collider的Collision Detection模式,从Discrete改成Continuous或Continuous Dynamic,代价是性能稍高。
如果你坚持用Transform做移动,又要防止穿透,可以改用Rigidbody.MovePosition方法,它在物理内部计算位移,而不是直接把坐标拽过去。这个API兼顾了Transform的直观和物理引擎的稳定性。
6.4 旋转相关的坑:欧拉角与四元数
移动做顺之后,很多人会顺手给物体加一个旋转逻辑,用transform.rotation = new Vector3(0, angle, 0),结果发现不仅报错,物体还会出现各种奇怪的姿态。这不是偶然,transform.rotation的类型是Quaternion四元数,不能直接用Vector3赋值。
正确写法有两个:要么用transform.Rotate每次叠加旋转角,要么用Quaternion.Euler(0, angle, 0)构造出四元数再赋值。以后涉及到更复杂的旋转插值、角色朝向时,你会发现四元数这东西绕不开,但在这个Demo阶段只需要记得一条红线:旋转的本质是四元数,不要用欧拉角向量直接赋值。
6.5 运行后Cube往下掉
还有一个非常“经典”的翻车:明明只给物体写了移动代码,结果一按Play,物体直接掉落,甚至掉出场景。原因很简单——你的Cube上挂了一个Rigidbody且Use Gravity勾选了。
如果你只是想演示键盘控制移动,最简单的办法是直接删除Rigidbody组件;如果你想保留物理,又要让代码控制位置,那就把Rigidbody的Interpolate启用、Constraints里的Freeze Position Y勾上,或者干脆把Is Kinematic打开。Kinematic刚体不会被重力推着走,但会通过代码移动去推动其他动态刚体,是一个好用的中间状态。
7. 后续扩展方向与一点个人心得
这个Demo跑通之后,你完全可以在它基础上继续扩展。最简单的几个方向是:给物体加一个跳跃逻辑,按下空格时修改Y方向速度;把移动代码从Update搬到FixedUpdate,观察手感差异;把速度和输入读取独立成单独的脚本,组件化地组合到角色身上。还有一个很推荐的做法:把MoveCube挂到摄像机父物体上,用方向键控制相机飞旋,你会立刻感受到坐标系选择带来的手感差异。
我在实际项目里最常见的教训是:过度关注“用哪种方式移动”这个表面问题,忽略了三件底层事——坐标系、更新周期、是否参与物理。这个Demo本身只有几十行代码,但只要你把这三件事理清了,以后控制人物、车辆、飞弹都不会再迷路。做实验阶段尽量用变量把速度暴露在Inspector上,因为“手感”不是一次调出来的,而是要反复试出来的。