三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity新手实战:从零开发“跳一跳”游戏,掌握2D/3D游戏开发核心模块

Unity新手实战:从零开发“跳一跳”游戏,掌握2D/3D游戏开发核心模块

1. 项目概述:为什么“跳一跳”是Unity新手的完美起点?

如果你对Unity游戏开发感兴趣,或者刚刚学完一些基础概念,正愁找不到一个能串联起所有知识点的实战项目,那么“跳一跳”这个完整版项目,我强烈推荐你亲手做一遍。这不仅仅是因为它曾经风靡一时,更重要的是,它的技术架构几乎涵盖了2D/3D休闲游戏开发的所有核心模块,从物理、动画、UI到游戏逻辑,麻雀虽小,五脏俱全。很多新手学Unity,容易陷入看教程都会、自己动手就废的困境,原因就在于知识点是零散的。“跳一跳”项目就像一个设计精巧的拼图,能让你把C#脚本、刚体物理、协程、UI交互、预制体管理这些碎片,亲手拼成一个能跑、能玩、能分享的完整作品。这种从0到1的成就感,是单纯看视频无法比拟的。

更重要的是,这个项目的复杂度恰到好处。它不会像大型RPG或开放世界那样让人望而生畏,其核心玩法(长按蓄力、松手跳跃、精准落点)逻辑清晰,但实现过程中需要你思考的细节却一点也不少。比如,如何计算蓄力时间和跳跃力度的关系?如何判断角色是否成功落在平台中心?如何生成看似随机但又保证可玩性的平台序列?解决这些问题的过程,正是你从“知道”到“会用”的关键跨越。我见过不少开发者,通过复现这个项目,真正理解了Update和FixedUpdate的区别,掌握了如何用射线检测进行碰撞判断,也学会了用脚本动态生成和管理游戏对象。接下来,我们就深入拆解这个项目的完整实现思路与核心细节。

2. 核心玩法与系统架构设计拆解

在动手写代码之前,我们必须先把游戏拆解成几个独立的系统模块,并理清它们之间的数据流动关系。一个好的架构能让后续开发事半功倍,也便于调试和扩展。

2.1 游戏核心循环与状态机设计

“跳一跳”的核心循环非常经典:准备 -> 蓄力 -> 跳跃 -> 判定 -> 得分/结束 -> 准备(下一轮)。我们需要用一个游戏管理器(GameManager)来统领全局状态。我通常会定义一个枚举GameState来管理游戏状态:

public enum GameState { Ready, // 游戏开始或角色着陆稳定后,等待玩家输入 Charging, // 玩家按下鼠标/屏幕,正在蓄力 Jumping, // 角色在空中跳跃 Landing, // 角色正在着陆判定 GameOver // 角色掉落或失败 }

GameManager作为单例,负责在这些状态间切换,并控制其他系统的启停(比如在GameOver状态禁用输入)。为什么用状态机?因为它能让逻辑变得清晰。你不需要在Update里写一堆if-else来判断“现在能不能蓄力”,只需要检查currentState == GameState.Ready即可。这避免了状态混乱导致的Bug。

2.2 物理与运动系统选型:刚体还是Transform?

这是第一个关键决策点。角色的跳跃运动,是用物理引擎(Rigidbody)模拟,还是直接用Transform.position修改坐标?

  • 方案一:使用Rigidbody(刚体)。这是更“物理正确”的方式。你可以给角色添加Rigidbody组件,通过AddForce施加跳跃力。Unity的物理引擎会帮你计算抛物线运动、碰撞检测。优点是运动效果自然,易于实现复杂的物理交互(比如被撞飞)。但缺点也很明显:控制不够“精准”,力的大小、空气阻力等因素可能导致每次跳跃的轨迹有细微差异,这对于要求精确落点的“跳一跳”来说,有时反而不好控制。
  • 方案二:使用Transform(变换组件)手动计算运动。即完全通过代码计算每一帧角色的位置。我们需要自己模拟重力加速度和速度公式。例如,在跳跃状态中:
    // 在Update中,如果状态是Jumping verticalVelocity += gravity * Time.deltaTime; // 模拟重力加速度 transform.position += new Vector3(horizontalSpeed, verticalVelocity, 0) * Time.deltaTime;
    这种方式的优点是控制力极强,跳跃轨迹完全由你的代码决定,可以做出非常稳定、可预测的移动,非常适合这种需要精确操作的休闲游戏。同时,性能开销也略小于物理引擎。

我的选择与理由:对于“跳一跳”这类对操作反馈和确定性要求极高的游戏,我推荐方案二,即手动控制Transform运动。我们牺牲一点物理真实性,换来的是完全可控的手感和更简单的落地判定逻辑(因为位置是我们精确计算的)。后续的讲解也将基于这个方案展开。

2.3 场景组成与对象管理

场景中主要包含以下几类对象:

