斯坦·李吐槽 DC 的那句“所以超人是无缘无故会飞的?”经常被复联讨论串拿出来玩梗,雷神相关视频里也会被剪成“锤哥果然是技术人才”。玩笑归玩笑,这个问题其实点中了超级英雄作品里最容易被跳过的一环:角色拥有能力的结果,却没有给能力设计可执行机制。超人一蹬地就离地,雷神把锤子转几圈就起飞,电影用画面让观众接受;但放到游戏、仿真或交互项目里,这种“无缘无故会飞”完全不能成立。飞行能力如果只写成一个isFlying = true,大概率会得到无限上升、穿墙、抖屏、状态无法退出等一系列问题。
这篇文章不讨论 DC 和漫威的设定哪个更合理,而是把“超人凭什么会飞”翻译成开发问题:一个角色要在三维空间里飞起来,需要哪些状态、物理参数、输入映射和表现手段?文章会记录在 Unity 中实现一个最小可运行飞行控制器的完整过程,从场景搭建、刚体参数、控制脚本,到手感调优、动画状态同步和多人网络同步时需要额外考虑的问题。内容更适合 Unity 初学者、想做超级英雄题材 Demo 的开发者,以及第一次接手角色控制、飞行手感相关需求的技术人员。
1. 为什么“超人会飞”能成立,而一个“飞行功能”不能直接成立
1.1 影视设定是叙事逻辑,程序逻辑需要明确边界
在漫画和电影里,超人会飞不需要向观众解释受力分析。导演要的是叙事结果:他飞起来、追上去、带着人降落。飞行是角色人设的一部分,不是一套可以被观众试验和反推的物理规则。
程序正好相反。玩家操作的“飞行能力”必须回答一连串基础问题:
- 按什么按键进入飞行状态?
- 离开地面和降落回地面的判断条件是什么?
- 角色当前的飞行速度是多少?
- 速度是通过瞬间赋值、加速度累加,还是按帧逼近?
- 转向时是平移转向还是先转身体朝向再移动?
- 飞行途中撞到建筑物会停下来、弹开,还是直接穿模?
- 悬停在空中时会不会继续下落?
- 飞行要不要消耗能量、体力或者冷却时间?
- 如果游戏是多人网络版本,其他玩家看到的飞行位置是否平滑?
这些问题不解决,程序中就会复现“无缘无故会飞”的体验。玩家按一次起飞键,角色就transform.position += Vector3.up * speed * Time.deltaTime,直接瞬移式上升,看起来和“开了外挂”没区别。角色从地台飞出去后因为无法落回地面,或者飞到高空就再也回不来,这些都是需求定义不完整导致的。
1.2 从“会飞”拆成可执行的四层结构
飞行能力在程序里可以拆成四层:
第一层是状态层,负责标记角色当前处于普通状态还是飞行状态,以及进入起飞或者着陆动作时是否处于过渡阶段。状态层的核心作用是避免同一帧里同时执行两种互相冲突的逻辑,比如一边向下碰撞检测,一边执行上升加速度。
第二层是输入层,负责读取键盘、手柄、触屏摇杆或虚拟按键。输入层最忌讳把“按键按下”直接当成“角色持续上升”。正确做法是把输入转换成目标方向、目标速度这样的数学量,再交给物理层执行。
第三层是物理层,负责把目标速度转换成实际受到的力或刚体速度变化。这一层需要处理重力开关、质量、阻力、阻尼以及碰撞反馈。无人机、喷射背包、蜘蛛侠摆荡、超人飞行,手感上的差异几乎都来自这一层参数不同。
第四层是表现层,包括动画状态机、粒子拖尾、相机视野和音效。表现层不对移动逻辑负责,但玩家对“飞得是否合理”的直接感知来自这一层。很多飞行 Demo 手感不算差,却会被认为“很假”,往往就是表现层没有跟随状态变化。
这四层在影视特效中分别对应分镜设定、角色动画、特效模拟和合成调色。区别是电影特效流程只需要让镜头里的动作好看,程序里还要让系统在不同输入、不同物理碰撞下都保持稳定。
2. 从空场景搭起:环境、刚体与飞行最小场景
2.1 Unity 版本和项目准备
这篇文章的示例以 Unity 2021.3 LTS 及以上版本为参考,项目类型选择 3D。如果项目使用 URP 也不需要改太多逻辑,URP 和内置渲染管线在这个最小示例中的差别主要在材质和光照,不影响刚体飞行控制。
为了减少输入系统的配置差异,示例先使用 Unity 旧版 Input Manager。项目创建后检查Edit > Project Settings > Player > Active Input Handling,如果当前是 New Input System,代码里调用Input.GetAxis会报错。建议开发初期设置为 Both,避免编译器找不到老接口。
在场景中需要准备的 GameObjects 如下:
| 对象 | 创建方式 | 作用 |
|---|---|---|
| Hero | 3D Object > Capsule | 作为玩家角色 |
| Main Camera | 自动创建即可 | 跟拍角色,控制移动方向 |
| Ground | 3D Object > Plane | 用来检测起飞和落地 |
| Directional Light | Light > Directional Light | 保证场景可见 |
Hermake sure the ground plane is positioned at y = 0。角色初始位置放在 y = 1 左右,避免 Capsule 一半卡进地面导致刚体疯狂抖动。
2.2 添加 Rigidbody 和 Collider
选中 Hero,依次添加 Rigidbody 和 Capsule Collider。如果角色身上原本已经存在 Collider,不需要重复添加。刚体是飞行控制的核心,角色必须具备物理响应能力,否则后续的AddForce无法生效。
Inspector 中推荐这样设置:
| 组件 | 参数 | 示例值 | 说明 |
|---|---|---|---|
| Rigidbody | Mass | 1 | 质量越小,被推动越明显 |
| Rigidbody | Drag | 0.05 | 线性阻力,太高会感觉角色像在水里飞 |
| Rigidbody | Angular Drag | 0.05 | 对旋转的阻力 |
| Rigidbody | Use Gravity | 开启 | 先将重力打开,后面用代码关闭 |
| Rigidbody | Interpolate | Interpolate | 降低物理刷新和渲染帧之间的抖动 |
| Rigidbody | Collision Detection | Discrete | 低速测试够用,高速飞行再改 Continuous |
| Capsule Collider | Height | 2 | 普通角色高度 |
| Capsule Collider | Center | (0, 1, 0) | 让胶囊体底部对准原点,方便做地面检测 |
学习阶段不要开启Is Kinematic。Kinematic 刚体不会被力驱动,只能通过代码直接设置transform或velocity。很多“为什么角色飞不起来”的问题,第一步就应该到 Inspector 里检查这个开关。
2.3 给相机挂一个跟随视角
飞行 Demo 里建议使用第三人称视角,因为角色飞行速度通常很快,第一人称视角容易让玩家晕头转向。也可以使用 Cinemachine 的 Third Person Follow,但为了减少依赖,可以先写一个最简单的跟随脚本:
using UnityEngine; public class SimpleFollowCamera : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0f, 2.5f, -4f); public float smoothTime = 0.15f; private Vector3 velocity; private void LateUpdate() { if (target == null) return; Vector3 targetPosition = target.position + offset; transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref velocity, smoothTime); transform.LookAt(target.position + Vector3.up * 0.5f); } }将这个脚本挂到 Main Camera,把 Hero 拖到 Target 字段。这样后期即使角色高速飞行,相机也能平滑跟随,不会因为主逻辑每帧写transform.position产生画面剧烈抖动。
3. 实现飞行控制器:代码、输入与状态判断
3.1 先确定“飞得合理”的最小规则
在写代码之前,先把最小规则定清楚:
| 行为 | 判断逻辑 | 对应参数 |
|---|---|---|
| 进入飞行 | 在地面上按下空格 | wantFly、onGround |
| 保持飞行 | 关闭重力,按方向键移动 | rb.useGravity = false |
| 上升 | 按住 E | ascendSpeed |
| 下降 | 按住 Q | ascendSpeed |
| 退出飞行 | 再次按空格 | wantFly = false |
| 飞行姿态 | 身体朝向水平移动方向 | rotationSmooth |
这里最需要注意的一点:不要通过修改transform.position来实现飞行。直接修改 position 会跳过刚体的碰撞响应,角色会直接穿过墙面、地形和建筑。正确的方案是把目标速度计算出来,再通过物理的方式让刚体朝着目标速度靠近。
3.2 完整飞行控制脚本
在 Project 窗口创建HeroFlightController.cs,代码如下:
using UnityEngine; [RequireComponent(typeof(Rigidbody), typeof(Collider))] public class HeroFlightController : MonoBehaviour { [Header("飞行参数")] public float forwardSpeed = 12f; public float ascendSpeed = 8f; public float acceleration = 15f; public float rotationSmooth = 8f; public float groundCheckDistance = 1.2f; public LayerMask groundMask = ~0; [Header("输入映射")] public string verticalAxis = "Vertical"; public string horizontalAxis = "Horizontal"; public KeyCode takeOffKey = KeyCode.Space; public KeyCode ascendKey = KeyCode.E; public KeyCode descendKey = KeyCode.Q; private Rigidbody rb; private Camera mainCamera; private bool isFlying; private bool wantFly; private void Awake() { rb = GetComponent<Rigidbody>(); mainCamera = Camera.main; } private void Update() { if (Input.GetKeyDown(takeOffKey)) { wantFly = !wantFly; } TryUpdateFlyState(); if (isFlying) { RotateTowardsMoveDirection(); } } private void FixedUpdate() { if (!isFlying) return; Vector3 horizontalDirection = GetHorizontalMoveDirection(); Vector3 verticalDirection = Vector3.zero; if (Input.GetKey(ascendKey)) { verticalDirection += Vector3.up; } if (Input.GetKey(descendKey)) { verticalDirection += Vector3.down; } Vector3 targetVelocity = horizontalDirection * forwardSpeed + verticalDirection * ascendSpeed; Vector3 deltaVelocity = targetVelocity - rb.velocity; // 使用 VelocityChange 可以在不依赖质量的情况下快速逼近目标速度 rb.AddForce(deltaVelocity, ForceMode.VelocityChange); } private void TryUpdateFlyState() { bool onGround = Physics.Raycast( transform.position + Vector3.up * 0.1f, Vector3.down, groundCheckDistance, groundMask, QueryTriggerInteraction.Ignore ); if (wantFly && !isFlying && onGround) { isFlying = true; rb.useGravity = false; rb.velocity = Vector3.zero; } else if (!wantFly && isFlying) { isFlying = false; rb.useGravity = true; } } private Vector3 GetHorizontalMoveDirection() { float h = Input.GetAxis(horizontalAxis); float v = Input.GetAxis(verticalAxis); Vector3 forward = mainCamera.transform.forward; Vector3 right = mainCamera.transform.right; forward.y = 0f; right.y = 0f; forward.Normalize(); right.Normalize(); Vector3 direction = forward * v + right * h; return direction.sqrMagnitude > 1f ? direction.normalized : direction; } private void RotateTowardsMoveDirection() { Vector3 direction = GetHorizontalMoveDirection(); if (direction.sqrMagnitude < 0.001f) return; Quaternion targetRotation = Quaternion.LookRotation(direction, Vector3.up); transform.rotation = Quaternion.Slerp( transform.rotation, targetRotation, rotationSmooth * Time.deltaTime ); } }这段代码是“速度逼近型”飞行控制。核心逻辑放在FixedUpdate,因为刚体物理计算发生在固定时间步长中,如果放在Update,不同帧率下飞行的叠加效果会不一致,出现“60 帧手感正常,144 帧手感发飘”的问题。
rb.AddForce(deltaVelocity, ForceMode.VelocityChange)表示给刚体施加一个速度改变量。目标速度与当前速度的差越大,角色受到的“速度修正”越强。这里使用VelocityChange的好处是:只要加速度参数合理,角色可以很快响应输入,不需要依赖 Mass 去计算力的大小。缺点是高速时会显得“没有惯性”,所以更适合用作可操作型飞行控制。
transform.rotation使用Quaternion.Slerp插值,而不是直接赋值。直接赋值会导致角色朝向瞬间跳变,看起来像拧头而不是转身。插值系数rotationSmooth越大,转身越灵敏;调小后角色会有更长距离的“盘旋转身”,更容易体现出大速度惯性。
3.3 为什么借助相机方向而不是角色自身方向
水平移动方向使用Camera.main.transform.forward和Camera.main.transform.right来计算。对于第三人称飞行,玩家通常希望按住 W 时角色朝屏幕里飞,按住 D 时角色朝屏幕右侧飞。如果改用角色自身transform.forward,初次转向后,玩家会容易迷失方向,按 W 可能让角色朝屏幕左边移动。
在实际项目中,主相机可能不止一个,也不建议反复调用Camera.main。上面的代码在Awake中缓存了一次相机引用,这已经是避免运行时性能问题的基本做法。如果场景里有多个相机或者相机是动态生成的,需要传入具体相机实例,而不是靠Camera.main查找。
4. 飞行手感的本质:给同一套能力调出不同“人设”
4.1 核心参数速查
同样一套代码,只调整参数就能得到差异很大的手感。以下表格是优先调整的参数,调试过程中应该一次只改一个,防止多个变量混合导致无法判断问题原因。
| 参数 | 含义 | 推荐初值 | 调大影响 | 调小影响 |
|---|---|---|---|---|
forwardSpeed | 水平最大飞行速度 | 12 | 飞行更快,转向更难 | 更像滑翔或悬浮 |
ascendSpeed | 上升和下降的最大速度 | 8 | 爬升反应迅速 | 垂直动作偏肉 |
acceleration | 速度逼近强度 | 15 | 起步和急停更快 | 惯性更明显,漂移感更强 |
rotationSmooth | 朝向插值速度 | 8 | 转向干脆 | 转向更柔滑,有“惯性体”感 |
Rigidbody.drag | 速度阻力 | 0.05 | 越大越容易急停 | 越小越容易漂移 |
groundCheckDistance | 地面检测距离 | 1.2 | 太小会导致腾空状态下误判可落地 | 太大会在距地较高时直接进入飞行状态 |
如果希望角色像悬停型能力,比如喷气背包,可以把forwardSpeed调到 8,acceleration调到 20,rotationSmooth调到 10,这样反应更灵敏,玩家可以精确停在平台边缘。
如果希望角色像直线突击型能力,比如高速冲锋飞行,可以把forwardSpeed调到 20,acceleration调到 10,rotationSmooth调到 3。此时转向会变得困难,玩家必须提前预判路径。这个手感其实不一定是缺点,它会让玩家感觉角色有质量、驾驶难度更高。
4.2 悬停和降落的细节
代码中的wantFly只是表示“玩家希望保持飞行”。角色还缺少一个自动判断:如果飞行状态下快速下降,脚底碰到地面,应该自动脱离飞行状态并落地。正确处理方式是在TryUpdateFlyState中增加一个“地面接触后是否退出飞行”的判断。
但这里需要做一个设计取舍。如果角色在飞行中碰到地面就立刻退出,玩家会很频繁地进入飞行、退出飞行,操作感受不连贯。比较合适的规则是:
- 玩家主动按下空格切换退出,才退出。
- 如果飞行高度很低,且玩家没有上升输入,允许角色慢慢落到地面不再弹起。
- 角色没有被持续上抬的输入时,下降触地不应该直接切回非飞行状态,而应保留一定飞行状态,用于快速重新起飞。
这个设计决定更多取决于游戏玩法。如果飞行是一种“临时魔法”,可以希望触地自动落地;如果飞行是角色的常态能力,建议保持手动开关状态,让玩家自己决定什么时候结束。
4.3 高速飞行中的碰撞检测
示例代码把 Collision Detection 设置成 Discrete,这只适合低速测试。飞行速度高于一定阈值后,刚体单帧移动的距离可能超过碰撞体厚度,从而出现“穿墙”。Unity 对这种情况提供 Continuous 和 Continuous Dynamic。简单修改如下:
rb.collisionDetectionMode = CollisionDetectionMode.Continuous;Continuous 模式会增加物理开销,但比 Discrete 更适合高速角色。注意:Continuous 不能完全解决所有隧道效应,尤其是非常薄的墙面。更稳妥的生产级做法是加射线检测或胶囊体扫掠,提前判断路径上是否有障碍物。这里先不展开,后面排错章节会继续提。
5. 让“飞”真正被玩家看到:动画、相机和特效
5.1 Animator 状态同步
物理参数调好后,画面中的角色可能仍然“直挺挺”地平移,玩家会觉得很僵硬。飞行能力需要动画状态机配合。
在 Animator 中至少准备这几个参数:
| 参数 | 类型 | 作用 |
|---|---|---|
Flying | Bool | 当前是否处于飞行状态 |
VerticalSpeed | Float | 控制上升或下降的动画层级 |
MoveSpeed | Float | 控制水平速度混合树切换 |
TakeOff | Trigger | 播放起飞动作 |
Land | Trigger | 播放着陆动作 |
在飞行控制器中增加对 Animator 的同步:
using UnityEngine; public class HeroAnimationSync : MonoBehaviour { public Animator animator; public HeroFlightController flightController; private void Update() { if (animator == null || flightController == null) return; animator.SetBool("Flying", flightController.IsFlying); animator.SetFloat("VerticalSpeed", flightController.CurrentVerticalSpeed); animator.SetFloat("MoveSpeed", flightController.CurrentHorizontalSpeed); } }这个脚本可以在 Demo 中使用。实际项目里推荐把状态修改交给飞行控制器,由飞行控制器统一调用动画系统,避免动画脚本和物理脚本互相反向读取状态,形成循环依赖。
5.2 相机随速度变化产生加速感
第三人称飞行中,玩家需要速度感来感知操作结果。一个低成本高反馈的手段是根据刚体速度改变相机 FOV。速度越快,视野越宽,配合道路或云层场景就能产生明显冲刺感。
给相机挂一个速度响应脚本:
using UnityEngine; public class FlightCameraFov : MonoBehaviour { public Rigidbody target; public float baseFov = 60f; public float boostFov = 85f; public float maxSpeed = 20f; public float smoothTime = 0.2f; private Camera cam; private float velocity; private void Awake() { cam = GetComponent<Camera>(); } private void Update() { if (target == null) { cam.fieldOfView = baseFov; return; } float speed = target.velocity.magnitude; float targetFov = Mathf.Lerp(baseFov, boostFov, Mathf.InverseLerp(0f, maxSpeed, speed)); float currentFov = Mathf.SmoothDamp(cam.fieldOfView, targetFov, ref velocity, smoothTime); cam.fieldOfView = currentFov; } }这个脚本不会改变实际移动速度,只改变视觉值。使用时要控制boostFov不要设太高,建议不超过 100。过大的 FOV 会产生鱼眼变形,反而让玩家失去距离判断能力。
5.3 拖尾与粒子效果不要直接绑在角色原点
飞行特效如果全部集中在角色身体中心,高速移动时很容易出现视觉穿透,模型感觉被一层光罩包住。更稳妥的做法是让拖尾、喷气粒子挂在角色脚底、手掌或背部,并让粒子系统以Local模式跟随角色。
粒子数量的设置也要匹配场景负载。角色飞行时,如果每个飞行玩家都生成大量粒子,移动端和低端 PC 会出现明显掉帧。建议先做特效基准测试:一个角色单人飞行维持 60 帧后,再依次增加特效数量;不要为了画面把粒子数调到看起来华丽但实际跑不动的档位。
6. 常见问题与排查:从现象倒推根因
6.1 排查优先级
飞行控制出现问题,首先不要怀疑“物理引擎坏了”。按照下面的顺序排查,几乎能把所有问题缩小到具体组件:
- 查看 Console 有没有报错。
- 确认代码脚本是否挂到角色上。
- 确认角色身上是否有 Rigidbody。
- 确认 Rigidbody 的
Is Kinematic是否被误开启。 - 确认角色所在层级是否被
groundMask排除。 - 检查
Camera.main是否为空。 - 检查飞行按键是否与其他系统冲突。
6.2 现象、原因与解决方案对照表
下面总结了几个飞行控制最常见的坑。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
代码报Input不存在 | Active Input Handling 被设置为 New Input System | 查看 Player Settings | 改为 Both,或改用新输入 API |
| 角色完全不动 | 脚本没挂到角色、Rigidbody 缺失、组件被禁用 | Inspect Hero 组件列表 | 添加 Rigidbody,启用脚本 |
| 角色一直往下掉 | Update中把useGravity改错,或isFlying从未变为 true | 加日志输出isFlying状态 | 检查地面射线是否命中,检查wantFly切换 |
| 角色飞起来直接穿模 | Collision Detection 为 Discrete,飞行速度过快 | 将速度调低测试,观察穿透现象 | 改为 Continuous,或加射线预测障碍 |
| 转向瞬间跳变 | 直接给transform.rotation赋值 | 查看代码是否用了LookRotation后直接赋值 | 改为 Slerp 插值 |
| 移动方向与相机不一致 | 水平移动方向用角色自身 forward,而不是相机 forward | 按住 W 后观察移动方向 | 改用相机的 forward 和 right |
| 角色起飞时卡住 | 飞行逻辑与地面移动控制同时执行 | 检查其他控制脚本是否强制覆盖速度 | 将飞行状态作为总开关,地面移动脚本检测到飞行后不执行 |
6.3 一个典型问题的完整排查例子
假设现象是:角色按空格后没有飞起来,但地面移动正常。
第一步,在Update中加临时日志:
Debug.Log($"wantFly={wantFly}, isFlying={isFlying}, onGround={HasGround()}");第二步,检查HasGround()的射线起点和检测距离。角色如果站在 Plane 正上方,但射线从transform.position + Vector3.up * 0.1f向下检测时,起始点可能已经低于 Capsule Collider 中心。如果角色脚下是活动平台,而LayerMask不包含该平台层级,也会检测不到地面。
第三步,检查玩家按空格后wantFly是否被切换。如果按键被其他系统消费掉,或者当前处于中文输入法状态,空格键可能不会触发GetKeyDown,要切换到英文输入法再测试。
第四步,确认rb.useGravity设置为 false 后,是否有其他脚本在FixedUpdate中把它改回 true。
这类排查方式的核心在于:先确认逻辑层状态有没有变化,再检查物理层和碰撞层。不要一开始就去调整forwardSpeed或acceleration,否则只会把问题从“飞不起来”改成“飞起来了但速度奇怪”,真正的根因还埋在某个被忽略的开关里。
7. 从 Demo 走向生产:多人网络、移动端与工程化
7.1 单机动画和多人网络不是同一个问题
单机 Demo 通过后,如果项目需要做多人飞行,最大问题在于高速实体如何在网络中保持同步。单人场景中,客户端本地的rb.velocity = targetVelocity完全可行。多人场景中,如果让每个客户端都运行本地物理,并且靠状态同步简单覆盖位置,会出现明显的角色瞬移、抖动和“回弹”。
推荐的多人做法是服务器权威。服务器拥有最终的位置和速度,客户端负责输入上报和渲染预测。网络同步的数据结构至少包含:
public struct FlightSyncData { public Vector3 Position; public Quaternion Rotation; public Vector3 Velocity; public bool IsFlying; public float Timestamp; }如果需要插值,不能只同步当前帧位置,还要保留上一帧的位置和对应时间戳。渲染端根据Timestamp做插值,而不是“收到数据后直接设置 position”。否则,其他玩家看到的角色会像橡皮筋一样被拉来拉去。
高速度还要求服务器端做防作弊校验。不能在服务器只接收position就认为角色合法,否则飞行角色可以瞬间穿越整个地图。一般会限制单帧最大位移,并结合射线检测判断路径上是否有阻挡。
7.2 移动端输入适配
移动端没有空格和 E/Q。最常用的方案是左边虚拟摇杆控制水平方向,右侧虚拟按钮负责上升、下降和切换飞行。输入层把虚拟摇杆的 x、y 转为Vector2,赋值给飞行控制器的移动向量。
如果直接复用 PC 的移动代码,触屏上很难同时控制方向、上升和视角。建议把上升和下降设计成一个滑块或者长按按钮。常见的做法是:右侧下半屏一个上升按钮,左侧一个下降按钮,或者将视角摇杆上滑对应上升,下滑对应下降。一定要在真机上反复测试手指遮挡屏幕的问题,不要只在编辑器里调参数。
7.3 学习环境与生产环境的差异
| 项目 | 学习 Demo | 生产项目 |
|---|---|---|
| 状态管理 | bool字段 | 独立角色状态机或 AbilitySystem |
| 物理计算 | 本地权威,直接AddForce | 服务器权威或客户端预测补偿 |
| 参数配置 | 公共字段拖到 Inspector 调整 | ScriptableObject、配置表或远程配置 |
| 碰撞 | 简单 Capsule Collider | 角色控制器、物理材质、墙体检测共同配合 |
| 日志 | Debug.Log | 结构化日志、指标采集、线上告警 |
| 回滚 | 直接重新编译 | 服务端热更新版本升级和灰度 |
| 性能 | 一个角色 | 同屏多人、粒子、光源和相机裁剪都要做预算 |
这里不是说不该做 Demo,而是建议在 Demo 阶段就把“哪些逻辑是临时设置、哪些是业务规则”区分清楚。飞行控制脚本里大量公共字段在 Demo 阶段方便调参,但到了生产阶段,频率、速度、判定距离写成硬编码会让策划和程序反复改脚本。更合理的结构是飞行参数作为一个独立的数据对象,逻辑代码只读取参数,不管理参数值。
7.4 发布前检查清单
飞行功能并不能以“能起飞”作为完成标准。在发布或并版前,可以按这份清单逐项检查:
- 角色在地面、屋顶、斜坡和移动平台四种地形上都能正常起飞。
- 起飞时不会因为
useGravity开关导致角色瞬间上浮或下坠。 - 飞行过程中撞墙不会穿模,不会卡在墙内持续抖动。
- 玩家无论从哪个方向起飞,WASD 对应的移动方向都符合操作预期。
- 速度很高时画面没有明显抖动,相机能保持稳定跟随。
- 飞行状态退出后,角色能正常回到普通移动状态,不残留惯性速度。
- 如果支持双人同屏或网络同步,另一台客户端上看到的飞行动作平滑连续。
- 飞行音效、粒子、FOV 变化都可以独立关闭,不影响核心移动逻辑。
- 连续快速切换起飞、降落状态 20 次以上,没有状态错误或落不了地的死锁。
- 用低端设备测试时,开飞行的帧率不能低于项目规定的目标帧率下限,必要时降低粒子数量。
8. 再回到那句“超人是无缘无故会飞的”
把超人的飞行能力写进程序以后,就会发现一句吐槽背后的价值:能力需要规则,规则需要验证。不是“会飞”这个概念不能成立,而是它必须被足够完整地定义为输入、状态、物理、表现和异常处理五层内容。斯坦·李吐槽 DC,本质是提醒创作者别把设定当作空白支票。对开发者来说,这个提醒一样适用:不要用isFlying = true掩盖一套没有设计的飞行系统。
后续如果想继续深入,可以把方向放在三个方面。第一是角色控制从单一脚本迁移到状态机,把 Idle、Walk、Fly、Land、Hurt 全部纳入统一切换。第二是加上预测和插值,做成一个能在多人网络下稳定运行的飞行实体。第三是把手感调优和策划配置分开,让同样的飞行逻辑加载不同的参数资产,表现出钢铁侠式悬停、超人式直飞或者无人机式盘旋。
从最小 Demo 开始,把代码跑起来,再按前面提到的参数表和排查清单做一遍,飞行系统就会从“无缘无故会飞”变成一套可复现、可调优、可维护的技能系统。遇到问题先查状态,再查物理,最后查表现层,这是飞行类需求里最重要也最值得先养成的排查习惯。