不少刚开始接触 Unity 的同学,都会在同一个问题前停下来:想做一个能跑起来的小游戏,但角色模型不会做、贴图不会画、动画不会调,甚至连场景里的树和石头都不知道去哪找。于是项目还没开始,就先被“美术成本”劝退了。其实,对于验证玩法和学习核心逻辑来说,美术完全可以放到最后,甚至根本不需要自己画。
这一期我们不画任何美术资源,完全用 Unity 自带的 Cube、Capsule、Sphere 等几何体,搭建一个类似《汤姆猫跑酷》(Talking Tom Gold Run)的三车道跑酷游戏原型。文章会从玩法拆解讲起,然后逐步实现玩家自动前进、左右换道、跳跃避障、下滑铲规避、金币拾取、对象池管理以及游戏状态控制。整套代码以 Unity + C# 为例,零美术基础也能跟着一步步做出来,做完之后你会得到一份可以反复扩展的跑酷项目底座。
1. 项目背景与玩法拆解
1.1 为什么“不画画”也能做跑酷原型
很多开发者对“做游戏”有一个误解,以为必须先有角色原画、场景模型、UI 图标才能开始写代码。但在实际开发流程中,玩法原型往往走在美术前面。游戏设计圈里有一类常用方案叫“程序员美术”(Programmer Art),就是用最简单的几何体、纯色材质和临时动画来验证核心玩法是否有趣。
跑酷类游戏特别适合这类方案,原因是它的核心玩法并不依赖具体形象。玩家在三条车道之间切换、跳跃、闪避,这一套操作逻辑用方块和胶囊体就能完整表达。等玩法验证通过后,再把几何体替换成真正的主角模型、把地面的 Cube 换成带贴图的赛道,项目风险会小很多。
不画画,不代表游戏简陋。这一期做完的原型包含完整的跑酷循环:跑动、换道、跳跃、滑铲、收集、失败、重置。这个循环本身已经具备一个可玩游戏的骨架。对于初学者来说,从零搭建这样一个循环,比直接下载一个完整资源包然后改几个参数,能学到的内容要多得多。
1.2 汤姆猫跑酷的核心玩法拆解
把《汤姆猫跑酷》这类游戏的玩法规整一下,可以拆成五个基本规则:
第一,角色自动向前跑。玩家不需要控制前进方向,角色始终沿着赛道 Z 轴正方向移动,玩家的注意力集中在障碍物和收集品上。
第二,路面分成三条车道。左、中、右三车道对应不同的空间位置,玩家通过左右操作切换当前所在车道,目的是避开当前车道上的障碍,或者移动到有金币的车道上。
第三,跳跃躲避低障碍。地面上会出现较低的障碍物,比如栅栏、箱子、路障,玩家需要跳跃从上方通过。
第四,滑铲躲避高障碍。空中会出现横梁类障碍,正常跑步状态会撞上,需要按下滑铲键让角色以低姿态通过。
第五,以金币和距离作为得分。角色跑动过程中会持续增长距离分数,同时赛道中散落金币,玩家尽可能收集更多金币,一旦碰到障碍,游戏结束。
这五个规则构成了跑酷游戏的最小闭环。后续无论是加上体力系统、角色皮肤、道具商店,还是接广告变现,都是在闭环之上做扩展。因此,这篇教程的核心目标,就是先把闭环跑通。
1.3 本项目的技术方案
技术方案尽量简单直接,目标是让读者能在一台电脑上快速复现。
游戏引擎使用 Unity,以 2022.3 LTS 或更高版本为例均可。渲染管线使用默认的内置渲染管线(Built-in Render Pipeline),不引入额外插件。代码使用 C#,输入部分使用 Unity 传统输入系统(Input Manager),方便直接复制运行。
运行场景采用“玩家自动前进 + 相机跟随”的方式。赛道用长条形 Cube 表示,角色用一个 Capsule 表示,障碍物用 Cube 表示,金币用一个发亮的 Sphere 表示。所有碰撞检测都用 Collider 的 Trigger 模式,障碍判定用逻辑判断替代精确实体碰撞,这样既简化了场景搭建,也便于新手理解。
2. 环境准备与项目创建
2.1 Unity 版本与输入方案
本教程的代码不依赖 Unity 新输入系统(Input System Package),只使用Input.GetKeyDown这类传统 API,因此项目创建时保持默认的“Input Manager (Old)”即可。
版本方面,建议安装 Unity 2022.3 LTS。如果你使用的是 Unity 2021 或 Unity 2019,代码同样可以运行,因为用到的 API 都相当稳定。唯一需要注意的地方是,如果你的项目切换到了新输入系统,默认情况下Input.GetKeyDown可能会提示编译错误,需要在 Player Settings 中把 Active Input Handling 改为 Both,或者直接使用新输入系统重写这部分的按键检测。
创建项目时选择 3D Core 模板即可,不需要勾选 URP 或 HDRP 模板。URP 在光照效果上更好看,但会增加一些额外概念,这一期先保持简单。场景中的灯光保持默认方向光,天空盒使用默认设置,不做额外调整。
2.2 项目目录结构
在动手写代码之前,先在 Assets 目录下创建清晰的文件夹结构。虽然这个原型体量不大,但养成“脚本归脚本、预制体归预制体”的习惯,对后续项目扩展很有帮助。
Assets/ Scripts/ Player/ PlayerController.cs World/ ObjectPool.cs、ObstacleSpawner.cs Collectible/ Collectible.cs Camera/ CameraFollow.cs Manager/ GameManager.cs Prefabs/ ObstacleLow.prefab ObstacleHigh.prefab Coin.prefab Scenes/ Main.unity创建方式:在 Project 窗口右键 → Create → Folder,按上面的结构依次建立文件夹。后面所有脚本都放到对应文件夹下,Prefab 完成后统一放进 Prefabs 目录。
2.3 搭建最小跑酷场景
先搭建一个最基础的场景,包含地面、玩家和跑道分隔提示。打开默认 SampleScene,另存为 Main。
创建地面:右键 Hierarchy → 3D Object → Cube,命名为 Ground。在 Inspector 中设置 Position 为 (0, -0.5, 100),Scale 为 (10, 1, 300)。这样地面会变成一个长达 300 米的长条形跑道,足够角色以默认速度跑相当长一段时间。
为了让三条车道在视觉上更清晰,可以再创建两个长条 Cube 作为车道分隔线。这两个分隔线的 Scale 设置为 (0.2, 0.05, 300),Position 分别放在 X = -1 和 X = 1 的位置,Y 方向比地面略高一点即可。分隔线颜色可以在 Mesh Renderer 的 Material 中调整为灰色或黄色。
创建玩家角色:右键 Hierarchy → 3D Object → Capsule,命名为 Player。Position 设置为 (0, 1, 0)。给 Player 添加 CharacterController 组件,这个组件负责角色移动、重力和触发器碰撞。随后添加 PlayerController 脚本,稍后编写。
创建相机:选中 Main Camera,Position 设置为 (0, 3, -6),最后挂上 CameraFollow 脚本,让相机自动跟随玩家。
搭建完成后场景非常简单,但它已经具备了跑酷游戏的空间基础:一条长赛道、三条虚线车道、一个胶囊体角色。
3. 核心机制设计
3.1 三车道与数学模型
三车道跑酷在逻辑上其实是一个一维平面上的位置切换问题。角色虽然可以左右移动,但移动目标只有三个固定位置:左车道、中车道、右车道。为了便于代码管理,我们用整数 0、1、2 来代表三条车道,0 表示最左侧,1 表示中间,2 表示最右侧。
假设赛道宽度为 4 米,三条车道均匀分布,那么最左车道中心 X 坐标为 -2,中间车道为 0,最右车道为 2。我们只需定义一个车道宽度变量laneWidth = 2f,那么任意车道对应的 X 坐标就是:
x = (laneIndex - 1) * laneWidth当玩家按左键时,laneIndex减 1;按右键时,laneIndex加 1。同时用Mathf.Clamp把索引限制在 0 到 2 之间,防止越界。
实际移动时不需要瞬间跳转到目标车道,这样手感太硬。更常见的做法是使用Mathf.Lerp每一帧向目标 X 坐标插值移动,让换道过程有一定的平滑时间。插值速度越快,换道越跟手,但也不能快得像传送;速度越慢,操作越黏滞。这个参数可以在 Inspector 里反复调整,直到手感合适为止。
3.2 跳跃、重力与滑铲的状态处理
跳跃的实现方式有很多种,可以用 Rigidbody 物理,也可以用 CharacterController 手动模拟重力。这个原型选择手动模拟,原因是逻辑更可控,不依赖物理材质和刚体参数。
所谓手动模拟重力,就是维护一个竖直方向速度变量verticalVelocity。每一帧让它加上gravity * Time.deltaTime,然后把竖直速度合并到移动向量里交给 CharacterController 的 Move 方法。当角色落地时,controller.isGrounded会变成 true,此时把竖直速度重置为一个小负数,避免角色反复上下抖动。
跳跃发生的条件有三条:角色在地面上、当前没有处于滑铲状态、玩家按下了跳跃键。跳跃初始速度通过公式Mathf.Sqrt(2 * jumpHeight * -gravity)计算。这个公式来自能量守恒,简单来说就是给定跳跃高度和重力加速度,反推出需要的初速度。
滑铲在逻辑上是一个持续一段时间的“状态”。进入滑铲状态后,游戏会记录一个slideTimer,每一帧减一帧 deltaTime,直到计时结束退出。滑铲期间禁止跳跃,避免在低姿状态下再跳起来造成判定混乱。
视觉上,滑铲不改变 CharacterController 的实际碰撞体,而是通过缩放玩家角色下的子物体 Visual,让胶囊体在视觉上“压扁”。这样做的好处是碰撞体数据不用动态修改,不会出现中心点偏移导致的穿地问题,障碍判定完全由状态变量决定。
3.3 对象池原理与回收思路
跑酷游戏的特点是物体大量且持续生成。如果每一段路障都使用Instantiate创建,玩家跑完后这些障碍会永远留在场景中,造成内存增长。如果使用Destroy销毁,又在后面不断创建,会在高频生成期间产生明显的卡顿。
对象池的改进思路是:提前一次性创建一批对象,平时隐藏起来。需要生成障碍物时,从池中取一个激活;当障碍物离开玩家一定距离后,隐藏起来并放回池中等待下次复用。这样一来,整个游戏过程中不会反复创建和销毁对象,内存稳定,CPU 压力也小很多。
对象池在 Unity 中有很多写法。本教程实现一个精简版本,核心是一个队列Queue<GameObject>。Get方法从队列取出对象并激活,Return方法关闭对象并重新放入队列。如果队列为空,则现场新建一个。这里需要留意,取出的对象在返回池之前,缓存池自身不知道它什么时候用完,所以归还动作通常由使用方,比如障碍物脚本或生成器,主动调用。
4. 完整实战案例
4.1 创建脚本目录与预制体
先把要用的脚本全部创建出来。在 Scripts 文件夹下,依次创建以下脚本文件:
PlayerController.cs:玩家移动、跳跃、滑铲、车道切换。ObjectPool.cs:通用对象池。ObstacleSpawner.cs:在玩家前方持续生成障碍和金币。Obstacle.cs:障碍碰撞判定。Collectible.cs:金币拾取。CameraFollow.cs:相机跟随。GameManager.cs:游戏状态与金币计数。
然后再创建三个预制体。以 ObstacleLow 为例:在 Hierarchy 中创建一个 Cube,Scale 设置为 (1, 1, 1),添加 Obstacle 脚本,将 Box Collider 的 Is Trigger 勾选为 true,最后拖到 Prefabs 文件夹中生成预制体。另外两个预制体类似,下面会详细说明。
4.2 PlayerController:玩家移动与状态
PlayerController 是本项目最核心的脚本。它负责处理输入、管理角色状态、执行移动与跳跃。新建一个脚本,代码如下:
// 文件路径:Assets/Scripts/Player/PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] public float forwardSpeed = 8f; // 自动前进速度 public float laneWidth = 2f; // 每条车道的宽度 public float laneChangeSpeed = 10f; // 换道的插值速度 [Header("跳跃与滑铲")] public float jumpHeight = 2f; // 跳跃高度 public float gravity = -20f; // 手动模拟的重力加速度 public float slideDuration = 0.6f; // 滑铲持续时间 [Header("视觉对象")] public Transform visualRoot; // 角色的子物体,用于表现滑铲 private CharacterController controller; private int currentLane = 1; // 0 左,1 中,2 右 private float verticalVelocity = 0f; private bool isSliding = false; private float slideTimer = 0f; public bool IsSliding => isSliding; public bool IsJumping => !controller.isGrounded; void Start() { controller = GetComponent<CharacterController>(); currentLane = 1; } void Update() { // 1. 左右换道 if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow)) ChangeLane(-1); if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow)) ChangeLane(1); // 2. 跳跃 if (Input.GetKeyDown(KeyCode.Space) && controller.isGrounded && !isSliding) { verticalVelocity = Mathf.Sqrt(2f * jumpHeight * -gravity); } // 3. 滑铲 if ((Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) && controller.isGrounded && !isSliding) { StartSlide(); } // 4. 更新滑铲计时 if (isSliding) { slideTimer -= Time.deltaTime; if (slideTimer <= 0f) StopSlide(); } // 5. 计算目标车道坐标并平滑移动 float targetX = (currentLane - 1) * laneWidth; float currentX = transform.position.x; float smoothX = Mathf.Lerp(currentX, targetX, Time.deltaTime * laneChangeSpeed); // 6. 重力处理 if (controller.isGrounded && verticalVelocity < 0f) verticalVelocity = -1f; verticalVelocity += gravity * Time.deltaTime; // 7. 组装移动向量,交给 CharacterController Vector3 move = new Vector3(smoothX - currentX, verticalVelocity, forwardSpeed); controller.Move(move * Time.deltaTime); } void ChangeLane(int dir) { int newLane = Mathf.Clamp(currentLane + dir, 0, 2); if (newLane != currentLane) currentLane = newLane; } void StartSlide() { isSliding = true; slideTimer = slideDuration; if (visualRoot != null) visualRoot.localScale = new Vector3(1f, 0.55f, 1f); } void StopSlide() { isSliding = false; if (visualRoot != null) visualRoot.localScale = Vector3.one; } }这里有几个关键点需要解释。
第一,CharacterController的 Move 方法会在内部处理碰撞,所以即使我们用smoothX - currentX作为水平位移,角色也不会直接穿过地面或墙。所有位移都通过Move完成,保证了碰撞体与场景物体的交互正常。
第二,跳跃的判断依赖controller.isGrounded。CharacterController 的 isGrounded 并不是立刻更新的,而是在调用 Move 之后根据碰撞结果更新。因此如果你的角色高度刚好在地面上,跳跃条件可以正常工作;如果角色悬空,则需要调整地面位置或胶囊体高度。
第三,滑铲没有修改 CharacterController 本身,而是只缩放了visualRoot的局部 Scale。这避免了动态修改碰撞体 Height 和 Center 时容易出现的中心偏移问题。障碍判定用到的是isSliding状态,而不是真实碰撞体积,所以视觉和判定是分离的,这在原型阶段是非常省心的做法。
4.3 对象池与障碍生成器
为了避免大量 Instantiate 和 Destroy 带来的性能抖动,我们实现一个精简对象池。代码如下:
// 文件路径:Assets/Scripts/World/ObjectPool.cs using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { private GameObject prefab; private Queue<GameObject> pool = new Queue<GameObject>(); private Transform poolParent; public void Init(GameObject prefab, int preloadCount, Transform parent) { this.prefab = prefab; this.poolParent = parent; for (int i = 0; i < preloadCount; i++) { GameObject item = CreateNew(); item.SetActive(false); pool.Enqueue(item); } } GameObject CreateNew() { GameObject item = Instantiate(prefab, poolParent); item.name = prefab.name + "_" + pool.Count; return item; } public GameObject Get(Vector3 position, Quaternion rotation) { GameObject item = pool.Count > 0 ? pool.Dequeue() : CreateNew(); item.transform.SetPositionAndRotation(position, rotation); item.SetActive(true); return item; } public void Return(GameObject item) { item.SetActive(false); pool.Enqueue(item); } }这个池子里所有对象都挂在同一个 parent 空物体下,便于在 Hierarchy 中折叠查看。Init方法预创建指定数量的对象,Get取出并激活,Return回收并隐藏。一个完整的对象池应当包含自动回收机制,但因为本项目采用“玩家前进 + 固定生成点”的方式,回收逻辑可以留到扩展阶段再处理。
障碍生成器负责在玩家前进过程中不断生成障碍和金币。它会在玩家前方约 40 米范围内循环检查,只要没有超出最大生成距离,就每隔spawnInterval米生成一段路障。代码如下:
// 文件路径:Assets/Scripts/World/ObstacleSpawner.cs using UnityEngine; public class ObstacleSpawner : MonoBehaviour { public GameObject lowObstaclePrefab; public GameObject highObstaclePrefab; public GameObject coinPrefab; public float spawnInterval = 8f; public float startZ = 20f; public float maxZ = 260f; public int preloadCount = 20; private ObjectPool lowPool; private ObjectPool highPool; private ObjectPool coinPool; private float nextSpawnZ; private PlayerController player; void Start() { lowPool = CreatePool(lowObstaclePrefab); highPool = CreatePool(highObstaclePrefab); coinPool = CreatePool(coinPrefab); nextSpawnZ = startZ; player = FindObjectOfType<PlayerController>(); } void Update() { if (player == null) return; float playerZ = player.transform.position.z; while (nextSpawnZ < playerZ + 40f && nextSpawnZ < maxZ) { SpawnSection(nextSpawnZ); nextSpawnZ += spawnInterval; } } ObjectPool CreatePool(GameObject prefab) { GameObject holder = new GameObject(prefab.name + "Pool"); holder.transform.SetParent(transform); ObjectPool pool = holder.AddComponent<ObjectPool>(); pool.Init(prefab, preloadCount, holder.transform); return pool; } void SpawnSection(float z) { int type = Random.Range(0, 3); if (type == 0) { // 地面障碍,随机一个车道 int lane = Random.Range(0, 3); Vector3 pos = new Vector3((lane - 1) * 2f, 0.25f, z); lowPool.Get(pos, Quaternion.identity); } else if (type == 1) { // 高空障碍,随机一个车道,需要滑铲通过 int lane = Random.Range(0, 3); Vector3 pos = new Vector3((lane - 1) * 2f, 1.4f, z); highPool.Get(pos, Quaternion.identity); } else { // 金币一排,三个车道各放一个 for (int lane = 0; lane < 3; lane++) { Vector3 pos = new Vector3((lane - 1) * 2f, 0.8f, z); coinPool.Get(pos, Quaternion.identity); } } } }生成逻辑中,障碍类型分为地面障碍、高空障碍和金币三种。地面障碍放在 Y=0.25 高度,玩家必须跳跃才能安全通过。高空障碍放在 Y=1.4 高度,玩家正常站立会撞上,必须滑铲。金币放在 Y=0.8 高度,刚好是玩家跑动过程中稍作调整就能碰到的高度。
随机生成虽然简单,但需要注意一点:如果连续随机出同一个车道的两个地面障碍,玩家可能来不及换道。更复杂的生成应该给“连续相同车道”加一个保护逻辑,例如记录上次生成的车道索引,本次尽量选择不同车道。这一点我会在后面的工程建议中展开。
4.4 障碍判定与金币拾取
障碍脚本挂在障碍预制体上,它本身只需要判断碰撞事件。由于检测逻辑主要依赖玩家当前是跳跃还是滑铲,这里需要引用 PlayerController。
// 文件路径:Assets/Scripts/World/Obstacle.cs using UnityEngine; public class Obstacle : MonoBehaviour { public bool isHighObstacle = false; private PlayerController player; void Start() { player = FindObjectOfType<PlayerController>(); } void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player")) return; // 高空障碍可以通过滑铲躲过 if (isHighObstacle && player.IsSliding) return; // 地面障碍可以通过跳跃躲过 if (!isHighObstacle && player.IsJumping) return; GameManager.Instance.GameOver(); } }金币拾取脚本类似,只是触发结果不是结束游戏,而是增加金币计数。
// 文件路径:Assets/Scripts/Collectible/Collectible.cs using UnityEngine; public class Collectible : MonoBehaviour { public int value = 1; void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player")) return; GameManager.Instance.AddCoin(value); gameObject.SetActive(false); } }这里需要注意,金币回收使用的是SetActive(false),并不是放进对象池。虽然对象池管理了金币对象,但从池中取出的金币如果被 SetActive(false),下次从池中取出时会继续使用这个隐藏对象,并不会引发错误。更严谨的做法是让金币归还池子,不过对于原型来说,直接隐藏已经足够。后面要做金币循环生成时,可以把Return方法接到金币对象上,让它在离开玩家身后一定距离后自动回归池子。
4.5 相机跟随与游戏管理
相机跟随很简单,使用 LateUpdate 而不是 Update,可以避免与角色移动产生先后帧冲突。
// 文件路径:Assets/Scripts/Camera/CameraFollow.cs using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0f, 3f, -6f); public float smoothSpeed = 5f; void LateUpdate() { if (target == null) return; Vector3 desiredPosition = target.position + offset; transform.position = Vector3.Lerp(transform.position, desiredPosition, Time.deltaTime * smoothSpeed); transform.LookAt(target.position + Vector3.up * 1f); } }游戏管理脚本采用单例模式,静态 Instance 方便其他脚本直接调用。它负责记录金币数量,管理游戏结束状态。原型阶段我们用Time.timeScale = 0f暂停整个游戏来表现游戏结束,实际项目中更推荐用状态机而不是直接暂停时间,因为暂停时间会影响 UI 动画和协程。
// 文件路径:Assets/Scripts/Manager/GameManager.cs using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance; public int CoinCount { get; private set; } public bool IsGameOver { get; private set; } void Awake() { if (Instance == null) Instance = this; } public void AddCoin(int value = 1) { if (IsGameOver) return; CoinCount += value; } public void GameOver() { if (IsGameOver) return; IsGameOver = true; Time.timeScale = 0f; Debug.Log("Game Over! 金币数量: " + CoinCount); } public void Restart() { CoinCount = 0; IsGameOver = false; Time.timeScale = 1f; } }这里的Restart()目前只重置了数据,实际还需要重置玩家位置和当前车道。如果你自己扩展,可以在这个方法里调用SceneManager.LoadScene重新加载当前场景,这样最简单,也能一并重置所有障碍和金币。
4.6 Inspector 配置与运行验证
脚本都写完后,回到 Unity 编辑器进行配置。
首先给 Player 的 Capsule 添加一个子物体 Capsule,命名为 Visual,并把它的 Collider 删除,只保留 MeshRenderer 用于显示。然后把 PlayerController 脚本中的visualRoot拖到该子物体上,这样滑铲时只有视觉部分缩放,CharacterController 的碰撞体不受影响。
给 Player 设置 Tag 为 Player。选中 Player,在 Inspector 右上角 Tag 下拉框中选择 Player,如果没有该选项,可以点击 Add Tag 创建。注意金币和障碍脚本中用的是CompareTag("Player"),所以这一步不能省略。
创建三个预制体,并配置对应脚本:
第一个 ObstacleLow:Cube,Scale 设为 (1, 1, 1),Position Y 保持 0.25 左右,Box Collider 勾选 Is Trigger,挂 Obstacle 脚本,isHighObstacle不勾选,颜色设为红色或灰色。第二个 ObstacleHigh:Cube,Scale 设为 (2, 0.4, 0.4),Position Y 设为 1.4,挂 Obstacle 脚本,勾选isHighObstacle,颜色设为橙色。第三个 Coin:Sphere,Scale 设为 (0.6, 0.6, 0.6),挂 Collectible 脚本,Sphere Collider 勾选 Is Trigger,颜色设为黄色。
创建空物体 Spawner,挂上 ObstacleSpawner,将三个预制体分别拖到对应字段。再创建一个空物体 GameManager,挂上 GameManager 脚本,把起点位置放在 (0, 0, 0)。
最后把 CameraFollow 脚本挂到 Main Camera 上,target 拖入 Player,调整 offset 为 (0, 3, -6)。
运行游戏,你应该能看到如下表现:角色在三条车道上自动向前跑;按 A/D 或左右方向键,角色平滑换道;按空格,角色跳起并落回地面;按 S 或下键,角色视觉压扁并保持一段时间;角色碰到地面障碍时游戏暂停并输出 Debug 日志;碰到高空障碍时滑铲可以安全通过;碰到金币时金币消失且金币计数增加。
5. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 角色不移动 | CharacterController 没有添加到角色上 | 确认 Player 挂了 CharacterController,且 forwardSpeed 不为 0 |
| 角色跳跃后卡在半空 | 重力值没有正确累加 | 检查 gravity 是否为负数,检查 verticalVelocity 是否参与 Move 向量 |
| 跳跃后角色轻微穿地 | isGrounded 判定时序不对 | 将重力落地检测改为verticalVelocity < 0 && controller.isGrounded再对速度清零 |
| 换道不顺滑 | laneChangeSpeed 设置过小 | 调大 laneChangeSpeed,例如 10 到 15 之间 |
| 碰撞后没有触发游戏结束 | 障碍 Collider 没有勾选 Is Trigger | 确认障碍的 Box Collider 勾选了 Is Trigger,角色 Tag 为 Player |
| 高空障碍滑铲后依然判定失败 | 使用了碰撞体高度而不是状态变量 | 确认 Obstacle 脚本中通过 player.IsSliding 判断,而不是修改了 CharacterController 高度 |
| 金币拾取后位置不变 | 对象池对象被 SetActive(false) 后依然保留位置 | 将拾取逻辑改为隐藏后,下一次生成时会通过 SetPositionAndRotation 重新设置位置 |
| Time.timeScale 暂停后 UI 不显示 | 暂停时 Canvas 使用默认缩放模式 | 将 Canvas 的 Render Mode 改为 Screen Space Overlay,或者在暂停期间用 UnscaledDeltaTime 驱动 UI 动画 |
第一个常见问题是角色不移动。很多情况下是因为角色身上有 Rigidbody,而代码又用了 CharacterController,两者同时存在时移动逻辑会互相干扰。这个原型只需要 CharacterController,不需要 Rigidbody,建议移除 Rigidbody。
第二个高频问题是跳跃判定。controller.isGrounded只有在 Move 调用后才会更新,因此如果你把verticalVelocity = Mathf.Sqrt(...)的代码写在 Move 之前,不会有问题;但如果你的 Move 被写在跳跃赋值之后,就会导致延迟一帧才起跳。上面代码中跳跃赋值在 Move 之前,所以逻辑正确。
还有一个容易忽视的问题:Time.timeScale 设置为 0 后,Update 依旧会执行,但逻辑中如果依赖 Time.deltaTime,角色会停止移动,这是正常现象。不过协程和动画不受影响,因此游戏结束后如果还想等待一下再显示 UI,要注意不要依赖普通的 WaitForSeconds,建议用WaitForSecondsRealtime。
6. 最佳实践与工程建议
6.1 输入层与移动端适配
本篇代码使用键盘输入,逻辑直观,适合学习。但游戏上线至少要支持手机,移动端没有物理键盘,需要通过触摸事件判断操作。最简单的方式是在输入层做一个抽象,例如定义InputManager,根据平台切换输入来源。
移动端跑酷通常采用滑动操作:左右滑动换道、上滑跳跃、下滑滑铲。判断手势可以在 Update 中记录手指按下和移动方向,也可以用 Unity 的新输入系统配置 Touch 与 Swipe 动作。从架构上,建议将玩家操作封装成一个方法,例如HandleInput(),底层使用键盘还是触摸,只在输入层判断,不影响上层逻辑。
6.2 对象池与回收时机
现在原型中的障碍物生成后不会自动回收,跑完 300 米后场景中会有几十个障碍和金币。虽然数量不多,但如果做成无尽模式,就必须引入回收机制。
推荐的做法是给障碍物增加一个“生命周期”概念,例如每个障碍物生成后记录生成时的 Z 坐标,当玩家位置与障碍位置差超过 20 米时,将障碍物归还给对象池。归还逻辑可以写在 Obstacle 脚本中,也可以写在 Spawner 的 Update 里统一遍历。统一遍历更清晰,但要注意遍历数量,不要每帧全量循环所有对象。
另外,对象池预创建数量需要根据生成频率调整。如果间距 8 米生成一组障碍,预加载 20 个可能不够,可以按公式估算:玩家前方 40 米范围内最多保持 5 组障碍,每组最多 3 个金币,所以预加载 20 到 30 个足够。预加载过大反而会增加启动耗时。
6.3 碰撞与事件解耦
现在的 Obstacle、Collectible 脚本都是直接调用 GameManager,早期原型可以这么写,但后续功能增加后会变得混乱,比如障碍可能触发音效、震动、分数结算等多个模块。更合理的做法是使用 UnityEvent 或 C# 事件。
例如 Obstacle 脚本中声明一个public static event System.Action OnObstacleHit;,撞击发生时触发事件,让 GameManager、AudioManager、VFXManager 各自订阅自己关心的部分。这样新增功能时不需要改动 Obstacle 和 Collectible 脚本,降低耦合度。
6.4 随机生成的安全策略
随机生成不能让玩家陷入必死局面。例如连续三个障碍都生成在同一车道,玩家可能来不及换道。更细的规则是:上一次生成地面障碍的左侧车道,下一次尽量不要选择左侧;高空障碍出现时,至少在相邻车道留一条安全路径;金币尽量成排或成弧线,让玩家有收集节奏感。
实现这些策略时,可以维护一个最近使用的危险车道列表,每次生成前把危险车道剔除,从剩余车道中随机选择。跑酷玩法的核心乐趣,是在“危险与安全并存”之间营造紧张感,而不是靠无脑随机让玩家碰运气。
7. 扩展方向与学习路线
这一期完成了跑酷游戏的最小闭环:角色自动前进、三车道切换、跳跃、滑铲、金币拾取、障碍判定和游戏结束。这些机制组合在一起,已经是一个可以让人玩起来的原型。你可以把它分享给朋友试玩,看看他们在操作时手感和判定是否舒服,这本身就是很好的测试。
如果要继续深入,下一步有几个明确的方向可以选择。
第一,提升视觉表现。把 Player 的 Capsule 替换成带跑步动画的 3D 角色,把地面 Cube 替换成带贴图的赛道资源,加入粒子特效和环境音效。这个阶段可以开始使用 Unity Asset Store 中的免费资源,不需要自己画。
第二,引入 UI 系统。在屏幕上显示金币数量、距离进度、暂停按钮和游戏结束弹窗。UI 放大了游戏的完成度,也让状态管理更清晰。
第三,改为无尽模式。把跑道从固定 300 米改为循环生成,障碍物回收池化,金币位置动态生成,再增加难度曲线与道具系统,比如磁铁、护盾、加速。
第四,接入移动端输入与广告平台。上线的跑酷游戏一般会接激励视频复活与插屏广告,这需要在游戏状态机里预留“复活”状态,并处理好 Time.timeScale 与广告回调时序。
如果你希望我把其中一个方向拆开详细写,可以收藏这篇文章并留言告诉我。下一篇可以考虑从“替换成带动画的角色”或者“无尽模式 + 对象池回收”开始。