  1. 玩家角色(Player):一个带有碰撞体(如Box Collider 2D)和自定义控制器脚本的GameObject。
  2. 平台(Platform):游戏的核心交互对象。通常做成预制体(Prefab),包含碰撞体、渲染组件(SpriteRenderer)以及一个用于判定得分区域的子物体(如一个空物体,标记为中心点)。
  3. 游戏管理器(GameManager):空物体,挂载管理全局状态的脚本。
  4. UI画布(Canvas):包含分数文本(TextMeshPro)、开始按钮、游戏结束面板等。
  5. 相机(Camera):跟随角色移动,确保角色始终在视野中央。

平台的管理是重点。我们不可能在场景里手动摆放成千上万个平台。通常的做法是,在游戏开始时或角色跳跃后,动态实例化(Instantiate)新的平台预制体,并按照一定规则(如随机距离、随机类型)摆放,同时回收(Destroy)已经远离屏幕的旧平台,形成一个对象池循环,这对性能至关重要。

3. 核心模块实现细节与实操要点

理解了整体架构,我们来逐一攻克每个核心模块的实现细节。这里会有大量的代码片段和设计思路,你可以直接应用到你的项目中。

3.1 角色控制器(PlayerController)的实现

这是游戏的心脏。脚本需要处理输入、蓄力、跳跃运动和碰撞检测。

1. 蓄力与跳跃力度计算:蓄力的本质是:按下时间越长,跳跃的初速度越大。我们需要在Update中检测输入。

public class PlayerController : MonoBehaviour { public float maxChargeTime = 1.0f; // 最大蓄力时间 public float maxJumpForce = 10.0f; // 对应的最大跳跃力 private float chargeTime = 0f; // 当前蓄力时间 private bool isCharging = false; void Update() { if (GameManager.Instance.CurrentState == GameState.Ready) { if (Input.GetMouseButtonDown(0)) // 鼠标按下或触摸开始 { isCharging = true; chargeTime = 0f; // 可以在这里触发蓄力动画或特效 } if (isCharging && Input.GetMouseButton(0)) // 按住期间 { chargeTime += Time.deltaTime; chargeTime = Mathf.Clamp(chargeTime, 0, maxChargeTime); // 更新UI蓄力条 UIManager.Instance.UpdateChargeBar(chargeTime / maxChargeTime); } if (isCharging && Input.GetMouseButtonUp(0)) // 松开时跳跃 { isCharging = false; float jumpForce = (chargeTime / maxChargeTime) * maxJumpForce; Jump(jumpForce); GameManager.Instance.ChangeState(GameState.Jumping); } } } }

注意Mathf.Clamp用于限制蓄力时间在合理范围内,防止“超充能”。跳跃力与蓄力时间的关系可以是线性的,你也可以尝试二次曲线,让蓄力初期力度增长慢,后期增长快,以调整手感。

2. 手动跳跃运动模拟:Jump方法中,我们设定一个初始的垂直速度(verticalVelocity),然后在FixedUpdate中(为了与物理帧同步,运动更平滑)模拟重力。

private float verticalVelocity; private float gravity = -25f; // 重力值,需要根据感觉调整 private bool isJumping = false; private Vector3 jumpDirection; // 跳跃方向,通常指向下一个平台 private void Jump(float force) { isJumping = true; verticalVelocity = force; // 设置初始向上速度 // 计算跳跃方向:指向下一个平台中心 jumpDirection = (targetPlatform.position - transform.position).normalized; jumpDirection.y = 0; // 通常我们只计算水平方向,垂直方向由速度和重力模拟 } private void FixedUpdate() { if (isJumping) { // 应用重力 verticalVelocity += gravity * Time.fixedDeltaTime; // 计算移动:水平方向匀速,垂直方向受重力影响 Vector3 moveVec = jumpDirection * horizontalSpeed + Vector3.up * verticalVelocity; transform.position += moveVec * Time.fixedDeltaTime; // 简单的地面检测:如果垂直速度向下且位置接近地面,则认为开始着陆 if (verticalVelocity < 0 && transform.position.y <= someGroundLevel) { // 触发着陆判定逻辑 StartLanding(); } } }

3.2 平台生成与管理策略

平台生成是保证游戏无限进行下去的关键。我们需要一个PlatformSpawner脚本。

1. 平台预制体与数据:首先,创建几种平台预制体(普通、移动、破碎等)。可以创建一个Platform脚本挂载在预制体上,里面包含平台类型、宽度、中心点位置(用于得分判定)等信息。

2. 动态生成算法:生成新平台的位置,需要基于上一个平台的位置进行计算。一个常见的策略是:

