Unity游戏开发入门:从零构建3D平台跳跃游戏Demo
最近在整理个人项目时,翻出了一个几年前用 Unity 开发的小游戏 Demo。虽然功能简单,但麻雀虽小五脏俱全,涵盖了从场景搭建、角色控制、UI交互到简单逻辑实现的完整流程。对于刚接触 Unity 或想了解一个独立游戏项目基本构成的开发者来说,这个 Demo 是一个很好的学习样本。本文将对这个名为“我的一个游戏Demo_No_1”的项目进行完整拆解,从项目结构、核心脚本到实现细节,手把手带你理解其背后的设计思路,并探讨如何在此基础上进行扩展和优化。
1. 项目概述与核心玩法
这个 Demo 是一个简单的 3D 平台跳跃/收集类游戏。玩家控制一个角色在由基础几何体(立方体、平面等)搭建的场景中移动、跳跃,目标是收集散落在场景中的特定物品(例如金币或星星),当收集到所有目标物品后,游戏即告胜利。
核心玩法循环:
- 玩家输入:通过键盘(WASD)控制角色移动,空格键控制跳跃。
- 角色交互:角色与场景中的可收集物发生碰撞时,触发收集逻辑。
- 状态更新:UI 界面实时更新已收集物品的数量。
- 胜利条件:当收集数量达到预设总数时,触发胜利事件(如显示胜利UI)。
虽然玩法基础,但其中涉及了 Unity 游戏开发中最核心的几个模块:物理系统(Rigidbody & Collider)、输入管理、预制体(Prefab)实例化与销毁、UI 绑定以及简单的游戏状态管理。理解这些模块的协作方式,是迈向更复杂游戏开发的第一步。
2. 环境准备与项目结构
在深入代码之前,我们先明确开发环境和项目的整体面貌。
2.1 开发环境说明
- 引擎版本:该项目最初使用 Unity 2019.4 LTS 版本创建。对于此类基础项目,Unity 2018 LTS 至 2022 LTS 的大部分版本均可兼容。建议使用稳定的 LTS(长期支持)版本以规避一些新版本可能引入的不稳定问题。
- 项目模板:3D 项目模板。
- 目标平台:PC 独立平台(Windows/macOS)。
- 关键依赖:项目仅使用了 Unity 标准资源,无需导入额外的 Asset Store 资源包或第三方插件。
2.2 项目文件夹结构解析
一个清晰的项目结构是良好开发习惯的开端。Demo 的 Assets 文件夹组织如下:
Assets/ ├── Scenes/ │ └── Main.unity # 主游戏场景 ├── Scripts/ # 所有C#脚本 │ ├── PlayerController.cs │ ├── CollectibleItem.cs │ ├── GameManager.cs │ └── UIManager.cs ├── Prefabs/ # 预制体资源 │ ├── Player.prefab │ └── Collectible_Coin.prefab ├── Materials/ # 材质球 │ ├── Ground.mat │ └── Player.mat └── UI/ # UI相关资源(如Canvas、Text等)结构说明:
- Scenes:存放游戏场景文件。复杂的游戏可能有多个场景(如开始菜单、关卡1、关卡2),本 Demo 只有一个主场景。
- Scripts:这是游戏逻辑的核心。所有自定义的 C# 脚本都放在这里。
- Prefabs:预制体是预先配置好的游戏对象(GameObject),可以被重复实例化到场景中。将角色和可收集物做成预制体是标准做法。
- Materials:用于定义物体表面的视觉属性(颜色、光滑度等)。
- UI:存放与用户界面相关的元素。
3. 核心脚本与组件拆解
接下来,我们逐一分析每个核心脚本的功能和实现。理解每个脚本的职责(Single Responsibility Principle)是编写可维护代码的关键。
3.1 PlayerController.cs - 角色控制
这是驱动玩家角色的脚本,挂载在Player预制体上。它主要负责处理玩家输入并将其转化为角色的物理运动。
// 文件路径:Assets/Scripts/PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { // 可调节的参数,方便在Unity编辑器中调试 [SerializeField] private float moveSpeed = 5f; [SerializeField] private float jumpForce = 7f; [SerializeField] private Transform groundCheck; // 用于检测是否在地面的空物体 [SerializeField] private LayerMask groundLayer; // 指定哪些层是地面 private Rigidbody rb; private bool isGrounded; private float checkRadius = 0.2f; // 检测半径 void Start() { // 获取角色身上的刚体组件,这是物理运动的基础 rb = GetComponent<Rigidbody>(); if (rb == null) { Debug.LogError("PlayerController: Rigidbody component not found!"); } if (groundCheck == null) { Debug.LogError("PlayerController: GroundCheck transform not assigned!"); } } void Update() { // 每帧检测角色是否接触地面 isGrounded = Physics.CheckSphere(groundCheck.position, checkRadius, groundLayer); // 处理跳跃输入(放在Update中以确保响应迅速) if (Input.GetButtonDown("Jump") && isGrounded) { Jump(); } } void FixedUpdate() { // 处理移动输入(放在FixedUpdate中以保证物理计算的稳定性) Move(); } private void Move() { float horizontalInput = Input.GetAxis("Horizontal"); float verticalInput = Input.GetAxis("Vertical"); // 计算移动方向向量 Vector3 movement = new Vector3(horizontalInput, 0f, verticalInput).normalized * moveSpeed; // 应用速度到刚体,注意这里修改的是velocity,直接控制速度 rb.velocity = new Vector3(movement.x, rb.velocity.y, movement.z); } private void Jump() { // 给刚体一个向上的瞬时力 rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); } // 在编辑器中可视化地面检测范围 void OnDrawGizmosSelected() { if (groundCheck != null) { Gizmos.color = Color.red; Gizmos.DrawWireSphere(groundCheck.position, checkRadius); } } }关键点解析:
[SerializeField]:将私有变量暴露在 Inspector 面板,方便调试而不破坏封装性。Rigidbody:Unity 的物理组件。角色的移动和跳跃都通过操作刚体的速度(velocity)或施加力(AddForce)来实现。UpdatevsFixedUpdate:Update:每帧调用,频率与设备帧率有关。适合处理输入(如跳跃按键检测)。FixedUpdate:固定时间间隔调用,与物理引擎更新同步。适合执行物理相关的操作(如移动计算)。
- 地面检测:通过
Physics.CheckSphere在角色脚部创建一个球形检测区域,判断是否与“地面”层接触。这是实现“只能在地上跳”的关键。 OnDrawGizmosSelected:一个调试辅助方法,在场景视图中绘制检测球体,便于可视化调试。
3.2 CollectibleItem.cs - 可收集物品逻辑
这个脚本挂载在Collectible_Coin预制体上,定义了物品被收集时的行为。
// 文件路径:Assets/Scripts/CollectibleItem.cs using UnityEngine; public class CollectibleItem : MonoBehaviour { [SerializeField] private int scoreValue = 1; // 该物品对应的分数 [SerializeField] private ParticleSystem collectEffect; // 收集时的粒子效果(可选) [SerializeField] private AudioClip collectSound; // 收集时的音效(可选) private void OnTriggerEnter(Collider other) { // 检查碰撞对象是否是玩家(通过Tag判断) if (other.CompareTag("Player")) { Collect(); } } private void Collect() { // 通知游戏管理器增加分数 if (GameManager.Instance != null) { GameManager.Instance.AddScore(scoreValue); } // 播放粒子效果(如果有) if (collectEffect != null) { Instantiate(collectEffect, transform.position, Quaternion.identity); } // 播放音效(如果有且游戏管理器中有AudioSource) if (collectSound != null && GameManager.Instance != null && GameManager.Instance.audioSource != null) { GameManager.Instance.audioSource.PlayOneShot(collectSound); } // 销毁自身游戏对象 Destroy(gameObject); } }关键点解析:
OnTriggerEnter:这是一个碰撞器回调函数。当该物体的Collider(需勾选Is Trigger)与另一个Collider发生接触时调用。使用Trigger而不是物理碰撞,因为收集行为不需要物理反馈(如弹开)。- Tag 的使用:通过
CompareTag(“Player”)精确判断碰撞对象是否为玩家。这是一种高效且清晰的对象识别方式。 - 与 GameManager 通信:收集物不直接处理游戏逻辑(如更新UI),而是通过单例模式的
GameManager.Instance发送消息。这降低了脚本间的耦合度。 - 效果与销毁:在销毁自身前,可以实例化一个粒子效果预制体并播放音效,增强游戏反馈。
Destroy(gameObject)会从场景中移除该物品。
3.3 GameManager.cs - 游戏状态管理
游戏管理器是一个中心化的单例类,负责管理游戏的全局状态,如分数、胜负条件等。
// 文件路径:Assets/Scripts/GameManager.cs using UnityEngine; using UnityEngine.SceneManagement; // 用于场景重载 public class GameManager : MonoBehaviour { // 单例实例 public static GameManager Instance { get; private set; } public AudioSource audioSource; // 用于播放全局音效 private int totalCollectibles; private int collectedCount = 0; [SerializeField] private int winThreshold = 5; // 胜利需要收集的数量 void Awake() { // 实现单例模式 if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); // 跨场景不销毁 } else { Destroy(gameObject); } } void Start() { // 在游戏开始时,查找场景中所有可收集物品的总数 // 注意:此方法适用于静态关卡。动态生成关卡需用其他方式统计。 totalCollectibles = GameObject.FindGameObjectsWithTag("Collectible").Length; Debug.Log($"Total collectibles in scene: {totalCollectibles}"); // 初始化UI显示 UIManager.Instance.UpdateScoreUI(collectedCount, totalCollectibles); } public void AddScore(int value) { collectedCount += value; Debug.Log($"Item collected! Total: {collectedCount}"); // 更新UI UIManager.Instance.UpdateScoreUI(collectedCount, totalCollectibles); // 检查胜利条件 if (collectedCount >= winThreshold) { WinGame(); } } private void WinGame() { Debug.Log("You Win!"); UIManager.Instance.ShowWinPanel(); // 这里可以暂停游戏、播放胜利动画等 // Time.timeScale = 0f; // 暂停游戏 } // 提供一个重启游戏的方法,可由UI按钮调用 public void RestartGame() { SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); Time.timeScale = 1f; // 确保时间缩放重置 } }关键点解析:
- 单例模式 (Singleton):通过静态属性
Instance提供全局访问点。Awake方法中确保只有一个实例存在,并用DontDestroyOnLoad使其在加载新场景时保留。这是管理全局状态的常用模式。 - 状态管理:它记录了
collectedCount和winThreshold,并提供了AddScore这个公共方法来修改状态。所有修改状态的逻辑都集中在这里。 - 职责分离:
GameManager不直接操作 UI,而是通过UIManager.Instance来调用UI更新方法。同样,它也不直接处理玩家输入。 - 场景管理:提供了
RestartGame方法,使用SceneManager.LoadScene重新加载当前场景,实现重置功能。
3.4 UIManager.cs - 用户界面管理
UI 管理器负责控制所有用户界面的显示和更新。
// 文件路径:Assets/Scripts/UIManager.cs using UnityEngine; using UnityEngine.UI; // 引入UI命名空间 public class UIManager : MonoBehaviour { public static UIManager Instance { get; private set; } [Header("UI References")] [SerializeField] private Text scoreText; // 用于显示分数的Text组件 [SerializeField] private GameObject winPanel; // 胜利时弹出的面板 void Awake() { if (Instance == null) { Instance = this; } else { Destroy(gameObject); } // 确保游戏开始时胜利面板是隐藏的 if (winPanel != null) { winPanel.SetActive(false); } } // 更新分数显示 public void UpdateScoreUI(int current, int total) { if (scoreText != null) { scoreText.text = $"Collected: {current} / {total}"; } } // 显示胜利面板 public void ShowWinPanel() { if (winPanel != null) { winPanel.SetActive(true); } } }关键点解析:
- UI 绑定:通过
[SerializeField]将场景中 Canvas 下的具体 UI 元素(如Text、GameObject)拖拽赋值给脚本变量。这是一种直接的引用方式。 - 简单的 UI 控制:核心方法是
UpdateScoreUI和ShowWinPanel,它们只是简单地更新文本内容或设置游戏对象的激活状态。 - 单例模式:同样采用单例模式,方便其他脚本(如
GameManager)调用。
4. 场景搭建与组件配置实战
理解了脚本之后,我们来看看如何在 Unity 编辑器中将这些元素组装起来。
4.1 创建基础场景
- 新建一个 3D 平面(GameObject -> 3D Object -> Plane)作为地面,重命名为
Ground。为其赋予一个Ground材质(例如绿色)。 - 创建一些 3D 立方体(Cube)作为平台和障碍物,摆放在地面上方,构成简单的跳跃路径。
4.2 设置玩家(Player)预制体
- 创建一个立方体(Cube),重命名为
Player。 - 为
Player添加Rigidbody组件。冻结 Rotation 的 X, Y, Z 轴,防止角色翻滚。 - 为
Player添加Capsule Collider或Box Collider组件,调整大小以匹配模型。 - 创建一个空子物体(Create Empty),重命名为
GroundCheck,将其位置(Position)调整到角色底部。这个物体将用于地面检测。 - 将
PlayerController脚本拖拽到Player对象上。 - 在 Inspector 面板中,将
GroundCheck对象拖拽到脚本的Ground Check槽位。 - 设置
Ground Layer:在 Layer 下拉菜单中,点击 “Add Layer…”,添加一个名为 “Ground” 的新层(例如第8层)。然后选中场景中的Ground和所有平台物体,在 Inspector 顶部将其 Layer 设置为 “Ground”。最后,在PlayerController脚本的Ground Layer属性中,选择 “Ground”。 - 将
Player对象从 Hierarchy 窗口拖入Assets/Prefabs文件夹,将其制成预制体。然后可以删除 Hierarchy 中的实例。
4.3 设置可收集物(Collectible)预制体
- 创建一个球体(Sphere)或使用其他模型,重命名为
Collectible_Coin。 - 添加一个
Sphere Collider组件,并勾选Is Trigger。 - 为它添加
CollectibleItem脚本。 - 可以为其创建一个金色的材质,并挂载一个简单的旋转动画脚本(可选)以增强视觉效果。
- 将其 Tag 设置为 “Collectible”(需先在 Tag 列表中创建)。
- 同样,将其拖入
Assets/Prefabs文件夹制成预制体。 - 在场景中,从 Prefabs 文件夹拖拽多个
Collectible_Coin预制体实例到不同位置。
4.4 设置 UI
- 右键 Hierarchy -> UI -> Canvas。这将创建默认的 Canvas 和 EventSystem。
- 在 Canvas 下创建 UI -> Text,重命名为
ScoreText。调整其锚点(Anchor)到左上角,并设置合适的字体、大小和颜色。在UIManager脚本所在的游戏对象上(可以是一个空的GameManager对象),将ScoreText拖拽到对应槽位。 - 创建 UI -> Panel,重命名为
WinPanel。将其设置为全屏大小,背景半透明。在面板上添加一个 “You Win!” 的 Text 和一个 “Restart” 的 Button。为 Button 的OnClick()事件添加监听,拖拽GameManager对象,并选择GameManager.RestartGame方法。最后,在UIManager脚本中,将WinPanel拖拽到对应槽位,并确保其在 Awake 时是隐藏的(SetActive(false))。
4.5 创建并配置 GameManager 和 UIManager
- 创建一个空 GameObject,重命名为
GameManager。 - 将
GameManager.cs脚本挂载上去。 - 添加一个
AudioSource组件到同一对象上,并将其拖拽到GameManager脚本的audioSource变量中,用于播放全局音效。 - 设置
Win Threshold为场景中可收集物的总数(或你期望的胜利数量)。 - 再创建一个空 GameObject,重命名为
UIManager,挂载UIManager.cs脚本,并按照 4.4 步骤完成 UI 元素的绑定。
5. 运行测试与常见问题排查
完成以上配置后,点击 Unity 编辑器上的播放按钮,你应该可以使用 WASD 移动,空格键跳跃,当角色触碰到金币时,金币会消失,UI 分数会更新,收集足够数量后胜利面板弹出。
常见问题与解决方案:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 角色无法移动或跳跃 | 1.Rigidbody组件未添加或未正确引用。2. 输入轴名称不匹配(默认是 “Horizontal”/“Vertical”/“Jump”)。 3. 地面检测失败,导致 isGrounded始终为 false。 | 1. 检查 Player 对象是否有Rigidbody。2. 检查 Edit -> Project Settings -> Input Manager 中的轴定义。 3. 检查 GroundCheck对象位置和groundLayer设置,使用OnDrawGizmosSelected可视化检测球。 |
| 碰撞到金币没有反应 | 1. 金币的Collider未勾选Is Trigger。2. 玩家或金币的 Tag 设置错误。 3. GameManager.Instance为 null(单例未初始化)。 | 1. 确认金币 Collider 的Is Trigger已勾选。2. 确认玩家 Tag 为 “Player”,金币 Tag 为 “Collectible”。 3. 确保场景中有一个激活的 GameManager游戏对象。 |
| UI 分数不更新 | 1.UIManager.Instance或scoreText引用为 null。2. UpdateScoreUI方法未被调用或调用参数错误。 | 1. 检查UIManager对象是否存在且脚本已挂载,检查scoreText是否在 Inspector 中绑定。2. 在 GameManager.AddScore方法中下断点或加Debug.Log,查看是否执行。 |
| 胜利面板不弹出 | 1.winPanel引用为 null。2. collectedCount未达到winThreshold。3. ShowWinPanel方法未被调用。 | 1. 检查winPanel是否在 Inspector 中绑定。2. 打印 collectedCount和winThreshold的值进行对比。3. 确认 WinGame()方法被正确调用。 |
| 游戏重启后状态未重置 | GameManager使用了DontDestroyOnLoad,但数据未在场景加载后重新初始化。 | 在GameManager的OnSceneLoaded回调中重置collectedCount并重新查找totalCollectibles。或者,不使用DontDestroyOnLoad,让GameManager随场景销毁重建。 |
6. 项目优化与扩展建议
这个 Demo 实现了基本功能,但在工程实践和游戏性上还有很大提升空间。
6.1 代码结构与设计模式优化
- 事件驱动通信:目前
CollectibleItem直接调用GameManager.Instance.AddScore,耦合度较高。可以考虑使用 C# 事件(event和delegate)或 Unity 事件(UnityEvent)。例如,CollectibleItem在收集时触发一个OnItemCollected事件,GameManager订阅这个事件。这样CollectibleItem完全不需要知道GameManager的存在。 - 脚本化对象(ScriptableObject):将游戏配置数据(如
moveSpeed,jumpForce,winThreshold,甚至关卡列表)抽离成ScriptableObject资产。这使平衡调整和内容管理更灵活,无需修改代码。 - 对象池(Object Pool):对于需要频繁生成和销毁的对象(如子弹、特效、敌人),使用对象池可以显著提升性能。不要总是
Instantiate和Destroy,而是从池中取用和归还。
6.2 功能扩展方向
- 敌人与生命值:添加具有简单 AI(如巡逻)的敌人。为
Player添加生命值(Health),与敌人碰撞时扣血,血量为零时游戏失败。 - 关卡系统:创建多个场景,每个场景是一个独立关卡。
GameManager可以管理关卡进度,在胜利时加载下一关。 - 数据持久化:使用
PlayerPrefs或序列化到文件,保存玩家的最高分、已解锁关卡等信息。 - 音频管理:创建独立的
AudioManager单例,管理背景音乐和多种音效的播放、音量混合,而不是依赖GameManager上的AudioSource。 - 更丰富的 UI:添加主菜单、暂停菜单、设置界面(调节音量和画质),并学习 Unity 的新 UI 系统(UI Toolkit)或更强大的第三方 UI 框架。
6.3 性能与体验优化
- 输入优化:将输入处理抽象出来,为未来支持手柄、移动端触摸输入留出接口。
- 相机跟随:为相机添加一个平滑跟随玩家角色的脚本,改善游戏体验。
- 动画状态机:为玩家角色添加 Animator 控制器和动画,根据移动、跳跃、闲置等状态播放不同动画。
这个“游戏Demo_No_1”虽然简单,但它像一颗种子,包含了游戏开发最基本的思想:对象、组件、消息、状态。通过拆解它、优化它、扩展它,你不仅能巩固 Unity 的基础,更能逐步建立起构建更复杂游戏系统的能力。建议读者在理解本文内容后,亲自动手复现这个 Demo,并尝试实现一两个扩展功能,这是学习游戏开发最有效的方式。
