这段时间很多刚开始接触 Unity 的新同学会卡在同一个位置:跟着教程敲完前面的基础部分,一到“第三章:场景交互、UI、镜头控制、场景切换”就开始断断续续,因为知识点突然变多,代码和场景文件也变多,一旦某个脚本没挂好,整个项目就动不起来。
本文是“Unity 新手项目练习”系列第三章部分的完整配套笔记,整套练习已经完结。也就是说,下面这些内容和代码不是零散片段,而是一个可以从空场景开始、一步步做到“可玩、可切换、可打包”的小项目闭环。我会把第三章用到的核心知识点重新拆开,再组合到一个完整的实战里,适合刚开始做 Unity 小项目的同学照着敲。
文章会包含以下内容:
- 第三章到底练了什么,为什么要练这些点。
- Unity 工程目录怎么规划,避免后面找不到文件。
- 角色移动、摄像机跟随、道具拾取、UI 得分、关卡切换的完整代码。
- 使用 Mathf.PerlinNoise 生成简单地形的可选练习。
- 运行验证清单、常见报错与工程建议。
1. 第三章练习概览:这个项目到底做了什么
1.1 项目目标
第三章的项目主线很简单:玩家控制一个小方块或胶囊体,在一个封闭场景里移动,收集散落在场景里的道具。每收集一个,左上角分数加一。当分数达到目标值后,自动跳转到下一个场景。
听起来很基础,但如果你真的自己动手做完,会发现这一章覆盖了 Unity 新手最容易忽略的几个问题:
- 场景中物体由谁控制、由谁显示、由谁计数,职责是否清楚。
- 脚本之间怎么通信,是用静态变量、单例,还是直接拖引用。
- 摄像机怎么跟随角色才不“硬邦邦”。
- 道具被拾取后怎么消失,是立刻隐藏还是做一个渐隐动画。
- 场景跳转用普通加载还是叠加加载,两者有什么不同。
这些问题在第三章之前基本不会遇到,但从第三章开始,你会频繁和它们打交道。
1.2 技术清单
本文实战部分涉及的技术点如下:
- Rigidbody 移动:使用物理引擎的 MovePosition 做平滑移动。
- 摄像机 LateUpdate 平滑跟随:用 Vector3.SmoothDamp 实现镜头缓冲。
- Collider 触发检测:OnTriggerEnter 判断玩家是否碰到道具。
- Coroutine 渐隐:协程控制物体透明度变化后销毁。
- UI Text 显示得分:通过 GameManager 统一更新。
- SceneManager 场景切换:LoadScene 和 LoadSceneMode.Additive 的区别。
- Mathf.PerlinNoise:可选练习,用柏林噪声生成简单的自然地形。
1.3 适合人群
这一章适合已经能看懂 Unity 基础操作、至少手动做过一个“物体移动”小案例的新手。如果你还不太清楚 Inspector 面板常用组件、预制体 Prefab 的本质,可以先把这些概念补一补再继续。
当然,如果你之前学过一点,但代码写到一半就乱了,这一章也可以帮你重新梳理“脚本挂载、引用、事件调用”的套路。
2. 环境准备与工程结构
2.1 Unity 版本与渲染管线
本文示例使用的是 Unity 2021.3 LTS。这是一个比较稳定的长期支持版本,适合新手学习。如果你使用的是 Unity 2022.3 LTS 或者更新版本,大多数 API 和操作流程没有变化。需要注意的是,不同版本对“输入系统”“渲染管线”的处理方式不同:
- 如果你项目里用了新版 Input System 包,那么老的
Input.GetAxis不一定能用。 - 如果你新建项目时选择了 URP 模板,部分材质默认使用 URP Shader,直接打开旧项目可能会出现材质变粉的问题。
所以下面所有代码都尽量使用 Unity 自带的旧版输入系统和标准材质,减少不必要的环境问题。
关于版本,有一点必须提醒:不要为了“最新”直接装 Preview 版本做练习,新手阶段遇到编辑器崩溃、API 变化会让你很难判断是代码问题还是编辑器问题。
2.2 工程目录规划
很多新手喜欢把脚本、场景、预制体全部丢在Assets根目录下,文件一多就找不到。第三章开始,请尽量养成分类习惯。我建议项目目录至少做两层结构:
Assets/ ├── Scenes/ │ ├── Main.unity │ └── Level2.unity ├── Scripts/ │ ├── PlayerController.cs │ ├── CameraFollow.cs │ ├── CollectibleItem.cs │ ├── GameManager.cs │ └── TerrainGenerator.cs ├── Prefabs/ │ ├── Player.prefab │ └── Collectible.prefab ├── UI/ │ └── UIManager.cs └── Materials/ └── PlayerMaterial.mat这里说的“两层树结构目录”不是强制规则,但它能解决一个非常实际的问题:当你一个月后再打开这个项目,你能快速定位到“场景、脚本、预制体”分别在哪。如果你做的项目更大,还可以按功能模块继续拆,比如Scripts/Player、Scripts/UI、Scripts/Level。
2.3 输入与场景设置
在 Unity 中新建一个 3D 项目后,做以下基础设置:
- 在
File -> Build Settings中把Main场景加入 Build。 - 给玩家对象添加
Rigidbody和Capsule Collider。 - 给地面添加
Box Collider,避免玩家掉下去。 - 给道具添加
Sphere Collider,并勾选Is Trigger。
为什么要勾选Is Trigger?因为这样道具不会阻挡玩家移动,玩家碰到它时只会触发OnTriggerEnter,而不是物理碰撞反弹。
如果你的项目里已经使用了UnityEvents或新版输入系统,代码里的Input.GetAxis可能需要替换。新手阶段建议先用旧版输入系统。
3. 核心知识点拆解
在写完整代码之前,先把第三章会用到的几个核心知识点单独讲一遍。理解了这些点,后面的完整代码看起来就不会一头雾水。
3.1 基于 Rigidbody 的角色移动
Unity 中控制角色移动的方式有很多,比如直接改Transform.position、使用CharacterController、使用Rigidbody。
比较推荐新手用的是Rigidbody.MovePosition。原因很简单:它会把移动交给物理系统计算,在角色碰到障碍物时不容易发生穿模或抖动。直接改Transform.position虽然简单,但在物理场景里会经常和小碰撞体“打架”。
核心思路是:
FixedUpdate中读取输入。- 把输入方向
Vector3(h, 0, v)标准化,避免斜向移动速度过快。 - 计算目标位置,调用
rb.MovePosition(targetPosition)。
读取输入用的是Input.GetAxis("Horizontal")和Input.GetAxis("Vertical"),返回的是-1 到 1之间的值,默认对应键盘的 WASD 和方向键。
3.2 摄像机平滑跟随
“摄像机跟随”最简单粗暴的写法就是把摄像机设为角色的子物体。但这样做的缺点很明显:角色旋转时摄像机也会跟着旋转,容易让人头晕。
更好的方式是让摄像机保持一个固定偏移,然后在LateUpdate里用插值接近目标位置。
为什么是LateUpdate而不是Update?因为Update中先更新角色位置,再在LateUpdate里更新摄像机位置,可以避免摄像机在每帧开始时拿到旧坐标,出现抖动。
用Vector3.SmoothDamp做平滑移动,比直接Lerp更自然。SmoothDamp自带“速度惯性”,角色急转弯时镜头不会瞬间甩过去。
3.3 道具旋转、检测与渐隐
道具最简单的表现效果是“原地转圈”,用transform.Rotate在Update中不断旋转即可。
检测玩家是否碰到道具,用OnTriggerEnter(Collider other)。这个碰撞回调之所以能触发,是因为:
- 玩家身上有
Rigidbody。 - 道具身上有
Collider且勾选了Is Trigger。 - 两者至少有一方带有
Rigidbody。
满足这三个条件,OnTriggerEnter才可靠。如果玩家没有Rigidbody,只有两个 Collider,触发检测经常不生效。
道具被拾取后,我希望它“慢慢透明,然后销毁”,而不是突然消失。有两种常见做法:
- 使用
Destroy(gameObject)直接销毁,适合简单需求。 - 使用协程
StartCoroutine修改Renderer.material.color的 alpha 值,再销毁。
注意:要让颜色透明度生效,材质必须支持透明渲染模式。默认 Standard 材质是“不透明”模式,直接改 alpha 不会有渐隐效果。新手最容易在这里踩坑。解决方式是把材质的Rendering Mode改成Transparent或Fade。
3.4 Mathf.PerlinNoise 地形生成
第三章如果只是平面地面,会有点单调。可以用Mathf.PerlinNoise生成一张高度图,让地面起伏,模拟自然地形。
Mathf.PerlinNoise(float x, float y)返回一个0 到 1之间的浮点数,它和普通随机数不同:相邻坐标返回的值是连续的,所以生成出来的高度会呈波浪状,很适合做地形。
使用时需要遍历 Terrain 的高度图数组,对每个像素点计算噪声值,再写回TerrainData。不需要完全理解柏林噪声的数学原理,但要记住两个参数:perlinScale控制起伏频率,heightIntensity控制起伏幅度。
3.5 场景切换:LoadScene 与 LoadSceneMode.Additive
场景切换是 Unity 开发里的高频操作,新手必须要区分两种加载模式:
SceneManager.LoadScene("Level2"):单独加载新场景,会卸载当前场景。SceneManager.LoadScene("Level2", LoadSceneMode.Additive):叠加加载新场景,当前场景继续保留。
Additive 模式在什么情况下用?比如你要做一个“主场景 + 多人联机大厅 + 战斗场景”分离的架构,或者希望 UI 场景一直常驻,就可以把具体玩法场景叠加加载进来。
不过新手阶段如果用 Additive,很容易遇到一个问题:场景叠加后,两个场景里的光照、UI、物体都混在一起,多加载几次还会有重复。所以第三章实战里先用普通LoadScene,把 Additive 作为延伸知识理解即可。
4. 完整实战:收集闯关小场景
下面开始从零做一个完整的“收集闯关”小项目。我尽量把每一步拆清楚,代码可以直接复制到对应脚本文件中。
4.1 场景与预制体准备
步骤 1:创建场景
在Assets/Scenes下新建两个场景,命名为Main和Level2。
Main场景作为第一关,里面需要有:
- 一个平面或立方体拉伸成的地面。
- 一个玩家角色(胶囊体)。
- 8 个可收集道具(小球)。
- 一个摄像机。
- 一个 Canvas UI,用于显示得分。
步骤 2:创建预制体
分别创建Player.prefab和Collectible.prefab。
玩家预制体的组件结构参考:
Player(Capsule) ├── Capsule Collider ├── Rigidbody └── PlayerController.cs道具预制体的组件结构参考:
Collectible(Sphere) ├── Sphere Collider(勾选 Is Trigger) └── CollectibleItem.cs把预制体拖到场景里,方便后续重复使用。
4.2 编写玩家控制脚本
文件路径:Assets/Scripts/PlayerController.cs
using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 5f; public float rotateSpeed = 120f; private Rigidbody rb; private void Start() { rb = GetComponent<Rigidbody>(); rb.freezeRotation = true; } private void FixedUpdate() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(h, 0f, v).normalized; if (direction.magnitude > 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.RotateTowards( transform.rotation, targetRotation, rotateSpeed * Time.fixedDeltaTime ); } Vector3 targetPosition = transform.position + direction * moveSpeed * Time.fixedDeltaTime; rb.MovePosition(targetPosition); } }解释几个关键点:
normalized保证斜着移动时不会因为两个方向叠加而速度变成1.414倍。freezeRotation = true防止角色在移动过程中被物理碰撞带倒。FixedUpdate适合物理相关操作,因为物理引擎的更新频率是固定的。
4.3 摄像机跟随脚本
文件路径:Assets/Scripts/CameraFollow.cs
using UnityEngine; public class CameraFollow : MonoBehaviour { [Header("跟随目标")] public Transform target; [Header("镜头偏移")] public Vector3 offset = new Vector3(0f, 5f, -6f); [Header("平滑参数")] public float smoothTime = 0.25f; private Vector3 velocity = Vector3.zero; private void LateUpdate() { if (target == null) { return; } Vector3 desiredPosition = target.position + offset; transform.position = Vector3.SmoothDamp( transform.position, desiredPosition, ref velocity, smoothTime ); transform.LookAt(target); } }把这个脚本挂到 Main Camera 上,然后在 Inspector 里把Target拖上玩家对象,摄像机就会自动跟随。
这里要特别说明SmoothDamp的velocity参数:你不能传一个局部变量直接当引用参数,必须在类里声明一个字段保存,否则每一帧速度都会归零,平滑效果就会失效。
4.4 可拾取道具逻辑
文件路径:Assets/Scripts/CollectibleItem.cs
using System.Collections; using UnityEngine; public class CollectibleItem : MonoBehaviour { [Header("道具参数")] public int scoreValue = 1; public float rotateSpeed = 180f; public float fadeDuration = 0.5f; private bool isCollected = false; private void Update() { transform.Rotate(0f, rotateSpeed * Time.deltaTime, 0f); } private void OnTriggerEnter(Collider other) { if (isCollected) { return; } if (other.CompareTag("Player")) { isCollected = true; GameManager.Instance.AddScore(scoreValue); StartCoroutine(FadeAndDestroy()); } } private IEnumerator FadeAndDestroy() { Renderer renderer = GetComponent<Renderer>(); if (renderer == null) { Destroy(gameObject); yield break; } Color startColor = renderer.material.color; Color targetColor = new Color(startColor.r, startColor.g, startColor.b, 0f); float elapsed = 0f; while (elapsed < fadeDuration) { elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / fadeDuration); renderer.material.color = Color.Lerp(startColor, targetColor, t); yield return null; } Destroy(gameObject); } }注意要点:
isCollected防止玩家在道具销毁前反复触发拾取。TryGetComponent或GetComponent判断有没有 Renderer,避免没有可视网格的物体报空。- 如果材质不是 Transparent 模式,fade 效果不会出现,但代码逻辑不会报错,只是看起来瞬间消失。
这是常见的“脚本控制逐渐消失”需求,核心就是协程 +Color.Lerp。
4.5 UI 得分与关卡切换
文件路径:Assets/Scripts/GameManager.cs
using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header("UI 引用")] public Text scoreText; [Header("关卡参数")] public int totalCollectibles = 8; public string nextSceneName = "Level2"; private int currentScore = 0; private void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } } private void Start() { UpdateScoreText(); } public void AddScore(int value) { currentScore += value; UpdateScoreText(); if (currentScore >= totalCollectibles) { SceneManager.LoadScene(nextSceneName); } } private void UpdateScoreText() { if (scoreText != null) { scoreText.text = "得分:" + currentScore.ToString(); } } }这段代码中使用了单例Instance。新手可能会觉得单例不如“用公共变量拖引用”直观,但在跨场景、跨脚本通信时,单例确实更简单。
使用单例有一个注意点:Awake里的重复检测非常重要。否则每次加载新场景,如果 GameManager 残留,会出现多个实例,事件也会重复执行。
UI 部分,你需要:
- 在
Main场景创建 Canvas。 - 在 Canvas 下创建 Text,重命名为
ScoreText。 - 把
ScoreText拖到 GameManager 的Score Text字段上。
如果你担心忘记拖引用,可以在GameManager中增加自动查找逻辑:
if (scoreText == null) { scoreText = GameObject.Find("ScoreText")?.GetComponent<Text>(); }但这只是兜底方案,正常开发中手动拖引用更清晰,也更容易排查问题。
4.6 可选:使用 PerlinNoise 生成自然地形
如果你的第三章练习不想用平地面,可以添加一个自动生成地形的脚本。
文件路径:Assets/Scripts/TerrainGenerator.cs
using UnityEngine; public class TerrainGenerator : MonoBehaviour { [Header("地形引用")] public Terrain terrain; [Header("噪声参数")] public float noiseScale = 0.05f; public float heightIntensity = 0.3f; private void Start() { if (terrain != null) { GenerateTerrain(); } } public void GenerateTerrain() { TerrainData terrainData = terrain.terrainData; int resolution = terrainData.heightmapResolution; float[,] heights = new float[resolution, resolution]; for (int z = 0; z < resolution; z++) { for (int x = 0; x < resolution; x++) { float noiseValue = Mathf.PerlinNoise( x * noiseScale, z * noiseScale ); heights[z, x] = Mathf.Clamp01(noiseValue * heightIntensity); } } terrainData.SetHeights(0, 0, heights); } }把脚本挂到包含 Terrain 组件的物体上,运行后就能看到地形从平地变成有起伏的丘陵。
这里有一个新手容易犯的错:heightmapResolution是“高度图分辨率”,不是地形尺寸。默认可能是 513 或 129,遍历两次循环可能会有点耗时。如果场景卡顿,可以降低分辨率。
另外,SetHeights数组的第一个索引是z,第二个才是x,写反了地形会出现镜像,这在很多新手项目中非常常见。
4.7 场景切换的另一种方式
上面用的SceneManager.LoadScene会卸载当前场景。如果后续你希望保留玩家数据或 UI 不重置,可以改成:
SceneManager.LoadSceneAsync("Level2", LoadSceneMode.Additive);但使用 Additive 后,你需要手动卸载旧场景:
SceneManager.UnloadSceneAsync("Main");这两个方法配合使用,才不会出现两个场景重叠、灯光重复的情况。新手阶段建议先不要过度设计,能用普通加载就把流程跑通,再研究叠加加载。
5. 运行效果与验证
5.1 预期表现
运行Main场景后,你应该看到:
- 角色可以用 WASD 或方向键控制移动,镜头会跟着角色平滑移动。
- 场景中 8 个道具小球会缓慢旋转。
- 角色碰到小球,小球开始渐隐并消失。
- 左上角得分从 0 变成 1、2、3……
- 分数达到 8 后,自动加载
Level2场景。
如果其中任何一步没有出现,不要急着改代码,先按下面清单排查。
5.2 手动测试清单
| 检查项 | 预期结果 | 常见问题 |
|---|---|---|
| 角色移动 | 按键后角色移动 | 没有 Rigidbody 或 FixedUpdate 未生效 |
| 镜头跟随 | 角色移动时镜头平滑跟随 | CameraFollow 的 Target 未赋值 |
| 道具旋转 | 小球在场景中旋转 | Update 被暂停或脚本未挂载 |
| 道具拾取 | 碰到小球后小球消失 | 玩家没有 Rigidbody 或道具没有勾选 Is Trigger |
| 得分更新 | UI 数字变化 | scoreText 引用未赋值 |
| 关卡切换 | 达到目标分数后进入下一关 | nextSceneName 场景未加入 Build Settings |
新手最容易忽略的是“场景没有加入 Build Settings”。即使你在编辑器中打开了Level2,但只要它不在File -> Build Settings -> Scenes In Build列表里,运行时SceneManager.LoadScene("Level2")就会直接报错,提示场景不存在。
6. 常见问题与排查思路
以下问题是我整理过程中发现新手高频踩坑点,尤其和第三章相关。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Unity Hub 打不开项目 | 项目版本与当前 Unity 编辑器版本不兼容,或打开的是旧版本项目 | 确认项目版本,安装对应 Unity 版本;升级前先备份 |
| 角色移动后镜头穿模或抖动 | 摄像机在 Update 中跟随,速度较慢;或角色移动过快 | 把跟随逻辑放到 LateUpdate,并适当提高平滑速度 |
| 道具拾取没有反应 | 玩家缺失 Rigidbody;或道具 Collider 未勾选 Is Trigger | 检查碰撞体组件,给玩家添加 Rigidbody |
| 道具渐隐变成瞬间消失 | 材质 Rendering Mode 不是 Transparent/Fade | 把材质改成 Transparent,并调整 Alpha 值 |
| 加载场景报错:“Scene 'Level2' couldn't be loaded” | 场景没有加入 Build Settings | 打开 File -> Build Settings,把场景加进去 |
| 使用 Additive 加载后 UI 重复 | 旧场景未卸载,或 GameManager 残留 | 调用 UnloadSceneAsync;检查单例销毁逻辑 |
| ScrollView 图片轮播不滚动 | RectTransform 尺寸错误,或 Content 没挂 Layout 组件 | 检查 Content 的宽度和高度;用 GridLayoutGroup/VerticalLayoutGroup 辅助布局 |
| URP 下材质变粉 | 项目使用 URP,但材质是 Built-in 管线 Shader | 升级材质 Shader 到 Universal Render Pipeline/Lit |
| 打包后右下角有“试用水印” | 使用 Unity Personal 版本且许可证未激活或激活失败 | 检查 Unity 账户授权;如果是个人学习版不能用于商业分发,需遵守许可规则 |
这里要重点说一个和“Set Mouse Cursor 为什么没有生效”类似的问题:很多新手在 UI 里改了Cursor.lockState或Cursor.visible但没生效,原因通常是项目启用了新版输入系统,或者在某帧又被其他脚本改回去。排查方法是搜整个项目中对Cursor的赋值,确认不是两段脚本互相覆盖。
另外,如果你在 UI 里想把 3D 模型显示在界面上,最简单的做法不是把 3D 物体塞进 Canvas,而是用 Render Texture:
- 创建一个摄像机,只渲染 3D 模型。
- 把摄像机输出到 Render Texture。
- 在 UI 中使用 Raw Image 显示这张纹理。
这样既不会污染主摄像机,也不会受 Canvas 渲染顺序影响。
7. 工程建议与最佳实践
7.1 目录与命名规范
从第三章开始,建议养成统一的命名习惯:
- 脚本名和类名保持一致:
PlayerController.cs对应public class PlayerController。 - 场景名称用有意义的英文:
Main、Level2比scene1、test更好。 - 资源文件名避免中文和特殊符号,避免跨平台打包问题。
- 目录按功能分两层以上,但不要过度嵌套。
如果你后续要接 Pico 4、微信小游戏、数字孪生等方向,规范的目录结构会更重要。项目一大,命名混乱带来的维护成本远超你想象。
7.2 脚本生命周期与引用管理
Unity 脚本的生命周期顺序是:
Awake -> OnEnable -> Start -> Update -> LateUpdate -> OnDisable -> OnDestroy新手常见错误是在Start里获取组件引用,但另一个脚本在Awake里就尝试调用这个引用,导致空引用错误。
我的建议:
Awake中只做自身初始化,比如GetComponent、单例赋值。Start中做需要依赖其他脚本初始化的操作。Update中做每帧变化逻辑。FixedUpdate中做物理相关逻辑。LateUpdate中做摄像机等“最后执行”的逻辑。
千万不要一上来就在Update里写GameObject.Find,每帧查找对象会造成不必要的性能消耗。应该把引用提前拖到 Inspector 或者在Awake中缓存。
7.3 安全边界与合法发布
如果你是在公司项目里学习或开发,要注意几个安全边界:
- 不要在未知来源下载
.dll、插件或“破解脚本”,防止引入恶意代码。 - 发布安卓应用前,要按平台要求处理隐私协议、权限说明;尤其是涉及网络、位置、摄像头时,必须申请合法授权。
- 数据库删除、生产环境变更等场景,必须先备份、在测试环境验证,再操作。
- 学习阶段如果使用 Unity Personal 版本,要注意许可限制。打包程序出现试用水印时,先检查授权,而不是尝试绕过授权。
这一点在“Unity 打包的软件右下角有试用水印”这个问题上很常见。很多新人以为是自己代码问题,实际上只是个人版许可证。遇到这种情况,请先确认账户登录状态和许可证激活状态,不要使用任何非官方激活方式。
7.4 性能与渲染管线注意事项
第三章的收集小项目不需要过度优化,但有几个习惯可以从现在养成:
- 减少 Update 中的
GetComponent、FindObjectOfType等高频调用。 - 道具类物体尽量使用对象池,而不是频繁 Instantiate 和 Destroy。但在项目简单时,Direct Destroy 也可以接受。
- 如果使用了 URP,建议提前了解 URP Asset 之间的区别;同一个项目里不要混用 Built-in 和 URP Shader。
- Shader 相关功能(比如描边、高亮)推荐先学习 URP 的 Shader 编写规范,不要复制旧版 Built-in 代码后直接改。
如果你要学习 Unity 自带的Mathf.PerlinNoise做随机地形,记住噪声函数是“确定性”的:同样的输入坐标永远返回同样的输出值。这意味着同一台机器上每次运行生成的地形是一样的,除非你给noiseScale或坐标加一个随机偏移。
8. 总结与下一步学习方向
第三章部分完结后,你应该已经掌握了一个 Unity 小项目从“静态场景”到“可交互玩法”的核心套路:角色用 Rigidbody 移动、摄像机 LateUpdate 跟随、道具用 Trigger 检测、UI 用 GameManager 统一更新、场景用 SceneManager 切换。
接下来可以继续深入的方向有很多。如果你觉得自己已经能独立跑通上面的案例,可以重点研究这几个方向:
- 道具使用对象池管理,提高大量生成和销毁时的性能。
- 使用新 Input System 重写角色控制,适应新项目和移动端。
- 用 Render Texture 在 UI 中显示 3D 角色模型,做角色预览界面。
- 用 ScrollView 做关卡选择列表,配合图片轮播展示关卡信息。
- 用 LoadSceneMode.Additive 拆分 UI 场景和玩法场景,为后续大型项目打基础。
- 学习 URP 下的 Shader 基础,比如描边、高光、溶解效果。
这些内容正好也是 Unity 面试、实际项目里经常被问到的点。如果你能把第三章的小项目完整做完,并且能清楚说出每个脚本“为什么这么写”,后面的进阶路线会顺畅很多。
建议你现在就新建一个空项目,从创建地面开始,一步一步把完整代码敲进去。遇到报错不用怕,对照文档里的排查清单逐项检查,一个能跑起来的“垃圾项目”远远胜过只收藏不实践的“完美教程”。