如果你是 27 届在校生,或者刚开始接触 Unity 的准开发者,想用一周时间做一个能放进作品集的 Demo,那么这篇文章很适合作为一条可参考的路线。
经常看到有同学问:“零基础自学 Unity 一周,到底能不能做出看得过去的小项目?”我的回答是:能做出来,但前提是你不要从零去啃完一本 600 页的引擎书,而应该带着一个明确的小目标,边做边补知识。本文就按这个思路,复盘一个“一周自学 Unity Demo”的完整过程。Demo 体量不大,但基本包含了移动控制、碰撞触发、跳跃、UI 计分、协程渐隐、程序化地形等常见功能,很适合作为新手第一个“五脏俱全”的练习项目。
需要说明的是,这篇文章不会教你怎么下载来路不明的工具,而是基于 Unity 官方渠道完成安装、激活与打包。Demo 里用到的核心代码全部会贴出来,附带运行思路和踩坑经验,目标是让完全没接触过 Unity 的同学也能跟着复现一版。
1. 一周 Demo 复盘:零基础做 Unity 小游戏可行吗
1.1 这个 Demo 解决什么问题
很多初学者一开始学 Unity 有个误区:先去看引擎菜单里每一个面板的作用,然后再决定做什么。结果学了两三天还停留在“设置了场景里的方块往左移动”的层次,到第七天赋闲时发现自己什么都没做出来。
更合理的方式是选定一个小的完整玩法,把流程先跑通。本文的 Demo 目标是做一个第三人称视角的“收集小游戏”:玩家在 3D 场景中控制胶囊体角色移动与跳跃,收集场景里的金币型 Pickup 物体,集齐所有收集物后,屏幕中央会显示“恭喜完成”提示并渐隐消失,最后把项目打包成 Windows 可运行程序。
从技术角度看,这个 Demo 涉及以下知识点:
- Unity 项目创建与场景组织。
- GameObject、Component、Prefab 的关系。
- 基于 Rigidbody 的角色移动。
- 基于物理触发器的拾取判定。
- 摄像机跟随角色。
- 使用 UGUI 显示得分与进度。
- 使用协程和 CanvasGroup 实现 UI 渐隐。
- 使用 Mathf.PerlinNoise 自动生成起伏地面。
一周时间把这些点走一遍,收获会比单纯看教程大很多。因为这个过程是在解决一个又一个具体问题,而不是被动吸收抽象概念。
1.2 一周时间怎么分配
如果每天大约投入 3 到 4 小时,可以参考下面的时间表:
| 天次 | 重点方向 | 建议产出 |
|---|---|---|
| 第 1 天 | 安装 Unity,熟悉编辑器布局 | 创建 3D 项目,能摆放简单物体 |
| 第 2 天 | C# 脚本基础与生命周期 | 写一个让物体旋转或移动的脚本 |
| 第 3 天 | 物理系统与碰撞器 | 角色能移动、跳跃并落到地面 |
| 第 4 天 | Prefab 与场景搭建 | 场景里布置障碍物和收集物 |
| 第 5 天 | UGUI 与游戏管理器 | 得分、进度、完成提示能显示 |
| 第 6 天 | 整合与细节 | 完成全部流程,加入音效和特效 |
| 第 7 天 | 打包与复盘 | Build 出 exe,并写一篇项目笔记 |
这个安排的核心思路是先做“最小的可玩闭环”,再逐步加内容。不要第一天就去研究光照烘焙或动画状态机,那不是一周新手阶段的主线任务。
2. Unity 安装与项目环境准备
2.1 Unity Hub、LTS 与项目创建
新手安装 Unity 时最容易踩的坑是:直接下载编辑器,然后再找 License 激活。更规范的做法是安装 Unity Hub,通过 Hub 来管理和下载不同版本的 Unity 编辑器。
Unity Hub 的主要作用有三个:
- 管理多个 Unity 编辑器版本。
- 创建新项目并选择模板。
- 管理许可证激活。
版本方面,建议长期支持版本 LTS。LTS 版本通常更稳定,适合学习和项目开发。本文示例不绑定某一个具体小版本,核心代码在 Unity 2021.3 和 Unity 2022.3 及以上版本都可以运行。如果你电脑上有 Unity 6 或更新版本,也可以直接使用。
安装完成后,打开 Unity Hub,点击“New project”,选择 3D 模板,输入项目名称,比如CollectDemo,然后创建。
这时如果弹出许可证相关提示,不要尝试绕过或使用非官方激活工具,正确做法是:
- 在 Unity Hub 右上角点击头像,登录 Unity 账号。
- 进入 Manage License 页面。
- 选择个人版 Personal 许可证,或学生版 Unity Student。
- 返回 Hub 启动项目即可。
启动后,你能看到默认场景中包含了一个平行光 Directional Light 和一个自带相机。
2.2 三个基础概念:GameObject、Component、Prefab
Unity 的核心概念并不复杂,第一周需要搞清楚下面三个词:
- GameObject:场景里的每一个物体都是 GameObject。它可以是一个圆球、一个空节点、一个 UI 文本,也可以是一盏灯。
- Component:组件挂在 GameObject 上,用来决定这个物体“能做什么”。比如 Transform 负责位置旋转缩放,Camera 负责渲染,Rigidbody 负责物理运动。
- Prefab:将某个 GameObject 连同它上面的组件和子物体一起保存到磁盘,形成可复用的预制体。可以在场景中多次摆放,修改 Prefab 后所有实例同步更新。
一个常见的比喻是:GameObject 像一个空容器,Component 是插进容器的能力模块。脚本也是一种 Component,只是它是开发者自己定义的。
在 Demo 开发中,收集物、障碍物、特效都适合做成 Prefab。这样场景里的重复物体就不用一个个单独配置。
2.3 项目目录结构建议
项目创建后,Assets 目录是 Unity 项目最重要的文件夹。新手为了省事把所有脚本和模型都放在根目录,等资源变多后非常难找。建议在一开始就建立清晰目录:
Assets/ Scenes/ Scripts/ Player/ Manager/ World/ Prefabs/ Materials/ UI/ Textures/其中Scenes存放场景文件,Scripts/Player存放角色相关脚本,Scripts/Manager存放 GameManager 这类全局管理脚本,Scripts/World存放地面生成、收集物等物体脚本。
在 Unity 中手动创建这些文件夹或直接在文件系统里创建都可以。每次新建脚本时,尽量根据职责放到对应文件夹中,能大幅减少后期维护成本。
3. 搭建 3D 场景并完成角色控制
3.1 场景搭建
首先打开默认场景文件,删除默认的 Main Camera,准备重新加一个相机并挂上跟随脚本。实际开发中经常先摆放角色,再根据角色位置调整相机。
在 Hierarchy 窗口右键依次创建:
- 一个 Cube,重命名为 Ground,作为地面。
- 一个 Capsule,重命名为 Player,作为玩家角色。
- 一个 Cube,重命名为 Obstacle,作为障碍物。
- 几个 Sphere,重命名为 Pickup_Coin 之类的收集物。
为了让视觉区分更明显,可以给不同物体创建材质:
- 在 Project 窗口的 Assets 下右键 -> Create -> Material。
- 将材质命名为 PlayerMaterial。
- 在 Inspector 中修改 Base Map 或 Albedo 颜色,将颜色改成亮黄色。
- 把材质拖到 Player 上。
障碍物可以做成红色,收集物做成金色或绿色,地面做成灰白色。这样游戏场景在视觉上更清晰。
地面默认 Cube 是 1 米大小,如果希望地面大一些,可以把 Transform 的 Scale 设置为(20, 1, 20),这样地面就能形成一块较大的平台。注意 Y 轴位置可以设为-0.5,让地面顶部正好在 y = 0 水平。
为了让玩家能被识别,需要把 Player 的 Tag 设置为 Player:
- 选中 Player。
- 在 Inspector 顶部点击 Tag 下拉框。
- 选择 Player,如果列表中没有就在 Add Tag 中添加。
如果 Player 后面要触发 Pickup 的碰撞事件,Tag 用于判断碰撞对象是不是玩家。
接着给 Player 添加 Rigidbody,保留 Capsule Collider。Rigidbody 能让物体受重力影响并参与物理模拟。这是角色移动代码的基础。
3.2 角色移动与跳跃
角色控制是 Demo 里最核心的脚本。新建脚本PlayerController.cs,路径放在Assets/Scripts/Player/PlayerController.cs。
using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 6f; public float jumpForce = 8f; [Header("地面检测")] public Transform groundCheckPoint; public float groundCheckRadius = 0.2f; public LayerMask groundLayer; private Rigidbody rb; private bool isGrounded; private void Start() { rb = GetComponent<Rigidbody>(); if (rb != null) { rb.interpolation = RigidbodyInterpolation.Interpolate; rb.collisionDetectionMode = CollisionDetectionMode.Continuous; } } private void Update() { isGrounded = Physics.CheckSphere(groundCheckPoint.position, groundCheckRadius, groundLayer); } private void FixedUpdate() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 input = new Vector3(horizontal, 0f, vertical); if (input.magnitude > 1f) { input.Normalize(); } Vector3 velocity = input * moveSpeed; velocity.y = rb.velocity.y; rb.velocity = velocity; } private void LateUpdate() { if (Input.GetButtonDown("Jump") && isGrounded) { rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); } } }这段脚本里有几个地方需要详细说明。
Physics.CheckSphere会在指定位置创建一个不显示的球形检测区,检测范围内是否存在指定 Layer 的碰撞体。我用它来判断角色是否站在地面上。实际使用时,需要给 Player 创建一个子物体 GroundCheck,放在角色底部中心靠下的位置,代码里的groundCheckPoint就指向它。
具体操作如下:
- 在 Hierarchy 中选中 Player。
- 右键 -> Create Empty,创建子物体。
- 重命名为 GroundCheck。
- 把 GroundCheck 的 Transform 位置调整到角色底部,比如 Capsule 的底部中心略微下方一点,可以参考
(0, 0, 0)附近再往下偏移一点。 - 将 GroundCheck 拖到 PlayerController 的 GroundCheck Point 字段。
Input.GetAxis("Horizontal")和Input.GetAxis("Vertical")读取的是键盘按键输入,默认情况下对应 A/D 和 W/S,也包含上下左右方向键。移动时会自动从一个值渐变到另一个值,手感比较平滑。如果想做更精确的手感,可以换成Input.GetAxisRaw,它是离散状态,按键瞬间到 1 或 -1。
FixedUpdate是固定频率执行的物理更新函数,适合处理刚体速度。每帧赋值的核心思路是:水平方向速度来自输入,垂直方向速度保留刚体原来的值。这样角色既能移动,又不会覆盖掉重力产生的下落速度。
调用rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse)时,ForceMode.Impulse表示给物体一个瞬间冲量,适合跳跃效果。
3.3 为什么用 FixedUpdate,而不是 Update
新手常见的问题是“移动代码放在 Update 里也能跑,为什么要用 FixedUpdate”。
原因是:Rigidbody 的物理模拟发生在 FixedUpdate 的固定时间步长中。如果在 Update 里修改刚体的 velocity 或施加力,会和物理系统的更新频率错开,导致运动抖动、穿过碰撞体等问题。Unity 官方推荐把与物理计算相关的逻辑放在 FixedUpdate 中。
但是,输入检测一般放在 Update 或 LateUpdate 中。上面代码把跳跃按键检测放在 LateUpdate,是因为如果在 FixedUpdate 中检测按键,可能会连续检测到一次按下的状态多次,出现“按一次跳两次”的问题。当然也可以用Input.GetButtonDown配合帧标记处理。对新手来说,使用上面代码简单且不容易出错。
如果你仔细观察,会发现这里的地面检测写在 Update 中,移动写在 FixedUpdate 中。这样可以让每帧显示的地面状态更新及时,尤其是按下跳跃键时isGrounded已经是当前帧更新后的结果。
4. 摄像机跟随:LateUpdate 和 SmoothDamp
摄像机跟随是几乎每个 3D Demo 都需要实现的功能。新建脚本CameraFollow.cs,路径放在Assets/Scripts/Player/CameraFollow.cs。
using UnityEngine; public class CameraFollow : MonoBehaviour { [Header("跟随目标")] public Transform target; [Header("偏移与平滑")] public Vector3 offset = new Vector3(0f, 6f, -8f); public float smoothTime = 0.2f; private Vector3 currentVelocity = Vector3.zero; private void LateUpdate() { if (target == null) { return; } Vector3 targetPosition = target.position + offset; transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref currentVelocity, smoothTime); transform.LookAt(target.position + Vector3.up * 1.5f); } }将脚本挂到主相机 Main Camera 上,然后把 Player 拖到 CameraFollow 的 Target 字段。
这里使用LateUpdate而不是Update的原因是:如果角色在物理系统中移动,摄像机和角色在同一帧的更新顺序没有保证;放在LateUpdate中可以确保所有物体的更新逻辑执行完之后,再计算相机位置,视觉上更稳定。
Vector3.SmoothDamp是 Unity 自带的一阶阻尼平滑算法。它比直接Vector3.Lerp的优点是不需要手动调节插值时间,只需要提供一个速度引用currentVelocity,最终效果是“先快后慢”的平滑跟随,有点类似于摄像机追着角色跑的感觉。
transform.LookAt让相机始终朝向角色。因为角色胶囊体的中心约在 y = 1,所以看向的点加了一个Vector3.up * 1.5f,保证视线大致对准角色躯干,而不是脚底。
运行项目后,可以用 WASD 移动角色,观察相机的跟随效果。如果觉得相机太远或太近,修改 Inspector 中的 Offset 即可。
5. 收集玩法、UI 与协程渐隐
5.1 收集物和触发器
给要拾取的 Sphere 添加 Box Collider 或 Sphere Collider,并勾选Is Trigger。然后创建标签或按类型区分,这里将收集物命名为 Pickup。
新建脚本PickupItem.cs,路径放在Assets/Scripts/World/PickupItem.cs。
using UnityEngine; public class PickupItem : MonoBehaviour { [Header("拾取配置")] public string itemType = "coin"; public int scoreValue = 10; public GameObject collectEffectPrefab; private void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player")) { return; } if (GameManager.Instance != null) { GameManager.Instance.AddCollect(itemType, scoreValue, gameObject.name); } if (collectEffectPrefab != null) { Instantiate(collectEffectPrefab, transform.position, Quaternion.identity); } Destroy(gameObject); } }OnTriggerEnter 是一个物理事件回调,当拥有 Collider 和 Rigidbody 的物体进入另一个 Collider 区域时触发。这里必须确保:
- Pickup 的 Collider 勾选了 Is Trigger。
- 玩家 Player 带有 Collider 和 Rigidbody。
- 玩家身上 Tag 设置为 Player。
常见问题是不给 Rigidbody 就拿触发器脚本去测,结果 OnTriggerEnter 一直不调用。请记住,物理事件要求至少有一方带有 Rigidbody。由于玩家已经在第 3 节加过 Rigidbody,这里没问题。
拾取特效可以做成一个临时 GameObject,例如使用粒子系统 Particle System,或者一个会缩放消失的小球。最简单的方式是创建一个空特效 Prefab,在里面放一个 Particle System,播放完由粒子系统自动销毁。如果没时间做特效,也可以先只在 GameManager 中增加得分。
5.2 GameManager 单例与数据统计
Demo 中需要一个全局对象来管理得分、收集进度和 UI 显示。不要直接把得分变量写死在玩家脚本里,那样后续扩展会很麻烦。这里使用单例模式创建 GameManager。
新建脚本GameManager.cs,路径放在Assets/Scripts/Manager/GameManager.cs。
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header("UI引用")] public Text scoreText; public Text progressText; public CanvasGroup centerTipGroup; public Text centerTipText; [Header("收集配置")] public int totalPickupCount = 6; private int totalScore = 0; private int collectedCount = 0; private List<string> collectRecords = new List<string>(); private Dictionary<string, int> typeCountMap = new Dictionary<string, int>(); private void Awake() { if (Instance == null) { Instance = this; } else { Destroy(gameObject); } } public void AddCollect(string itemType, int score, string itemName) { totalScore += score; collectedCount++; if (!typeCountMap.ContainsKey(itemType)) { typeCountMap.Add(itemType, 0); } typeCountMap[itemType]++; string record = $"[{itemType}] {itemName} +{score}"; collectRecords.Add(record); scoreText.text = $"得分:{totalScore}"; progressText.text = $"收集进度:{collectedCount} / {totalPickupCount}"; if (collectedCount >= totalPickupCount) { StartCoroutine(ShowCenterTip("恭喜完成全部收集!")); } } public IEnumerator ShowCenterTip(string content) { if (centerTipGroup == null || centerTipText == null) { yield break; } centerTipText.text = content; centerTipGroup.alpha = 1f; yield return new WaitForSeconds(1.2f); while (centerTipGroup.alpha > 0f) { centerTipGroup.alpha -= Time.deltaTime / 0.6f; yield return null; } centerTipGroup.alpha = 0f; } public string BuildStatistic() { string typeLine = "类型统计:"; foreach (KeyValuePair<string, int> kv in typeCountMap) { typeLine += $"{kv.Key}x{kv.Value} "; } string recordLine = "收集记录:\n" + string.Join("\n", collectRecords); return typeLine + "\n" + recordLine; } }在场景中新建一个空物体,重命名为 GameManager,把脚本挂上去。之后还需要制作 UI。在 Hierarchy 中右键 -> UI -> Canvas,创建 Canvas。在 Canvas 下创建两个 Text,分别命名为 ScoreText 和 ProgressText,并把它们拖到 GameManager 的 UI 引用位置。
Canvas 默认自带 Canvas Scaler 组件,需要把 UI Scale Mode 设置为 Scale With Screen Size,Reference Resolution 可填写1920 x 1080。文本位置可以放在屏幕左上角,ProgressText 放在 ScoreText 下方。字体大小建议设为 32 左右,颜色改为黑色或白色,具体取决于背景亮度。
CanvasGroup 组件要挂在 Canvas 或某个 UI 父节点上。这里用一个中心提示区域来演示渐隐:在 Canvas 下创建一个空物体 CenterTip,再添加一个 Text 子物体,中心提示文字可以设为“恭喜完成全部收集!”。然后在 CenterTip 上添加 CanvasGroup 组件,把初始 Alpha 设置为 0,这样运行游戏时不可见。
代码中StartCoroutine(ShowCenterTip(...))会启动一个协程。协程允许方法在yield处暂停,在下一帧或指定时间后继续执行。这里先显示文字并等待 1.2 秒,然后在一帧一帧递减 CanvasGroup 的 Alpha,直到文字完全隐藏。
这种做法的好处是非常通用:以后要做一个开场提示、结算提示或场景淡入淡出,都可以复用同一套协程思路。
5.3 为什么用 CanvasGroup
很多新手想实现 UI 渐隐,会直接去修改 Text 组件的 Color 透明度。这样做有一个问题:如果 UI 节点下面有很多 Text、Image 子节点,需要遍历每个子节点去改颜色,非常麻烦。
CanvasGroup 是一个统一控制 UI 透明度和交互的组件。修改它的 Alpha 会影响所有子 UI 的显示透明度。所以这里直接把 CanvasGroup 挂到包含所有提示文字的父节点上,然后在协程里修改centerTipGroup.alpha,一行代码就完成了整个提示区域的渐进隐藏。
如果你还需要禁用按钮点击或阻止一段 UI 遮住鼠标事件,可以修改 CanvasGroup 的blocksRaycasts属性,配合 Alpha 使用。
5.4 C# 里 List 与 Dictionary 的配合使用
GameManager 中使用了一个 List 和一个 Dictionary。
List<string> collectRecords保存收集记录的列表,用于保留完整日志。Dictionary<string, int> typeCountMap保存统计信息,例如 coin 收集了多少次、gem 收集了多少次。
在 Unity 开发中,这两种容器非常常用。List 适合保存有序数据,Dictionary 适合按键快速查找数据。很多场景里需要把 List 转换为 Dictionary,比如有一批网络下发数据或配置表格,需要按 ID 快速查找。
下面给出一个简单的 List 转 Dictionary 的例子,后续做背包或关卡系统时会经常用到:
using System.Collections.Generic; using UnityEngine; public class ListToDictExample : MonoBehaviour { public List<ItemConfig> itemConfigs; private Dictionary<string, ItemConfig> configDic = new Dictionary<string, ItemConfig>(); private void Start() { configDic.Clear(); foreach (ItemConfig config in itemConfigs) { if (!configDic.ContainsKey(config.itemId)) { configDic.Add(config.itemId, config); } else { configDic[config.itemId] = config; } } } } [System.Serializable] public class ItemConfig { public string itemId; public string displayName; public int price; }这个例子的要点是:在 foreach 循环里先判断ContainsKey,如果已经存在同名的 key 再决定是覆盖还是跳过。如果项目里列表有重复数据,直接 Add 会抛出ArgumentException: An item with the same key has already been added,这是初学者很容易踩的坑。
实际项目里,如果静态配置很多,还可以用 Unity 的 SerializableDictionary 等扩展实现,但原理仍然是 Dictionary。
6. 自动生成起伏地形:Mathf.PerlinNoise 初体验
Demo 如果只用一块平坦 Cube 做地面,显得有点单调。除了可以用 Unity 的 Terrain 地形系统,用脚本生成网格也是一种很有意思的练习。这里介绍用 Mathf.PerlinNoise 生成起伏地形的方法,很多随机地图和地面 Demo 都会用到它。
新建脚本TerrainGenerator.cs,路径放在Assets/Scripts/World/TerrainGenerator.cs。
using UnityEngine; [RequireComponent(typeof(MeshFilter))] [RequireComponent(typeof(MeshRenderer))] public class TerrainGenerator : MonoBehaviour { [Header("尺寸")] public int width = 30; public int depth = 30; [Header("起伏参数")] public float heightScale = 2f; public float noiseScale = 0.15f; private void Start() { CreateMesh(); } private void CreateMesh() { Mesh mesh = new Mesh(); int vertexCountX = width + 1; int vertexCountZ = depth + 1; Vector3[] vertices = new Vector3[vertexCountX * vertexCountZ]; for (int z = 0; z < vertexCountZ; z++) { for (int x = 0; x < vertexCountX; x++) { float sampleX = x * noiseScale; float sampleZ = z * noiseScale; float y = Mathf.PerlinNoise(sampleX, sampleZ) * heightScale; vertices[z * vertexCountX + x] = new Vector3(x - width / 2f, y, z - depth / 2f); } } int[] triangles = new int[width * depth * 6]; int triIndex = 0; for (int z = 0; z < depth; z++) { for (int x = 0; x < width; x++) { int topLeft = z * vertexCountX + x; int topRight = topLeft + 1; int bottomLeft = (z + 1) * vertexCountX + x; int bottomRight = bottomLeft + 1; triangles[triIndex++] = topLeft; triangles[triIndex++] = bottomLeft; triangles[triIndex++] = topRight; triangles[triIndex++] = topRight; triangles[triIndex++] = bottomLeft; triangles[triIndex++] = bottomRight; } } mesh.vertices = vertices; mesh.triangles = triangles; mesh.RecalculateNormals(); GetComponent<MeshFilter>().mesh = mesh; } }把脚本挂到一个空物体 GameObject 上,它会自动创建 MeshFilter 和 MeshRenderer。由于场景里需要用网格生成一个可站立的平台,还需要挂 MeshCollider。如果 MeshCollider 性能影响可接受,这个 Demo 里直接用也没有问题。
代码的核心是Mathf.PerlinNoise(sampleX, sampleZ)。它根据两个坐标值返回一个约 0 到 1 的噪声值。将噪声值乘以heightScale就得到了该点的高度 y。因此,不同的位置会有不同高度的起伏,整体看起来比较自然,而不是完全随机跳变。
需要说明的是,PerlinNoise 本身不是用来生成“均匀随机”的,它生成的是一种自然的连续变化。如果要生成随机地形,最适合用它;如果要模拟“每局完全不同的地形”,可以在采样前加上一个随机偏移量,例如:
float sampleX = (x + Random.Range(0f, 1000f)) * noiseScale; float sampleZ = (z + Random.Range(0f, 1000f)) * noiseScale;这样每一局运行时地形都会有变化。不过要注意,放在 Start 里生成的地形不会在编辑模式下刷新。如果想在编辑模式下实时预览,可以加[ExecuteInEditMode]或使用按钮调用方法生成。本文不展开这个高级用法,新手先理解点、三角形网格、噪声三个概念即可。
如果你暂时不需要程序化地形,可以直接用 Unity 的地形 Terrain 系统刷一座山。但手动刷地形速度慢,而且不方便参数化调整。以后再学 3D 地图随机生成时,PerlinNoise 是绕不开的工具。
7. 运行验证与 Build 打包
7.1 编辑器模式运行验证
在 Unity 编辑器里点击顶部 Play 按钮前,先把需要引用的对象拖拽好:
- 将 Player 拖到 CameraFollow 脚本的 Target。
- 在 Player 下创建 GroundCheck,并拖到 PlayerController 的 Ground Check Point。
- 将 Ground 或地面 Layer 设置为 Ground 层,PlayerController 中 Ground Layer 选择该层。
- 将 GameManager 拖到场景中。
- 将 UI 引用拖到 GameManager 脚本组件上。
- 将收集物 PickupItem 脚本中的 GameManager 在代码中通过单例获取,无需手动绑定。
点击 Play 后,应验证以下几点:
- 角色能在地面上移动和跳跃。
- 跳跃后能重新落地,不会持续悬空。
- 相机平滑跟随角色。
- 角色接触收集物后,收集物消失且得分增加。
- 收集全部 Pickup 后,屏幕中央显示文字提示并缓慢消失。
如果出现“角色按跳跃键没反应”,首先检查 Player 的 GroundCheck 点是否放到了角色脚下,再检查 PlayerController 中 Ground Layer 是否选对了层。如果地面没有指定为可识别层,则isGrounded会一直是 false,所以不能跳跃。
7.2 Windows 打包
确认 Demo 在编辑器里运行正常后,准备打包。
点击菜单栏 File -> Build Settings,打开 Build Settings 窗口。点击 Add Open Scenes,将当前场景加入构建列表。Platform 选择 Windows, Mac, Linux 下的 Windows,Target Platform 为 Windows,Architecture 选择 x86_64。
如果项目需要发布到移动端或 WebGL,可以切到对应平台。但第一次打包建议先做 Windows,因为构建速度快,且不需要额外配置 SDK。
设置完成后点击 Build,选择一个输出文件夹,比如项目根目录下面的Build/Windows。Unity 会开始生成 exe 文件和_Data文件夹。
打包完成后,双击.exe文件运行。如果只有自己使用,使用 Mono 脚本后端就可以了。如果要发布 Android 或 iOS,在 Player Settings 里可以将 Scripting Backend 切到 IL2CPP。对于新手 Demo,这一步不是必须的。
打包时还有一个常见问题:如果项目路径中有中文或特殊字符,某些 Unity 版本可能出现构建异常。建议整个项目路径都使用英文,例如D:/UnityProjects/CollectDemo。
8. 一周自学遇到的常见问题与排查
这一周里我整理了初学者最容易遇到的几个问题,按照“现象、原因、解决”三个维度列出来:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 角色穿过地面掉出场景 | 没有 Collider,或 Rigidbody 碰撞检测模式为默认 Discrete 且速度过快 | 给地面添加 MeshCollider 或 BoxCollider;检查角色碰撞体 |
| 跳跃键没反应 | 地面检测点位置不对,或 Ground Layer 未设置 | 将 GroundCheck 放在角色底部;确认层选择正确 |
| OnTriggerEnter 不触发 | Collider 没有勾选 Is Trigger,或双方都没有 Rigidbody | 至少让移动方带 Rigidbody,并勾选目标 Collider 的 Is Trigger |
| 移动时角色抖动 | 在 Update 中直接修改刚体速度,或没有开启插值 | 将物理移动放入 FixedUpdate;设置 Rigidbody Interpolation |
| 相机抖动或卡顿 | 跟随逻辑放在 Update 而不是 LateUpdate | 改用 LateUpdate;并用 SmoothDamp 平滑 |
| UI 文字不清晰 | Canvas Scaler 设置不当 | 设置为 Scale With Screen Size,Reference Resolution 设为 1920x1080 |
| 控制台报错 NullReferenceException | 没有给 UI Text 或 CanvasGroup 赋值 | 在 Inspector 中检查引用是否为空 |
| Build 失败 | 项目路径含中文,或场景没有加入 Build |