尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity格斗游戏打击感实现:从帧数据到战斗状态机

Unity格斗游戏打击感实现:从帧数据到战斗状态机 简介这是一份基于Unity 2018.2.20f1及以上版本开发的火柴人主题格斗游戏完整源代码使用C#编写面向Unity游戏开发者、独立开发者及希望学习商业级游戏架构的爱好者。项目提供200个闯关关卡内置Admob插页式与奖励视频广告支持IL2CPP和Android/iOS双平台发布代码结构干净适合直接运行体验或作为格斗类游戏二次开发的基础。压缩包共包含2004个文件大小约501.92MB主要文件类型包括165个C#脚本、373个动画文件、96个预制体、27个动画控制器以及Android依赖库aar、音频wav、PSD设计稿和DLL插件等目录清晰便于按模块查阅。目前已有243人学习下载对想研究格斗游戏战斗系统、动画状态机、移动端广告接入与跨平台打包的开发者来说这份源码具有较高的参考价值。1. 火柴人之神3 这类格斗 Unity 项目拆开源代码后先看战斗帧数据拿到 God Of Stickman 3火柴人之神3这类格斗类型游戏的 Unity 项目源代码时C# 项目里最容易让人看花眼的是角色控制器、技能特效和 UI 脚本但真正决定游戏好不好玩的是战斗动作的帧数据怎么排布。火柴人只是几根线、几个圆打击感的差异完全压在动画、判定和位移的咬合上哪一帧出判定盒、哪一帧允许取消、打中后停顿几帧这些数值比模型精度更重要。本文会从战斗状态机、命中反馈、对手 AI 到发布验证把一套可以直接复现的格斗框架讲清楚适合正在做横版动作游戏、或者拿到类似源码却不知道从哪里下手的 Unity 开发者。2. 战斗核心状态机用 C# 把 Animator 的判定时机接管过来2.1 为什么 Animator 的 Transition 不适合直接做攻击判定Unity 的 Animator 自带过渡机制看起来把攻击连招做成状态机很自然但实战里问题很大。Transition 的本质是交叉淡化在过渡区间内新旧两个动画片段按权重混合攻击判定如果挂在动画事件上事件会在权重未完全切换时触发两次或者干脆被中断的过渡吞掉。格斗游戏的判定要求的是“这一帧有判定下一帧没有”这种二值化行为交给动画系统做不可控。常见做法是让 Animator 退化成纯粹的播放器只负责待机、走路、死亡这类表现层动画出招、受击僵直、能否取消这些逻辑全部由 C# 战斗状态机驱动。角色攻击动作按格斗游戏的标准拆成三段前摇 Startup、判定活跃 Active、恢复 Recovery。前摇是攻击尚未产生判定的预备帧Active 是判定盒开放的帧Recovery 是动作收尾阶段。这样拆之后连招取消、被打断、命中后追击都变成了对这三个阶段的帧数操作而不是去动画窗口里找按钮。2.2 最小可跑的 C# 状态机与帧窗口下面是一个可以放进场景直接跑的最小战斗状态机骨架重点看输入缓冲和帧推进逻辑的处理方式。public enum AttackPhase { Startup, Active, Recovery } public class CombatStateMachine : MonoBehaviour { private class PendingInput { public int frame; public string command; // light, heavy, jump } private readonly QueuePendingInput inputBuffer new QueuePendingInput(); private int currentFrame; private AttackPhase phase; [Header(帧数据按60fps基准)] public int startupFrames 4; public int activeFrames 3; public int recoveryFrames 10; public int cancelWindowFrames 5; private void Update() { string cmd GetButtonCommand(); if (cmd null) return; if (inputBuffer.Count 4) inputBuffer.Dequeue(); // 丢掉最旧输入避免连打串招 inputBuffer.Enqueue(new PendingInput { frame Time.frameCount, command cmd }); } private void FixedUpdate() { if (phase AttackPhase.Recovery inputBuffer.Count 0) { var cmd inputBuffer.Peek(); // 只在后摇结束前的 cancelWindowFrames 帧内消费输入 if (currentFrame recoveryFrames - cancelWindowFrames) { inputBuffer.Dequeue(); StartAttack(cmd.command); return; } } currentFrame; AdvancePhase(); } private void StartAttack(string command) { currentFrame 0; phase AttackPhase.Startup; // 根据 command 切换不同攻击的前摇/活跃/后摇帧数 } }输入读取放在 Update 而不是 FixedUpdate是因为 FixedUpdate 的调用频率不跟渲染帧对齐玩家在两次物理帧之间连续按两下攻击键第二下可能直接被物理管线丢掉。Update 里把输入压入队列FixedUpdate 只在满足取消窗口的帧上消费输入这个分离是格斗项目里最常见的防丢输入写法。缓冲队列用 Dequeue 而不是 Clear 也是有意为之。Clear 会造成长按攻击键时输入被清空玩家必须松开再按才出招而只丢最旧的一帧连打时节奏自然连贯。输入缓冲区通常控制在 4 到 8 帧太大会出现“我只按了两下他怎么打了两套”的手感错位。2.3 输入缓冲与取消窗口的关键参数参考这套帧数据没有绝对标准但刚调通项目时可以按下面的初始值起步再根据实际手感上下浮动。参数建议初值说明startupFrames4~6前摇越短起手越快前摇太长会显得角色迟钝activeFrames2~4判定活跃帧不需要太长太宽的攻击判会显得软recoveryFrames8~12后摇是格斗游戏的心理博弈空间火柴人也不要省略cancelWindowFrames4~6后摇末段的输入预读窗口直接决定连招流畅度举个例子把 cancelWindowFrames 从 5 提到 8普通玩家也能轻松按出三连击但代价是整体帧数变宽高手会利用这个窗口在连续攻击中插入投技导致平衡性崩坏。我一般先按 5 帧起步实测连段成功率低于一半时逐帧加不要一次加太多。3. 打击感落成的三个反馈HitStop、位移冲量与受击判定盒3.1 受击判定不是碰撞体而是离散采样很多刚摸到格斗项目的开发者会习惯性给拳头挂一个 BoxCollider再用 OnTriggerEnter 判断打到了谁。这个思路在动作游戏里要慎用。OnTriggerEnter 依赖物理引擎的连续检测高速挥拳时碰撞体会穿透目标出现“明明打上却不出伤害”的灵异事件而且物理回调的频率和动画帧率并不完全同步判定时机很难对齐到攻击动作的视觉瞬间。常见做法是把判定做成离散采样攻击动画推进到 Active 帧时用Physics.OverlapBox做一次即时检测拿到该帧位置所有处于 targetLayer 的物体再把伤害、击退、顿帧统一分发给IDamageable接口。这样判定结果只依赖这一帧的攻击者位置和判定盒参数不依赖物理引擎的连续碰撞调度。public class HitboxFrameReceiver : MonoBehaviour { [SerializeField] private Vector3 hitboxSize new Vector3(0.8f, 1.2f, 1.0f); [SerializeField] private LayerMask targetLayer; [SerializeField] private float hitStopSeconds 0.06f; [SerializeField] private float hitStopTimeScale 0.08f; public void OnAttackActive() { Collider[] hits Physics.OverlapBox(transform.position, hitboxSize * 0.5f, transform.rotation, targetLayer); foreach (var hit in hits) { if (hit.TryGetComponentIDamageable(out var damageable)) { damageable.TakeDamage(10, transform.forward); StartCoroutine(HitStopCoroutine()); break; // 一次攻击只打中一个目标防止范围判定重叠 } } } private System.Collections.IEnumerator HitStopCoroutine() { Time.timeScale hitStopTimeScale; float elapsed 0f; while (elapsed hitStopSeconds) { elapsed Time.unscaledDeltaTime; yield return null; } Time.timeScale 1f; } }OnAttackActive由动画事件在 Active 帧的第一帧调用而不是每帧调用。这样即使攻击判定持续 3 帧伤害结算也只在起始帧执行一次后续帧只负责表现不会出现一次挥击结算三次伤害的重复扣血问题。TargetLayer 只包含玩家和敌人避免场景装饰物也进入判定范围。3.2 HitStop 顿帧的实现与保护对象上面的 HitStop 是最直接的实现方式把全局时间缩放拉低几帧让动作在命中瞬间“钉”住一下。这里的两个核心参数需要按攻击类型分开配轻击的 hitStopSeconds 可以降到 0.04 秒重击拉到 0.1 秒对应慢放的 timeScale 保持在 0.05 到 0.1 之间太低会让画面像死机太高又感受不到停顿。注意协程里恢复时间用的是Time.unscaledDeltaTime。如果用 scaledDeltaTime顿帧期间时间流速变慢剩余倒计时也会跟着变慢实际停顿时间会被拉长好几倍这是最容易踩的坑。使用 Time.timeScale 做顿帧时还要考虑三个保护对象UI 动画、摄像机震动、玩家菜单。UI 如果跟随 scaleTime 一起停玩家会感觉整个游戏卡死而不是打击停顿我一般把血条闪白和伤害数字用 unscaledDeltaTime 驱动保持“画面静止但 UI 仍有反馈”的微妙反差。提示在战斗场景里用全局 timeScale 前先确认项目里有没有技能慢动作、子弹时间之类的系统。多个慢动作互相覆盖 timeScale 值会直接污染顿帧效果建议用一个全局 TimeScaleManager 统一管理后请求的慢动作与顿帧做叠加或排队。3.3 受击位移冲量与火柴人的视觉补偿火柴人几乎没有脸部表情和肌肉变形可以表达受伤玩家对命中的感知主要靠三样东西受击方被推动的距离、攻击方的前插位移、以及屏幕的短暂震动。受击位移一般做成 0.08 到 0.12 米的瞬发位移持续 0.06 秒左右方向用攻击者的 forward 而不是固定的世界坐标轴否则角色转向后位移方向就会错乱。public void ApplyHitReaction(Vector3 hitDirection, float distance, float duration) { StartCoroutine(DisplaceRoutine(hitDirection, distance, duration)); } private IEnumerator DisplaceRoutine(Vector3 direction, float distance, float duration) { float timer 0f; Vector3 startPos transform.position; while (timer duration) { timer Time.deltaTime; float t Mathf.Clamp01(timer / duration); transform.position Vector3.Lerp(startPos, startPos direction * distance, t); yield return null; } }这个位移写在协程里直接改 Transform 位置不要动 Rigidbody 的速度。格斗游戏如果同时依赖物理速度与动画根运动两者叠加会让角色漂移也很难控制受击后的精确落地位置。在连段中这个位移建议调小到 0.05 米因为连段不需要大位移玩家更需要的是每次命中都有一次明确的节奏停顿。整套命中反馈可以按下面的参数表做初始预设再按角色逐个细调。反馈项建议初值调整方向容易踩的坑HitStop0.06 秒timeScale 0.08重击加时轻击减时打了 UI、菜单也会停屏幕震动0.12 秒振幅 0.3连段中调低震动摇摆在 UI 层要单独处理受击位移0.10 米0.06 秒直击大于连段方向要跟随攻击者 forward攻击方前插0.04 米0.04 秒取消窗口越大越不需要目标已死亡时跳过前插4. 对手 AI 与回合流程做像人的决策器而不是读帧机器人4.1 格斗 AI 的架构取舍权重打分比行为树更合适给火柴人对手做 AI常见误区是先去搭行为树。行为树适合策略类游戏那种多条件分支决策而格斗游戏的 AI 每次只需要决定“接下来打什么”一帧内从几个动作里选一个就结束了。用权重打分配合随机选择代码直观难度递进也好做把权重表里的数值改一改就是一个新的难度档位。AI 决策要避开最明显的作弊读玩家按键。让 AI 每帧都响应玩家输入玩家会觉得自己不是在跟人打而是在被程序读心。常见的解决办法是给 AI 加一个反应延迟约 3 帧再加一个决策冷却时间约 10 帧让 AI 的动作变化频率贴近真人玩家的反应节奏。4.2 带感知延迟的决策循环实现public class AiController : MonoBehaviour { private int frameCounter; private int reactionFrames 3; private int decisionCooldown 10; [SerializeField] private Transform player; [SerializeField] private Health health; private void FixedUpdate() { frameCounter; if (frameCounter % reactionFrames ! 0) return; if (frameCounter % decisionCooldown 0) DecideNextAction(); } private void DecideNextAction() { float distance Mathf.Abs(player.position.x - transform.position.x); float healthRatio health.Current / health.Max; float attackWeight distance 1.2f ? 0.55f : 0.1f; float blockWeight player.IsAttacking ? 0.45f : 0.1f; float approachWeight distance 2.0f ? 0.7f : 0.15f; if (healthRatio 0.3f) { attackWeight 0.15f; blockWeight - 0.1f; } float roll Random.value; if (roll attackWeight) DoLightAttack(); else if (roll attackWeight blockWeight) DoBlock(); else if (roll attackWeight blockWeight approachWeight) ApproachPlayer(); else WaitForOpening(); } }这个 AI 的决策依据只有三个量与玩家的横轴距离、自身血量比例、玩家是否正在攻击。格斗游戏的 AI 不需要读玩家精确帧数据横轴距离用绝对值竖轴距离只在跳跃判定里参与不然 AI 会追着跳跃中的玩家上下移动看起来非常愚蠢。随机选择用累加权重的方式先把所有动作权重求和再用 Random.value 滚动落到某个区间。这样做的好处是加新动作时只需要追加一个权重字段不必改既有的分支判断。濒死逻辑里攻击权重加 0.15、防御权重减 0.1这样血线越低对手越激进符合玩家对“残血反杀”的预期。4.3 回合流程通过事件把战斗逻辑与表现解耦God Of Stickman 3 这类项目通常不是单场战斗而是连续闯关或者多波敌人。战斗管理器与 UI 之间如果直接互相引用每加一个 Boss 就要改一遍 UI 脚本。用事件解耦是更稳的做法血量的变化只通知关心它的模块攻击方不需要知道谁在听。public class Health : MonoBehaviour, IDamageable { [SerializeField] private int current 100; [SerializeField] private int max 100; public event System.Actionint, Vector3 OnDamaged; public event System.Action OnKO; public void TakeDamage(int amount, Vector3 direction) { current - amount; OnDamaged?.Invoke(amount, direction); if (current 0) OnKO?.Invoke(); } }事件解耦之后血条 UI、受击闪白、摄像机震动各自订阅 OnDamaged互不干扰。开新 Boss 时只需要给新角色挂上 Health 组件再传入一组 AI 权重配置战斗流程代码一行都不用动。这里 AI 权重的关键参数按不同兵种差别设计AI 行为近战杂兵远程骚扰精英 Boss攻击倾向0.550.20.4防御倾向0.150.10.4后撤倾向0.10.50.1决策冷却10 帧14 帧8 帧远程单位的决策冷却比近战长因为远程角色的攻击窗口本来就宽松AI 不需要高频做决定Boss 决策冷却短配合高防御倾向给玩家的压迫感不是靠伤害堆出来的而是靠频繁的进退和变招。5. 调试和发布判定盒可视化、输入回放与 IL2CPP 差异5.1 OnDrawGizmos 实时画出判定盒调打击感时最怕的就是“不知道判定盒实际在哪”。在角色脚本里补一个 Gizmos 绘制方法就能在 Scene 视图直接看到每次攻击的判定范围。这种方法比看数值快得多改一次大小马上看到视觉命中范围与实际判定的差异。private void OnDrawGizmosSelected() { if (phase ! AttackPhase.Active) return; Gizmos.color Color.red; Gizmos.matrix transform.localToWorldMatrix; Gizmos.DrawWireCube(Vector3.zero, hitboxSize); }5.2 输入回放把难复现的手感问题变成可回归用例格斗游戏里最头疼的 bug 是“第三下连击偶尔接不上”这种问题靠手动复现成功率极低。我一般把输入读取封装成 IInputProvider 接口正常游戏用键盘输入实现调试时用回放实现。把每一条输入记录成帧号和按键名的列表重放时按帧喂给同一个战斗状态机就能稳定复现问题。回放跑输了就改参数再跑同一条输入一次回归就能定位是取消窗口不够还是输入缓冲丢了帧。5.3 发布后的两个注意差异编辑器里的运行表现和真机发布后并不是完全一致的。GameAssembly.dll 是 IL2CPP 编译后生成的原生库托管堆分配行为和编辑器 AOT 环境下有明显差异GC 敏感的逻辑要在真机上用 Profiler 重新验证。另外 WebGL 发布时如果战斗结算频繁写存档IndexedDB 在受击顿帧期间大量写入容易触发 idbfs 写入失败写档放到场景切换时机最稳妥。把输入回放文件保存到本地每隔几帧采样一次文件系统状态就能在发布版本里继续复现手感问题。本文还有配套的精品资源点击获取
返回列表