简介:面向Unity3D入门与进阶开发者的跑酷类小游戏完整工程源码,适合希望通过可运行项目理解游戏开发流程、积累实战经验的学习者。压缩包共5428个文件,约18.22MB,以bin、info等工程缓存与配置数据为主,含197个C#脚本、334个meta、46张png贴图、29个mat材质、24个shader及8个prefab预制体,另有dll、xml与Unity场景资源,覆盖脚本、材质、动画与资源配置各环节。已有2787人学习下载。源码围绕角色控制器、碰撞检测、物理运动、动画状态机、得分与生命值逻辑、UI计分板及SceneManager场景切换展开,读者可据此梳理组件化组织方式与脚本调用关系,理解刚体、碰撞器、Animator Controller的配合用法,并参照现有结构修改关卡、调整参数或扩展玩法,是练习面向对象设计与模块化拆分的实用素材。
1. 一条跑道撑起一局游戏:这份 Unity3D 跑酷源码能省掉的调试时间
跑酷游戏的骨架其实特别小:一条不断延伸的跑道、一个只会跳和左右闪的角色、一套把身后地块回收再丢到前面的循环。可就是这三块拼到「能上手玩」之间,隔着大量琐碎调试——接缝处的抖动、轻点没反应、撞上障碍却不判定、跑三分钟后帧率从 60 掉到 35。这份 Unity3D 跑酷游戏源码把这些坑基本踩过一遍:世界推进式的无限跑道、对象池管理的地块与障碍生成、带土狼时间和跳跃缓冲的角色控制器,外挂一份可横向改难度曲线的 ScriptableObject 配置。它适合两类人,一类是想在一天内把 Unity3D 跑酷游戏从输入到打包的完整链路走通的新手,另一类是手里已经有半成品、卡在「能跑但不顺手」阶段,想找一份实现来对照结构的老手。下面按拆包顺序讲,先看骨架,再看跑道,最后落到手感与打包。
2. 拆包先看骨架:目录分层、主循环调用链与参数反推
拿到一个陌生工程,最忌讳的就是直接点 Play 看效果。跑酷这类项目的逻辑密度集中在两三个脚本里,剩下的大多是配置和美术资源,先花二十分钟把目录和调用链摸清,后面改什么都快。
2.1 目录分层:为什么脚本要按 Core / Player / Level / UI 拆
一份能长期改的跑酷工程,Assets下的结构大致长这样:
Assets/ ├─ Scenes/ Main.unity(正式局)、Sandbox.unity(调参用,不带 UI) ├─ Scripts/ │ ├─ Core/ GameManager.cs、GameState.cs、PoolManager.cs │ ├─ Player/ PlayerController.cs、PlayerInput.cs、PlayerVisual.cs │ ├─ Level/ LevelSpawner.cs、Tile.cs、ObstacleSpawner.cs │ └─ UI/ HUDController.cs、GameOverPanel.cs ├─ Prefabs/ Player.prefab、Tile_Straight.prefab、Obstacle_*.prefab ├─ Config/ LevelConfig.asset、PlayerConfig.asset └─ Art/ Audio/这种拆法的判断依据不是「好看」,而是扩展点在哪。跑酷的改动 90% 落在两处:加新地块、调角色手感。地块相关的东西全塞在Level/下,加一种新跑道只需要新建一个继承Tile的预制体并挂进tilePrefabs数组;手感相关全在Player/,改跳跃不用去翻 UI 脚本。把这两块混在一起的项目,改到第三个月基本没人敢动。
配置用 ScriptableObject 而不是直接写在 MonoBehaviour 的字段里,理由有三个:调参不进代码、不同关卡可以挂不同配置、打包后的资源不会被运行时手滑改掉。工程里LevelConfig通常只放几组曲线和阈值,PlayerConfig放速度、跳跃、重力这些手感数字。
注意:如果拿到手的工程把所有
.cs平铺在Assets/Scripts根目录,先别急着重构,第一步是把GameManager、PlayerController、LevelSpawner这三个文件找出来,剩下的排序可以等跑通再说。
2.2 主循环:GameManager 只管状态,不管位移
跑酷的状态机只有三段,Ready(准备)、Running(跑)、Dead(结算)。很多人会把它写成一个巨大的GameManager.Update,里面塞位移、塞生成、塞 UI 刷新,最后变成一个谁也看不懂的黑匣子。这份源码的做法是状态机只做状态机该做的事:
using UnityEngine; public enum GameState { Ready, Running, Dead } public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [SerializeField] private PlayerController player; [SerializeField] private LevelSpawner levelSpawner; [SerializeField] private float startDelay = 0.5f; // 开局缓冲,给玩家反应时间 public GameState State { get; private set; } = GameState.Ready; public float PlayerSpeed { get; private set; } public float Distance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; } private void Start() => EnterReady(); private void Update() { if (State != GameState.Running) return; // 距离只在这里累加,UI 和难度曲线都读它,避免多处计时漂移 Distance += PlayerSpeed * Time.deltaTime; } public void EnterReady() { State = GameState.Ready; Distance = 0f; PlayerSpeed = 0f; player.ResetPose(); levelSpawner.Rebuild(); } public void EnterRunning() { State = GameState.Running; player.SetControlEnabled(true); } public void EnterDead() { State = GameState.Dead; player.SetControlEnabled(false); } }三个函数对应三条边,状态只能按 Ready → Running → Dead → Ready 流转,没有回跳。Distance放在 GameManager 里累加而不是让 UI 自己算时间,是因为难度曲线和分数展示必须读同一个数,两个地方各算各的,跑长了必然对不上。
startDelay这个参数容易被忽略,它的作用是开局那半秒不接收跳跃输入,否则玩家手指还在屏幕上,角色已经起跳了,观感很怪。参数本身没多复杂,但缺了它,第一局的体验会差一截。
和 GameManager 打交道的方式要统一:LevelSpawner和PlayerController在自己的Update里读GameManager.Instance.State,不用 C# 事件。事件写法更优雅,但跑酷的帧预算很紧,加上 Unity 里事件订阅漏取消会导致对象泄漏,工程里干脆用轮询。代价是要注意脚本执行顺序,把 GameManager 的 Script Execution Order 调到 -100,或者接受「状态晚一帧生效」这个事实——跑酷里这一帧没人看得出来。
2.3 用 Inspector 参数反推设计意图
打开Player.prefab和LevelSpawner,把面板上的数字抄下来,基本就能反推出原作者的手感取向。下面这张表是一份典型配置,值不必照抄,但数量级可以参考:
| 参数 | 所在脚本 | 典型值 | 作用 | 改大的后果 |
|---|---|---|---|---|
| forwardSpeed | PlayerController | 12 | 起始前进速度 m/s | 开局就压不住,新手劝退 |
| maxSpeed | LevelSpawner | 22 | 速度上限 | 上限越高,障碍间距要求越宽 |
| acceleration | LevelSpawner | 0.6 | 每秒加速度 | 太大等于每局前 10 秒都在加速 |
| jumpVelocity | PlayerController | 9 | 起跳初速度 | 滞空变长,判定窗口变松 |
| gravity | PlayerController | 30 | 基础重力 | 下落变快,落地更「脆」 |
| fallMultiplier | PlayerController | 2.0 | 下落重力倍率 | 手感变沉,视觉上更利落 |
| coyoteTime | PlayerController | 0.12 | 离地后的宽容跳 | 超过 0.2 会显得「悬空跳」 |
| jumpBuffer | PlayerController | 0.15 | 落地前的预输入 | 太大导致连跳误触 |
| laneWidth | PlayerController | 2.5 | 车道间距 | 影响闪避的横向距离感 |
| tileLength | LevelSpawner | 30 | 单个地块长度 | 必须与预制体实际尺寸一致 |
| visibleTiles | LevelSpawner | 4 | 同时存在的地块数 | 太少会看到地块凭空出现 |
读这张表有个小技巧:先用forwardSpeed除以tileLength,算出每秒过几个地块(12 / 30 = 0.4 个/秒),再乘以visibleTiles,得到视野内大概几秒换一轮。这个数字决定了池子需要多大、障碍生成器要提前多久投放。参数之间是有算术关系的,不是逐个拍脑袋定的。
3. 无限跑道怎么做:三种推进方案的取舍与回收逻辑
跑道是这个项目里最容易写出玄学 bug 的地方。跑着跑着开始抖、接缝处露出一条缝、跑久了帧率往下掉,基本都出在这一层。
3.1 角色前进、世界后移、UV 滚动:三种方案怎么选
| 方案 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| A 角色前进 | 角色 transform 向 +z 推进,相机跟随,前方生成、身后销毁 | 距离就是坐标,调试直观 | float 精度随距离退化,跑久了抖动;相机跟随要额外做阻尼 | 有终点的短局 |
| B 世界后移 | 角色固定在 z≈0,地块向 -z 移动,越界后回收再来 | 坐标恒定,精度稳定,物理和阴影都不飘 | 距离要单独记账;写 AI 时要在脑子里做一次坐标换算 | 无限跑酷,主流做法 |
| C 材质滚动 | 只滚动 UV 或贴图偏移 | 极省性能 | 无法做真实碰撞,障碍要另做一套 | 伪 3D 或背景层 |
这份源码用的是 B。理由很实际:float 在 10 万量级时最小可分辨间隔已经到 0.0078 米左右,速度 15 m/s 跑两小时就是 10 万米,那时候角色和相机的相对位置会出现肉眼可见的抖动,阴影也跟着闪。A 方案不是不能做,要么上 double 做逻辑坐标、渲染时再折算回 float,要么定期「重置世界原点」,两种都是额外成本。B 方案把角色钉在原点附近,这个精度问题从根上消失,代价只是分数要单独算——而分数本来就要单独算。
C 方案在跑酷里偶尔会看到,一般只用来做远景的云层或地面纹理,让静止的背景看起来在流动,真正的地块还是走 B。
3.2 地块拼接:位置用索引乘,不要累加
先看代码。这段是LevelSpawner的核心,负责推进、回收、再生成:
using System.Collections.Generic; using UnityEngine; public class LevelSpawner : MonoBehaviour { [SerializeField] private float tileLength = 30f; // 必须与预制体 z 轴实际长度一致 [SerializeField] private int visibleTiles = 4; // 同时存在的地块数量 [SerializeField] private float recycleZ = -60f; // 越过这个 z 就回收 [SerializeField] private Tile[] tilePrefabs; // 可轮换的地块池 private readonly List<Tile> active = new List<Tile>(); private int nextTileIndex; // 用整数记序号,不用浮点累加 private void Update() { if (GameManager.Instance.State != GameState.Running) return; float speed = GameManager.Instance.PlayerSpeed; for (int i = active.Count - 1; i >= 0; i--) // 倒序遍历,边删边遍历不出错 { Tile t = active[i]; t.transform.position += Vector3.back * (speed * Time.deltaTime); if (t.transform.position.z <= recycleZ) { active.RemoveAt(i); PoolManager.Instance.Release(t.gameObject); SpawnNext(); } } } private void SpawnNext() { Tile prefab = tilePrefabs[nextTileIndex % tilePrefabs.Length]; Tile tile = PoolManager.Instance.Get<Tile>(prefab); // 关键:位置 = 序号 × 长度,误差不滚动累加 tile.transform.position = new Vector3(0f, 0f, nextTileIndex * tileLength); nextTileIndex++; active.Add(tile); } public void Rebuild() { foreach (Tile t in active) PoolManager.Instance.Release(t.gameObject); active.Clear(); nextTileIndex = 0; for (int i = 0; i < visibleTiles; i++) SpawnNext(); } }几处细节值得单独说。第一,SpawnNext里的位置是nextTileIndex * tileLength,不是「上一个地块的 z 加上长度」。后者看着更自然,但浮点误差会一块一块累积,跑两百块之后接缝处就能看到一丝裂缝或者一块重叠。用整数序号乘长度,误差永远只有一次乘法,不会滚雪球。第二,回收循环必须倒序,正序RemoveAt会跳过元素,表现就是「偶尔有一块地不回收」,跑几分钟后场景里堆了几百个地块,帧率自然就崩了。第三,nextTileIndex只增不减是可以的,因为只是个序号,但Rebuild时记得归零,否则重开局时地块从几千米外飞过来。
参数之间有一条硬约束:tileLength必须等于预制体在 z 轴上的实际包围盒长度,并且 pivot 要放在地块的入口边缘而不是几何中心。pivot 在中心是新手最容易翻车的地方——拼出来的跑道会重叠半个地块长度,看起来像是「地面被切掉一截」。
recycleZ的取值取决于相机。相机在 z 轴负方向看向原点,视野能看到大约 -30 到 +40,回收阈值设在 -60 比较安全,既保证视野外的地块已经没用了,又不会因为太靠近视野而在回收瞬间被玩家看到消失。
3.3 对象池:把 Instantiate 从每帧路径里拿掉
跑酷是典型的高频生成场景,一块地跑过去就要换一块新的,障碍物更密。如果每次生成都Instantiate、每次回收都Destroy,GC 会在每秒几十次的频率上触发,真机上表现就是周期性卡顿。
using System.Collections.Generic; using UnityEngine; public class PoolManager : MonoBehaviour { public static PoolManager Instance { get; private set; } private readonly Dictionary<GameObject, Stack<GameObject>> pools = new Dictionary<GameObject, Stack<GameObject>>(); private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; } public T Get<T>(T prefab, int prewarm = 0) where T : Component { GameObject key = prefab.gameObject; if (!pools.TryGetValue(key, out Stack<GameObject> stack)) { stack = new Stack<GameObject>(Mathf.Max(prewarm, 8)); pools[key] = stack; } GameObject go = stack.Count > 0 ? stack.Pop() : Instantiate(key); go.transform.SetParent(null); // 出池时脱离父节点,避免跟着父物体位移 go.SetActive(true); return go.GetComponent<T>(); } public void Release(GameObject go) { go.SetActive(false); go.transform.SetParent(transform); // 统一挂到池节点下,Hierarchy 干净 if (!pools.TryGetValue(go, out Stack<GameObject> stack)) { // 这里用原始预制体做 key 是简化写法,正式工程建议给对象打 PoolId 标记 Destroy(go); return; } stack.Push(go); } }Get里做了两件事:池子空了就Instantiate,池子有货就从栈里取。SetParent(null)这一行不是可选项,出池的对象如果还挂在池节点下,池节点一旦移动或者缩放,地块位置就全乱了,这类问题在编辑器里不一定复现,打包后才会暴露。
回收侧注意SetActive(false)之后要记得把 transform 重置(旋转、缩放、挂在身上的特效状态),否则复用的障碍物会带着上一次的「被撞碎」状态出场。工程里的做法是给Tile和Obstacle各自写OnSpawn/OnDespawn,把重置逻辑收在预制体自己身上,池子不关心业务字段。
3.4 速度与难度耦合:障碍间距不是拍脑袋定的
难度曲线通常是一条AnimationCurve,横轴是已跑距离,纵轴是速度倍率,从 0 到 1 采样后映射到forwardSpeed到maxSpeed的区间。真正需要算的是障碍物间距的下限。
人的视觉反应加操作大概需要 0.6 到 0.8 秒,跳跃滞空时间由t = 2v / (g × fallMultiplier)决定,起跳速度 9、基础重力 30、下落倍率 2 的时候,上升 0.3 秒、下落 0.15 秒左右,总滞空约 0.45 秒。那么:
- 速度 12 m/s 时,滞空覆盖的水平距离约 5.4 米,障碍最小间距建议 10 米以上;
- 速度 22 m/s 时,滞空覆盖约 9.9 米,障碍最小间距要拉到 16 米以上。
如果障碍是「必须跳过去」的那类,间距下限就是speed × 反应时间 + 滞空距离;如果是「可以左右闪避」的,间距取决于车道切换耗时。工程里ObstacleSpawner就是这么算的,每次投放时读当前速度,反推最小间距,再在这个基础上加一点随机量。写死一个常量间距的项目,跑到后半段必然出现「障碍贴脸」。
4. 手感调优:输入封装、跳跃宽容与碰撞判定
跑道跑起来之后,剩下的全是手感。手感这东西看着玄学,其实是几个具体参数和几处判定逻辑堆出来的,每一条都能落到代码上。
4.1 输入层:把键盘和触摸压成一个快照结构
移动端和编辑器的输入路径不同,很多工程写两套if,结果真机上轻点不跳、编辑器里好好的。这里的做法是把输入压成一个结构体,每帧只采样一次:
using UnityEngine; public struct InputSnapshot { public bool jumpDown; // 本帧按下(边沿) public bool jumpHeld; // 持续按住(电平) public int laneDelta; // -1 左移,1 右移,0 不动 } public class PlayerInput : MonoBehaviour { [SerializeField] private float swipeMinPixels = 40f; // 小于这个位移算点击 [SerializeField] private float swipeMaxSeconds = 0.4f; // 超过这个时长算拖拽 private Vector2 startPos; private float startTime; public InputSnapshot Snapshot { get; private set; } private void Update() { var s = new InputSnapshot(); // 编辑器与 PC 调试路径 if (Input.GetKeyDown(KeyCode.Space)) s.jumpDown = true; s.jumpHeld = Input.GetKey(KeyCode.Space); if (Input.GetKeyDown(KeyCode.A)) s.laneDelta = -1; if (Input.GetKeyDown(KeyCode.D)) s.laneDelta = 1; // 触摸路径:只在 Began/Ended 两帧结算,中间不做判定 if (Input.touchCount > 0) { Touch t = Input.GetTouch(0); if (t.phase == TouchPhase.Began) { startPos = t.position; startTime = Time.unscaledTime; } else if (t.phase == TouchPhase.Ended) { Vector2 d = t.position - startPos; float dur = Time.unscaledTime - startTime; if (dur <= swipeMaxSeconds) { if (d.magnitude < swipeMinPixels) s.jumpDown = true; // 轻点 = 跳 else if (Mathf.Abs(d.x) > Mathf.Abs(d.y)) // 横向滑 = 换道 s.laneDelta = d.x > 0f ? 1 : -1; else if (d.y > 0f) s.jumpDown = true; // 上滑 = 跳 } } } Snapshot = s; } }为什么用快照而不是直接暴露bool JumpPressed属性:属性每次读都会重新算一遍,控制器的Update里可能读三次(判缓冲、判土狼、判可变高度),性能是一方面,更要命的是在同一帧里三次调用可能返回不同结果,逻辑就开始飘。快照还有个额外好处——录制回放时只要把每帧的快照存下来就能重放,调跳跃参数时很好用。
swipeMinPixels这个阈值不要照抄,它跟屏幕 DPI 有关。手机上 40 像素可能只是手指抖了一下,平板上又太大了,常见做法是按Screen.dpi折算成物理尺寸(比如 8 毫米),这样换设备不用重调。
4.2 跳跃手感:土狼时间、跳跃缓冲与可变重力
三个东西凑在一起,跳跃才会「跟手」:
using UnityEngine; public class PlayerController : MonoBehaviour { [SerializeField] private float jumpVelocity = 9f; [SerializeField] private float gravity = 30f; [SerializeField] private float fallMultiplier = 2f; // 下落时重力加倍 [SerializeField] private float coyoteTime = 0.12f; // 离地后仍可起跳的宽限 [SerializeField] private float jumpBuffer = 0.15f; // 落地前预输入的缓存 [SerializeField] private Transform feet; [SerializeField] private LayerMask groundMask; [SerializeField] private PlayerInput inputSource; private float coyoteTimer; private float bufferTimer; private float vy; private bool controlEnabled; public void SetControlEnabled(bool on) => controlEnabled = on; private void Update() { InputSnapshot input = inputSource.Snapshot; bool grounded = Physics.CheckSphere(feet.position, 0.15f, groundMask); coyoteTimer = grounded ? coyoteTime : coyoteTimer - Time.deltaTime; // 只有在 Running 状态才吃输入,死亡后按跳不起作用 bufferTimer = (controlEnabled && input.jumpDown) ? jumpBuffer : bufferTimer - Time.deltaTime; if (bufferTimer > 0f && coyoteTimer > 0f) { vy = jumpVelocity; bufferTimer = 0f; coyoteTimer = 0f; // 防止一次缓冲触发两连跳 } // 上升段松手立刻加大重力 => 按得越久跳得越高 float g = vy < 0f ? gravity * fallMultiplier : (input.jumpHeld ? gravity : gravity * fallMultiplier); vy -= g * Time.deltaTime; Vector3 p = transform.position; p.y += vy * Time.deltaTime; if (p.y <= 0f) { p.y = 0f; vy = 0f; } // 简化的地面钳制,实际用碰撞结果 transform.position = p; } }coyoteTime解决的是「明明还没掉下去,按跳却没反应」的挫败感。人眼判断离地有一两帧的延迟,0.1 到 0.15 秒的宽限几乎不会被察觉,但能救回大量「差一点」的操作。超过 0.2 秒就不行了,角色明显悬空还能起跳,看起来像 bug。
jumpBuffer解决的是相反的场合:玩家提前按了跳,角色还在空中,落地瞬间应该立刻起跳。0.15 秒左右的缓存能覆盖大部分人的操作提前量,设太大就会变成「松开手了还自己跳一下」。
可变重力那一段是这套里面最影响手感的一行。上升段按住用正常重力,松手立刻切到fallMultiplier倍,角色会「短促地」落下来;下落段始终用高重力,落地干脆。同一条跳跃曲线,加上这一行前后完全是两个游戏。
4.3 碰撞判定:胶囊体、射线预判与车道插值
角色碰撞体用 Capsule 而不是 Box,原因不太直观:跑酷角色的视觉轮廓是圆的(头、肩、膝盖),Box 的四个角在擦过障碍物边缘时会「勾住」,表现为角色贴着障碍物蹭了一下就被判定死亡,玩家觉得莫名其妙。Capsule 没有角,擦边就是擦边。
死亡判定不要只靠OnTriggerEnter。速度 22 m/s、60 帧的情况下,一帧位移 0.37 米,遇到薄障碍物(比如一道激光)理论上有穿透风险,尤其是物理更新频率设成默认 50Hz 的时候。工程里的做法是在FixedUpdate里做一次CapsuleCast,从前一帧位置往前探 0.3 米:
private void FixedUpdate() { if (GameManager.Instance.State != GameState.Running) return; float castDist = Mathf.Max(vy * Time.fixedDeltaTime, 0.3f); if (Physics.CapsuleCast(capsuleTop.position, capsuleBottom.position, radius, Vector3.forward, out RaycastHit hit, castDist, obstacleMask)) { GameManager.Instance.EnterDead(); } }castDist取「本帧下落位移」和 0.3 米的较大值,是为了让快速下落时也有足够的探测距离。用Vector3.forward是因为世界在往 -z 移动,等价于角色在往 +z 撞东西,方向别搞反。
车道切换不要用物理。角色换道是一段插值动画,用Mathf.SmoothDamp把 x 坐标推向目标车道,同时把laneDelta锁 0.15 秒防止连点换两条道。用刚体加力做换道会带来惯性和回弹,跑酷不需要这种「真实」,需要的是「按了就到位」。
5. 避坑与排查:跑酷项目里最常见的五个翻车现场
下面这些是我在类似工程里反复见到的,每条按现象、原因、解决写,遇上了直接对号入座。
5.1 场景与资源层面的问题
现象:编辑器里跑得顺,打包到真机第一帧地块全部错位,角色像掉进了地缝。原因通常是Awake或Start里用了Time.deltaTime做位移,第一帧的 deltaTime 在移动端可能是 0.1 秒以上(加载卡顿),一次性把地块推出去好几米。另一种是初始位置依赖了上一个场景残留的 transform。解决方式是把所有初始化位置写成绝对值而不是增量,位移只在Update里用 deltaTime,并且给第一帧加一个if (Time.frameCount < 2) return;的保护。
现象:跑道接缝处偶尔出现一条细缝,或者两块地叠在一起。原因是地块预制体的 pivot 放在了几何中心,而拼接脚本按「入口边缘」计算位置。改了 pivot 之后还要检查tileLength是否等于实际包围盒长度,两者差 0.1 米,跑两百块之后就是 20 米的偏移。快速验证方法是在场景里连续生成 20 块,用 Scene 视图拉远看接缝。
现象:跑三四分钟后帧率从 60 掉到 30 左右,重启又好一会儿。对象池没生效或者回收漏了。最常见的是回收时只SetActive(false)没有回池,对象还挂在场景里,物理和渲染仍然在算。打开 Profiler 看 Hierarchy 里的对象数量是不是在单调增长,或者在Release里加一行计数日志,跑一分钟看数字对不对得上。
5.2 运行时逻辑层面的问题
现象:撞上障碍物偶尔不判定,尤其是高速段。离散碰撞检测在高速下会漏掉薄物体。除了前面说的CapsuleCast预判,还可以把障碍物的 Rigidbody 碰撞检测模式改成 Continuous,代价是物理开销上升。更省的做法是给障碍物加一层「加厚的触发盒」,视觉上用薄模型,判定上用厚盒,这是跑酷里很常见的做法。
现象:手机上轻点屏幕不跳,但在编辑器里点鼠标一切正常。两个常见原因。一是手势阈值按像素写死,高分屏上 40 像素还不够手指抖动的量,折算成物理尺寸就好;二是判定逻辑写在了TouchPhase.Moved里,手指稍微动一下就进了 Moved 分支,Ended 时又被时长条件拦掉。把结算统一放在Ended,并且用Time.unscaledTime而不是Time.time(暂停时Time.time不走,会导致时长判定失效)。
现象:跳跃在编辑器里手感很好,打包后「重」了一截。帧率不同导致的。如果跳跃逻辑写在Update里且依赖帧率,60 帧和 30 帧的重力积分结果会差一截。检查所有vy -= gravity * Time.deltaTime是否都在Update,而涉及碰撞的位移是否在FixedUpdate,两者混用会出现「逻辑上落了地,物理上还在空中」的错位。
6. 进阶:帧率无关的写法与打包前的自检清单
调完手感之后还有一道坎——同一份代码在不同帧率下表现不一致。上面那段跳跃代码用的是Update加Time.deltaTime,在 30 到 120 帧之间基本稳定,但有个更稳的写法值得替换:
// 用半隐式欧拉,先积分速度再积分位置,低帧率下更贴近高帧率的结果 vy -= g * dt; if (vy < terminalVelocity) vy = terminalVelocity; // 终端速度,防止长时间下落穿地 transform.position += Vector3.up * (vy * dt);区别在于先更新速度再更新位置。显式欧拉(先位置后速度)在 dt 变大时误差会明显偏向一个方向,表现为低帧率下跳得更高或者更低。这个差别在 60 帧下看不出来,在 30 帧的旧机型上就能摸到。
打包前我会固定走一遍这张清单,逐项确认:
| 检查项 | 判定方法 | 不合格的表现 |
|---|---|---|
| 地块位置是否用索引乘 | 搜+= tileLength之类的累加写法 | 长局后接缝漂移 |
| 池子是否真的复用 | Profiler 里看 Hierarchy 对象数 | 数量单调增长 |
| 每帧是否有堆分配 | Profiler 的 GC Alloc 列 | 每秒几百字节的黄色柱 |
| 输入是否只采样一次 | 搜Input.出现的位置 | 同帧结果不一致 |
| 死亡判定是否走 Cast | 高速段手动撞薄障碍 | 偶尔穿模 |
有无Find/GetComponent在 Update | 全局搜关键字 | 帧率随场景复杂度下降 |
最后一条尤其值得说。跑酷工程里最常见的性能杀手不是渲染,而是Update里藏着的GetComponent或Camera.main,一次调用几微秒,一帧几十次就是几百微秒,帧预算直接吃掉一截。Camera.main尤其隐蔽,它在旧版本里每次都做一次标签查找,改成在Awake里缓存一个字段就没事了。
血泪经验是:任何一次改动之后,别只看编辑器里的 Game 视图,一定用 Profiler 录一段三十秒的完整局,看 GC Alloc 和帧时间的曲线。跑酷的问题几乎都是「跑久了才出现」,前十秒一切正常。从那以后我每次改完跑道或池子相关的代码,都会强制跑满三分钟再提交,宁可多等这一会儿,也不想在别人机器上复现一个「跑两分钟就卡」的问题说不清楚。希望帮到你。
本文还有配套的精品资源,点击获取