ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity横版飞行跑酷开发:碰撞体设计、对象池与手感调校

Unity横版飞行跑酷开发:碰撞体设计、对象池与手感调校 简介这是一套Unity横版飞行跑酷游戏《Dragon Trial 龙之试炼》的C#项目源码面向有Unity基础、想学习休闲游戏完整实现与移动广告接入的开发者。项目采用点击操控幼龙移动、射击与吃Buff的玩法覆盖技能升级、皮肤解锁、护盾磁铁、慢动作特效等机制并针对移动端做了性能优化。资源共1543个文件压缩包约188.7MB主要包括C#脚本、预制体、FBX模型、材质贴图、动画控制器及Shader同时附AdMob与UnityAds的AAR文件可直接对照学习商业广告SDK的集成流程。场景、UI、资源管理按模块划分代码结构清晰换皮方便适合作为独立游戏Demo或毕业设计的二次开发基础。已有197人学习下载运行于Unity 2018.2.0f2及以上版本。1. Dragon Trail 这类 Unity 横版飞行跑酷最值钱的不是龙是那套“可调手感”的架子很多人接手龙之试炼Dragon Trail这一套 Unity 横版飞行跑酷源码包时第一反应是把龙骨骼动画和火焰特效翻个遍。但真正决定这个项目能不能从“演示”变成“能上线的小游戏”的是藏在预制体底下的三样东西刚体是否接管重力、碰撞判定盒放在模型之外还是之内、场景滚动与对象回收怎么配合。这三个位置只要有一个用了“看着对、实则失控”的写法真机上的横版飞行跑酷就会变成“屏幕中间一条龙在抖”的演出事故。下面按理论、实现、验证的路线把横版飞行跑酷的 Unity/C# 落地路径完整过一遍新手能照着把源码架起来熟手能拿参数边界做对照。2. 横版飞行跑酷的 Unity 底座碰撞盒、刚体模式与骨骼动画位移龙之试炼这类飞行跑酷的碰撞方案和地面跑酷有一个本质区别龙在飞行时有俯仰和扇翅变化模型视觉边缘一直在动。如果直接把碰撞体挂在模型根节点每一帧翘起的翅膀尖都会成为判定边缘玩家会频繁觉得“我明明离刺还有半个身位它却判我撞上”。把判定盒做成独立子物体并让它比视觉轮廓窄 10%~20%是横版飞行跑酷项目的常见约定。判定盒负责游戏逻辑模型只负责表现两者通过 LateUpdate 做软跟随互不阻塞。2.1 碰撞层矩阵用 Layer 隔离“地形、障碍、判定盒”常见的错误做法是让所有碰撞体都挂在默认层然后靠标签在OnTriggerEnter里逐个判断。跑酷游戏的碰撞频率很高每帧都有多个触发器同时在结算靠标签字符串做分支的写法既不直观也浪费性能。更稳的写法是把判定盒放在单独 Layer比如PlayerHitbox障碍和地形分别放Obstacle与Terrain然后在 Project Settings Physics 2D 的 Layer Collision Matrix 里只让PlayerHitbox与这两层响应其他层之间全部断开。// CollisionProfile.cs —— 判定盒跟随视觉根节点移动 public class CollisionProfile : MonoBehaviour { [Header(判定盒在模型空间里的偏移)] public Vector2 hitboxOffset new Vector2(0f, 0.2f); public Transform visualRoot; // 骨骼动画的根节点 private Transform hitbox; void Awake() { hitbox transform.Find(Hitbox); } void LateUpdate() { // TransformPoint 会把旋转后的偏移也算进去 Vector3 offset new Vector3(hitboxOffset.x, hitboxOffset.y, 0f); Vector3 target visualRoot.TransformPoint(offset); hitbox.position new Vector3(target.x, target.y, hitbox.position.z); } }这里有几个容易忽略的细节第一不能用Update直接赋值因为骨骼根在播放扇翅动画时会上下抖动赋值会让判定盒跟着抖物理引擎的接触点会变得神经质第二Z 轴保持hitbox.position.z避免 2D 场景里两个碰撞体因深度差产生奇怪的重叠第三hitboxOffset只要给出模型空间里的固定偏移即使未来换模型碰撞范围也不会受影响。2.2 关闭 Root Motion飞行位移与动画位移二选一龙之试炼的龙动画导入后应该取消勾选 Apply Root Motion。飞行时的位移应该由输入和速度曲线决定而不是由动画片段里手动 Key 的 root motion 参数决定。两条线同时推会在动画状态机切换时产生一帧“位置回跳”。典型症状是动画从扇翅切换到俯冲滑翔时角色瞬间后退两米。位移逻辑统一放进 C# 脚本用Rigidbody2D的速度字段更新而不是直接改transform.position。注意命名差异Unity 6 使用linearVelocity2022 LTS 及更早版本是velocity迁移时只要把统一入口的名字换掉即可。// FlightControl.cs —— 直接用速度驱动飞行 public class FlightControl : MonoBehaviour { public Rigidbody2D rb2d; public float forwardSpeed 8f; // 前进基准速度 public float upAccel 12f; // 按住上升的加速度 public float downAccel 6f; // 松手下落的加速度 public float maxUpSpeed 7f; public float maxFallSpeed -9f; void Update() { bool holding Input.GetMouseButton(0) || Input.GetKey(KeyCode.Space); float targetVy holding ? maxUpSpeed : maxFallSpeed; float accel holding ? upAccel : downAccel; // MoveTowards 让速度在帧间连续变化不会出现跳变 float newVy Mathf.MoveTowards( rb2d.linearVelocity.y, targetVy, accel * Time.deltaTime); rb2d.linearVelocity new Vector2(forwardSpeed, newVy); } }forwardSpeed不要乘进transform.position做手动位移。场景滚动由 ScrollManager 统一负责龙只负责上下。两个量解耦之后调难度只需要改滚动速度这一个变量不需要动龙的飞行参数。2.3 一套可以直接抄的横版飞行跑酷参数表参数推荐初始值依据Rigidbody2D.gravityScale0飞行阶段保留正常重力会让松手下落和速度曲线叠加手感变飘Hitbox 中心(0.15, 0.28)中心略高于视觉重心玩家感知不到判定偏心Hitbox 尺寸(1.25, 0.72)比龙身窄 10%~20%给玩家留容错forwardSpeed 增长步长每 1000 米 0.35保持压迫感又不至于让障碍密度同步失控Physics2D Contact Offset保持默认 0.01上调到 0.02 会造成视觉上还没碰到就判死注意gravityScale 0不等于物理系统全停。落地判定和跌出场景仍需要独立的高度检测可以在龙脚底挂一个小 Trigger只检测 Terrain 层用来判断当前是否能受击或是否掉出赛道。3. 场景分段生成与 C# 对象池把“越跑越快”做成零 GC 的流水线跑酷关卡不会一次性铺完。龙之试炼的地图结构是“堆叠的横版走廊 悬浮平台”全场景摆满在移动端既吃内存也没法做无限跑。常见做法是把地图切成 20 米一段的 Chunk 预制体运行时按世界坐标动态生成和回收。横版飞行跑酷的景观范围有限多数实现走“龙保持相对静止、场景向后退”的路线因为这样判定表达式是纯减法调试时一眼能看出问题出在哪一段。3.1 为什么用预生成队列而不是 Addressables 按需加载Addressables 在移动端确实能压低首包体积但飞行跑酷这类密集生成的玩法关心的是“下一段障碍必须在上一段消失前就位”。异步加载的返回时机不可控一旦真机上出现几百毫秒的资源加载卡顿玩家的飞行节奏就断了。更简单的方案是启动时把当前关卡的 Chunk 预制体、图集和材质全部加载进来用对象池管理。20 米一段、池容量控制在 20 个左右整关素材加起来通常不超过 40MB这个体量不值得引入异步管理成本。3.2 用 C# 队列实现对象池并明确回收边界对象池的本质就是“预先创建反复复用”。Unity 里最直接的容器是QueueGameObject出队即生成入队即回收。下面是一个可以放进源码工程的通用池// ChunkPool.cs —— 通用 GameObject 对象池 using System.Collections.Generic; using UnityEngine; public class ChunkPool : MonoBehaviour { public GameObject chunkPrefab; public int prewarmCount 8; // 启动时预生成数量 public int maxPoolSize 20; // 池子最大容量 private QueueGameObject pool new QueueGameObject(); public void Initialize() { for (int i 0; i prewarmCount; i) { var go CreateNew(); go.SetActive(false); pool.Enqueue(go); } } private GameObject CreateNew() { var go Instantiate(chunkPrefab, transform); go.SetActive(true); return go; } public GameObject Spawn(Vector3 pos, Quaternion rot) { GameObject go pool.Count 0 ? pool.Dequeue() : CreateNew(); go.transform.SetPositionAndRotation(pos, rot); go.transform.SetParent(transform.parent); go.SetActive(true); // 通过接口重置段内障碍取出的段不能带上次状态 if (go.TryGetComponentIResettable(out var resettable)) resettable.ResetToSpawn(); return go; } public void Despawn(GameObject go) { go.SetActive(false); go.transform.SetParent(transform); if (pool.Count maxPoolSize) pool.Enqueue(go); else Destroy(go); } }调用方按距离滚动和回收// ScrollManager.cs —— 场景滚动与回收 void Update() { float step speed * Time.deltaTime; // 倒序遍历避免在循环里 RemoveAt 导致索引错乱 for (int i activeChunks.Count - 1; i 0; i--) { var chunk activeChunks[i]; chunk.transform.Translate(-step, 0f, 0f); if (chunk.transform.localPosition.x -despawnX) { activeChunks.RemoveAt(i); pool.Despawn(chunk.gameObject); } } if (distanceSinceLastSpawn spawnInterval) { SpawnNextChunk(); // 从池里取下一段放到场景最右侧 distanceSinceLastSpawn 0f; } }参数怎么定prewarmCount取 8等于一屏内可见 Chunk 数再加 2 段余量maxPoolSize取 20大于“存活 Chunk 预生成”的总量despawnX设成负的场景边界确认整段移出相机再回收。一个常见的坑是Despawn里先SetActive(false)再挂回池子父物体否则父物体处于隐藏状态时子物体的激活状态会被覆盖下次Spawn时取出来是“活的尸体”。3.3 FlowParameter把难度曲线拆成三个可调数字跑酷游戏的难度主要来自三个量前进速度、障碍间隔、空隙宽度。全部写死玩家两分钟后就会进入机械记忆状态。比较稳的做法是做一个FlowParameter按飞行距离做线性插值。// FlowParameter.cs —— 按距离驱动难度 [System.Serializable] public class FlowParameter { public float startSpeed 9f; public float maxSpeed 16f; public float speedRampDistance 3000f; public float minGap 2.2f; public float maxGap 3.6f; public float GetSpeed(float distance) { float t Mathf.Clamp01(distance / speedRampDistance); return Mathf.Lerp(startSpeed, maxSpeed, t); } }障碍生成时需要让空隙和高度联动空隙变宽时上下墙的开口高度不变但走廊整体高度差也要增加否则玩家会发现“明明能飞过去却被屏幕顶弹回来”。这一步看起来是调参实际是调玩家的心理模型。对象池参数推荐值说明prewarmCount8一屏可见段数加 2 段余量maxPoolSize20prewarm 数量加同时存活段数despawnX-30m确保整段移出相机边缘后再回收4. 手感三旋钮触摸输入曲线、滑动条与受击反馈飞行跑酷的“手感”说到底是三个可调旋钮按住上升的响应曲线、松手后的下落惯性、受击时镜头和时间的反馈。这三个旋钮如果写成硬编码测试同学每次调手感都要重新打开工程效率很低。把它们全部暴露成公有字段或 ScriptableObject真机上用调试台改是源码工程里比较成熟的写法。4.1 上升要“蓄力”松手要“留惯性”手指按下就瞬间给满速度龙会显得像被绳子拎起来玩家连点时会看到明显的抽搐感。常见做法是按住后的 0.12~0.2 秒内加速度从低到高渐变模拟一个“提气”动作。松手后也不要立刻倒头向下先让速度惯性维持一帧再进入落体曲线。// FlightControl.cs 中的核心速度更新 float holdElapsed 0f; if (holding) { holdElapsed Time.deltaTime; // 前 0.15 秒低加速度模拟提气动作 float charge Mathf.Clamp01(holdElapsed / 0.15f); rb2d.linearVelocity Vector2.MoveTowards( rb2d.linearVelocity, new Vector2(forwardSpeed, maxUpSpeed), charge * upAccel * Time.deltaTime); } else { // 松手后先维持 0.08 秒惯性再下落 rb2d.linearVelocity new Vector2( forwardSpeed, Mathf.MoveTowards(rb2d.linearVelocity.y, -maxFallSpeed, 0.08f * downAccel)); }这里的0.15f和0.08f不是随手填的。太短会瞬移太长会拖沓。建议真机上把这两个值暴露到 Inspector用“快速连点十次”的测试方式确定范围玩家连点时龙不应该出现明显的高度断层。4.2 用触摸滑动条替代虚拟摇杆做高度映射横版飞行跑酷用虚拟摇杆会吃掉屏幕大半视野只手负责上下移动不需要摇杆的二维自由度。在 Unity 里做一个滑动条不一定要引用 UI 的 Slider 组件可以用触摸点在屏幕右半区的纵向位移直接驱动目标高度。// TouchSliderInput.cs —— 屏幕右侧上下滑动映射到飞行高度 public class TouchSliderInput : MonoBehaviour { public float touchSensitivity 1.0f; public float positionLerpSpeed 0.3f; public float minFlightY -2f; public float maxFlightY 4f; private float lastTouchY; private float targetY 0.5f; // 0~1 归一化高度 void Update() { if (Input.touchCount 0) { Touch t Input.GetTouch(0); // 只处理屏幕右半侧避免和 UI 冲突 if (t.position.x Screen.width * 0.5f) return; if (t.phase TouchPhase.Began) { lastTouchY t.position.y; } else if (t.phase TouchPhase.Moved || t.phase TouchPhase.Stationary) { float delta (t.position.y - lastTouchY) * touchSensitivity; targetY Mathf.Clamp(targetY delta / Screen.height, 0f, 1f); lastTouchY t.position.y; } } float worldY Mathf.Lerp(minFlightY, maxFlightY, targetY); transform.position Vector3.Lerp( transform.position, new Vector3(transform.position.x, worldY), positionLerpSpeed * Time.deltaTime); } }手感集中在两个参数touchSensitivity控制屏幕距离到高度距离的缩放建议 0.8~1.2positionLerpSpeed控制跟随速度0.25~0.35 是比较舒服的“跟手但不即贴”区间。太快会甩头太慢会拖累反应。输入参数推荐范围失败表现charge 时长0.12~0.2s太短像瞬移太长像拖沓touchSensitivity0.8~1.2太小推不动太大轻轻一划撞天花板positionLerpSpeed0.25~0.35太快甩头太慢不跟手4.3 受击反馈与音频混音一个 hit stop 让“撞上”变得可信龙碰到刺时如果只播放掉血特效玩家会觉得“这游戏没碰撞”。运动中的游戏要建立“击中感”时间缩放比特效更重要。碰撞瞬间把Time.timeScale压到 0.25持续 0.08 秒再恢复同时让摄像机抖动一下大脑会自然把这段“卡顿”解读为一次真实撞击。// HitFeedback.cs —— 受击瞬间时间缩放 IEnumerator HitStop() { Time.timeScale 0.25f; yield return new WaitForSecondsRealtime(0.08f); Time.timeScale 1f; camShake.Shake(0.12f, 0.4f); }注意WaitForSecondsRealtime必须搭配真实时间否则timeScale被压低后协程也会被拖慢。另外建议把 UI 上的“点击上升”提示关掉 RaycastTarget否则手指按压时触摸事件先被 Image 吃掉输入永远到不了飞行控制脚本。5. 真机与 WebGL 发布前的验证细节5.1 Profiler 里看池命中率和 GC 分配横版飞行跑酷最容易在 Profiler 里露出马脚的是每帧 GC。打开 Profile 的Allocation Callstack跑五分钟后重点看两个指标每帧GC.Alloc是否出现稳定增长以及Instantiate的调用次数是否为零。如果池写对了Instantiate应该只在启动阶段出现如果New GameObject零散分布在游戏过程中说明有对象忘了走池子。池回收是否稳定可以看activeChunks的长度是否收敛跑三分钟后长度应该落在“初始段数 滚动速度决定的常数”附近如果持续上涨检查Despawn条件是否被距离阈值正常触发。5.2 WebGL 存档的 IDBFS 写入失败与落盘节流横版飞行跑酷通常存最高分和角色解锁。WebGL 下Application.persistentDataPath指向浏览器 IndexedDB 的虚拟文件系统真机 Android/iOS 则是 FileSystem。最常见的unity 发布 webgl 使用 idbfs 写入失败原因是每次死亡回调都直接写档、写入频率过高触发 IndexedDB 事务并发限制或者页面在 flush 前被关闭。已验证的写法是给存档加节流每 3 秒或仅在OnApplicationPause时落盘一次先写临时文件再改名避免同一帧内重复打开文件句柄。// SaveManager.cs —— 节流保存 void ScheduleSave() { if (Time.unscaledTime - lastSaveTime 3f) return; string tmpPath savePath .tmp; File.WriteAllText(tmpPath, JsonUtility.ToJson(saveData)); File.Move(tmpPath, savePath, true); lastSaveTime Time.unscaledTime; }5.3 保留一个运行时调试台把版本号显示在 UI 角落挂一个DebugConsole.cs用斜杠命令直接修改 FlowParameter 里的所有字段不重启场景。例如flow speed 13.5改当前推进速度flow gap 2.4改障碍间隔spawn debug打印当前池中对象数量。相比改一次编译一次这个调试台能在一分钟内把手感所有参数过完纯粹靠真机试玩判断是否保留改动。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进