很多 Unity 开发者在入门中期会陷入同一种状态:场景会搭了,材质会换了,脚本也能挂上去了,但一运行就出各种“怪毛病”——角色按下方向键不动,或者原地抽搐;明明有碰撞体,角色还是穿墙;敌人站在那里,怎么都不追击玩家。表面看是不同功能出了问题,实际上根因是一样的:把 C# 脚本、输入系统、物理组件、AI 寻路和多平台构建当成孤立知识点学,却没有建立它们之间的协作关系。
这篇文章是 Unity 3D 游戏开发入门实战的下半篇,重点从“搭场景”转向“让场景真正动起来”。读完你会理解 Unity 生命周期中各个方法的调用时机,角色移动应该写在哪里,碰撞体与触发器如何配合,敌人如何用导航网格追击玩家,以及完成一个小游戏后如何在 Windows、Android、WebGL 等平台构建发布。全文保持入门向,但会把原理讲透,让你后续自己扩展功能时不再全靠试错。
这里想先说一个明确判断:Unity 入门阶段最重要的不是记住更多按钮和 API,而是学会用“组件协作”的思维方式看问题。只要把输入、物理、脚本调用这三条关系理清,入门阶段常见的抖动、穿墙、AI 不响应等问题会消失大半。接下来直接用完整案例带你走一遍链路,先看清整体关系,再动手写代码。
1. 先理清 Unity 开发里那张“关系网”
Unity 的场景编辑器看起来很“可视化”,拖拽、点击、改参数就能做出很多效果。但这只是表象。Hierarchy 窗口里的每个游戏对象,本质是一系列组件的容器,而 C# 脚本本身也是一种组件。真正驱动游戏运行的,不是你在编辑器里看到的那张平面图,而是组件之间的事件调用和数据交换。
以最常规的角色控制为例,一张最小协作网是这样构成的:
- 输入系统:读取键盘、手柄或触摸屏的输入,转换成数值交给脚本。
- 角色脚本:把输入值转成移动方向,决定角色该往哪走。
- 刚体组件(Rigidbody):由物理引擎接管角色的位置和速度变化,避免开发者直接改 Transform 造成抖动或穿墙。
- 碰撞体组件(Collider):告诉物理引擎“这个对象占用多少空间”,以及与其他碰撞体接触时发生什么。
- 相机脚本:跟随角色位置并调整观察方向。
这里最大的误解是:很多新手认为“角色的位置应该由脚本直接改 Transform”。这个思路在简单 2D 小游戏中可以凑合,但到了 3D 物理场景,一旦对象挂上了 Rigidbody,物理引擎就认为位置和速度应该由它接管。如果你在同一帧里反复改 transform.position,物理引擎同时又在更新刚体状态,角色就会抖动或表现异常。Unity 官方在这方面的建议很明确:对带刚体的对象,尽量通过 AddForce、velocity 等物理接口改变运动,而不是直接改 Transform。
另一个容易被忽略的概念是生命周期调用关系。Update 每帧执行,和帧率绑定;FixedUpdate 按固定时间步执行,通常用于物理运算。如果你在 Update 里改变刚体速度,而物理引擎在同一固定时间步里处理碰撞,两者会出现不同步。这块理解不到位,后面写移动、写跳跃都会遇到诡异现象。
所以这个章节的核心目标,是先建立一张心智地图:脚本是数据加工器,物理组件是执行器,输入是信号源,构建流程是最后一道关卡。凭借这张地图进入下面的实践,效率会高很多。
2. 环境准备与项目结构规划
动手写脚本之前,先把工程环境调整一下,能省掉后期大量因版本、导入和目录混乱导致的麻烦。
Unity 编辑器版本建议使用较新的 Unity 6 或 Unity 2022 LTS 之后的版本。本文示例使用传统输入接口 Input.GetAxis,这是目前网上大量教程仍然采用的方式,也最适合新手先跑通逻辑。如果你的 Unity 版本启用了新版 Input System,在 Project Settings > Player > Active Input Handling 中可以选择 Both,让新旧输入系统同时可用。如果只选择 Input System Package,传统写法会直接报“Input 已过时”或编译错误。
项目结构方面,建议在 Assets 下建立固定目录,不要把所有资源堆在根目录。下面是推荐结构:
Assets/ ├── Scenes/ # 场景文件 ├── Scripts/ # C# 脚本 ├── Prefabs/ # 预制体 ├── Materials/ # 材质 ├── Models/ # 模型资源 └── Textures/ # 贴图资源很多初学者习惯把脚本直接挂在对象上,文件也乱放,项目小的时候没感觉,场景一复杂就会出现脚本丢失引用、插件目录冲突的问题。建议从第一天就建立目录规范。后续导入第三方资源和插件时,也不要让 Assets 根目录失控。
测试场景可以复用上一篇文章创建的地形或房间。为了演示本章内容,需要一个平整地面、一个角色胶囊体、一个立方体或球体作为收集物。角色建议由一个空物体和子模型组成,比如创建一个名为 Player 的空物体,下面放置 Capsule 子物体。这样后续修改模型、调整碰撞体和挂载脚本都比较清晰。搭建完成后,在 Project 窗口把场景保存到 Scenes 目录。
在 Scripts 目录右键创建 C# Script 时,文件名建议和类名保持一致。如果文件名叫 PlayerMovement.cs,类名自动为 PlayerMovement。不要在创建后随意改名字,否则会触发“类与文件不一致”的编译错误。
3. C# 脚本生命周期:每个方法该放什么代码
Unity 的 C# 脚本不需要手动 new 对象。编辑器会在场景加载时自动实例化挂在游戏对象上的 MonoBehaviour 脚本,并在特定时机调用对应方法。理解这些调用时机,比记住几十个 API 更重要。
常用生命周期方法只有这几个:
| 方法 | 调用时机 | 典型用途 |
|---|---|---|
| Awake | 对象创建时立即调用 | 获取组件引用、初始化私有字段 |
| OnEnable | 对象或脚本被启用时调用 | 开启监听、订阅事件 |
| Start | 第一次 Update 前调用 | 设置初始状态,引用外部对象 |
| Update | 每帧调用 | 处理输入、动画状态、普通判断 |
| FixedUpdate | 固定时间间隔调用 | 处理刚体、物理碰撞逻辑 |
| LateUpdate | 所有 Update 执行之后调用 | 相机跟随、后处理类逻辑 |
新手常常纠结:既然 Update 每帧调用,为什么刚体移动要写到 FixedUpdate?因为物理引擎的模拟步长是固定的,默认固定时间步约为 0.02 秒,也就是每秒 50 次。帧率则会随硬件波动,可能是 60 帧、120 帧甚至更低。如果移动逻辑跟着帧率跑,而物理引擎按固定步长跑,两个系统的数据就会互相干扰。刚体位置变化由物理引擎推演,自然应该与它的模拟步长对齐。
下面是一个生命周期演示脚本,注意每个方法里的代码说明:
// 文件路径:Assets/Scripts/LifecycleDemo.cs using UnityEngine; public class LifecycleDemo : MonoBehaviour { private Rigidbody rb; private float counter; private void Awake() { rb = GetComponent<Rigidbody>(); Debug.Log("Awake 调用"); } private void Start() { Debug.Log("Start 调用"); } private void Update() { counter += Time.deltaTime; transform.Rotate(0f, 30f * Time.deltaTime, 0f); } private void FixedUpdate() { // 对刚体施力请放在这里 if (rb != null) { rb.AddForce(Vector3.down * 0.5f, ForceMode.Acceleration); } } private void LateUpdate() { // 摄像机跟随通常会写在这里 } }运行场景后,Console 窗口会依次打印 Awake 和 Start 调用信息,你可以直观感受顺序。另一个要点是:如果脚本 A 在 Awake 中初始化数据,脚本 B 在 Start 中引用这些数据,顺序通常是安全的。反之,如果在 Awake 里引用其他脚本在 Start 中初始化的字段,很可能拿到空引用。稳妥做法是在自己的 Awake 里获取自身组件,在 Start 里获取外部对象或管理器。
4. 角色移动控制:输入、刚体与相机配合
这一节真正开始实现一个可在场景里走动的角色。输入部分采用传统 Input Manager,Input.GetAxis("Horizontal") 和 Input.GetAxis("Vertical") 已经经过大量项目验证,适合入门。后续需要手柄、触摸屏或虚拟按键时,再切换到新 Input System 也不冲突。
PlayerMovement 脚本的核心思路是:在 Update 中读取输入并计算目标移动方向,在 FixedUpdate 中把方向转换成刚体速度并处理角色转向。不要直接在 Update 里改 Transform,这是避免抖动和物理异常的关键。
// 文件路径:Assets/Scripts/PlayerMovement.cs using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class PlayerMovement : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 5f; public float rotationSpeed = 720f; private Rigidbody rb; private Vector3 moveDirection; private void Awake() { rb = GetComponent<Rigidbody>(); } private void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); moveDirection = new Vector3(h, 0f, v).normalized; } private void FixedUpdate() { if (moveDirection.magnitude < 0.01f) { rb.velocity = new Vector3(0f, rb.velocity.y, 0f); return; } Vector3 targetVelocity = moveDirection * moveSpeed; rb.velocity = new Vector3(targetVelocity.x, rb.velocity.y, targetVelocity.z); Quaternion targetRotation = Quaternion.LookRotation(moveDirection, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, targetRotation, rotationSpeed * Time.deltaTime ); } }代码里有几个关键点值得注意。第一,moveDirection 做了 normalized 处理,否则斜向移动时速度会变成 1.414 倍,移动速度会明显比横向或纵向更快。第二,rb.velocity 在赋值时保留了 Y 轴速度,如果不保留,角色会在落地瞬间丢失重力速度,跳跃和下坡表现都会异常。第三,旋转使用 RotateTowards 而不是直接赋角度,让转身有一定过渡,手感更自然。这套设计的本质是速度控制,而不是位移控制,后续扩展跳跃、冲刺、掉落都更容易。
给 Player 物体挂载 Rigidbody 和 Capsule Collider。Rigidbody 的 Use Gravity 保持勾选,Interpolate 建议设置为 Interpolate,能减少渲染抖动。Capsule Collider 的中心和高度要尽量贴合角色模型,不要让碰撞体明显小于或大于视觉模型。
相机跟随脚本放在 LateUpdate 里。因为 LateUpdate 在所有 Update 和动画处理之后执行,此时玩家位置已经更新,相机再移动过去,不会出现角色已经移动而相机还停在上一帧位置的割裂感。参考实现如下:
// 文件路径:Assets/Scripts/CameraFollow.cs using UnityEngine; public class CameraFollow : MonoBehaviour { [Header("跟随目标")] public Transform target; [Header("偏移量")] public Vector3 offset = new Vector3(0f, 2.5f, -4f); public float smoothSpeed = 8f; private void LateUpdate() { if (target == null) return; Vector3 desiredPosition = target.position + offset; transform.position = Vector3.Lerp( transform.position, desiredPosition, smoothSpeed * Time.deltaTime ); transform.LookAt(target.position + Vector3.up * 1.2f); } }把脚本挂到相机上,将 Player 物体拖到 Target 字段。运行场景后使用 WASD 或方向键,观察角色是否正常移动和转向。如果发现角色一直下沉或侧翻,通常是 Collider 没有包住模型,或者 Rigidbody 的 Constraints 没有设置好。对于只在地面行走的角色,勾选 Constraints 中 Freeze Rotation X/Y/Z,可以防止撞到障碍物时角色被物理引擎翻倒。
5. 物理系统:碰撞体、触发器与力的用法
物理系统是入门阶段最容易混淆的部分,因为“碰撞检测”和“触发器触发”看起来相似,内部机制却完全不同。碰撞体是空间几何体的抽象,刚体是物理引擎对物体运动状态的描述。当两个碰撞体接触时,引擎会产生碰撞事件;如果把一个碰撞体标记为 Is Trigger,它就不再参与物理阻挡,只负责探测进出事件。
碰撞体之间的典型用法是角色与地面的接触。角色胶囊体与地面碰撞时,物理引擎阻止角色穿透;碰到墙壁时会被挡下。这要求两个 Collider 都有效,并且至少一方带 Rigidbody。角色移动用速度控制,碰撞结果交给物理引擎处理,脚本里不要试图在 OnCollisionEnter 中手动纠正位置,否则会造成位置跳变。
触发器最典型的设计是金币收集。把金币的 Box Collider 设置为 Is Trigger,脚本接收 OnTriggerEnter 事件。触发事件要求双方中至少一方带 Rigidbody,否则不会触发。参考实现:
// 文件路径:Assets/Scripts/CoinPickup.cs using UnityEngine; public class CoinPickup : MonoBehaviour { [Tooltip("该金币增加的分数")] public int scoreValue = 1; private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { // 实际项目中通常调用 GameManager.Instance.AddScore(scoreValue) Debug.Log($"玩家拾取了金币,增加 {scoreValue} 分"); Destroy(gameObject); } } }使用 CompareTag 之前,记得在 Tag Manager 中创建 Player 标签,并把角色物体的 Tag 设置为 Player。如果忘记了,拾取事件永远不会进入 if 分支。Tag 名称拼写不一致也是新手经常遇到的坑。
关于力的使用,Unity 的 AddForce 提供多种 ForceMode:ForceMode.Force 是持续力,受质量影响;ForceMode.Impulse 是一瞬间的冲击力,适合开炮、跳跃;ForceMode.Acceleration 直接给加速度,忽略质量;ForceMode.VelocityChange 直接改变速度,忽略质量,适合特殊效果。初学者最容易困惑的是 Force 与 Impulse 的数值受刚体质量影响,同样一个冲量,质量不同的物体速度变化不同。如果两个角色需要跳得不一样高,不要只调力的大小,先检查质量参数。
物理事件的方法名也有明显区别:OnCollisionEnter 需要双方都是非触发器状态,OnTriggerEnter 用于触发器场景。不要混淆,否则会出现“方法写了但始终不触发”的情况。如果拿不准,先看 Inspector 中 Collider 的 Is Trigger 是否勾选,再决定写哪个事件。
验证物理逻辑时,可以在 Window > Analysis > Physics Debugger 中打开刚体与碰撞体的可视化。很多看起来“穿墙”的问题,其实是 Collider 尺寸调得太小,模型看着很大,但碰撞区域很小,两者不匹配。
6. 简易敌人 AI:NavMeshAgent 与追击逻辑
游戏 AI 可以拆成三部分:感知、决策、运动。入门项目中感知用距离判断,决策只做“是否追击”,运动交给导航网格。Unity 的 NavMeshAgent 是处理 3D 寻路的核心组件,它基于预烘焙的 NavMesh 数据计算从当前位置到目标点的可行路径,并自动避障。
使用 NavMeshAgent 之前,需要先烘焙导航网格。在场景中选择产生阻挡的物体,如墙壁、地板、障碍物,打开 Window > AI > Navigation 窗口。在 Bake 页签设置 Agent Radius、Agent Height 等参数,点击 Bake。烘焙完成后,地面会被蓝色网格覆盖,这就是 AI 可行走的区域。烘焙参数决定了寻路的精细程度,Agent Radius 如果设置太小,寻路会贴近墙边,角色容易卡在转角处。
给敌人物体添加 NavMeshAgent 组件后,设置 Speed、Angular Speed、Stopping Distance 等参数。Stopping Distance 表示 AI 逼近目标但还没撞上目标时就会停下的距离。如果设置为 0,敌人会一直顶着玩家模型。
下面是一套最基本的敌人追击逻辑:玩家进入检测范围后开始追击,离开范围后停止。
// 文件路径:Assets/Scripts/EnemyAI.cs using UnityEngine; using UnityEngine.AI; [RequireComponent(typeof(NavMeshAgent))] public class EnemyAI : MonoBehaviour { [Header("AI 参数")] public Transform playerTarget; public float detectionRange = 8f; public float loseRange = 12f; private NavMeshAgent agent; private bool isChasing; private void Awake() { agent = GetComponent<NavMeshAgent>(); } private void Update() { if (playerTarget == null) return; float distance = Vector3.Distance(transform.position, playerTarget.position); if (!isChasing && distance <= detectionRange) { isChasing = true; } else if (isChasing && distance > loseRange) { isChasing = false; } if (isChasing) { agent.isStopped = false; agent.SetDestination(playerTarget.position); } else { agent.isStopped = true; } } }这个实现最值得学习的点,是把进入范围和离开范围拆成两个数值,形成滞回区间。这样做可以防止敌人站在临界距离时反复切换追击和取消追击,造成视觉上的抽搐行为。“判断状态”和“执行行为”被分开了,Update 先计算状态,再根据状态控制寻路代理,逻辑更清晰。
运行验证时,把 Player 物体拖到 EnemyAI 的 playerTarget 字段。玩家靠近敌人后,敌人从静止立刻转为追击;玩家远离超过 loseRange 后,敌人停在当前位置。地形复杂时,还要注意玩家站立的位置有没有被烘焙进 NavMesh。如果玩家站在未烘焙的平台,NavMeshAgent 无法算出路径,敌人会停在原地。可以在 Scene 窗口直接查看导航网格覆盖范围,并留意 Inspector 中 NavMeshAgent 的 remainingDistance 属性,判断代理是否在正常工作。
7. 多平台构建:Windows、Android、WebGL
Unity 被称为跨平台引擎,得益于成熟的构建管线。一套场景、一套脚本,通过 Build Settings 配置即可输出 Windows、Android、WebGL 等平台的版本。但“一套代码到处构建”不意味着完全不管平台差异,渲染特性、输入方式和性能约束都不同。
打开 File > Build Settings,在 Platform 列表选择目标平台。切换平台时,Unity 会重新导入一部分资源,耐心等待即可。以 Windows 为例,选择后点击 Build,指定输出目录,Unity 会生成 exe 文件和同名数据目录。这两个部分缺一不可,发布时需同时提供。如果想打包成单个可执行文件,需要用第三方工具把数据目录合并进 exe。
构建 Android 前需要确认几项配置。在 Player Settings > Android 中填写 Package Name,例如 com.example.firstgame,设置 Minimum API Level。Unity 默认使用 IL2CPP 后端构建 Android,首次构建耗时较长,而且需要本机安装 Android SDK、NDK 和 JDK。更常见的问题是 Android Build Support 模块没有安装。如果 Build Settings 无法切换到 Android 目标,回 Unity Hub 的 Modules 中安装 Android Build Support,包含 SDK、NDK 和 OpenJDK。
WebGL 平台的构建逻辑也很有特点。WebGL 目标不能完全依赖编辑器里的 Play 模式,部分浏览器端功能需要在真实浏览器中验证。实际执行 Build 后,Unity 会导出一个浏览器可运行的目录,需要通过本地 HTTP 服务或部署到服务器访问。WebGL 默认有内存限制,过于庞大的场景、纹理和音频会导致浏览器崩溃甚至白屏。建议在 Player Settings > WebGL 中降低纹理压缩等级,控制首包体积,或使用 Addressables 做资源分级加载。
三个平台都在 Build Settings 中操作,但验收标准差异很大。Windows 版本要测试不同分辨率窗口、Alt+F4 退出逻辑和 DPI 缩放;Android 版本要测试触摸输入、屏幕适配和后端生命周期;WebGL 版本要测试浏览器加载进度、包体大小和内存占用。如果项目用了手柄或新 Input System,还需要在键盘、触摸屏、手柄三种输入之间来回切换验证。
构建产物建议放在工程目录之外的 Build 文件夹,不要混入 Assets,否则版本库体积会快速膨胀。Unity 项目由大量文件和 meta 文件组成,多人协作时场景文件容易冲突,务必配合 Git 等版本控制工具管理,并提交 meta 文件。
8. 常见问题与排查思路
入门阶段会遇到的问题高度集中,下面是一个排查表,基本覆盖了上述内容的常见失败场景。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 角色按下方向键不动 | 输入模块未启用、脚本没挂载 | 查看 Console 是否有报错,确认 Input Handling | 检查脚本挂载和 Input Manager 设置 |
| 角色抖动明显 | 直接改 Transform 与刚体冲突 | 检查角色是否带 Rigidbody,移动代码位置 | 改用 velocity 控制,开启 Interpolate |
| 角色穿墙落地 | Collider 尺寸过小或位置偏差 | 打开 Physics Debugger 查看碰撞体范围 | 调整 Collider 中心、半径和高度 |
| OnTriggerEnter 不触发 | Is Trigger 未勾选或缺少 Rigidbody | 检查 Collider 属性和物体组件 | 勾选 Is Trigger,给一方添加 Rigidbody |
| OnCollisionEnter 始终不执行 | 方法名写错或状态不对 | 检查事件方法名和碰撞体状态 | 修正方法名,确认非触发器状态 |
| 敌人不追击玩家 | NavMesh 未烘焙、引用为空 | 查看 Scene 中是否有蓝色导航区域 | 重新烘焙,拖拽 playerTarget 引用 |
| Android 构建一直失败 | SDK/NDK/JDK 缺失 | 在 Unity Hub 检查模块安装 | 安装 Android Build Support |
| WebGL 打开白屏 | 内存超限或资源包过大 | 查看浏览器 Console 报错 | 压缩纹理和音频,减少首包资源 |
| 脚本报 CS0246 | using 命名空间缺失 | 查看错误输出中类型名 | 补充 using UnityEngine 等命名空间 |
排查问题要讲究顺序,不要一上来就改代码。先看 Console 有没有红色错误;没有报错就看 Inspector 中的引用是否完整;再不行就用 Debug.Log 打印关键变量,确认逻辑执行到哪一步中断。这个流程比盲目搜索错误信息可靠得多。
9. 最佳实践与工程建议
入门实战到这里接近尾声,最后说几点对未来开发真正有帮助的经验。
第一,脚本职责要单一。角色控制脚本只负责输入和移动,AI 脚本只负责寻路行为,拾取脚本只处理收集事件。不要在一个几百行的 Update 里把移动、射击、血条、金币全写了。这样便于调试和复用,后续替换输入方案或 AI 行为时只需改对应脚本,不影响其他模块。
第二,合理使用 RequireComponent。它强制依赖组件,脚本挂载时 Unity 会自动补齐所需组件,减少“组件缺失导致空引用”的低级错误。PlayerMovement 已经用了 [RequireComponent(typeof(Rigidbody))],后续写其他脚本时可以沿用这个思路。
第三,善用 Tag 和 Layer。角色、敌人、道具尽量使用独立 Tag 或 Layer,而不要靠对象名判断。对象名在运行时可能被修改,而 Tag 和 Layer 是稳定属性。CompareTag 比直接比较 name 更安全,性能也更可控。
第四,控制物理物体的数量。不要在场景中放置大量实时刚体。如果要模拟碎片、爆炸效果,优先考虑对象池或粒子系统,而不是堆大量真实刚体。大型地图可以拆成多个小块 Collider,减少碰撞检测开销,尤其移动端对此非常敏感。
第五,构建要尽早开始。项目第二天就可以尝试切换到 WebGL 或 Android 做一次构建测试。性能问题和兼容性问题发现得越晚,修改成本越高。移动端显存占用、WebGL 包体大小这类指标,应该从早期就纳入验收标准。
第六,版本管理要规范。Unity 依赖 .meta 文件维护资源映射关系,放入 Git 时不要删除 meta 文件,也不要随意重命名资源,否则场景里的引用会断。团队协作时统一 Unity 版本和目录结构,避免不同版本互相覆盖。
入门阶段最容易产生的错觉是“做完验证 Demo 就算结束”。实际上,把本系列的模块串起来——角色移动、拾取金币、敌人追击、多平台构建——你已经拥有一个 3D 跑图小游戏的雏形。接下来可以继续学习 Animator 动画系统、UGUI 界面、粒子特效、对象池、行为树等进阶内容。无论学什么,坚持“先理解协作关系,再动手写代码”的习惯,踩坑数量会明显减少。