
1. 项目概述Unity脚本的基石如果你刚开始接触Unity可能会觉得它像一个功能强大的“玩具箱”里面装满了各种现成的组件Component比如刚体Rigidbody、碰撞器Collider、渲染器Renderer等等。用这些组件你确实能快速拼凑出一些简单的交互。但很快你就会发现想让一个角色按照你的想法移动、让敌人拥有特定的AI行为、或者制作一个复杂的UI交互逻辑仅仅拖拽内置组件是远远不够的。这时你就需要自己动手写“说明书”来指挥这些组件协同工作——这份“说明书”就是脚本Script。《唐老狮Unity入门》笔记的第九篇聚焦的正是“Unity脚本基本规则”。这可以说是从“Unity使用者”迈向“Unity开发者”最关键的一步。脚本是赋予游戏对象GameObject灵魂和智慧的核心手段。无论你的游戏创意多么天马行空最终都需要通过一行行代码将其转化为可运行的逻辑。理解脚本的基本规则就如同学习一门新语言的语法和单词拼写是后续所有复杂创作的基础。这篇笔记将帮你扫清入门编码时的第一个障碍让你明白在Unity里写代码有哪些必须遵守的“规矩”以及为什么要有这些规矩。2. 脚本的创建、命名与基础结构2.1 创建脚本的正确姿势在Unity中创建脚本最标准的方式不是去外部IDE如Visual Studio新建一个.cs文件再拖进来而是直接在Unity编辑器内部完成。这样做的好处是Unity会自动为你生成一个符合其框架要求的基础模板并建立好脚本资源与项目之间的关联。你可以在Project窗口中右键点击你想放置脚本的文件夹选择Create - C# Script。或者使用顶部菜单栏的Assets - Create - C# Script。我强烈建议你为脚本建立一个专门的文件夹比如“Scripts”这样能保持项目结构的清晰。注意创建脚本时Unity会立即让你输入脚本名称。请务必在此刻就想好并输入最终名称而不是随意命名后再去修改文件名。因为Unity会将你输入的脚本名称同时作为生成的C#类的类名。如果后续只修改了文件名而没同步修改类名会导致脚本无法被正确附加到游戏对象上你会看到一个令人困惑的“脚本类缺失”的错误提示。2.2 命名规范不仅仅是好看脚本命名是第一个需要遵守的规则。一个好的命名应该清晰、具体并采用帕斯卡命名法PascalCase即每个单词的首字母大写且不使用下划线。例如好的命名PlayerMovement,EnemySpawner,UIManager不好的命名playerMovement首字母小写,enemy_spawner使用下划线,NewBehaviourScript1无意义且带数字为什么强调这个因为脚本文件名必须与脚本内部定义的类名Class Name完全一致包括大小写。这是C#语言的要求也是Unity识别脚本组件的基础。当你双击新创建的脚本文件时默认的代码编辑器通常是Visual Studio会打开它你会看到类似下面的代码using UnityEngine; using System.Collections; public class NewBehaviourScript : MonoBehaviour { // 在游戏开始时调用一次 void Start() { } // 每一帧调用一次 void Update() { } }这里public class NewBehaviourScript就是类名。如果你在创建时命名为PlayerController那么这里的类名也应该是public class PlayerController。Unity正是通过这个类名来唯一标识你的脚本组件。2.3 理解默认模板MonoBehaviour 是什么观察上面的代码你会发现类定义后面跟着: MonoBehaviour。这表示NewBehaviourScript这个类继承inherit自MonoBehaviour类。这是Unity脚本最核心的规则之一。你可以把MonoBehaviour理解为一个“通行证”或“基础协议”。只有继承自MonoBehaviour的类才能被Unity引擎识别为一个可以挂载到游戏对象GameObject上的组件Component。它为你提供了一系列可以被Unity引擎自动调用的生命周期函数如Start,Update以及访问Unity特定功能如变换组件、刚体组件的属性和方法。为什么是继承而不是自己写Unity引擎底层管理着成千上万个游戏对象和组件它需要一种统一的方式来调度和交互。MonoBehaviour定义了一套标准的接口。当你的脚本继承它就等于告诉Unity“我遵守你的规则我这里有你可以调用的Start和Update函数请你在适当的时候调用它们。” 这样Unity引擎就能在游戏启动时自动调用你的Start在每一帧自动调用你的Update而无需你手动去创建对象实例或调用循环。3. 脚本的生命周期引擎如何驱动你的代码理解了MonoBehaviour后就必须了解它提供的几个核心生命周期函数。这些函数是Unity引擎在特定时间点自动调用的“钩子”你的游戏逻辑主要就编写在这些函数里。3.1 Awake() 与 Start()初始化之争这是新手最容易混淆的两个函数。Awake()无论脚本是否启用enabled在游戏对象被实例化的那一刻立即调用。调用顺序在所有Start函数之前。Awake用于设置脚本内部的引用、初始化变量这些工作不依赖于其他游戏对象是否已经完成初始化。因为它的调用时机最早所以适合做一些最基础的准备工作。Start()仅在脚本启用enabled后在第一次Update函数调用之前执行一次。它会在所有游戏对象的Awake函数都执行完毕后才被调用。因此Start适合执行那些需要依赖其他游戏对象或组件已经初始化完成的逻辑。例如在Start中获取另一个游戏对象上的组件引用通常是安全的因为那个对象的Awake肯定已经执行过了。实操心得我个人的习惯是在Awake中初始化脚本自身的私有变量、获取挂载在同一个游戏对象上的其他组件如GetComponent。而在Start中去获取其他游戏对象上的组件或执行需要场景中其他对象都已就绪的逻辑。这样可以有效避免空引用异常。3.2 Update()、FixedUpdate() 与 LateUpdate()帧循环三兄弟游戏是连续运行的逻辑需要不断更新。这三个函数都每帧被调用但时机和用途不同。Update()每一帧调用一次。这是最常用的函数用于处理大部分游戏逻辑如读取玩家输入、处理非物理相关的移动、动画状态机更新等。但要注意它的调用频率取决于游戏的帧率FPS是不稳定的。在性能差的机器上一帧的时间可能很长。FixedUpdate()固定时间间隔调用。默认是每0.02秒50次/秒调用一次与帧率无关。所有与物理引擎Physics相关的操作都必须放在FixedUpdate中例如给刚体Rigidbody施加力AddForce、修改速度等。这能保证物理模拟的稳定性和可重复性避免因帧率波动导致物体运动“飘忽不定”。LateUpdate()在每一帧的所有Update函数执行完毕后调用。常用于跟随逻辑如相机跟随角色。因为角色的移动计算在Update中完成等到LateUpdate时角色的最终位置已经确定此时再更新相机位置可以确保相机捕捉到的是角色本帧最终的位置避免出现抖动。常见问题为什么我的角色施加力后运动不规律十有八九是因为把Rigidbody.AddForce写在了Update里。在高帧率下可能正常低帧率下力会施加得很少导致运动缓慢甚至失效。务必移至FixedUpdate。3.3 其他常用生命周期函数OnEnable() / OnDisable()当脚本组件被启用或禁用时调用。常用于注册/注销事件监听、开启/关闭协程等资源管理操作。OnDestroy()当脚本所属的游戏对象被销毁时调用。用于清理资源如取消未完成的网络请求、释放非托管资源等。OnTriggerEnter(Collider other)等用于处理碰撞和触发事件这属于物理交互的一部分通常由物理引擎在FixedUpdate周期内检测并调用。4. 访问游戏对象与组件脚本间的对话脚本很少孤立工作。一个控制玩家移动的脚本可能需要读取输入、修改自身位置Transform也可能需要与身上的动画控制器Animator通信或者检测与其他物体的碰撞。4.1 获取组件GetComponent这是最常用、最重要的方法之一。用于获取挂载在同一个游戏对象GameObject上的其他组件。public class PlayerController : MonoBehaviour { private Rigidbody rb; private Animator animator; void Awake() { // 获取挂载在同一个游戏对象上的 Rigidbody 组件 rb GetComponentRigidbody(); // 获取 Animator 组件 animator GetComponentAnimator(); } }GetComponent是一个相对耗时的操作尤其是在每帧调用时。因此最佳实践是在初始化阶段如Awake或Start获取组件引用并存储在变量中之后一直使用这个变量而不是每次都去GetComponent。4.2 查找其他游戏对象Find、GameObject.FindWithTag 与序列化字段如何获取其他游戏对象上的组件有几种方法各有利弊GameObject.Find(string name)通过游戏对象在场景中的名称查找。不推荐在Update中频繁使用因为它是全局遍历查找性能开销大。且如果对象改名或不存在会返回null导致错误。GameObject.FindWithTag(string tag)通过标签查找。比Find稍好因为Unity内部对标签有优化。适合查找场景中唯一的对象如“MainCamera”、“GameManager”。推荐公共序列化字段这是最灵活、性能最好、也最利于设计时配置的方法。在脚本中声明一个公共字段然后在Unity编辑器的Inspector窗口中直接拖拽赋值。public class PlayerShooter : MonoBehaviour { // 在Inspector中可以将一个子弹预制体拖到这里 public GameObject bulletPrefab; // 可以将另一个游戏对象如枪口拖到这里 public Transform firePoint; }这种方式将对象间的依赖关系可视化了无需硬编码名称或标签修改起来非常方便且没有运行时查找的性能损耗。4.3 父子层级关系Transform游戏对象之间存在父子关系通过Transform组件来管理。transform脚本中直接可用的属性指向自身挂载的游戏对象的Transform组件。transform.parent获取父对象的Transform。transform.GetChild(int index)按索引获取子对象。transform.Find(string path)按路径查找子对象如“Arm/Hand/Weapon”。5. 变量、属性与Inspector的联动Unity脚本的一个强大特性是能与编辑器Inspector无缝联动这大大方便了调试和设计。5.1 序列化字段在Inspector中显示和编辑在C#中默认情况下私有变量private在Inspector中是不可见的公共变量public是可见的。但将变量设为公共会破坏封装性。Unity提供了[SerializeField]属性来解决这个矛盾。public class Health : MonoBehaviour { // 公共变量Inspector可见其他脚本也可直接访问可能不安全 public int maxHealth 100; // 私有变量但加上[SerializeField]后在Inspector中可见并可编辑 [SerializeField] private int currentHealth 100; // 完全私有的变量Inspector不可见仅本脚本内部使用 private bool isInvincible false; }最佳实践我通常将需要设计时调整的变量设为[SerializeField] private这样既保证了在Inspector中的可配置性又防止了其他脚本随意修改其值。对于需要外部脚本安全访问的变量则使用公共属性Property或公共方法Method来提供访问接口。5.2 常用属性Attribute装饰器除了[SerializeField]Unity和C#提供了很多属性装饰器来增强Inspector的功能或控制行为[Header(“分组标题”)]在Inspector中创建一个标题用于对变量进行逻辑分组让界面更清晰。[Tooltip(“提示文字”)]当鼠标悬停在Inspector中的变量上时显示提示信息。对于团队协作或日后回顾代码非常有用。[Range(min, max)]将一个数值型变量如float,int在Inspector中显示为一个滑动条并限制其取值范围。[HideInInspector]将一个公共变量在Inspector中隐藏。[RequireComponent(typeof(ComponentType))]加在类定义上方。当将此脚本挂到游戏对象上时如果该对象没有指定的组件Unity会自动添加一个。例如[RequireComponent(typeof(Rigidbody))]可以确保移动脚本所挂载的对象一定有刚体。[RequireComponent(typeof(Rigidbody))] public class PlayerMovement : MonoBehaviour { [Header(移动设置)] [Tooltip(角色的移动速度)] public float moveSpeed 5f; [Tooltip(角色的跳跃力)] [Range(1, 20)] public float jumpForce 10f; [Header(状态)] [SerializeField] private bool isGrounded; }6. 协程Coroutine处理延时与异步操作在Update中直接使用Time.deltaTime来实现延时或间隔执行逻辑代码会变得非常臃肿且难以维护。Unity提供了协程这个强大的工具。协程本质上是一个可以分步执行、在特定点暂停并在下一帧或一段时间后继续执行的函数。它用IEnumerator作为返回类型并使用yield return语句来定义暂停点。6.1 基本语法与应用void Start() { // 启动一个协程 StartCoroutine(MyCoroutine()); } IEnumerator MyCoroutine() { Debug.Log(协程开始时间: Time.time); // 等待一秒钟 yield return new WaitForSeconds(1f); Debug.Log(等待1秒后时间: Time.time); // 等待直到下一帧 yield return null; Debug.Log(下一帧); // 等待直到某个条件为真 yield return new WaitUntil(() isGrounded); Debug.Log(角色已落地); }6.2 协程的典型应用场景延时执行如技能冷却、道具生成间隔、UI淡入淡出。按顺序执行一系列动作如剧情对话逐字显示、角色沿着路径点移动。处理异步加载如场景加载时显示进度条。代替复杂的计时器逻辑让代码更清晰。重要注意事项协程不是线程它仍然运行在主线程上。当一个协程在yield return处暂停时Unity主线程会继续执行其他逻辑如渲染、处理其他脚本的Update。此外禁用脚本组件或销毁游戏对象会停止其上运行的所有协程。如果希望协程在对象禁用后仍能运行可以考虑使用一个全局的、永不销毁的管理器对象来启动它。7. 常见问题排查与调试技巧即使理解了所有规则编码时也难免出错。掌握有效的调试方法是快速解决问题的关键。7.1 利用 Debug.Log 进行输出调试这是最直接的方法。你可以输出变量值、函数调用顺序等信息到Unity控制台。Debug.Log(当前血量: currentHealth); Debug.LogWarning(这是一个警告信息); Debug.LogError(发生了一个错误);技巧使用Debug.LogFormat可以格式化字符串使输出更清晰Debug.LogFormat(“对象 {0} 在位置 {1}”, gameObject.name, transform.position);技巧在关键的生命周期函数开始处加上Debug.Log可以帮你理清脚本的执行顺序。7.2 在Inspector中实时监控变量对于标记为public或[SerializeField]的变量在Play模式下你可以在Inspector中实时看到它们的值变化。这对于调试状态机、计时器、血量等非常直观。你甚至可以临时修改这些值来测试游戏的不同反应。7.3 使用断点进行逐行调试虽然Debug.Log好用但对于复杂的逻辑流使用Visual Studio的断点调试更高效。在代码行号左侧点击设置一个红色断点。在Unity中点击Play运行游戏。当代码执行到断点处时游戏会暂停Visual Studio会获得焦点。你可以查看当前所有变量的值鼠标悬停可以单步执行F10步入函数F11来一步步跟踪代码的执行路径和逻辑错误。7.4 常见错误速查表错误现象可能原因解决方案“NullReferenceException”尝试访问一个未初始化为null的对象引用。1. 检查GetComponent或Find是否成功获取到对象。2. 检查Inspector中序列化字段是否被正确赋值拖拽。3. 确保在访问对象前它已经被实例化例如Start中访问其他对象的Awake里初始化的内容可能不安全。脚本挂载了但不执行1. 脚本组件前方的复选框未勾选未启用。2. 脚本类名与文件名不一致。3. 脚本有编译错误。1. 勾选Inspector中脚本组件前的复选框。2. 确保类名与文件名完全一致。3. 查看Unity控制台Console是否有编译错误并修复。物理效果不正常物理相关代码如AddForce写在了Update中。将物理逻辑移至FixedUpdate函数中。协程不执行1. 只定义了协程函数但没有用StartCoroutine启动。2. 脚本被禁用或对象被销毁。1. 调用StartCoroutine(YourCoroutineName())。2. 检查脚本和对象的激活状态。Inspector中公共变量不显示变量不是public也没有加[SerializeField]属性。将变量设为public或添加[SerializeField]属性。掌握这些基本规则和技巧你就跨过了Unity脚本编程的第一道门槛。记住编程是一个实践性极强的技能多写、多试、多调试遇到问题先自己根据这些规则和现象排查再查阅文档或社区你的成长速度会快得多。从理解生命周期开始到熟练运用组件访问和协程每一步都扎实了后面构建复杂的游戏系统才会得心应手。