  • 水平间隔:在X轴上,设置一个最小和最大距离范围,在此范围内随机。
  • 垂直变化:可以引入轻微的Y轴随机偏移,让平台有高低起伏,增加趣味性。
  • 类型随机:根据游戏进度,动态调整不同类型平台(如移动平台)出现的概率。
public class PlatformSpawner : MonoBehaviour { public GameObject[] platformPrefabs; // 平台预制体数组 public Transform lastPlatform; // 上一个生成的平台 public float minXDistance = 2f; public float maxXDistance = 4f; public float maxYOffset = 0.5f; public void SpawnNextPlatform() { // 1. 计算生成位置 Vector3 spawnPos = lastPlatform.position; spawnPos.x += Random.Range(minXDistance, maxXDistance); spawnPos.y += Random.Range(-maxYOffset, maxYOffset); // 2. 随机选择预制体 int index = Random.Range(0, platformPrefabs.Length); GameObject newPlatformObj = Instantiate(platformPrefabs[index], spawnPos, Quaternion.identity); // 3. 更新lastPlatform引用 lastPlatform = newPlatformObj.transform; // 4. (可选)将平台加入管理列表,用于后续回收 GameManager.Instance.AddPlatform(newPlatformObj); } }

3. 对象池优化:频繁的InstantiateDestroy会产生内存碎片。对于平台这种频繁创建销毁的对象,强烈建议使用对象池(Object Pool)。原理是预先创建一批平台对象并禁用,需要时激活并放到指定位置,不需要时禁用而非销毁。Unity官方现在也提供了ObjectPool类,可以很方便地使用。

3.3 碰撞与得分判定逻辑

这是游戏规则的核心。如何判断是“精准命中中心”还是“勉强落在边缘”?

1. 判定区域设计:在平台预制体内,创建一个子物体(如一个空GameObject),命名为“CenterPoint”。这个点的位置就是平台的完美落点。我们通过判断角色与这个点的距离来打分。

2. 着陆判定时机:在角色的PlayerController中,当检测到可能着陆时(比如verticalVelocity < 0且射线检测到下方有平台),触发判定。

3. 距离计算与得分:

private void StartLanding() { // 假设通过射线检测到了脚下的平台 hitPlatform Platform platform = hitPlatform.GetComponent<Platform>(); if (platform != null) { Vector3 playerFootPos = transform.position; // 可能需要一个脚部位置的偏移量 Vector3 perfectPos = platform.centerPoint.position; float distance = Vector2.Distance( new Vector2(playerFootPos.x, playerFootPos.z), // 如果是2D,忽略Y轴 new Vector2(perfectPos.x, perfectPos.z) ); int score = CalculateScore(distance, platform.type); GameManager.Instance.AddScore(score); // 如果距离过大,判定为掉落 if (distance > platform.safeRadius) { GameManager.Instance.ChangeState(GameState.GameOver); } else { // 成功着陆,准备下一次跳跃 GameManager.Instance.ChangeState(GameState.Ready); } } } private int CalculateScore(float distance, PlatformType type) { // 根据距离远近给出不同分数,例如: if (distance < 0.1f) return 3; // 完美 else if (distance < 0.3f) return 2; // 良好 else return 1; // 普通 // 还可以根据平台类型有加分 }

3.4 相机跟随与画面平滑

为了让游戏体验舒适,相机需要平滑地跟随角色移动。不要简单地将相机位置设置为角色位置,这会导致画面抖动。推荐使用Vector3.SmoothDamp方法,它能实现平滑的阻尼跟随效果。

public class CameraFollow : MonoBehaviour { public Transform target; // 玩家角色 public float smoothTime = 0.3f; // 平滑时间,越小跟随越快 private Vector3 velocity = Vector3.zero; private Vector3 offset; // 相机与角色的初始偏移 void Start() { offset = transform.position - target.position; } void LateUpdate() // 在角色移动后更新相机 { if (target != null) { Vector3 targetPosition = target.position + offset; // 通常只跟随X轴(横向移动),Y轴可以固定或在一定范围内跟随 targetPosition.y = transform.position.y; // 锁定Y轴高度 transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref velocity, smoothTime); } } }

实操心得smoothTime参数需要仔细调试。太大会有延迟感,太小会有抖动感。对于“跳一跳”,可以设置X轴平滑跟随,Y轴仅在角色跳跃到一定高度时才缓慢上移,角色下落时相机延迟下移,这样能营造更好的空间感和跳跃成就感。

4. 完整开发流程与关键步骤实现

现在,让我们把这些模块串联起来,看看一个完整的开发流程应该是怎样的。我将按照实际开发的时间顺序来梳理关键步骤。

4.1 第一步:项目初始化与基础场景搭建

  1. 创建新项目:打开Unity Hub,创建一个新的2D或3D项目(“跳一跳”更适合3D视角的2D玩法,即3D项目,但使用正交相机)。模板选择“3D Core”即可。
  2. 设置项目结构:在Assets文件夹下创建清晰的目录,如_Scripts,_Prefabs,_Scenes,_Arts,_UI等。良好的习惯从文件夹开始。
  3. 搭建基础场景
    • 创建一个平面(Plane)或Sprite作为地面背景。
    • 创建一个Cube或导入角色模型,命名为“Player”,为其添加Box Collider。暂时不添加刚体。
    • 创建第一个平台(也是一个Cube),调整大小和颜色,做成预制体。
    • 设置正交相机(Camera的Projection设为Orthographic),调整Size,确保角色和平台在视野中比例合适。
    • 创建一个空物体命名为“GameManager”,一个空物体命名为“PlatformSpawner”。

4.2 第二步:实现核心游戏逻辑链

这是编码的核心阶段,建议按以下顺序进行,每完成一步都进行测试:

  1. 编写GameManager脚本:先实现游戏状态枚举和单例模式。添加一个简单的UI文本用来显示状态。确保你能通过代码切换状态(例如,按空格键切换到Jumping状态)。
  2. 编写PlayerController脚本(第一版):先实现输入检测和蓄力时间累加,并在控制台打印出蓄力时间。暂时不实现跳跃运动。
  3. 实现手动跳跃运动:在PlayerController中添加Jump方法和FixedUpdate中的运动模拟。暂时让角色固定向一个方向跳。调整gravityjumpForce,直到跳跃的弧线感觉自然。
  4. 实现平台生成器:编写PlatformSpawner,让它能在指定位置生成平台预制体。手动调用SpawnNextPlatform方法,在场景中生成几个平台。
  5. 连接跳跃与平台:修改PlayerController,让角色跳跃的方向指向PlatformSpawner当前记录的下一个平台。实现简单的着陆检测(比如用OnCollisionEnter或射线检测到平台就认为着陆),着陆后通知GameManager切换回Ready状态,并调用PlatformSpawner生成新的平台。
  6. 实现得分判定:在平台预制体上添加“CenterPoint”。在角色着陆时,计算与中心点的距离,并根据距离在GameManager中加分,同时更新UI分数显示。

4.3 第三步:打磨体验与添加内容

核心玩法跑通后,开始打磨游戏体验,增加趣味性:

  1. 完善相机跟随:实现CameraFollow脚本,让相机平滑跟随角色在X轴移动,Y轴可以做一些弹性跟随。
  2. 丰富平台类型
    • 移动平台:给平台添加一个脚本,让其在一定范围内左右或上下循环移动。角色落在上面时,需要成为平台的子物体(player.transform.SetParent(movingPlatform.transform)),以确保角色随平台一起移动。
    • 脆弱平台:角色落下后,平台等待片刻然后消失(使用Destroy(gameObject, 1f)或播放破碎动画后销毁)。
    • 弹簧平台:落在上面会触发一个更大的弹跳力。
  3. 添加音效与粒子特效
    • 在蓄力、跳跃、着陆(不同得分)、掉落、打破平台等关键节点添加音效(AudioSource.PlayOneShot)。
    • 在角色起跳点、着陆点添加灰尘粒子,在完美着陆时添加星星特效等。
  4. 构建UI系统
    • 开始游戏界面。
    • 游戏内HUD(分数、当前连击、蓄力条)。
    • 游戏结束界面(最终分数、重新开始按钮)。
    • 使用Unity的UI系统(Button, TextMeshPro)实现,并通过UIManager脚本控制显示与隐藏。

4.4 第四步:性能优化与发布准备

  1. 实现对象池:将平台的生成与销毁改为对象池管理。这是提升性能最有效的一步。
  2. 优化绘制调用:如果使用2D精灵,尽量使用Sprite Atlas(精灵图集)将多个小图打包,减少Draw Call。
  3. 构建与测试
    • 在Unity编辑器中频繁测试。
    • 构建到PC端进行测试,检查分辨率适配和性能。
    • 如果目标是移动端(Android/iOS),需要安装对应模块,并进行真机测试,重点测试触控输入、性能(60fps是否稳定)和内存占用。
  4. 调试与调整:这是最花时间也最重要的环节。反复游玩自己的游戏,调整参数:
    • gravitymaxJumpForce:影响跳跃手感的根本。
    • 平台生成的距离范围:太近太简单,太远不可能。
    • 相机跟随的平滑度和偏移量。
    • 不同得分区间的距离阈值和分数奖励。

5. 常见问题、调试技巧与避坑指南

在开发过程中,你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法总结出来,希望能帮你节省大量时间。

5.1 物理与运动相关

问题1:角色跳跃“滑”或者“粘”在平台边缘。

  • 原因:碰撞体形状或大小不匹配。例如,角色的碰撞体比视觉模型大,导致在视觉上还没碰到平台,逻辑上就已经触发碰撞。
  • 解决:在Scene视图中勾选碰撞体显示(Gizmos -> Colliders),仔细检查角色和平台的碰撞体范围是否精确。适当调整碰撞体的大小和偏移(Offset)。对于2D游戏,使用Circle Collider 2D有时比Box Collider 2D手感更好。

问题2:手动模拟跳跃时,角色穿过了平台。

  • 原因:在FixedUpdate中移动角色时,如果单帧移动距离过大(速度过快),可能会从平台的一侧“穿越”到另一侧,而中间帧没有触发碰撞检测。
  • 解决
    1. 降低每帧移动速度。如果速度必须很快,可以考虑使用Rigidbody并设置碰撞检测模式为Continuous(连续检测),但这会消耗更多性能。
    2. 更推荐的方法:使用射线检测(Raycast)进行预判。在移动前,从角色当前位置向移动方向发射一条长度等于本帧移动距离的射线。如果检测到碰撞,则调整移动终点为碰撞点。
    Vector3 moveDelta = moveVec * Time.fixedDeltaTime; if (Physics.Raycast(transform.position, moveVec.normalized, out RaycastHit hit, moveDelta.magnitude)) { // 如果检测到碰撞,将移动终点设为碰撞点 transform.position = hit.point; // 触发着陆逻辑 StartLanding(); } else { // 无碰撞,正常移动 transform.position += moveDelta; }

5.2 输入与控制相关

问题3:在移动设备上,长按蓄力有时不灵敏或误触。

  • 原因:直接使用Input.GetMouseButton系列函数在移动端虽然可用,但可能无法很好地处理多点触控和复杂手势。
  • 解决:使用Unity的Input.touches数组来处理触摸输入。对于蓄力,通常只关心第一个触摸点(touches[0])。
    if (Input.touchCount > 0) { Touch touch = Input.GetTouch(0); if (touch.phase == TouchPhase.Began) { /* 开始蓄力 */ } else if (touch.phase == TouchPhase.Stationary || touch.phase == TouchPhase.Moved) { /* 蓄力中 */ } else if (touch.phase == TouchPhase.Ended) { /* 结束蓄力,跳跃 */ } }

    注意:在编辑器测试时,可以用鼠标模拟触摸,但真机测试必不可少。

5.3 性能与内存相关

问题4:游戏运行一段时间后变卡,尤其是平台生成很多之后。

  • 原因:没有回收旧平台,导致场景中GameObject数量无限增长;或者粒子特效、音效没有正确回收。
  • 解决
    1. 必须实现对象池:这是解决此问题的标准答案。
    2. 设置平台回收机制:在PlatformSpawnerGameManager中维护一个活跃平台列表。当相机位置超过某个平台一定距离后,将该平台放回对象池,而不是Destroy
    3. 检查Instantiate/Destroy调用:在Profiler窗口(Window -> Analysis -> Profiler)中查看CPU使用情况,定位频繁的实例化操作。

问题5:构建到Android/iOS后崩溃或黑屏。

  • 原因:可能性很多,常见的有:脚本编译错误、资源格式不支持、移动端特定API使用不当、Unity版本与JDK/NDK不兼容(常见于Android)。
  • 排查步骤
    1. 检查编辑器日志:在构建前,确保编辑器控制台(Console)没有任何错误(红色)或警告(黄色)。警告有时也会在移动端导致问题。
    2. 检查Player Settings:确保Bundle Identifier唯一,设置正确的目标API级别(Android)或部署目标(iOS)。
    3. 使用ADB Logcat(Android):通过Android Studio的Logcat或命令行adb logcat查看设备运行日志,搜索“Unity”、“Fatal”、“Error”等关键词,这是定位崩溃原因最直接的方法。
    4. 简化测试:创建一个全新的空白场景,只放一个Cube和你的核心脚本,构建测试。如果正常,再逐步添加其他内容,以定位问题模块。

5.4 设计逻辑相关

问题6:得分判定不准确,有时落在边上也算完美。

  • 原因:判定中心点(CenterPoint)的位置设置不对,或者计算距离时使用的角色位置(如transform.position)是角色的中心(Pivot),而不是脚底。
  • 解决:在角色身上创建一个空子物体,命名为“FootPoint”,放在脚底位置。在判定时,使用footPointTransform.position而不是角色自身的transform.position来计算与平台中心点的距离。

问题7:游戏节奏单调,玩一会儿就腻了。

  • 原因:只有一种平台和固定的生成规则。
  • 解决:引入“阶段”概念。在GameManager中记录当前分数或连续成功次数。根据这个值动态调整PlatformSpawner的参数:
    • 增加难度:随着分数提高,逐渐增大平台生成的最小和最大距离。
    • 引入新元素:分数达到100分后,开始出现移动平台;达到200分后,出现脆弱平台。
    • 添加连击奖励:连续完美着陆(如3次)可以获得额外分数加成,并在UI上显示连击特效。

开发这样一个完整的项目,最大的收获不是代码本身,而是解决问题的思维方式和对引擎各个模块如何协同工作的深刻理解。当你看到自己亲手打造的角色在屏幕上跳跃、得分,那种满足感会驱动你继续探索Unity更广阔的天地。记住,参数没有标准答案,gravity是-25还是-28,smoothTime是0.2还是0.25,都需要你根据自己的游戏感觉去反复调试。最好的老师就是你自己的游戏体验,多玩、多改、多试错,乐趣就在这个过程之中。

← 返回列表