1. 项目概述:从“跳一跳”到你的第一个Unity3D游戏
几年前,一款名为“跳一跳”的微信小游戏几乎在一夜之间风靡了所有人的手机。它的规则简单到极致:长按屏幕蓄力,松开让角色从一个平台跳到另一个平台,距离越远,分数越高。但就是这种极简的玩法,配合精准的物理反馈和清新的美术风格,让它成为了现象级产品。对于很多想入门游戏开发的朋友来说,“跳一跳”是一个绝佳的练手项目。它麻雀虽小,五脏俱全:涵盖了玩家输入、物理模拟、游戏逻辑、UI交互、分数计算等游戏开发的核心模块。
今天,我们就用Unity3D和C#,从零开始完整复刻一个“跳一跳”游戏。无论你是刚学完C#基础,想找个项目练手,还是对Unity引擎好奇,想了解游戏是如何运作的,这个教程都将带你走完全程。我会假设你安装了Unity Hub和任意一个版本的Unity(建议2021 LTS或更新版本),并且对C#语法有最基本的了解,比如知道什么是类、变量、函数。我们的目标不仅仅是让方块跳起来,而是要深入理解每一步背后的“为什么”,比如为什么用Rigidbody而不是直接改位置,为什么用协程处理跳跃蓄力。当你完成这个项目,你收获的将不仅仅是一个能运行的游戏,更是一套可以迁移到其他2D/3D项目中的Unity开发思维和实用技巧。
2. 核心设计思路与架构拆解
在动手写代码之前,我们先花点时间把整个游戏的逻辑框架想清楚。一个好的设计能让你在编码时事半功倍,避免后期陷入“打补丁”的泥潭。
2.1 游戏对象与组件规划
Unity的核心是“组件化”设计。我们的游戏主角(那个跳跳的小方块或小人)在Unity中就是一个游戏对象(GameObject)。这个对象本身是空的,它的所有能力都来自于挂载的组件(Component)。
对于我们的玩家角色,至少需要以下组件:
- Transform: 这是每个游戏对象都自带的,负责位置、旋转和缩放。
- Rigidbody 2D: 这是实现物理跳跃的核心。它会受到重力影响,并允许我们通过施加力(Force)来让它跳跃。为什么不直接用代码修改Transform的position来移动?因为那样你需要自己模拟重力、碰撞、惯性等所有物理效果,而
Rigidbody让Unity的物理引擎(PhysX)帮你处理这一切,效果更真实、代码更简洁。 - Box Collider 2D: 碰撞体,用于检测角色是否站在平台上(与平台发生碰撞)。
- Sprite Renderer: 渲染器,用于显示角色的图片(精灵)。
对于跳跃的目标——平台,我们同样需要:
- Transform
- Box Collider 2D(或其它形状的Collider): 用于与角色发生碰撞。
- Sprite Renderer: 显示平台的样式。我们可以准备几种不同颜色或形状的精灵来增加游戏视觉效果。
此外,我们还需要一个全局的游戏管理器(GameManager)。它通常是一个空的游戏对象,挂载一个我们自己编写的C#脚本。这个脚本是游戏的大脑,负责:
- 管理游戏状态(开始、进行中、结束)。
- 生成和回收平台。
- 计算和更新UI上的分数。
- 处理重新开始逻辑。
2.2 核心玩法逻辑流程图
整个游戏的运行可以简化为一个循环:
- 等待输入: 游戏运行时,持续检测玩家的屏幕触摸或鼠标按下事件。
- 蓄力阶段: 当按下时,开始计时,同时可以通过某种视觉反馈(比如角色下方出现一个不断变大的圆圈)显示蓄力程度。
- 执行跳跃: 当松开时,根据按下的时长计算一个跳跃力度,然后以这个力度给角色的
Rigidbody施加一个向上的力。 - 物理模拟: 物理引擎接管,角色在空中运动,受重力下落。
- 碰撞检测: 角色落地(与平台发生碰撞)。此时,判断落点:
- 落在平台中心区域: 成功,根据跳跃距离加分,并准备下一次跳跃。
- 落在平台边缘或掉落: 游戏结束。
- 平台生成: 角色成功跳跃后,在前方随机位置生成一个新的平台,并可能回收已经远离屏幕的旧平台,实现无限循环。
注意:在Unity中,物理计算(如
Rigidbody移动、碰撞检测)和我们的逻辑更新(如输入检测、分数计算)是在不同的“循环”中进行的。Update()函数每帧调用,适合处理输入;而FixedUpdate()在固定的物理时间步长调用,适合处理施加力的操作,这样能保证物理模拟的稳定性。这是我们第一个要区分的重点。
3. 项目搭建与基础环境配置
打开Unity Hub,创建一个新的2D项目,命名为“JumpJumpClone”。创建好后,我们先来组织一下项目文件夹结构,这是保持项目整洁的好习惯。
在Project窗口的Assets文件夹下,创建以下子文件夹:
Scripts: 存放所有C#脚本。Sprites: 存放角色、平台、背景等图片资源。Prefabs: 存放预制体(Prefab),这是Unity中可重复使用的模板对象。Scenes: 存放游戏场景。Audio(可选): 存放音效。
3.1 创建玩家角色
- 在Hierarchy窗口右键 -> 2D Object -> Sprites -> Square。重命名这个新建的方块为“Player”。
- 选中Player,在Inspector窗口中点击“Add Component”,搜索并添加
Rigidbody 2D组件。 - 默认的
Rigidbody 2D属性基本够用,但我们可以微调一下:将Gravity Scale(重力缩放)设为3,让下落更快,手感更干脆。将Linear Drag(线性阻尼)设为1,可以稍微减缓它在空中的水平移动速度,避免滑过头。 - 确保它已经有
Box Collider 2D组件。如果没有,同样方式添加一个。 - 去
Sprites文件夹,你可以导入一张自己喜欢的图片,或者就用Unity自带的白色方块。将图片拖到Player对象的Sprite Renderer组件的Sprite属性上。
现在,如果你点击运行,这个方块会直接掉出视野。别急,我们马上来创建接住它的平台。
3.2 创建平台预制体
- 在Hierarchy窗口再创建一个Square,重命名为“Platform”。
- 调整它的Scale,比如X设为3,Y设为0.5,让它变成一个长条状。
- 为它添加
Box Collider 2D。关键一步来了:我们需要这个平台是静止的,作为地面。所以,在Inspector中,找到Box Collider 2D组件,勾选Used By Effector(如果看不到这个选项,点击组件右上角的齿轮图标,选择“Edit Collider”先调整一下碰撞体大小,再勾选)。然后点击“Add Component”,搜索并添加Platform Effector 2D组件。这个组件允许我们设置平台只有上表面能站人。 - 在
Platform Effector 2D组件中,勾选Use Collider Mask(如果未勾选),这很重要。 - 将Platform从Hierarchy窗口拖到
Assets/Prefabs文件夹中,它就变成了一个蓝色的预制体。之后我们可以通过代码实例化这个预制体来生成无数个平台。最后,删除Hierarchy中的原始Platform对象。
3.3 创建游戏管理器
- 在Hierarchy中创建一个空的GameObject,重命名为“GameManager”。
- 在
Assets/Scripts文件夹右键 -> Create -> C# Script,命名为GameManager。双击用VS Code或Visual Studio打开。 - 我们将在这个脚本里写核心逻辑,先搭个架子:
using UnityEngine; using UnityEngine.UI; // 引入UI命名空间,用于后续操作Text using System.Collections.Generic; // 引入集合命名空间,用于管理平台列表 public class GameManager : MonoBehaviour { // 单例模式,方便其他脚本访问 public static GameManager Instance; // 平台预制体的引用 public GameObject platformPrefab; // 玩家角色的引用 public PlayerController player; // 当前分数 private int currentScore = 0; // 分数显示UI文本 public Text scoreText; // 存储已生成平台的列表 private List<GameObject> platformList = new List<GameObject>(); void Awake() { if (Instance == null) { Instance = this; } else { Destroy(gameObject); } } void Start() { // 游戏初始化逻辑将在这里编写 InitializeGame(); } void InitializeGame() { currentScore = 0; UpdateScoreUI(); // 生成初始平台 GenerateInitialPlatforms(); } void UpdateScoreUI() { if (scoreText != null) scoreText.text = "Score: " + currentScore; } void GenerateInitialPlatforms() { // 在起始位置生成第一个平台 Vector3 startPos = new Vector3(0, -2, 0); // 假设从屏幕下方开始 GeneratePlatformAtPosition(startPos); // 接着生成几个平台,让玩家有个起始区域 for (int i = 1; i < 5; i++) { Vector3 nextPos = startPos + new Vector3(Random.Range(2f, 4f), Random.Range(0.5f, 1.5f), 0); GeneratePlatformAtPosition(nextPos); } } public void GeneratePlatformAtPosition(Vector3 position) { GameObject newPlatform = Instantiate(platformPrefab, position, Quaternion.identity); platformList.Add(newPlatform); // 可以在这里随机设置平台的颜色或大小,增加变化 SpriteRenderer sr = newPlatform.GetComponent<SpriteRenderer>(); if (sr != null) { sr.color = new Color(Random.value, Random.value, Random.value, 1); } } // 玩家成功跳跃后调用的方法 public void OnPlayerLanded(Vector3 playerPosition) { // 加分 currentScore += 10; // 简单计分,实际可根据跳跃距离计算 UpdateScoreUI(); // 在玩家前方生成新平台 Vector3 newPlatformPos = playerPosition + new Vector3(Random.Range(3f, 6f), Random.Range(-1f, 2f), 0); GeneratePlatformAtPosition(newPlatformPos); // 清理太远的旧平台,防止内存泄漏(可选,但建议做) CleanupOldPlatforms(playerPosition); } void CleanupOldPlatforms(Vector3 currentPlayerPos) { for (int i = platformList.Count - 1; i >= 0; i--) { if (platformList[i] != null) { // 如果平台在玩家后方一定距离之外(比如X轴小于玩家位置-10),就销毁它 if (platformList[i].transform.position.x < currentPlayerPos.x - 10f) { Destroy(platformList[i]); platformList.RemoveAt(i); } } } } // 游戏结束 public void GameOver() { Debug.Log("Game Over! Final Score: " + currentScore); // 这里可以弹出游戏结束UI,暂停游戏等 Time.timeScale = 0; // 暂停游戏 // 实际项目中,应该激活一个包含“重新开始”按钮的UI面板 } }这个GameManager脚本已经具备了管理分数、生成和清理平台的核心框架。现在我们需要一个真正能跳的玩家控制器。
4. 玩家控制器:实现跳跃与物理交互
在Scripts文件夹创建新的C#脚本,命名为PlayerController。这个脚本将挂载到Player游戏对象上。
4.1 跳跃蓄力机制
“跳一跳”的核心是“按得越久,跳得越远”。我们需要测量玩家按下屏幕的时长,并将其映射为一个跳跃力。
using UnityEngine; public class PlayerController : MonoBehaviour { // 跳跃相关参数 public float maxJumpForce = 800f; // 最大跳跃力 public float minJumpForce = 200f; // 最小跳跃力(即使轻点也跳一下) public float chargeTimeMax = 1.5f; // 最大蓄力时间(秒) private float currentChargeTime = 0f; // 当前已蓄力时间 private bool isCharging = false; // 是否正在蓄力 private bool isGrounded = false; // 是否在地面上 // 组件引用 private Rigidbody2D rb; private GameManager gameManager; // 蓄力指示器(可选,一个UI图像或角色脚下的缩放物体) public Transform chargeIndicator; void Start() { rb = GetComponent<Rigidbody2D>(); if (rb == null) { Debug.LogError("PlayerController: Rigidbody2D component not found!"); } gameManager = GameManager.Instance; if (chargeIndicator != null) chargeIndicator.localScale = Vector3.zero; // 初始隐藏指示器 } void Update() { // 处理输入:鼠标左键或屏幕触摸 HandleInput(); // 更新蓄力指示器 UpdateChargeIndicator(); } void HandleInput() { // 只有在地面上才能开始新的跳跃 if (isGrounded) { if (Input.GetMouseButtonDown(0)) // 鼠标按下或触摸开始 { StartCharging(); } if (Input.GetMouseButton(0) && isCharging) // 按住期间 { ContinueCharging(); } if (Input.GetMouseButtonUp(0) && isCharging) // 松开 { ReleaseJump(); } } } void StartCharging() { isCharging = true; currentChargeTime = 0f; Debug.Log("开始蓄力"); } void ContinueCharging() { currentChargeTime += Time.deltaTime; // 限制最大蓄力时间 if (currentChargeTime > chargeTimeMax) currentChargeTime = chargeTimeMax; } void ReleaseJump() { if (!isCharging) return; // 计算跳跃力:蓄力比例映射到最小和最大力之间 float chargeRatio = Mathf.Clamp01(currentChargeTime / chargeTimeMax); // 比例在0到1之间 float jumpForce = Mathf.Lerp(minJumpForce, maxJumpForce, chargeRatio); // 执行跳跃 Jump(jumpForce); // 重置蓄力状态 isCharging = false; currentChargeTime = 0f; if (chargeIndicator != null) chargeIndicator.localScale = Vector3.zero; Debug.Log("跳跃力: " + jumpForce); } void Jump(float force) { // 关键:给刚体施加一个向上的瞬时力 rb.velocity = new Vector2(rb.velocity.x, 0); // 跳跃前先将垂直速度归零,保证每次跳跃力度一致 rb.AddForce(new Vector2(0, force)); isGrounded = false; // 起跳后离开地面 } void UpdateChargeIndicator() { if (isCharging && chargeIndicator != null) { float scale = currentChargeTime / chargeTimeMax; // 0到1 chargeIndicator.localScale = new Vector3(scale, scale, 1); // 等比例放大 } } }4.2 碰撞检测与落地判断
角色如何知道它“落地”了?这需要通过Unity的碰撞系统来检测。
// 在PlayerController脚本中继续添加 void OnCollisionEnter2D(Collision2D collision) { // 检查碰撞对象是否是平台(可以通过Tag或Layer,这里用Tag更清晰) if (collision.gameObject.CompareTag("Platform")) { // 简单的落地判断:检查碰撞点是否主要来自上方 // 更精确的做法是检查角色底部与平台顶部的接触 ContactPoint2D contact = collision.contacts[0]; if (contact.normal.y > 0.5f) // 法线向量朝上,说明是从上面碰到的 { LandOnPlatform(collision.gameObject); } else { // 如果从侧面或下面碰到平台,游戏结束 Debug.Log("撞到平台侧面,游戏结束!"); gameManager?.GameOver(); } } // 如果碰到标记为“Death”的区域(比如掉出屏幕底部的触发器),也游戏结束 else if (collision.gameObject.CompareTag("Death")) { Debug.Log("掉落死亡!"); gameManager?.GameOver(); } } void LandOnPlatform(GameObject platform) { if (!isGrounded) // 避免连续触发 { isGrounded = true; Debug.Log("成功着陆在平台"); // 通知GameManager玩家着陆,并传递位置信息用于生成新平台 gameManager?.OnPlayerLanded(transform.position); // 可以在这里添加着陆特效或音效 // AudioManager.Instance.PlaySound("land"); } } void OnCollisionExit2D(Collision2D collision) { // 离开平台时,理论上isGrounded应该为false,但物理引擎可能有多帧延迟 // 我们主要依靠OnCollisionEnter2D来设置isGrounded = true,在Jump()里设为false }实操心得:碰撞检测的坑:
OnCollisionEnter2D在碰撞发生的那一帧调用。如果角色速度非常快,可能会发生“穿透”现象,即一帧内从平台一边移动到另一边,错过了碰撞检测。对于“跳一跳”这种游戏,角色速度不会太夸张,所以问题不大。但对于高速物体,可以考虑使用Rigidbody2D的Collision Detection属性设置为Continuous(连续检测),但这会更耗性能。另一种方法是使用触发器(OnTriggerEnter2D)配合射线检测来做更精确的落地判断。
别忘了回去给Platform预制体设置Tag。在Project窗口选中Platform预制体,在Inspector顶部的Tag下拉菜单中选择“Add Tag...”,创建一个新Tag叫“Platform”,然后给预制体分配这个Tag。同样,可以创建一个“Death” Tag,并创建一个位于屏幕下方的Collider作为死亡区域。
5. 平台生成器:实现无限关卡
我们的GameManager已经有了生成平台的基础方法GeneratePlatformAtPosition。但如何让平台生成得更自然,符合“跳一跳”的节奏?我们需要一个更智能的生成算法。
5.1 基于玩家位置的动态生成
我们不希望平台生成得太近或太远,需要在一个合理的随机范围内生成。修改GameManager中的相关方法:
// 在GameManager类中添加或修改以下变量和方法 public float minPlatformDistanceX = 2.5f; public float maxPlatformDistanceX = 4.5f; public float minPlatformDistanceY = -1f; public float maxPlatformDistanceY = 2f; public void GenerateNextPlatform(Vector3 referencePosition) { // 生成下一个平台的相对位置 float offsetX = Random.Range(minPlatformDistanceX, maxPlatformDistanceX); float offsetY = Random.Range(minPlatformDistanceY, maxPlatformDistanceY); Vector3 newPos = referencePosition + new Vector3(offsetX, offsetY, 0); // 简单防止平台生成在屏幕外(可做更复杂的屏幕边界检查) Camera mainCam = Camera.main; Vector3 screenPos = mainCam.WorldToViewportPoint(newPos); if (screenPos.x > 1.1f || screenPos.y < -0.1f || screenPos.y > 1.1f) { // 如果太靠右或太靠上下,调整位置 offsetY = Mathf.Clamp(offsetY, -0.5f, 1.5f); // 调整Y轴范围 newPos = referencePosition + new Vector3(offsetX, offsetY, 0); } GeneratePlatformAtPosition(newPos); } // 修改OnPlayerLanded方法中的生成调用 public void OnPlayerLanded(Vector3 playerPosition) { currentScore += 10; UpdateScoreUI(); // 使用新的智能生成方法 GenerateNextPlatform(playerPosition); CleanupOldPlatforms(playerPosition); }5.2 平台池优化
不断Instantiate(实例化)和Destroy(销毁)平台对象,在移动设备上可能会引起GC(垃圾回收)卡顿。一个常见的优化是使用对象池(Object Pool)。对象池预先创建一批平台对象,禁用并存放起来。需要时从池中取出激活,不需要时放回池中禁用,而不是销毁。
// 在GameManager中添加对象池逻辑 using System.Collections.Generic; public class GameManager : MonoBehaviour { // ... 其他变量 ... public int platformPoolSize = 20; // 对象池初始大小 private Queue<GameObject> platformPool = new Queue<GameObject>(); void Start() { InitializeGame(); InitializePlatformPool(); } void InitializePlatformPool() { for (int i = 0; i < platformPoolSize; i++) { GameObject platform = Instantiate(platformPrefab); platform.SetActive(false); // 先禁用 platformPool.Enqueue(platform); // 放入队列 } } public void GeneratePlatformAtPosition(Vector3 position) { GameObject platform; if (platformPool.Count > 0) { // 从池中取出 platform = platformPool.Dequeue(); } else { // 池空了,新建一个(这种情况应该很少) platform = Instantiate(platformPrefab); Debug.LogWarning("Platform pool empty, instantiating new one."); } platform.transform.position = position; platform.SetActive(true); // 设置随机颜色 SpriteRenderer sr = platform.GetComponent<SpriteRenderer>(); if (sr != null) { sr.color = new Color(Random.Range(0.6f, 1f), Random.Range(0.6f, 1f), Random.Range(0.6f, 1f), 1); } platformList.Add(platform); } // 修改清理方法,将平台放回池中而不是销毁 void CleanupOldPlatforms(Vector3 currentPlayerPos) { for (int i = platformList.Count - 1; i >= 0; i--) { if (platformList[i] != null && platformList[i].activeInHierarchy) { if (platformList[i].transform.position.x < currentPlayerPos.x - 15f) // 回收距离可以设远一点 { // 放回对象池 platformList[i].SetActive(false); platformPool.Enqueue(platformList[i]); platformList.RemoveAt(i); } } } } }注意事项:对象池的管理:对象池大大提升了性能,但管理起来稍复杂。确保平台被回收时,其状态(如位置、颜色)被正确重置。对于有复杂行为或子物体的平台,可能需要一个专门的
Reset()方法。另外,池的大小需要根据游戏节奏调整,太小会频繁创建新对象,太大会占用多余内存。
6. UI系统与游戏流程控制
一个完整的游戏需要开始界面、分数显示和结束界面。
6.1 创建基础UI
- 在Hierarchy窗口右键 -> UI -> Canvas。这会创建一个Canvas(画布)和一个EventSystem。
- 在Canvas下右键 -> UI -> Text,创建两个Text对象,分别重命名为“ScoreText”和“GameOverText”。
- 调整ScoreText的位置到屏幕左上角,设置字体大小、颜色。将它的Text组件拖到GameManager脚本的
scoreText公共变量上。 - 设置GameOverText的文本为“Game Over!\nTap to Restart”,居中,字体调大,并默认禁用(取消Inspector顶部的勾选)。
- 在Canvas下再创建一个Button,重命名为“RestartButton”,设置文本为“Restart”,也默认禁用。
6.2 完善游戏状态管理
修改GameManager脚本,加入更完整的游戏流程控制:
public class GameManager : MonoBehaviour { // ... 其他变量 ... public GameObject gameOverUI; // 可以将GameOverText和RestartButton放在一个父物体下,拖拽这个父物体 public Button restartButton; public enum GameState { Menu, Playing, GameOver } private GameState currentState = GameState.Playing; void Start() { // ... 初始化 ... if (gameOverUI != null) gameOverUI.SetActive(false); if (restartButton != null) { restartButton.onClick.AddListener(RestartGame); restartButton.gameObject.SetActive(false); } } void Update() { // 在游戏结束状态下,检测点击重新开始 if (currentState == GameState.GameOver && Input.GetMouseButtonDown(0)) { RestartGame(); } } public void GameOver() { if (currentState == GameState.GameOver) return; // 防止重复调用 currentState = GameState.GameOver; Debug.Log("Game Over! Final Score: " + currentScore); // 显示游戏结束UI if (gameOverUI != null) gameOverUI.SetActive(true); if (restartButton != null) restartButton.gameObject.SetActive(true); // 可以暂停游戏物理,但Time.timeScale = 0会停止所有协程和动画,需谨慎 // Time.timeScale = 0; // 更好的做法是禁用玩家输入 if (player != null) { player.enabled = false; // 禁用PlayerController脚本 } } public void RestartGame() { // 重置游戏状态 currentState = GameState.Playing; currentScore = 0; UpdateScoreUI(); // 隐藏游戏结束UI if (gameOverUI != null) gameOverUI.SetActive(false); if (restartButton != null) restartButton.gameObject.SetActive(false); // 重置玩家 if (player != null) { player.enabled = true; player.transform.position = Vector3.zero; // 重置到原点,实际可能需要一个重生点 Rigidbody2D rb = player.GetComponent<Rigidbody2D>(); if (rb != null) { rb.velocity = Vector2.zero; } } // 清理所有旧平台并重新初始化 foreach (var platform in platformList) { if (platform != null) { // 如果是对象池,需要放回池并禁用 platform.SetActive(false); platformPool.Enqueue(platform); } } platformList.Clear(); // 重新生成初始平台 GenerateInitialPlatforms(); // 恢复时间(如果之前暂停了) Time.timeScale = 1; } }同时,需要在PlayerController的LandOnPlatform方法中,加入对游戏状态的检查,确保游戏结束时不会继续生成平台。
void LandOnPlatform(GameObject platform) { if (!isGrounded && GameManager.Instance.currentState == GameManager.GameState.Playing) { // ... 原有逻辑 ... } }7. 打磨与优化:让游戏更像“跳一跳”
基础功能完成后,我们可以添加一些“调味料”,让游戏体验更上一层楼。
7.1 跳跃手感优化
原版“跳一跳”的跳跃弧线非常舒服。我们可以通过调整物理参数和加入一些视觉反馈来模拟。
- 调整物理材质:为Player的
Rigidbody 2D添加一个Physics Material 2D。在Assets中右键创建 -> Physics Material 2D,命名为“PlayerBouncy”。设置Bounciness(弹性)为0.1,Friction(摩擦力)为0.4。将这个材质拖到Player的Collider 2D组件的Material属性上。这会让落地时有一点轻微的弹性,手感更好。 - 跳跃音效:在
PlayerController的Jump方法中播放一个音效,音调可以根据蓄力时间变化。 - 落地粒子效果:创建一个粒子系统预制体,当
LandOnPlatform时,在角色脚下实例化一个短暂的粒子效果。
7.2 相机跟随
为了让玩家始终在视野中心,我们需要一个简单的相机跟随脚本。
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; // 玩家 public float smoothSpeed = 0.125f; // 平滑跟随的速度 public Vector3 offset; // 相机相对于玩家的偏移,比如 (0, 2, -10) void LateUpdate() // 在Update之后执行,确保玩家移动完再移动相机 { if (target != null) { Vector3 desiredPosition = target.position + offset; // 通常我们只希望相机在X轴上跟随,Y轴可以固定或缓动 desiredPosition.y = transform.position.y; // 锁定Y轴,或者用Mathf.Lerp平滑过渡Y轴 desiredPosition.z = transform.position.z; // 锁定Z轴 Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed); transform.position = smoothedPosition; } } }将这个脚本挂到Main Camera上,并把Player对象拖到Target变量上。调整Offset,比如(0, 2, -10)。
7.3 距离分数计算
原版游戏是根据跳跃距离的平方来加分的。我们可以在玩家起跳点和落地点之间计算距离。
在PlayerController中:
private Vector3 jumpStartPos; void StartCharging() { isCharging = true; currentChargeTime = 0f; jumpStartPos = transform.position; // 记录起跳点 Debug.Log("开始蓄力"); } // 在LandOnPlatform中计算分数 void LandOnPlatform(GameObject platform) { if (!isGrounded && GameManager.Instance.currentState == GameManager.GameState.Playing) { isGrounded = true; // 计算跳跃距离(水平距离) float jumpDistance = Mathf.Abs(transform.position.x - jumpStartPos.x); // 根据距离计算分数,例如每单位距离得2分,并取整 int scoreToAdd = Mathf.RoundToInt(jumpDistance * 2); // 确保至少有基础分 scoreToAdd = Mathf.Max(10, scoreToAdd); // 通知GameManager加分 gameManager?.AddScore(scoreToAdd, transform.position); // ... 其他逻辑 ... } }在GameManager中添加AddScore方法:
public void AddScore(int score, Vector3 landPos) { currentScore += score; UpdateScoreUI(); // 可以在落地位置生成一个分数飘字效果 // Instantiate(scorePopupPrefab, landPos, Quaternion.identity).GetComponent<TextMesh>().text = "+" + score; GenerateNextPlatform(landPos); CleanupOldPlatforms(landPos); }8. 常见问题与调试技巧实录
在开发过程中,你几乎一定会遇到下面这些问题。这里是我踩过坑后的总结。
8.1 角色跳跃无力或过猛
- 症状:按下屏幕,角色跳不起来,或者像火箭一样飞出去。
- 排查:
- 检查
Rigidbody 2D组件的Gravity Scale。值太小(如0.1)会导致下落慢,感觉“飘”;值太大(如10)会导致下落太快,来不及操作。建议从3开始调整。 - 检查
Jump方法中施加的力(maxJumpForce和minJumpForce)。力是质量乘以加速度,如果你的角色Rigidbody 2D的Mass(质量)是1,那么力在200-800范围内是合理的。如果质量是10,那你就需要2000-8000的力。建议将角色质量设为1,然后调整力的大小。 - 确保在
Jump前将rb.velocity.y归零。否则,如果角色正在下落,向下的速度会抵消一部分向上的力。
- 检查
8.2 碰撞检测不准确或穿透
- 症状:角色明明看起来落在平台上,却直接穿过去了;或者站在平台边缘却触发了落地。
- 排查:
- 检查碰撞体大小:在Scene视图中,确保Player的
Box Collider 2D(绿色线框)和Platform的Box Collider 2D大小合适,并且有重叠。可以适当调大一点。 - 使用正确的碰撞层:Unity默认所有层之间都会碰撞。但如果你的对象被设到了不同的层,并且层之间没有碰撞关系,就不会触发。在Edit -> Project Settings -> Physics 2D中检查
Layer Collision Matrix。 - 平台Effector设置:确保平台的
Platform Effector 2D组件已启用,并且Use Collider Mask已勾选。这能确保只有从上方碰撞才会被当作“地面”。 - 落地判断逻辑:我们的
OnCollisionEnter2D中使用了contact.normal.y > 0.5f来判断是否从上方碰撞。这个阈值0.5可以调整。如果平台有斜坡,可能需要更复杂的判断,比如计算角色底部与平台顶部的距离。
- 检查碰撞体大小:在Scene视图中,确保Player的
8.3 对象池平台回收后状态异常
- 症状:回收再利用的平台,颜色没有重置,或者位置不对。
- 解决:为平台创建一个专门的脚本,比如
PlatformController,并在其中实现一个ResetPlatform方法。
public class PlatformController : MonoBehaviour { private SpriteRenderer sr; private Color originalColor; void Awake() { sr = GetComponent<SpriteRenderer>(); originalColor = sr.color; // 记住初始颜色 } public void ResetPlatform() { // 重置为初始颜色,或随机一个新颜色 // sr.color = originalColor; sr.color = new Color(Random.Range(0.6f, 1f), Random.Range(0.6f, 1f), Random.Range(0.6f, 1f), 1); // 如果有其他需要重置的状态,也在这里处理 } }然后在GameManager的GeneratePlatformAtPosition中,取出平台后调用ResetPlatform。
8.4 游戏卡顿,特别是在生成平台时
- 症状:游戏运行一段时间后感觉变卡。
- 排查:
- 打开Unity的Profiler窗口(Window -> Analysis -> Profiler)。在Game视图下运行游戏,观察CPU和GC(垃圾回收)的情况。如果GC Alloc(垃圾分配)频繁出现高峰,说明你在频繁创建和销毁对象。对象池就是为了解决这个问题。
- 检查
CleanupOldPlatforms方法的调用频率。不要在Update中每帧都调用,只在玩家成功跳跃后调用一次。 - 确保没有在
Update中做昂贵的操作,比如FindGameObjectsWithTag。
8.5 构建到手机后输入无效
- 症状:在Unity编辑器中用鼠标玩得好好的,打包到安卓或iOS后,触摸没反应。
- 解决:Unity的
Input.GetMouseButtonXXX系列函数在移动端同样对应触摸操作,所以代码本身是兼容的。最常见的问题是UI按钮挡住了输入。检查Canvas上的UI元素(特别是全屏透明的Image)是否设置了Raycast Target为true,这会导致触摸事件被UI拦截,无法传递到场景中的物体。对于不需要接收点击的UI元素,取消勾选Raycast Target。另外,确保EventSystem存在且正常工作。
开发这个“跳一跳”克隆项目,最大的收获不是代码本身,而是理解了Unity“组件驱动”的设计哲学,以及如何将游戏玩法拆解成一个个可管理的系统(输入、物理、生成、UI)。当你下次想做一个“飞机大战”或者“跑酷游戏”时,你会发现思路是相通的:一个控制主角的脚本,一个管理敌人的生成器,一个处理碰撞和分数的管理器。把这个项目吃透,你就拿到了进入Unity游戏开发世界的第一把钥匙。