ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity跑酷游戏开发Day2:从能跑到能玩的核心技术拆解

Unity跑酷游戏开发Day2:从能跑到能玩的核心技术拆解 《重生之我靠汤姆猫跑酷挣大钱》这个标题放在网文分类里毫无违和感。但如果把它当作游戏开发日志来看Day 2 才是真正见分晓的时候Day 1 谁都能搭出一个“小人在跑”的场景Day 2 却要面对跳跃手感、碰撞判定、三跑道切换、障碍物生成这些真正决定“能不能玩”的问题。做跑酷游戏最常见的错觉是觉得它简单。三条路、一个跳跃键、一个下滑键、外加捡金币玩法机制听起来十分钟就能讲完。但新手做完第一个原型后反馈往往是统一的跳起来很飘、手感很钝、障碍物偶尔全堵死、角色会穿模。这些问题不是“美术不够好”能解决的它们是玩法工程问题。这篇文章就是 Day 2 的完整开发复盘。我会从技术选型开始把玩家控制、三跑道切换、对象池、计分与受击反馈逐块拆开给出可以直接放进 Unity 项目的 C# 代码并说明每个模块里新手最容易踩的坑。看完之后你应该能跑出一份连续玩一分钟不露馅的跑酷原型。1. 这篇文章真正要解决的问题标题可以网文化工程不能网文化。“重生”只是一个叙事包装Day 2 真正要解决的是从“能跑”到“能玩”的跨越。很多新手第一天就会把场景搭好一条长长的地面、一个角色、一排障碍物预制体按下 Play 能看到角色往前移动于是发一条动态“跑酷原型完成”。但第二天往往陷入焦头烂额角色跳跃高度忽高忽低、碰撞体对不上、障碍物随机生成时三个跑道全堵死、切跑道像在溜冰。这些问题的根源不是某一个 Bug而是整个玩法循环缺少工程化设计。本文要解决的问题包括玩家控制怎么设计才不飘自动前进、跳跃、滑铲、输入缓冲。三跑道切换怎么做才有“精准感”而不是直接改坐标的瞬移感。障碍物生成为什么必须用对象池以及随机时怎么保证“至少有一条路能走”。计分、金币、受击反馈怎么在原型阶段就搭好不影响后续扩展。跑酷游戏的手感参数到底调什么、怎么记录、怎么验证。如果你正准备做休闲游戏 Demo、想用 Unity 快速验证一个跑酷玩法的可行性或者已经做过一个“能跑但不好玩”的原型这篇文章应该能帮你把 Day 2 的效率提上来。整篇文章只依赖 Unity 自带组件和基础 C#不引入第三方插件。2. 跑酷游戏核心玩法与技术选型2.1 先拆玩法在三跑道之间做决策跑酷游戏看起来是“一直往前跑”但它的核心乐趣点并不是移动而是决策频率。典型的一局跑酷玩家循环是这样观察前方三条跑道上的障碍物分布。快速决策换道、跳跃还是滑铲。执行操作获得金币或避开障碍。进入下一组障碍重复以上循环。所以跑酷关卡设计的本质不是“随机放障碍”而是“给玩家制造一个有时间压力的决策点”。游戏难度来自决策时间变短、障碍物组合变复杂、金币位置诱导玩家冒险。理解了这一点你就明白为什么 Day 2 要先做稳定的玩家控制而不是先做美术资源和怪物。标题里提到的“汤姆猫跑酷”核心特色其实不只是跑跳滑铲而是角色会说话、有表情、撞到障碍物后会给玩家即时反馈。这种“角色与玩家互动”的设计让休闲玩家即使不冲高分也愿意反复打开游戏。Day 2 的原型阶段不需要接入麦克风但可以在受击反馈里做一个简化版的表情气泡把“角色反馈”这个特色先跑起来。2.2 为什么选择 Unity CharacterControllerUnity 做跑酷原型有天然优势场景搭建快、组件齐全、跨平台打包方便网上也有大量现成资源做参考。真正需要决策的是角色移动方案。跑酷角色移动有三条常见路线方案优点缺点适合场景CharacterController移动稳定、自带碰撞、isGrounded 好用适合“自动前进 跳跃 滑铲”逻辑不模拟物理弹跳需要自己写重力本文的休闲跑酷原型Rigidbody 物理移动真实物理、支持碰撞冲击反馈手感容易发飘需要处理刚体休眠、速度变化需要物理交互的跑酷直接改 Transform实现最简单容易穿模没有碰撞难以扩展不建议演示或测试时可用我的建议是原型阶段用 CharacterController。原因有三第一跑酷本质是“受控移动”不需要真实物理模拟第二CharacterController 的isGrounded判断非常稳定跳跃、落地、滑铲都依赖它第三它不参与物理计算性能开销小后续要加对象池、大量障碍物时不容易出问题。如果你已经有了一批基于 Rigidbody 的代码也不是不能用但需要额外处理跳跃时刚体力对地面的影响整体调参成本会高不少。2.3 Day 2 的模块划分为了避免脚本越来越乱Day 2 一开始就把代码分成几块。场景里计划创建以下对象和脚本模块脚本职责玩家PlayerController.cs自动前进、跳跃、滑铲、换道玩家受击PlayerCollisionHandler.cs和障碍物、金币触发障碍物ObjectPool.cs对象池管理障碍物复用障碍物ObstacleSpawner.cs决定何时、在哪条跑道生成障碍障碍物回收ObstacleDespawner.cs把跑出场景的障碍物还给对象池计分与金币ScoreManager.cs距离计分、金币计数金币Coin.cs拾取逻辑原型阶段不追求架构完美但目录要清晰。建议在 Assets 下建Scripts/Player、Scripts/Obstacle、Scripts/Pickup、Scripts/Manager、Scripts/UI五个文件夹后续加动画、音效、关卡配置时都会方便很多。3. 环境准备与 Day 2 开发计划3.1 环境准备本文按 Unity 长期支持版LTS的 3D 模板演示版本号请以你本机安装的为准。2020 LTS 以上的版本操作差异不大旧版本如果使用内置输入管理器代码逻辑基本一致如果你启用了新的 Input System需要把Input.GetKeyDown替换成新输入事件或者先在项目设置里切回旧输入模式。需要准备的组件Unity Hub 对应 LTS 版本编辑器。一个 3DBuilt-in Render Pipeline项目。可选VS Code 或 Visual Studio用于写 C# 脚本。不需要导入任何商店资源跑酷原型用基础几何体就能完成。3.2 场景与预制体搭建进入新场景后按下面顺序准备基础物体创建地面一个CubeScale 设为20, 1, 200Position 的 y 设为 0让它贯穿整个赛道。创建玩家一个Capsule命名为PlayerPosition 设为0, 1, 0Scale 约0.8, 0.8, 0.8。这个胶囊体只是占位模型后续可以替换成带动画的角色模型。给Player添加CharacterController组件并把高度调成 1.8 左右让胶囊体的外观和碰撞体大致匹配。CharacterController 的Center设置为0, 0.9, 0让碰撞体底部贴着地面。给Player添加PlayerController.cs脚本。创建障碍物预制体一个Cube命名为ObstacleScale 设为1.5, 1.2, 0.6Position 的 y 设为 0.6让底部刚好贴地。添加 Box Collider 并勾选Is Trigger然后把它存成预制体。创建金币预制体一个Sphere命名为CoinScale 约0.4, 0.4, 0.4添加 Sphere Collider 并勾选Is Trigger再挂Coin.cs存成预制体。创建两个空物体SpawnPoint和DespawnPoint分别放在场景前方和后方的 z 轴边界处用于控制障碍物生成和回收。这里有一个很容易被忽略的步骤勾选Is Trigger。如果障碍物和金币的碰撞体不是 Trigger玩家碰撞时会产生物理阻挡甚至出现角色被顶飞的情况。跑酷原型里所有“路过触发”的碰撞体都应该优先用 Trigger。3.3 Day 2 验收标准开发完之后按下 Play 应该满足这几条角色自动向前移动不会穿模也不会脱离地面。空格跳跃能跳过障碍物S 下滑能穿过高障碍物。A/D 或左右方向键可以在三条跑道间切换。障碍物不断从前方生成通过玩家后自动回收。不会出现三跑道同时被障碍物堵死的无解局面。金币碰到玩家后消失计分增加。角色撞到障碍物后出现受击反馈。这七条是 Day 2 的“通关条件”。如果全部跑通这个原型就已经具备一个休闲跑酷最基础的玩法闭环。4. 玩家控制自动前进、跳跃与滑铲4.1 输入缓冲设计很多新手写跳跃是这样在 Update 里检测到空格按下就直接给一个向上的速度。这在低帧率、输入抖动、角色刚好处于落地前一帧时非常容易出现“按了没反应”或“跳空了”的问题。更稳妥的做法是引入输入缓冲。玩家按下跳跃键后不是立刻执行跳跃而是先记下“玩家想跳”这个意图在角色落地时再消费这个意图。这样即使玩家在空中最后一帧按下跳跃落地瞬间也能马上起跳手感会柔和很多。同样滑铲必须限定在地面。如果角色在空中按下滑键最合理的做法是忽略而不是在空中做一个不自然的收缩动作。判断地面就用CharacterController.isGrounded。4.2 完整实现 PlayerController.cs以下是 Day 2 最核心的玩家控制代码。代码同时处理自动前进、重力、跳跃缓冲、滑铲碰撞体收缩以及三跑道切换。后续第 5 章会单独讲解换道的细节。// 文件路径Assets/Scripts/Player/PlayerController.cs using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerController : MonoBehaviour { [Header(基础移动)] [SerializeField] private float forwardSpeed 8f; [SerializeField] private float jumpHeight 1.2f; [SerializeField] private float gravity -20f; [Header(跑道切换)] [SerializeField] private float laneDistance 2f; [SerializeField] private float laneChangeSpeed 10f; [Header(滑铲)] [SerializeField] private float slideDuration 1f; [SerializeField] private float slideHeight 0.8f; [SerializeField] private float slideCenterY 0.4f; private CharacterController controller; private float verticalVelocity; private int currentLane 1; private float targetX; private bool jumpBuffered; private bool isSliding; private float slideTimer; private float normalHeight; private float normalCenterY; private void Awake() { controller GetComponentCharacterController(); normalHeight controller.height; normalCenterY controller.center.y; targetX (currentLane - 1) * laneDistance; } private void Update() { HandleInput(); UpdateMotion(); UpdateSlideState(); } private void HandleInput() { if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow)) { ChangeLane(-1); } if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow)) { ChangeLane(1); } if (Input.GetKeyDown(KeyCode.Space)) { jumpBuffered true; } if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) { TryStartSlide(); } } private void UpdateMotion() { float delta Time.deltaTime; float newX Mathf.Lerp(transform.position.x, targetX, laneChangeSpeed * delta); if (controller.isGrounded verticalVelocity 0f) { verticalVelocity -2f; if (jumpBuffered) { verticalVelocity Mathf.Sqrt(jumpHeight * -2f * gravity); jumpBuffered false; isSliding false; } } else { verticalVelocity gravity * delta; } Vector3 motion new Vector3( newX - transform.position.x, verticalVelocity * delta, forwardSpeed * delta ); controller.Move(motion); } private void UpdateSlideState() { if (!isSliding) { RestoreHeight(); return; } slideTimer - Time.deltaTime; if (slideTimer 0f) { isSliding false; RestoreHeight(); } } private void TryStartSlide() { if (!controller.isGrounded) return; isSliding true; slideTimer slideDuration; controller.height slideHeight; controller.center new Vector3(0f, slideCenterY, 0f); } private void RestoreHeight() { if (Mathf.Abs(controller.height - normalHeight) 0.01f) return; controller.height normalHeight; controller.center new Vector3(0f, normalCenterY, 0f); } private void ChangeLane(int direction) { currentLane Mathf.Clamp(currentLane direction, 0, 2); targetX (currentLane - 1) * laneDistance; } }这段代码有几个关键点。第一所有移动都汇总成Vector3 motion最后统一交给controller.Move。不要分成多个地方修改transform.position否则 CharacterController 的碰撞检测容易失效。第二前进方向用的是transform.forward * forwardSpeed。如果你在场景里把玩家的初始朝向面向 z 轴负方向那么前进方向也要相应调整。最简单的方法是在场景里让角色面朝跑道延伸方向。第三跳跃初速度通过公式Mathf.Sqrt(jumpHeight * -2f * gravity)计算。重力是负值乘 2 再取负刚好得到向上跳跃的初始速度。这样调整jumpHeight和gravity时跳跃高度是符合直觉的不用反复试值。第四滑铲时只改了 CharacterController 的height和center。在真机项目中这一步应该同时触发角色动画的蹲伏状态Day 2 先用碰撞体收缩跑通逻辑。4.3 手感参数调优表以下参数直接决定跑酷手感建议每个数值都亲手试一遍参数当前示例值作用调优方向forwardSpeed8角色前移速度越快压力越大新手不建议超过 10jumpHeight1.2跳跃高度对应障碍物高度至少多出 0.3 余量gravity-20重力加速度绝对值越大跳跃越“脆”下落越快laneChangeSpeed10换道插值速度越大换道越“硬”越小越“飘”slideDuration1滑铲持续时间和障碍物宽度、角色前移速度相关这里给一个实际建议不要追求“参数看起来合理”而是用一组固定障碍物反复测试让跳跃手感尽量“脆”。跑酷游戏里玩家宁可觉得角色跳太高也不愿意觉得角色跳不起来。5. 三跑道切换从“位移”到“手感”5.1 为什么不能直接改坐标三跑道切换最简单的实现是把角色的 x 坐标直接改成目标跑道的 x。但这样会带来两个问题。第一位置突变会让玩家视觉上“瞬移”缺乏过渡感看起来像卡顿。第二直接设置transform.position时CharacterController 的碰撞体可能瞬间穿插到障碍物模型里导致碰撞判断出现诡异的结果。跑酷里的换道应该是一种“平滑但快速”的位移。好的换道手感是明确知道角色正在换道但换道过程足够快不会让玩家觉得角色在漂移。5.2 换道核心逻辑换道本身只需要维护三个信息当前跑道编号currentLane、目标 x 坐标targetX、以及一个插值速度。每次按方向键时更新跑道编号然后在移动逻辑里用Mathf.Lerp让 x 坐标向目标值靠近。核心方法如下private void ChangeLane(int direction) { // 限制跑道范围0、1、2分别对应左、中、右 currentLane Mathf.Clamp(currentLane direction, 0, 2); // laneDistance 2 时三条跑道的 x 分别是 -2、0、2 // 当前跑道序号从 0 到 2(currentLane - 1) 得到 -1、0、1 targetX (currentLane - 1) * laneDistance; }真正执行换道移动的代码在UpdateMotion里float newX Mathf.Lerp(transform.position.x, targetX, laneChangeSpeed * Time.deltaTime);这里laneChangeSpeed乘以Time.deltaTime的方式是常见的不稳定插值写法。它在帧率稳定时效果良好但如果帧率波动较大换道速度也会波动。更精确的写法是使用指数平滑公式不过在跑酷原型阶段这个写法已经够用也最容易理解。为什么用Mathf.Lerp而不是直接计算目标位置因为Mathf.Lerp天然产生“减速接近”的效果离目标越近每帧移动越小。这在观感上非常接近真实角色侧移的物理感觉又不至于像弹簧一样过头。5.3 换道的边界问题跑道范围是 0、1、2代码里已经用Mathf.Clamp限制。这一点非常重要如果不做限制玩家持续按右键会走到场景外的坐标直接穿出跑道边界。即使后续要做更多跑道也建议先把跑道抽象成一个配置数组而不是硬编码三条。laneChangeSpeed的值不建议低于 8。低于 8 时玩家在两个障碍物空隙间换道会有明显的“来不及”感高于 15 时换道过程太短看起来像瞬移玩家反而看不出角色在切道。实际调校时可以在 10 到 12 之间多试几次。6. 障碍物生成对象池与关卡节奏6.1 为什么必须用对象池新手最容易犯的错误是在 Update 里不断Instantiate和Destroy障碍物。这会导致两个问题一是每次创建销毁都有内存分配频繁操作会产生 GC 压力二是 GC 触发时会出现一瞬间卡顿而那一瞬间正好可能发生在玩家跳跃的关键帧直接破坏手感。对象池的思路是提前创建一批障碍物用的时候从池里取不用的时候放回池里而不是真正销毁。这样Instantiate只发生在一开始后续所有生成回收都是内存复用。6.2 对象池代码// 文件路径Assets/Scripts/Obstacle/ObjectPool.cs using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { [SerializeField] private GameObject obstaclePrefab; [SerializeField] private int initialSize 10; private readonly QueueGameObject pool new QueueGameObject(); private void Start() { for (int i 0; i initialSize; i) { GameObject instance Instantiate(obstaclePrefab, transform); instance.SetActive(false); pool.Enqueue(instance); } } public GameObject Get(Vector3 position, Quaternion rotation) { GameObject result pool.Count 0 ? pool.Dequeue() : Instantiate(obstaclePrefab, transform); result.transform.SetPositionAndRotation(position, rotation); result.SetActive(true); return result; } public void Return(GameObject instance) { instance.SetActive(false); pool.Enqueue(instance); } }这个对象池只处理障碍物。如果后续要加入金币池、特效池可以把它抽成泛型PoolT或者直接做多个池实例。Day 2 先保持简单。6.3 障碍物生成与可解随机生成器决定什么时候生成、生成在哪条跑道。除此之外它还要负责一个隐藏规则不允许三个跑道同时出现障碍物。// 文件路径Assets/Scripts/Obstacle/ObstacleSpawner.cs using UnityEngine; public class ObstacleSpawner : MonoBehaviour { [SerializeField] private ObjectPool pool; [SerializeField] private Transform spawnPoint; [SerializeField] private float spawnInterval 1.5f; [SerializeField] private float minSpawnInterval 0.8f; [SerializeField] private float intervalDecreaseStep 0.01f; private float timer; private void Update() { timer - Time.deltaTime; if (timer 0f) { SpawnWave(); float currentInterval Mathf.Max(minSpawnInterval, spawnInterval - Time.time * intervalDecreaseStep); timer currentInterval; } } private void SpawnWave() { int obstacleCount Random.Range(1, 3); bool[] occupied new bool[3]; for (int i 0; i obstacleCount; i) { int lane Random.Range(0, 3); while (occupied[lane]) { lane Random.Range(0, 3); } occupied[lane] true; SpawnObstacleInLane(lane); } } private void SpawnObstacleInLane(int lane) { float x (lane - 1) * 2f; Vector3 spawnPosition new Vector3(x, 0.6f, spawnPoint.position.z); GameObject obstacle pool.Get(spawnPosition, Quaternion.identity); ObstacleDespawner despawner obstacle.GetComponentObstacleDespawner(); if (despawner ! null) { despawner.Configure(pool); } } }这段代码有两个关键设计。第一SpawnWave每次生成 1 到 2 个障碍物并用occupied数组保证不会在同一个跑道重复生成所以必然有一个跑道是空着的。即便最倒霉的情况也至少有两条路空着玩家始终有逃生路线。第二障碍物生成间隔随游戏时间逐渐缩短intervalDecreaseStep控制缩节奏。这个“难度曲线”是跑酷游戏手感的另一个核心Day 2 先用线性缩进后续可以改成基于分段曲线的难度配置表。6.4 障碍物回收障碍物离开场景后需要回到对象池。给障碍物预制体挂上ObstacleDespawner// 文件路径Assets/Scripts/Obstacle/ObstacleDespawner.cs using UnityEngine; public class ObstacleDespawner : MonoBehaviour { private ObjectPool pool; public void Configure(ObjectPool ownerPool) { pool ownerPool; } private void Update() { if (transform.position.z -30f) { pool?.Return(gameObject); } } }原型阶段用 z 坐标判断回收最简单。缺点是为了避免漏回收Update每帧都要比较一次位置。后续可以改用触发器或事件系统把DespawnPoint设为 Trigger当障碍物进入触发器时回收。如果障碍物较多ObstacleDespawner里Update的开销会累积。更专业的做法是在生成时登记一个“回收时间”或者统一由一个障碍物管理器扫描。Day 2 先不做优化但你应该知道这是后续性能优化的一个方向。7. 计分、金币与受击反馈7.1 ScoreManager跑酷天然有两个数值感很强的系统距离分和金币数。它们本质上都是“玩家跑得多好”的度量。原型阶段用一个简单管理类// 文件路径Assets/Scripts/Manager/ScoreManager.cs using UnityEngine; using UnityEngine.UI; public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } [SerializeField] private Text scoreText; [SerializeField] private Text coinText; public int Coins { get; private set; } public float Score { get; private set; } private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; } private void Update() { Score Time.deltaTime * 10f; if (scoreText ! null) { scoreText.text SCORE Mathf.FloorToInt(Score); } } public void AddCoin(int amount) { Coins amount; if (coinText ! null) { coinText.text COIN Coins; } } }单例在原型阶段很实用写起来最快。但到了生产项目单例容易出现跨场景残留、依赖混乱的问题。如果后续项目变大可以改成事件总线或者模块化服务Day 2 先用单例跑通流程不算错误决策。7.2 金币拾取金币本身不需要额外管理器它在被玩家触发时直接调用ScoreManager.AddCoin然后隐身即可// 文件路径Assets/Scripts/Pickup/Coin.cs using UnityEngine; public class Coin : MonoBehaviour { [SerializeField] private int value 1; private void OnTriggerEnter(Collider other) { if (!other.CompareTag(Player)) return; if (ScoreManager.Instance ! null) { ScoreManager.Instance.AddCoin(value); } gameObject.SetActive(false); } }这里要注意金币的碰撞体必须勾选Is Trigger并且玩家身上要挂一个 Tag 为 “Player” 的碰撞体。CharacterController 自带的碰撞体可以作为触发器的信号源条件是玩家对象上至少有一个 Collider。占位胶囊体自带 Capsule Collider所以这一步没问题。7.3 受击反馈用表情气泡模拟汤姆猫式互动“汤姆猫跑酷”类游戏最有辨识度的设计不是跑酷本身而是角色给玩家的即时互动反馈。Day 2 先做一个最简单的“受击表情气泡”// 文件路径Assets/Scripts/Player/PlayerCollisionHandler.cs using UnityEngine; public class PlayerCollisionHandler : MonoBehaviour { [SerializeField] private GameObject hitFace; private void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { if (hitFace ! null) { hitFace.SetActive(true); } Debug.Log(撞到障碍物原型阶段先暂停结束流程); // 后续在这里接入游戏失败动画、重开逻辑 } } }把hitFace做成玩家头顶的一个 2D Sprite 或 3D 模型默认关闭。撞到障碍物时打开就形成了一个最简单的角色反馈。后续再往这个方向扩展就是“受击时播放角色表情动画”“成功吃到金币时嘴角上扬”“连续加速时角色进入兴奋状态”等表现层内容。这一节的信息增量在于跑酷玩法本身同质化严重反应在玩法决策上的竞争空间越来越小。角色互动、声效反馈、表情动画才是休闲跑酷留存率的关键。Day 2 不需要做复杂动画但必须先把反馈挂点留出来。8. 编辑器内运行验证与调试清单8.1 运行步骤把所有脚本挂好后按以下顺序运行在 Hierarchy 里选中Player查看 Inspector 里的PlayerController各参数是否为预期值。把SpawnPoint放到跑道前方合适位置例如 z 30。把DespawnPoint放到 z -30。在场景里创建一个空物体挂ObjectPool、ObstacleSpawner、ScoreManager并在 Inspector 里拖好引用。创建 UI Canvas添加两个 Text 作为分数和金币显示分别拖给ScoreManager。按下 Play用键盘操作角色。8.2 调试清单检查项预期结果不达标时看哪里角色自动前进角色平稳向前不穿地检查地面碰撞体、CharacterController 高度空格跳跃跳跃能越过障碍物调整 jumpHeight 和 gravityS 滑铲碰撞体高度降低看 slideHeight、CharacterController.centerA/D 换道平滑切到左右道看 laneChangeSpeed 和 currentLane障碍物出现每条跑道不会全堵看 ObstacleSpawner.SpawnWave障碍物回收角色后方没有残留物体看 ObstacleDespawner 的 z 阈值金币拾取金币消失且计数增加看 Coin 的 Trigger 设置和 Tag撞到障碍物表情气泡出现看 hitFace 是否在 PlayerController 上有引用8.3 手感自测方法跑酷手感是否合格不要只看参数要实际“玩” 30 秒并且用一套固定障碍顺序测试。具体做法是在场景中手动摆放一组固定的障碍物序列比如“跳、滑、左道、右道、跳”然后反复跑这一段。如果这组固定障碍都能顺利通过再开启自动生成器测试随机逻辑。固定序列测试的价值是你能稳定复现手感问题而不是被随机生成干扰判断。很多新手会跳过固定序列测试直接在随机生成下调试结果很难判断是“手感问题”还是“随机生成的问题”。建议 Day 2 后半段把这两件事分开做。9. 常见问题与排查方法问题现象可能原因排查方式解决方案Input.GetKeyDown 编译报错项目启用了新版 Input System检查 Package Manager 里的 Input System 包在 Player Settings 里把输入模式切回旧版或改用 Input System API角色一直下落穿透地面角色没有添加 CharacterController或地面没有 Collider检查 Player 的 Inspector 组件列表给角色添加 CharacterController给地面添加 Box Collider跳跃高度不一致gravity 与 jumpHeight 数值不匹配或帧率波动大固定场景帧率测试统一时间步长使用跳跃初速度公式计算障碍物生成后三跑道全堵死SpawnWave 没有做“至少留一条路”的判断检查 occupied 数组逻辑每一轮生成 1 到 2 个障碍物且用 occupied 去重障碍物回到场景后位置不对对象池回收时没有重置障碍物状态检查 Return 方法是否执行 SetActive(false)回收时关闭对象生成时重新 SetPositionAndRotation滑铲后角色一直保持矮个子滑铲计时结束后没有恢复高度检查 RestoreHeight 是否被调用在 UpdateSlideState 中恢复 height 和 center金币碰到角色不消失金币碰撞体没有勾选 Is Trigger检查 Coin 预制体上的 Sphere Collider勾选 Is Trigger并确定玩家有 Tag 为 Player 的碰撞体角色撞到障碍物不触发失败障碍物不是 Trigger或挂了刚体检查障碍物预制体统一把障碍物碰撞体设置为 Trigger由 PlayerCollisionHandler 处理这里有一个容易被新手忽略的关键点如果是 Trigger 碰撞还需要玩家身上有一个非 Trigger 的 Collider。CharacterController 自带碰撞体所以没问题但如果玩家用的是普通的 Capsule Collider 而没有加刚体触发器事件仍然会触发不需要额外添加刚体。如果你使用的是新版 Input SystemInput.GetKeyDown默认会编译报错或者运行时警告。最常见的原因不是代码问题而是项目设置里的 Active Input Handling 没有切回 Input Manager。在 Player Settings 里找到后改成 “Both” 即可兼容新旧两套输入。10. 最佳实践让原型走向可发布的工程10.1 目录与命名Day 2 的脚本量还不大但建议直接按可扩展的方式命名和分组。脚本文件名必须和类名一致这是 C# 的基本约束在目录上按 Player、Obstacle、Pickup、Manager、UI 拆分后续新增音效、关卡、存档模块时不会把Assets/Scripts变成一个垃圾堆。10.2 参数集中管理目前 PlayerController 和 ObstacleSpawner 的参数都写在 Inspector 里。如果打算长期迭代建议把所有手感参数收进一个GameConfig配置脚本或者使用 ScriptableObject 管理默认数值。跑酷项目后续要有角色升级、不同角色手感差异参数集中管理是刚需。Day 2 至少可以先做一件事把调好的数值记录成表格。记录包含参数名、当前值、测试场景、手感评价。跑酷项目最值钱的就是这套调参记录因为它们会被反复使用。10.3 素材与商业化合规提醒一定要区分“致敬玩法”和“搬运素材”。玩法机制本身不是代码层面的保护对象但角色形象、美术资源、音乐音效、商标名称都受版权和商标保护。做学习案例时用几何体跑通逻辑没有任何问题如果要做成上架游戏必须有原创角色和原创素材或者获得授权。另外做休闲游戏商业化先考虑的不是“怎么加内购”而是合规。隐私政策、未成年人保护、实名认证、广告 SDK 的权限声明等都要在提审之前准备好。这些内容看起来和代码无关但在实际发布中被卡住的概率远比想象中高。10.4 真机验证编辑器里手感好不代表真机手感好。触摸输入的响应、屏幕尺寸、帧率波动都会影响跑酷手感。Day 2 原型在编辑器跑通后建议尽早打包到手机真机测试一次。真机测试重点看两个地方一是触摸输入会不会有延迟感二是帧率能否稳定。跑酷游戏里一次掉帧可能就造成一次误判碰撞。如果项目目标平台是低端 Android 机最好一开始就把画质和特效控制在较低水平。11. 总结与 Day 3 方向Day 2 跑通的东西总结下来其实就一句话把一个跑酷原型从“能跑”变成“能玩”。具体做到了三件事第一玩家控制和换道系统稳定可靠跳跃、滑铲、输入缓冲都有了第二障碍物生成用对象池实现并且保证了随机场景“始终可解”第三计分、金币、受击反馈已经接入角色互动反馈留下了扩展点。如果你按这个流程做完现在应该已经能体验到一个相对完整的休闲跑酷循环。接下来 Day 3 的方向可以选两条路并行一条是表现层给角色加入动画、音效、更丰富的表情反馈另一条是系统层把难度曲线做成配置表加入关卡分段、不同障碍物类型、以及失败结算界面。我更建议先把难度曲线做出来。跑酷游戏的核心留存来自“难度刚好压着你极限走”的紧张感而不是越来越多的障碍物堆叠。你可以统计玩家当前跑到多少分、第几波障碍物、死亡时前几波生成间隔是多少再反推到难度配置里。这个数据驱动的调优思路会在后面每个迭代阶段都帮你做决策。如果 Day 2 里有哪段代码没跑通先从第 8 章的调试清单逐项过一遍大多数问题是 Tag、Trigger、CharacterController 高度这几个基础配置造成的。把这些基础配置理顺后再回头改手感参数你会明显感觉到每一次调整都更可控。现在可以去做两件事打开 Unity 项目跑一遍你的原型再把所有手感参数抄到一张表格里。跑酷游戏真正值钱的不是玩法创意而是这张越填越厚的调参记录。
返回列表