ARTICLE DETAIL

资讯详情

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

Unity动作游戏角色能力实现:浮空与瞬移的状态机方案

Unity动作游戏角色能力实现:浮空与瞬移的状态机方案 之前在测试一个动作游戏原型的时候我在某个角色身上塞了两个很有意思的能力悬浮在空中慢慢飘以及瞬间往前闪出一段距离。这种“会浮空、会瞬移”的角色玩起来手感很爽但真正放进游戏工程里落地时问题比想象中多角色浮空时乱抖、瞬移直接穿墙、动画状态和移动逻辑对不上甚至瞬移之后卡在墙体里出不来。这篇文章就围绕一个我临时命名为airi的测试角色把浮空Hover和瞬移Blink两套能力的原理、状态设计、完整实现代码、踩坑点以及工程化建议整体拆一遍。适合正在做动作游戏、ARPG、俯视角 Demo或者单纯想学习角色能力系统怎么组织的同学参考读完你应该能复刻这套“浮空 瞬移”角色原型。1. 背景与核心概念1.1 浮空能力是什么在动作游戏里“浮空”通常指角色脱离地面站立状态在空中保持稳定悬浮的特殊状态。它和跳跃有本质区别跳跃是受重力影响的抛物线运动角色跳起来之后高度会先升后降一定会落回地面而浮空是一个可以持续控制高度、可以在空中水平移动、并且允许忽略或减弱重力的状态。我从玩法角度梳理一下浮空能力常见的应用场景躲避地面陷阱、敌方地面攻击获得更好的视野和远程攻击角度在空中与下落攻击联动形成连招节奏作为角色“轻盈”特质的表现手段。要实现浮空不能只考虑“让角色不会掉下来”这一件事。它至少涉及物理层重力怎么处理、动画层悬浮姿态怎么播放、玩法层浮空状态下能不能攻击、能不能受击、能不能被击退三层内容。很多新手第一次做浮空只是把 Rigidbody 的useGravity关掉结果角色确实不会下落但也只能停在原地整体手感非常生硬。1.2 瞬移能力是什么瞬移Blink / Teleport指角色在极短时间内从一个坐标移动到另一个坐标中间不经过常规位移过程。瞬移和冲刺Dash不同冲刺必须沿路径运动途中可以被障碍物拦截表现上是一个连续加速减速的过程瞬移在表现上几乎“无视中间距离”但从工程实现角度来看必须关心“中间有没有墙”和“落点是否安全”。瞬移在游戏中的作用也很明确近战角色快速贴近远程敌人脆皮角色用来逃生解谜场景中穿过特殊机关制造“闪现残影”的视觉冲击力。常见的瞬移实现方式有三种直接修改transform.position或Rigidbody.MovePosition最简单但容易穿墙只适用于没有任何遮挡的测试场景先做射线/球体检测判断路径是否被阻挡再计算安全落点推荐在大多数单机项目中使用基于 NavMesh 采样落点适合有寻路网格的项目能保证落点在可行走区域但需要额外引入导航网格。1.3 为什么一定要用状态机管理角色能力说到“浮空”和“瞬移”一个常见的做法是“加两个 bool 变量控制”。isHovering、isBlinking看起来没什么问题但一旦能力多起来就会遇到状态冲突瞬移过程中还能按跳跃导致动画和坐标乱跳浮空状态下可以无限瞬移冷却时间形同虚设角色被击退时浮空高度被打乱重力重新开启后又掉回地面动画播一半被下一个输入打断表现异常。所以我会先用一个轻量枚举状态机把角色拆成三个状态Normal、Hovering、Blinking。所有能力都围绕当前状态做判断这样状态之间的边界清晰后续加攻击、受击、冲刺也会更容易。这个方案的代码量不大但对后续扩展很有帮助。2. 环境准备与版本说明2.1 开发环境本文示例使用Unity 2021 LTS 及以上版本C# 脚本基于旧版 Input Manager 输入系统编写。不同 Unity 版本在 Rigidbody、Input 系统上有些细微差异特别是如果你使用 Input System 新输入系统Input.GetAxis和Input.GetKey的写法需要替换成InputAction对应回调。本文为了让示例代码直接可读统一使用旧版 Input Manager代码核心思路在新输入系统下依然适用。版本号不需要刻意追新关键是稳定可复现。如果你的项目使用不同 Unity 版本遇到编译报错时优先检查 API 变更即可。2.2 项目结构为了方便理解下面先给出本文示例项目的整体结构Assets/ ├── Scenes/ │ └── AiriDemo.unity ├── Scripts/ │ ├── AiriController.cs │ └── AfterImage.cs ├── Prefabs/ │ └── Airi.prefab └── Animations/ ├── Idle.controller └── AiriAnimator.controller其中核心逻辑集中在AiriController.cs它负责角色移动、状态切换、浮空高度控制、瞬移落点计算AfterImage.cs用来做瞬移残影效果属于表现层可选用。3. 核心原理拆解3.1 浮空实现重力控制与高度插值浮空的关键点有两个重力处理和高度控制。先说重力。如果直接把useGravity设为falseRigidbody 会忽略重力角色不会再下落这是一个最基础的浮空表现。但问题在于从地面切换到浮空状态时角色当前高度可能已经在地面上直接关掉重力后角色不会自己升起来而是停在原地看起来像“卡在地面”。因此浮空需要同时做“上升补偿”。最简单的方法是在进入浮空状态时把竖直速度清零然后在每一帧物理更新里把角色位置向目标高度移动。这里的“目标高度”应该是地形高度 浮空高度不能是一个固定世界坐标否则角色走到斜坡或者高低起伏地形上时浮空高度会忽高忽低。高度移动我推荐使用Mathf.MoveTowards它比Mathf.Lerp更可控不会因为帧率波动造成高度不断逼近但永远达不到的感觉速度参数也直观。核心片段如下// 片段浮空开关 if (currentState AiriState.Hovering) { rb.useGravity false; rb.velocity new Vector3(rb.velocity.x, 0f, rb.velocity.z); } else { rb.useGravity true; }// 片段浮空高度控制 Vector3 pos rb.position; float terrainY GetTerrainHeight(pos); float targetY terrainY hoverHeight; pos.y Mathf.MoveTowards(pos.y, targetY, hoverRiseSpeed * Time.fixedDeltaTime); rb.MovePosition(pos);这里有个经常会忽略的细节关闭重力之后Rigidbody 的velocity.y可能残留之前跳跃或掉落的速度所以进入浮空时应该把竖直速度清零。否则你会看到角色进入浮空状态后先是向上飘一下然后又慢慢往下掉非常奇怪。3.2 瞬移实现目标点计算与遮挡检测瞬移的核心是“从当前位置计算目标位置然后瞬间把角色移动过去”。最简单的写法是transform.position transform.position direction * blinkDistance;这一段代码在没有任何障碍物的测试场景里是可以用的。但真实项目里很容易出现两种问题瞬移直接穿墙路径上有墙角色从墙的一边直接移动到另一边穿模卡在墙体内目标点刚好落在墙体内部角色被 Collider 挤来挤去或者直接被困住。解决方法是加一层“路径检测”。用Physics.Raycast从角色当前位置沿瞬移方向发射射线检测距离之内是否有碰撞体。如果有就把落点从命中点往回退一小段距离而不是直接使用原始目标点。往回退的 0.2 到 0.3 个单位是为了防止角色紧贴墙面生成导致碰撞体互相穿透。核心片段如下// 片段瞬移目标点计算 Vector3 dir GetBlinkDirection(); Vector3 target transform.position dir * blinkDistance; if (Physics.Raycast(transform.position, dir, out RaycastHit hit, blinkDistance)) { target hit.point - dir * 0.2f; } if (Physics.CheckSphere(target, 0.4f)) { target transform.position; }Physics.CheckSphere是第二道安全校验确保最终落点周围没有别的碰撞体。如果落点不安全就别瞬移了总比卡墙里强。3.3 动画状态与特效衔接浮空和瞬移在表现层需要配合动画和特效否则玩家会感觉“只是坐标变了”。动画方面我建议在 Animator 中定义两个触发参数IsHovering和IsBlinking。IsHovering是 Bool 类型控制普通状态和浮空状态动画的切换IsBlinking也是 Bool 类型但只在瞬移那一小段时间里为 true瞬移结束后复位。特效方面瞬移最常用的表现是“残影”。在瞬移发生时从角色身上生成一个带半透明材质的静态副本让它停留在原地并快速淡出同时角色本体出现在目标点。这套表现可以用一个小脚本实现思路是用MeshFilter和MeshRenderer复制当前角色的网格和材质然后每帧降低材质 alpha一段时间后销毁。4. 完整实战案例Airi 的浮空与瞬移下面进入完整 Demo 实现环节。我会从场景搭建开始最后给出可以直接运行的完整代码。4.1 创建场景与角色打开 Unity新建一个 3D 项目然后按下面步骤操作新建场景AiriDemo.unity在场景中创建一个 Plane 作为地面创建一个空物体命名为Airi给Airi添加Capsule Collider用来表示角色体积给Airi添加Rigidbody勾选Use Gravity在Airi下创建一个子物体Body用 Cube 或任意模型作为可视化形象在场景中创建一个主摄像机保证MainCamera标签正确。这里强调一点Rigidbody 的Interpolate建议设置为Interpolate否则瞬移和浮空移动时角色渲染可能出现抖动。4.2 编写完整角色控制器脚本在Assets/Scripts目录下创建AiriController.cs代码如下using UnityEngine; public enum AiriState { Normal, // 普通状态 Hovering, // 浮空状态 Blinking // 瞬移中 } public class AiriController : MonoBehaviour { [Header(移动参数)] public float moveSpeed 6f; public float rotateSpeed 720f; [Header(浮空参数)] public float hoverHeight 2.2f; public float hoverRiseSpeed 3f; [Header(瞬移参数)] public float blinkDistance 6f; public float blinkCooldown 1.2f; public float blinkDuration 0.12f; private AiriState currentState AiriState.Normal; private Rigidbody rb; private Animator anim; private Transform mainCamera; private float lastBlinkTime -99f; private float blinkTimer 0f; void Start() { rb GetComponentRigidbody(); anim GetComponentAnimator(); mainCamera Camera.main ! null ? Camera.main.transform : null; rb.useGravity true; currentState AiriState.Normal; } void Update() { if (anim ! null) { anim.SetBool(IsHovering, currentState AiriState.Hovering); anim.SetBool(IsBlinking, currentState AiriState.Blinking); } // 瞬移状态计时结束后自动回到普通状态 if (currentState AiriState.Blinking) { blinkTimer - Time.deltaTime; if (blinkTimer 0f) { currentState AiriState.Normal; } return; } // 空格切换浮空 if (Input.GetKeyDown(KeyCode.Space)) { ToggleHover(); } // 左 Shift 触发瞬移 if (Input.GetKeyDown(KeyCode.LeftShift)) { TryBlink(); } } void FixedUpdate() { // 瞬移过程中不响应常规移动和浮空高度控制 if (currentState AiriState.Blinking) { return; } if (currentState AiriState.Hovering) { HoverMove(); } else { VelocityMove(); } } void ToggleHover() { if (currentState AiriState.Hovering) { currentState AiriState.Normal; rb.useGravity true; } else if (currentState AiriState.Normal) { currentState AiriState.Hovering; rb.useGravity false; rb.velocity new Vector3(rb.velocity.x, 0f, rb.velocity.z); } } void HoverMove() { Vector3 move GetMoveVector() * moveSpeed; Vector3 pos rb.position; pos move * Time.fixedDeltaTime; float terrainY GetTerrainHeight(pos); float targetY terrainY hoverHeight; pos.y Mathf.MoveTowards(pos.y, targetY, hoverRiseSpeed * Time.fixedDeltaTime); rb.MovePosition(pos); if (move.sqrMagnitude 0.01f) { Quaternion targetRotation Quaternion.LookRotation(move.normalized, Vector3.up); rb.rotation Quaternion.RotateTowards(rb.rotation, targetRotation, rotateSpeed * Time.fixedDeltaTime); } } void VelocityMove() { Vector3 move GetMoveVector() * moveSpeed; Vector3 velocity new Vector3(move.x, rb.velocity.y, move.z); rb.velocity velocity; if (move.sqrMagnitude 0.01f) { Vector3 horizontalMove new Vector3(move.x, 0f, move.z).normalized; Quaternion targetRotation Quaternion.LookRotation(horizontalMove, Vector3.up); rb.rotation Quaternion.RotateTowards(rb.rotation, targetRotation, rotateSpeed * Time.fixedDeltaTime); } } void TryBlink() { if (Time.time - lastBlinkTime blinkCooldown) { return; } Vector3 dir GetMoveVector(); if (dir.sqrMagnitude 0.01f) { dir transform.forward; } Vector3 target rb.position dir * blinkDistance; if (Physics.Raycast(rb.position, dir, out RaycastHit hit, blinkDistance)) { target hit.point - dir * 0.2f; } // 二次校验落点不能挤进其他碰撞体里 if (Physics.CheckSphere(target, 0.4f)) { return; } lastBlinkTime Time.time; currentState AiriState.Blinking; blinkTimer blinkDuration; rb.MovePosition(target); } Vector3 GetMoveVector() { float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); if (mainCamera ! null) { Vector3 forward mainCamera.forward; forward.y 0f; forward.Normalize(); Vector3 right mainCamera.right; right.y 0f; right.Normalize(); return (forward * v right * h).normalized; } return new Vector3(h, 0f, v).normalized; } float GetTerrainHeight(Vector3 pos) { Ray ray new Ray(new Vector3(pos.x, 100f, pos.z), Vector3.down); if (Physics.Raycast(ray, out RaycastHit hit, 200f)) { return hit.point.y; } return 0f; } }4.3 代码结构与设计说明上面的脚本虽然是一个文件但内部职责已经拆开Update负责输入检测、状态切换、动画同步FixedUpdate负责物理移动和浮空高度控制VelocityMove处理普通状态下的刚体速度移动HoverMove处理浮空状态下的位置插值移动TryBlink处理瞬移落点计算与冷却。为什么普通状态用rb.velocity而浮空状态用rb.MovePosition因为普通状态需要保留重力和碰撞反馈直接设置速度是最符合物理引擎习惯的方式而浮空状态下我们希望高度严格受控不希望重力和速度干扰竖直方向所以完全通过MovePosition控制位置。这里要特别注意不要在Update里移动 Rigidbody物理相关的位置修改尽量放在FixedUpdate中否则容易出现“帧率越高移动越快”或物理穿插的问题。4.4 添加 Animator 参数与简单残影效果如果你的角色模型带有动画需要创建一个 Animator Controller并添加两个参数IsHovering类型 BoolIsBlinking类型 Bool。然后参考下面的示例创建残影脚本AfterImage.cs。这个脚本不依赖复杂特效资产适合临时测试using UnityEngine; public class AfterImage : MonoBehaviour { public float lifeTime 0.4f; public float fadeSpeed 4f; private float remain; private Material mat; void Start() { remain lifeTime; MeshFilter source GetComponentMeshFilter(); MeshRenderer renderer GetComponentMeshRenderer(); if (renderer ! null) { mat renderer.material; } } void Update() { remain - Time.deltaTime; if (mat ! null) { Color c mat.color; c.a Mathf.Lerp(c.a, 0f, fadeSpeed * Time.deltaTime); mat.color c; } if (remain 0f) { Destroy(gameObject); } } }在瞬移触发处生成一个残影副本复制当前位置、旋转和外观GameObject ghost Instantiate(gameObject, rb.position, rb.rotation); ghost.GetComponentCollider().enabled false; Destroy(ghost.GetComponentRigidbody()); ghost.AddComponentAfterImage();这个片段是思路示例实际项目里你可以直接用 Pool 管理残影避免频繁 Instantiate 和 Destroy 造成 GC 压力。4.5 运行与验证运行场景你会看到以下行为使用 WASD 控制 airi 在地面移动按下空格键airi 会关闭重力并逐渐上升最终停留在高于地面约hoverHeight的高度在浮空状态下再次按空格airi 恢复重力并落回地面按左 Shiftairi 会朝向当前移动方向或面朝方向瞬移一段距离如果瞬移路径上有墙airi 会停在墙前不会穿墙瞬移结束后角色自动回到普通状态。预期顺滑效果是浮空高度稳定不会上下抖动瞬移即时生效不会卡进墙里动画参数随状态正确切换。5. 常见问题与排查思路在实现这套“浮空 瞬移”能力时有几个高频问题我整理成排查表问题现象常见原因解决思路浮空时角色上下抖动高度控制、重力、velocity.y 互相干扰进入浮空时清零竖直速度关闭重力统一用 MovePosition 控制高度浮空状态升不上去目标高度使用世界固定 Y 值把目标高度改成 TerrainY hoverHeight瞬移直接穿墙缺少路径遮挡检测增加 Physics.Raycast命中后落点回退 0.2 单位瞬移后卡在墙里落点正好在碰撞体内部增加 Physics.CheckSphere 二次校验落点不安全直接取消瞬移瞬移无冷却可无限连按没有记录时间戳用 Time.time 和 lastBlinkTime 控制最小间隔瞬移后角色一直处于 Blinking 状态状态没有退出机制用 blinkTimer 倒计时自动切回 Normal浮空时无法水平移动浮空状态没有执行水平移动逻辑检查 FixedUpdate 是否在 Hovering 状态下同时处理水平位移和高度动画切换卡顿Animator 参数没同步在 Update 里设置 IsHovering、IsBlinking单独说一下“浮空抖动”这个最常见的问题。抖动通常不是因为代码写法错而是因为多个系统在抢角色的 Y 轴重力系统想往下拉高度控制想往上拉velocity.y 又残留了旧速度。解决方法是把 Y 轴责任收敛到一个系统里。浮空状态下关闭重力、清空 velocity.y、只用 MovePosition 修改 Y三者缺一不可。6. 最佳实践与工程建议6.1 用状态机取代散落的 bool 开关上面示例虽然只用了枚举但已经是一种轻量状态机。实际项目里角色能力一多我建议直接引入正式的状态机框架或者至少把“状态进入、状态退出、状态更新”分方法管理。例如void EnterHovering() { } void UpdateHovering() { } void ExitHovering() { }这样每个状态的边界非常清楚以后加“浮空时攻击”“浮空时不能瞬移”等规则只需要在对应状态方法里写判断。6.2 物理与动画解耦物理层只负责坐标、速度、碰撞动画层只负责播放表现逻辑层负责决策。很多角色抖动问题本质上是物理层和动画层互相覆盖位置。比如 Animator 的Apply Root Motion开启时动画本身会修改 transform.position如果你同时在 Rigidbody 里 MovePosition两个系统就会打架。建议做法是Animator 上关闭 Root Motion角色位移完全由脚本控制动画只提供姿态。6.3 瞬移安全边界与防作弊瞬移这种“瞬间改变坐标”的能力在单机项目里很自由但在多人联机或正式上线项目里必须注意安全边界服务端要校验瞬移距离和冷却时间不能完全信任客户端上报的新坐标落点必须做服务器端射线检测防止玩家瞬移穿墙或瞬移到地图外如果场景有碰撞体加载延迟客户端先瞬移、服务器后验证可能出现“本地正常其他玩家看到穿墙”的问题。我建议在联机项目中瞬移改为“客户端发起请求 - 服务器校验 - 广播结果”的流程。虽然延迟会高一点但安全性和一致性都能保证。6.4 性能与资源管理浮空本身不消耗额外资源但残影特效如果每次瞬移都Instantiate一个带 Mesh 的 GameObject会在高频瞬移时产生大量 GC 压力。建议用对象池管理残影或者用TrailRenderer临时拖尾代替残影副本。同时Physics.Raycast和Physics.CheckSphere的检测距离不建议设得过大检测层也要通过 LayerMask 过滤避免扫描到不必要的碰撞体。6.5 测试与回归这类角色能力代码最好有一组固定测试场景平地浮空稳定性测试斜坡浮空测试验证地形高度检测贴墙瞬移测试验证射线回退墙后落点测试验证 CheckSphere 二次校验连续瞬移测试验证冷却时间浮空进入瞬移、瞬移后浮空的组合测试。每改一次物理参数或状态机至少跑一遍这些场景能避免“改一个能力坏掉另一个能力”的回归问题。7. 总结与学习路线这套“浮空 瞬移”的 Demo 已经涉及了角色能力系统里最核心的几个点状态管理、物理控制、碰撞检测、动画同步、表现层特效。把 airi 的能力代码跑通之后你再去看其他动作游戏角色会发现大部分特殊能力都是同一套框架演化出来的。下一步建议按这个顺序继续深入把枚举状态机改造成类状态机支持状态进入和退出回调给浮空状态加入攻击动作验证空中攻击手感给瞬移加入方向输入阈值实现“没有方向输入时默认向前瞬移”的细节引入对象池管理残影特效如果是联机项目尝试把瞬移逻辑改成服务器校验。实际项目里优先关注的是状态冲突和物理参数稳定性。不要急着堆功能先把“浮空不抖、瞬移不穿墙”这两个手感底线守住其他表现层的东西都可以慢慢加。如果这篇文章对你有帮助可以收藏备用动手把代码跑到你项目里试试多改几个参数你会对刚体运动和角色状态设计有更深的理解。
返回列表