多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity新手入门:从零实现3D收集星星游戏案例详解

Unity新手入门:从零实现3D收集星星游戏案例详解 1. 项目概述从“小案例”到“大理解”“Unity基础六小案例”这个标题乍一看像是某个系列教程的第六部分内容可能是一个简单的功能实现。但作为在游戏开发一线摸爬滚打多年的老手我深知这个“小案例”背后蕴含的价值远不止几行代码那么简单。它往往是新手从“知道”到“会用”的关键转折点也是检验你是否真正理解Unity引擎核心工作流的一块试金石。很多开发者卡在入门阶段就是因为看了无数概念却没能亲手把一个完整的、哪怕很小的功能从零到一跑通。这个“小案例”的核心价值在于它强制你将之前学到的零散知识点——比如场景Scene、游戏对象GameObject、组件Component、脚本Script——串联起来在一个具体的、有明确目标的上下文中应用。它解决的不仅仅是“如何让一个方块移动”的问题更是“如何在Unity的编辑器和代码之间建立思维桥梁”的问题。无论你是刚下载完Unity对着空荡荡的编辑器不知所措的纯新手还是已经看过一些教程但动手时仍感迷茫的进阶学习者通过一个设计良好的小案例来巩固基础都是性价比最高的学习路径。2. 案例设计与核心思路拆解2.1 案例目标定义为什么是“收集星星”一个优秀的基础案例目标必须极其清晰且具备完整的游戏循环。我见过太多教程案例目标模糊比如“学习物理引擎”结果做了一个球掉落的场景就结束了学习者并不知道这能用来做什么。因此我强烈建议将第一个综合性小案例定为“3D场景中的玩家移动与物品收集”俗称“收集星星”游戏。这个案例麻雀虽小五脏俱全它能覆盖以下核心基础场景搭建理解Hierarchy层级视图和Scene场景视图的关系学会摆放基础3D物体如平面、方块、球体。组件思维为游戏对象添加并配置Rigidbody刚体、Collider碰撞体、Mesh Renderer网格渲染器等内置组件。脚本驱动编写C#脚本实现玩家控制键盘输入、物理运动力或速度、触发检测OnTriggerEnter。预制体与实例化理解Prefab预制体的概念用于管理可重复使用的“星星”对象。简单的UI交互使用Canvas和Text组件实时更新收集到的星星数量。基础的游戏状态管理判断胜利条件收集所有星星。选择这个案例是因为它的每一步都有明确的、可视化的反馈。移动键盘角色动碰到星星星星消失计数器增加。这种即时正反馈对初学者建立信心和兴趣至关重要。2.2 工程结构与资源管理在动手写第一行代码前合理的项目结构能避免后续的混乱。我建议在Project项目窗口中创建以下文件夹Scenes: 存放.unity场景文件。Scripts: 存放所有C#脚本。Prefabs: 存放预制体。Materials: 存放材质球用于给物体上色。Audio(可选): 存放音效。注意很多新手喜欢把所有文件都扔在根目录一旦项目稍微复杂找东西就会变成噩梦。养成分类存放的习惯是从业者的基本素养。对于这个案例我们需要创建一个场景文件MainScene.unity保存在Scenes文件夹。三个C#脚本PlayerController.cs,Collectible.cs,GameManager.cs保存在Scripts文件夹。一个星星的预制体Star.prefab保存在Prefabs文件夹。几个简单的材质球比如Green.mat给玩家Yellow.mat给星星保存在Materials文件夹。3. 核心模块实现与细节解析3.1 玩家控制器输入与运动的结合玩家控制是游戏交互的核心。我们将创建一个胶囊体Capsule作为玩家角色并为其挂载PlayerController脚本。PlayerController.cs脚本详解using UnityEngine; public class PlayerController : MonoBehaviour { // 1. 定义公开变量便于在Inspector中调整 public float moveSpeed 5f; // 移动速度 public float jumpForce 7f; // 跳跃力 private Rigidbody rb; // 对刚体组件的引用 private bool isGrounded; // 是否在地面 // 2. Start方法在游戏开始时调用一次 void Start() { // 获取挂载在同一游戏对象上的Rigidbody组件 rb GetComponentRigidbody(); // 如果没找到输出错误防御性编程 if (rb null) { Debug.LogError(Rigidbody component not found on Player!); } } // 3. Update方法每一帧调用用于处理输入 void Update() { // 处理跳跃输入 if (Input.GetButtonDown(Jump) isGrounded) { rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); isGrounded false; // 起跳后不再接地 } } // 4. FixedUpdate方法在固定的物理时间步长调用用于物理运动 void FixedUpdate() { // 获取原始输入-1到1 float moveHorizontal Input.GetAxis(Horizontal); float moveVertical Input.GetAxis(Vertical); // 构建一个基于世界坐标的移动方向向量 Vector3 movement new Vector3(moveHorizontal, 0.0f, moveVertical); // 将输入向量转换为速度施加给刚体 // 这里使用velocity直接设置操作更直接。也可用AddForce模拟物理推动。 rb.velocity new Vector3(movement.x * moveSpeed, rb.velocity.y, movement.z * moveSpeed); } // 5. 碰撞检测判断是否着地 void OnCollisionEnter(Collision collision) { // 简单判断如果碰撞体法线朝上与世界“上”方向夹角很小则认为着地 foreach (ContactPoint contact in collision.contacts) { if (Vector3.Dot(contact.normal, Vector3.up) 0.5f) { isGrounded true; break; } } } void OnCollisionExit(Collision collision) { // 离开碰撞时保守地设为未接地由下一次OnCollisionEnter修正 isGrounded false; } }关键点解析与避坑指南UpdatevsFixedUpdate这是新手最容易混淆的点。Update与帧率相关帧率高调用次数就多适合处理输入、动画等。FixedUpdate调用间隔固定默认0.02秒与物理引擎更新同步所有与刚体Rigidbody相关的操作都应放在这里如AddForce、修改velocity。把物理代码写在Update里会导致运动速度因帧率不同而波动。GetAxisvsGetAxisRawGetAxis(“Horizontal”)会返回一个平滑的值如-1, -0.8, -0.3…0, 0.3, 0.8, 1带有模拟缓动适合角色移动。GetAxisRaw只返回-101三个值适合需要精确响应的场景如菜单选择。刚体与碰撞体确保玩家对象既有Rigidbody负责物理模拟也有Collider如Capsule Collider负责形状界定。Rigidbody的Collision Detection属性对于快速移动的物体建议设为Continuous或Continuous Dynamic防止“穿模”。跳跃实现这里用了AddForce并指定ForceMode.Impulse瞬间力。也可以直接修改rb.velocity.y。地面检测采用碰撞法线判断虽然简单但足够用于此案例。更严谨的做法可以使用射线检测Raycast。3.2 可收集物触发与销毁星星作为可收集物不需要物理模拟但需要能被玩家“穿过”并触发事件。因此我们使用触发器Trigger而非普通碰撞体。Collectible.cs脚本using UnityEngine; public class Collectible : MonoBehaviour { public int scoreValue 1; // 这颗星星值多少分 public ParticleSystem collectEffect; // 收集时的粒子效果可选 public AudioClip collectSound; // 收集音效可选 void OnTriggerEnter(Collider other) // 注意参数是Collider不是Collision { // 1. 判断触发对象是否是玩家 // 通常通过标签(Tag)来判断比直接比较物体名更高效、更规范 if (other.CompareTag(Player)) { // 2. 通知游戏管理器增加分数 // 使用“单例”或“查找”方式获取GameManager实例。这里用查找适合小项目。 GameManager gameManager FindObjectOfTypeGameManager(); if (gameManager ! null) { gameManager.AddScore(scoreValue); } // 3. 播放效果如果有 if (collectEffect ! null) { Instantiate(collectEffect, transform.position, Quaternion.identity); } if (collectSound ! null Camera.main ! null) { AudioSource.PlayClipAtPoint(collectSound, Camera.main.transform.position); } // 4. 销毁自身游戏对象 Destroy(gameObject); } } }实操要点创建星星预制体在场景中创建一个Sphere球体缩放至合适大小赋予黄色材质。移除其Rigidbody组件不需要物理。为其Sphere Collider组件勾选Is Trigger属性。将Collectible脚本拖拽上去。最后将这个球体从Hierarchy拖到Project窗口的Prefabs文件夹就创建了预制体。之后可以从场景中删除这个实例所有星星都通过预制体实例化。玩家标签务必在Inspector中将玩家对象的Tag设为“Player”或你自定义的标签。Unity默认没有“Player”标签需要你点击Tag下拉列表选择“Add Tag…”先添加。Destroy的时机Destroy(gameObject)不会立即执行而是在当前帧更新结束后。所以在Destroy之前播放音效和粒子是安全的。粒子与音效这是提升游戏反馈感的关键。粒子系统可以创建一个简单的爆炸或星光消散效果。音效通过AudioSource.PlayClipAtPoint在指定位置通常是主摄像机播放一次无需为星星预先添加AudioSource组件。3.3 游戏管理器状态与UI的桥梁游戏管理器GameManager通常作为场景中唯一的、全局的协调者。它负责管理游戏状态分数、胜负、更新UI、控制流程等。GameManager.cs脚本using UnityEngine; using UnityEngine.UI; // 引入UI命名空间 using UnityEngine.SceneManagement; // 引入场景管理命名空间 public class GameManager : MonoBehaviour { // 1. 单例模式方便其他脚本访问 public static GameManager Instance { get; private set; } // 2. 公开UI组件引用 public Text scoreText; public GameObject winPanel; // 胜利时显示的UI面板 // 3. 私有变量记录状态 private int totalCollectibles; private int currentScore 0; void Awake() { // 实现简单的单例 if (Instance null) { Instance this; // DontDestroyOnLoad(gameObject); // 如果需要跨场景取消注释 } else { Destroy(gameObject); } } void Start() { // 初始化时查找场景中所有可收集物 // 注意这里假设所有可收集物都有Collectible脚本。更好的做法是给它们一个共同的Tag或父类。 totalCollectibles FindObjectsOfTypeCollectible().Length; UpdateScoreUI(); if (winPanel ! null) winPanel.SetActive(false); // 隐藏胜利面板 } // 4. 供Collectible调用的公共方法 public void AddScore(int value) { currentScore value; UpdateScoreUI(); CheckWinCondition(); } void UpdateScoreUI() { if (scoreText ! null) { scoreText.text $Score: {currentScore} / {totalCollectibles}; } } void CheckWinCondition() { if (currentScore totalCollectibles) { WinGame(); } } void WinGame() { Debug.Log(You Win!); if (winPanel ! null) { winPanel.SetActive(true); // 显示胜利UI } // 可以在这里暂停游戏时间 // Time.timeScale 0f; } // 5. 提供给UI按钮的方法 public void RestartGame() { // 重新加载当前场景 SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); Time.timeScale 1f; // 恢复时间如果之前暂停了 } public void QuitGame() { // 在Unity编辑器中停止播放在打包后的应用中退出 #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying false; #else Application.Quit(); #endif } }设计思路与扩展单例模式这是小型项目中最常用的全局访问模式。通过GameManager.Instance任何脚本都能轻松访问到管理器。注意Awake中的判空逻辑防止重复创建。UI绑定在Unity编辑器中你需要创建一个Canvas里面包含一个Text作为scoreText和一个隐藏的Panel作为winPanel里面可以放“You Win!”文字和“Restart”按钮。然后将这些UI对象拖拽到GameManager脚本的对应公开字段上。这种拖拽赋值的方式称为“序列化字段赋值”是Unity的特色。查找对象FindObjectsOfTypeCollectible()在Start中调用一次获取初始数量。在大型场景中频繁使用Find或GetComponent会影响性能但在这个小案例中完全可以接受。更优的做法是让可收集物在生成时主动向管理器注册。场景管理SceneManager.LoadScene是加载场景的标准方式。GetActiveScene().buildIndex获取当前场景的索引。你需要确保在File - Build Settings中将你的场景添加到了“Scenes In Build”列表中。4. 场景搭建与整合测试4.1 场景构建步骤创建地面创建一个Plane或Cube缩放至合适大小重命名为“Ground”赋予一个深色材质。创建玩家创建一个Capsule重命名为“Player”。重置其Transform。添加Rigidbody组件调整Mass质量为1Drag阻力为0。添加Capsule Collider。将Tag设为“Player”。将PlayerController脚本拖拽上去。创建一个绿色材质赋给它。创建游戏管理器创建一个空GameObject重命名为“GameManager”。将GameManager.cs脚本拖拽上去。布置星星从Prefabs文件夹将Star预制体拖入场景复制多个随意摆放在地面上方。设置UI菜单栏GameObject - UI - Canvas创建画布。在Canvas下创建GameObject - UI - Text重命名为“ScoreText”调整位置到屏幕左上角在Inspector中设置好字体、大小、颜色。将其拖拽给GameManager的Score Text字段。在Canvas下创建GameObject - UI - Panel重命名为“WinPanel”。在Panel下创建“You Win!”Text和“Restart”Button。取消勾选WinPanel的激活复选框使其隐藏。将WinPanel对象拖拽给GameManager的Win Panel字段。选中“Restart”按钮在Inspector的Button组件下方点击“On Click()”列表的“”号将GameManager对象拖入对象框然后在函数下拉列表中选择GameManager - RestartGame。4.2 运行测试与参数调优点击Play按钮运行。使用WASD或方向键移动空格键跳跃。触碰星星分数应增加星星消失。收集完所有星星应弹出胜利面板点击按钮可重启游戏。常见调优点移动手感飘或钝调整PlayerController中的moveSpeed。如果用了物理力可以调整刚体的Drag空气阻力和Angular Drag旋转阻力。跳跃过高或过低调整jumpForce。重力大小Edit - Project Settings - Physics - Gravity也会影响跳跃高度。碰撞体大小不准在Inspector中调整玩家和星星Collider的Center和Size/Radius使其与视觉模型匹配。5. 进阶思考与常见问题排查5.1 从案例延伸的核心概念完成这个基础案例后你不应止步于此。试着思考以下扩展每一个都对应着Unity的一个核心技能点动画为玩家添加跑动和跳跃动画。这需要学习Animator Controller和状态机。音效管理创建一个AudioManager单例来统一播放背景音乐和音效而不是在每个物体上PlayClipAtPoint。对象池如果星星需要反复生成和消失比如被收集后隔一段时间再生频繁的Instantiate和Destroy会产生GC垃圾回收开销。学习使用对象池Object Pooling来优化性能。数据持久化保存最高分。学习使用PlayerPrefs或序列化到文件。场景切换制作一个开始菜单场景学习使用SceneManager.LoadScene按名称切换。5.2 高频问题与解决方案实录以下是我在带新人或自己开发中遇到关于此类基础案例最多的几个问题问题1脚本编译无误但挂到对象上后公开的变量在Inspector中不显示排查检查脚本类名和文件名是否完全一致包括大小写。Unity通过文件名关联类。如果不一致Unity会为组件创建一个默认的占位符脚本你的变量自然不会显示。解决确保public class PlayerController所在的文件必须命名为PlayerController.cs。问题2玩家移动时尤其是旋转摄像机后方向乱了不是按世界坐标的前后左右动原因代码中movement向量是基于世界坐标的。当摄像机旋转后玩家期望的是按摄像机视角的方向移动。解决将移动方向转换为相对于摄像机的方向。void FixedUpdate() { float h Input.GetAxis(“Horizontal”); float v Input.GetAxis(“Vertical”); // 假设有一个指向摄像机的Transform变量 camTransform Vector3 forward camTransform.forward; Vector3 right camTransform.right; forward.y 0; // 确保不向上下方向偏 right.y 0; forward.Normalize(); right.Normalize(); Vector3 movement forward * v right * h; rb.velocity new Vector3(movement.x * moveSpeed, rb.velocity.y, movement.z * moveSpeed); }问题3星星被收集时有时分数会加两次或更多原因可能是碰撞/触发事件在单次接触中被多次调用。例如玩家碰撞体形状复杂或者物理更新间隔内多次检测到接触。解决在Collectible脚本中添加一个标记位确保只触发一次。private bool isCollected false; void OnTriggerEnter(Collider other) { if (!isCollected other.CompareTag(“Player”)) { isCollected true; // … 后续收集逻辑 … Destroy(gameObject); } }问题4打包后运行UI显示错位或大小不正常原因Canvas的缩放模式Canvas Scaler设置不当。在不同分辨率下UI需要自适应。解决选中Canvas在Canvas Scaler组件中将UI Scale Mode设为Scale With Screen Size并设置一个参考分辨率如1920x1080。这样UI元素会根据屏幕尺寸等比缩放。问题5在编辑器中运行正常但脚本修改后重新运行状态没有重置原因Unity在Play模式下对脚本的修改会实时重编译并热重载但某些静态变量或单例的状态可能不会被自动重置。解决对于管理器类在OnDisable或OnDestroy方法中清理静态实例引用。或者养成习惯在测试时完全停止播放模式后再修改代码。这个“小案例”就像学习游泳时在浅水区的第一次换气动作简单但包含了呼吸、划水、蹬腿的完整协调。它本身不复杂但为你打开了用Unity创造交互世界的大门。接下来你可以尝试改变规则比如让星星会逃跑、给玩家添加冲刺技能、设计一个简单的敌人AI。每一次修改都是对已学知识的巩固和新知识的探索。真正的学习始于你关闭教程开始自己折腾的那一刻。
返回列表