最近在带新手朋友完成 Unity 练习项目时,发现一个很常见的现象:前两章往往学得挺顺利,一旦进入第三章,开始涉及玩家控制、摄像机跟随、UI 反馈和场景切换这些 "把多个模块组合起来" 的内容时,网上资料就变得特别零散,查了非常多资料,勉强跑通一个 Demo,却说不清楚每一步到底在做什么。
这篇文章围绕 Unity 新手练习项目第三章的内容做一个完整复盘,从环境搭建、核心概念、完整代码实现,到高频报错和工程建议,一次性整理清楚。无论你是刚学完 Unity 基础操作的在校学生,还是准备用 Unity 做毕业设计或独立小游戏的开发者,都可以直接参考这套闭环流程。
1. 第三章练习在 Unity 新手路线中的定位
1.1 新手项目第三章通常要掌握什么
在 Unity 新手练习路线中,第一章一般是软件安装、界面认识、场景基础操作;第二章会扩展到预制体、物理碰撞、UI 基础;到了第三章,通常就会进入一个 "综合小玩法" 阶段,也就是说,要把之前学过的分散知识点组合成一个可以交互的完整流程。
常见的第三章练习目标包括:
| 学习模块 | 具体能力 |
|---|---|
| 玩家控制 | 使用键盘/鼠标控制角色移动、旋转、跳跃 |
| 摄像机系统 | 实现第三人称跟随、平滑插值、固定视角切换 |
| 物理交互 | 碰撞检测、触发器、拾取物品 |
| UI 反馈 | 文本显示、按钮交互、渐隐渐现效果 |
| 场景管理 | 场景切换、单场景加载与叠加加载 |
| 数据基础 | 使用集合类(List、Dictionary)管理游戏数据 |
如果你正在做的项目是 "角色收集物品 + 到达终点" 这类经典玩法,那第三章正好是实现核心玩法的阶段。
1.2 本章练习的核心玩法设计
为了让教程有落点,我们设计一个极简但完整的练习项目:
玩家通过 WASD 控制小球移动,鼠标控制视角旋转,摄像机跟随小球。地图上摆放金币,碰到金币后触发得分,同时 UI 显示 "获得 1 金币" 并逐渐消失。收集全部金币后,自动切换到一个结束场景。
这个设计很典型,覆盖了移动、旋转、摄像机、UI、场景切换这些第三章高频知识点,代码量不大,但能帮助你把 Unity 组件逻辑串起来。
1.3 完整功能清单
完成第三章后,你的项目应该具备以下能力:
- 角色移动逻辑写在
MonoBehaviour脚本的Update或FixedUpdate中; - 使用物理组件(
Rigidbody或CharacterController)驱动角色,而不是直接改 Transform; - 摄像机在
LateUpdate中做平滑跟随,避免抖动; - 通过触发器检测拾取物,配合
CanvasGroup实现 UI 渐隐; - 使用
SceneManager实现场景加载和切换; - 合理使用
Dictionary管理金币状态或收集数据。
带着这个目标清单去写代码,会比 "照着别人的项目抄一遍" 有效得多。
2. 环境准备与版本说明
2.1 Unity 安装与 Unity Hub 使用
如果你还没有安装 Unity,建议先通过 Unity Hub 统一管理多个 Unity 编辑器版本,方便后续不同项目切换。安装流程大致是:
- 下载并安装 Unity Hub;
- 在 Unity Hub 的 "安装" 页面选择 Unity Editor 版本,勾选需要的 Build Support 模块(比如 Android Build Support、Windows Build Support);
- 在 "项目" 页面新建 3D 核心(3D Core)项目;
- 如果需要接入微信小游戏或移动端平台,再单独安装对应模块。
不同项目的模板不同,但对新手练习项目来说,3D Core(内置渲染管线)和Universal 3D(URP 渲染管线)都可以选。URP 的画面效果更现代一些,学习曲线会稍微多一点点概念,但后续做移动端和性能优化时更有优势。本文代码以 URP 项目为例也能兼容,核心逻辑不受影响。
版本方面需要提醒一点:Unity 从 2020 到 2023 的 LTS 版本,在 API 层面总体稳定,但如果你后续接入其他 SDK,建议先确认 SDK 支持哪个 Unity 版本范围。不需要追求最新版本,稳定优先。
2.2 创建项目时的推荐设置
新建项目后,建议先做几项基础设置:
- 进入
Edit > Project Settings > Player,设置产品名称和公司名称; - 进入
Edit > Project Settings > Quality,调整像素质量和阴影质量,保证运行流畅度; - 如果使用 URP,需要确认
Graphics设置中渲染管线资产已指定。
对于初学者,不需要花太多时间在画质参数上,重点是先跑通逻辑。
2.3 推荐目录结构
一个清晰的目录结构会让后期维护舒服很多,推荐按以下方式组织:
Assets/ Scenes/ // 存放场景,如 Main.unity、End.unity Scripts/ // C# 脚本 Player/ Camera/ Pickup/ UI/ Manager/ Prefabs/ // 预制体:Player、Coin 等 Materials/ // 材质 ScriptableObjects/ // 可选,配置类资产之所以按功能拆分文件夹,是因为第三章之后项目会继续变大,比如加入音效、动画、数据配置等,提前养成分层管理的习惯,后续会省很多时间。
3. 核心知识拆解:控制、跟随、UI 与场景切换
3.1 使用 Transform 实现位移与旋转
Unity 中最基础的移动方式就是直接操作Transform。
using UnityEngine; public class SimpleMove : MonoBehaviour { public float moveSpeed = 5f; void Update() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0, vertical); transform.Translate(direction * moveSpeed * Time.deltaTime, Space.World); } }这段代码的作用是:
Input.GetAxis("Horizontal")读取 A/D 或左右方向键输入;Input.GetAxis("Vertical")读取 W/S 或上下方向键输入;- 将输入方向乘以速度,再乘
Time.deltaTime,保证不同帧率下移动速度一致。
需要注意,直接操作 Transform 适合简单原型,但如果角色需要物理碰撞,更推荐使用Rigidbody或CharacterController,否则容易穿墙或抖动。
3.2 摄像机平滑跟随的两种写法
摄像机跟随是第三章的必做功能。最简单的写法是把摄像机拖到角色物体下作为子物体,但这种跟随是"硬跟随",旋转和抖动都会直接传过去,而且不适合很多需要缓冲效果的场景。
更推荐自己在脚本中实现平滑跟随。
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0, 4, -6); public float smoothTime = 0.2f; private Vector3 velocity = Vector3.zero; void LateUpdate() { if (target == null) return; Vector3 targetPosition = target.position + offset; transform.position = Vector3.SmoothDamp( transform.position, targetPosition, ref velocity, smoothTime ); transform.LookAt(target); } }选择LateUpdate而不是Update的原因在于:Update中先完成角色移动,LateUpdate中再更新摄像机位置,能减少因帧顺序问题造成的抖动。
如果希望摄像机跟随鼠标旋转视角,可以在此基础上叠加欧拉角:
float mouseX = Input.GetAxis("Mouse X") * rotateSpeed; float mouseY = Input.GetAxis("Mouse Y") * rotateSpeed; rotationX -= mouseY; rotationX = Mathf.Clamp(rotationX, -30f, 60f); transform.rotation = Quaternion.Euler(rotationX, transform.eulerAngles.y + mouseX, 0);3.3 CanvasGroup 实现 UI 渐隐效果
第三章不少人会卡在 "提示文字慢慢消失" 这个需求上。最优雅的方式是使用CanvasGroup。
CanvasGroup是挂在 UI 物体上的组件,它的alpha属性会影响整个子 UI 集合的透明度,用协程来控制 alpha 从 1 渐变到 0,非常方便。
using System.Collections; using UnityEngine; public class UIFadeOut : MonoBehaviour { public CanvasGroup group; public float fadeDuration = 1f; public void StartFadeOut() { StartCoroutine(FadeOutCoroutine()); } private IEnumerator FadeOutCoroutine() { float elapsed = 0f; while (elapsed < fadeDuration) { elapsed += Time.deltaTime; group.alpha = Mathf.Lerp(1f, 0f, elapsed / fadeDuration); yield return null; } group.alpha = 0f; group.blocksRaycasts = false; } }关键点在于:
group.blocksRaycasts = false可以在渐隐结束后让 UI 不再拦截鼠标点击;- 如果需要反过来淡入,只需要把
Mathf.Lerp的起始值和目标值调换即可。
3.4 场景管理:Additive 模式
第三章后期还会接触到场景切换。Unity 提供SceneManager类来加载场景,其中LoadSceneMode.Additive是一种叠加模式,可以在不卸载当前场景的情况下加载新场景。
using UnityEngine; using UnityEngine.SceneManagement; public class SceneSwitcher : MonoBehaviour { public void LoadEndScene() { SceneManager.LoadScene("End", LoadSceneMode.Additive); } public void UnloadCurrentScene() { SceneManager.UnloadSceneAsync(gameObject.scene); } }使用 Additive 模式的典型场景是:在全局关卡场景中叠加 UI、对话场景,或者把多个小游戏场景叠加在一个主容器中。
对新手来说,先掌握最简单的SceneManager.LoadScene("End")就够了,但知道 Additive 可以帮你理解多场景管理思路。后续做大型项目时,多场景工作流几乎必备。
3.5 集合类:使用 Dictionary 管理金币数据
第三章功能中,收集金币往往需要记录 "哪些金币已被收集" 或 "各种币种数量",此时Dictionary比List更合适。
using System.Collections.Generic; using UnityEngine; public class CoinManager : MonoBehaviour { private Dictionary<string, int> coinInventory = new Dictionary<string, int>(); public void AddCoin(string coinType, int count) { if (coinInventory.ContainsKey(coinType)) { coinInventory[coinType] += count; } else { coinInventory.Add(coinType, count); } } public int GetCoinCount(string coinType) { if (coinInventory.TryGetValue(coinType, out int value)) { return value; } return 0; } }使用Dictionary的好处是查找键值的时间复杂度接近 O(1),适合按类型快速累计数量。新手不必过度设计数据结构,但学会在合适的场景用集合类,是第三章之后的重要进阶点。
3.6 宏定义与平台相关代码
第三章的练习项目后期可能要打包到 Windows、Android,甚至是微信小游戏平台。此时可以使用 Unity 的宏定义来隔离平台相关代码。
using UnityEngine; public class PlatformHelper : MonoBehaviour { void Start() { #if UNITY_ANDROID Debug.Log("当前运行在 Android 平台"); #elif UNITY_IOS Debug.Log("当前运行在 iOS 平台"); #elif UNITY_STANDALONE_WIN Debug.Log("当前运行在 Windows 桌面"); #else Debug.Log("当前平台未知"); #endif } }实际操作中,宏定义还可以配合移动端权限、隐私弹窗、分辨率适配等场景使用。新手可以先记住#if UNITY_ANDROID、#if UNITY_EDITOR、#if UNITY_STANDALONE_WIN这三个最常用的。
4. 完整实战案例:从一个空场景到可玩的小型练习项目
这一部分我们一步步搭建完整项目。为了方便理解,我把整个流程拆成下面几个步骤。
4.1 场景与角色搭建
第一步,创建两个场景:
Main.unity:主游戏场景,包含玩家、金币、摄像机、UI;End.unity:结束场景,展示 "收集完成" 文字。
在Main场景中创建一个球体作为玩家:
GameObject > 3D Object > Sphere,重命名为Player;- 在
Player上添加Rigidbody组件; - 创建材质,设置成你喜欢颜色,方便区分场景物体。
给地面创建平面:
GameObject > 3D Object > Plane,重命名为Ground;- 调整大小或缩放,确保地面范围足够。
然后创建金币物体:
GameObject > 3D Object > Capsule,重命名为Coin_01,或者直接使用 Sphere 缩小到合适大小;- 添加
SphereCollider,勾选Is Trigger; - 挂到
Assets/Prefabs/下,重命名为Coin。
4.2 玩家移动控制脚本
在Assets/Scripts/Player/下新建PlayerController.cs,使用刚体控制小球移动:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class PlayerController : MonoBehaviour { public float moveSpeed = 8f; public float rotateSpeed = 120f; private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); rb.freezeRotation = true; } void FixedUpdate() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 move = new Vector3(horizontal, 0f, vertical).normalized; rb.MovePosition(transform.position + move * moveSpeed * Time.fixedDeltaTime); if (move != Vector3.zero) { Quaternion targetRotation = Quaternion.LookRotation(move); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotateSpeed * Time.fixedDeltaTime); } } }说明:
FixedUpdate适合物理操作,因为它与物理引擎同步;rb.MovePosition不会破坏刚体的物理碰撞;freezeRotation = true防止角色在碰撞时任意翻转;Quaternion.Slerp让角色平滑转向移动方向。
4.3 摄像机跟随脚本
在Assets/Scripts/Camera/下新建CameraFollow.cs。
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0, 6, -8); public float smoothTime = 0.3f; private Vector3 velocity = Vector3.zero; void LateUpdate() { if (target == null) return; Vector3 targetPos = target.position + offset; transform.position = Vector3.SmoothDamp(transform.position, targetPos, ref velocity, smoothTime); transform.LookAt(target); } }在场景中把 Main Camera 的CameraFollow组件中Target拖入 Player,并调整Offset,确保能完整看到玩家和周围环境。这里的smoothTime越小,跟随越灵敏,越大越有"缓冲感",可以根据手感调整。
4.4 拾取物与碰撞判定
在Assets/Scripts/Pickup/下新建Coin.cs。
using UnityEngine; public class Coin : MonoBehaviour { [SerializeField] private int scoreValue = 1; private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { GameManager.Instance.AddScore(scoreValue); gameObject.SetActive(false); } } }这里使用GameManager.Instance来统一管理得分,是第三章很好的工程实践,避免到处都是静态变量。
先在玩家物体上设置 Tag:
- 选中 Player;
- 在 Inspector 顶部 Tag 下拉菜单中选择
Player,如果没有就点击Add Tag添加。
4.5 UI 渐隐与场景切换
创建 UGUI 文本提示。
- 在场景中
GameObject > UI > Text - TextMeshPro,选择导入 TMP Essentials; - 将文本内容设置为 "获得金币 +1";
- 挂上
CanvasGroup组件,初始alpha = 1; - 新建脚本
UIFadeOut.cs,内容和 3.3 节一样,并通过GetComponent<CanvasGroup>获取组件。
再创建游戏管理器GameManager.cs,放于Assets/Scripts/Manager/。
using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [SerializeField] private TextMeshProUGUI scoreText; [SerializeField] private CanvasGroup fadeGroup; [SerializeField] private float fadeDuration = 1f; private int score = 0; private int totalCoins; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() { totalCoins = FindObjectsOfType<Coin>().Length; UpdateScoreText(); } public void AddScore(int value) { score += value; UpdateScoreText(); StartCoroutine(FadeTextRoutine()); } private void UpdateScoreText() { if (scoreText != null) { scoreText.text = $"金币:{score} / {totalCoins}"; } } private System.Collections.IEnumerator FadeTextRoutine() { fadeGroup.alpha = 1f; float elapsed = 0f; while (elapsed < fadeDuration) { elapsed += Time.deltaTime; fadeGroup.alpha = Mathf.Lerp(1f, 0f, elapsed / fadeDuration); yield return null; } fadeGroup.alpha = 0f; } public void CheckAllCoinsCollected() { if (score >= totalCoins) { SceneManager.LoadScene("End"); } } }这里有一个小优化点:在AddScore后调用CheckAllCoinsCollected,判断是否已经收集所有金币,是就切换场景。
为了让TextMeshProUGUI可用,需要引入TMPro命名空间,或在代码中写成TMPro.TextMeshProUGUI。我这里用的是TextMeshProUGUI,需要加上using TMPro;,否则会报类型找不到。
正确引入后开头应该是:
using TMPro; using UnityEngine; using UnityEngine.SceneManagement;4.6 运行与验证
全部脚本挂好后运行项目,验证顺序建议如下:
- 检查 Player 是否能通过 WASD 移动,并且移动方向符合预期;
- 检查摄像机是否平滑跟随,不穿墙、不抖动;
- 触碰金币后,金币隐藏,UI 文字更新并渐隐;
- 收集完所有金币后,场景自动切换到 End;
- 在 End 场景中添加一个 Button 或按任意键回到主场景,完整闭环。
End 场景中,如果要做得友好一点,可以添加一个脚本监听输入来返回主场景:
using UnityEngine; using UnityEngine.SceneManagement; public class EndPanel : MonoBehaviour { void Update() { if (Input.anyKeyDown) { SceneManager.LoadScene("Main"); } } }需要注意,如果使用LoadScene切换且没有指定LoadSceneMode.Additive,默认会替换当前场景,GameManager 如果不保留就会销毁。为了让分数跨场景保留,我们在Awake中使用了DontDestroyOnLoad(gameObject),这样场景切换时 GameManager 对象不会消失。
5. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 角色按 WASD 不动 | 没有挂脚本、方向速度为零、Rigidbody 被冻结 | 检查脚本是否挂到对象,输出 Debug.Log 验证输入值 |
| 摄像机抖动 | 在 Update 中跟随、SmoothDamp 参数过小 | 把跟随逻辑移到 LateUpdate,调大 smoothTime |
| UI 渐隐不生效 | CanvasGroup 未挂、alpha 被覆盖 | 确认调用的组件是 CanvasGroup,而不是文本自身透明度 |
| OnTriggerEnter 不触发 | 物体没有 Collider、未勾选 Is Trigger、没有 Rigidbody | 拾取物勾选 Is Trigger;角色必须带 Rigidbody |
| LoadScene 后 GameManager 丢失 | 没有 DontDestroyOnLoad | 在 Awake 中调用 DontDestroyOnLoad(gameObject) |
| 打包后 UI 显示模糊 | Canvas 缩放模式设置不合理 | UI Canvas 的 CanvasScaler 使用 Scale With Screen Size |
| 新场景加载后相机找不到目标 | CameraFollow 中的 Target 为空 | 场景中重新指定 Target,或用查找代码自动查找 |
5.1 排查思路补充
“角色不动”是很经典的问题,新手可以参考下面的排查顺序:
- 打开 Inspector,确认脚本是否成功挂载,且没有任何脚本报错;
- 检查
Input.GetAxis是否收到输入,可以在Update中加一行Debug.Log(horizontal + "," + vertical);; - 检查刚体是否被冻结位置或旋转;
- 检查移动代码是否写在
Update中且没有乘以Time.deltaTime,导致不同设备速度不一致。
如果你遇到NullReferenceException,大多是 Inspector 中引用没有拖拽到位,比如CameraFollow的target没有赋值。
6. 最佳实践与工程建议
6.1 脚本组织规范
Unity 项目中脚本最好按职责分层,不要把所有逻辑堆在 Player 身上。推荐的分层方式:
Controller:玩家控制、摄像机控制;System:游戏管理器、场景切换、存档;UI:UI 面板逻辑、文本动画、按钮事件;Data:数据结构定义、配置。
每一层只负责自己的事,模块之间通过GameManager或事件通信,避免互相强引用。
6.2 输入系统选型建议
Unity 现在有两套输入系统:旧的Input Manager和新的Input System。
- 初学者建议先用旧
Input Manager,因为教程多、简单直接; - 如果项目要支持手柄、触屏、多平台映射,可以逐步迁移到
Input System。
个人建议是:第三章新手练习不要同时学两套输入方案,先跑通一套,后续再切换。
6.3 渲染管线建议
URP 是当前 Unity 官方主推的渲染管线,适合移动端优化。如果你的电脑性能允许,可以在项目初始阶段就选择 Universal 3D 模板,这样可以提前积累 URP 的概念,比如 Renderer Feature、后处理 Volume 等。
但如果你只是做纯逻辑练习,内置渲染管线也能跑,不需要频繁切换。脚本逻辑在两种管线中是相通的,这一点不用担心。
6.4 性能优化注意点
虽然新手项目规模小,但从一开始就注意性能,能省不少后期成本:
- 尽量使用对象池,而不是频繁
Instantiate和Destroy金币; - 场景中不要放太多带实时阴影的光源;
- UI 动画尽量减少每帧修改大尺寸区域的
Layout; - 使用
Profiler窗口观察 CPU 耗时,确认卡顿来源; - 打包移动端时,关注 IL2CPP 构建时间,同时检查 API Level 和权限声明,涉及 Android 隐私合规时要提前处理用户协议弹窗。
6.5 日志与调试习惯
调试时不要只靠print,可以规范化日志格式:
Debug.Log($"[{nameof(GameManager)}] score = {score}", this);导出正式包之前,建议关闭多余的Debug.Log,或者在代码中使用#if UNITY_EDITOR包一层,避免影响性能。
7. 总结与学习路线
这一章你练到的核心能力可以总结为四个方面:刚体驱动的玩家控制、LateUpdate 中的摄像机跟随、CanvasGroup 渐隐反馈、场景切换与单例管理。这四个能力可以组合成大量常见玩法原型,比如收集类闯关、逃离类小游戏、模拟经营中的点击反馈等。
下一步建议按顺序继续学习:
- 预制体与对象池:把金币改为动态生成 + 对象池回收,提升运行效率;
- 动画系统:给玩家模型添加简单的 Animator 状态切换;
- 音效管理:用
AudioSource+AudioMixer管理音效分类; - 存档功能:把得分通过 PlayerPrefs 或 JSON 文件保存,让项目有 "持久化" 概念;
- 移动端适配:打包到 Android 前处理分辨率、触控输入和隐私弹窗;
- 事件驱动:学习
UnityEvent、Action、ScriptableObject事件系统,减少脚本之间的耦合。
这一套流程走完,你的 Unity 基础就算扎实了。之后不管是学 Urp 渲染、Shader 还是 DOTS,都会更从容。
如果你在做第三章练习时碰到了问题,欢迎在评论区把报错信息贴出来,也可以聊聊你目前的练习项目做到哪一步了,一起排查。