ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity战斗系统实战:从技能判定到状态机与误触保护

Unity战斗系统实战:从技能判定到状态机与误触保护 先把标题拆开看看“鲨鱼大招炸空气之后破防误吃麦的章鱼老头”。这句话放在程序员的讨论区里其实是一个非常典型的事件现场。主角“鲨鱼”把大招按出去目标却已经进入不可受击状态于是高伤害技能砸在空气上随后玩家心态崩了又误触了消耗品把珍贵的“麦”系列道具浪费掉而被吐槽的“章鱼老头”则是一个会阶段性无敌的Boss。这个场景既能当游戏梗看也非常适合用来复盘战斗系统开发里的命中判定、状态机和误触保护。如果你是游戏开发者、Unity入门者或者正在做回合制RPG/动作玩法的原型那么这篇笔记会拆解一个简化可运行的小型战斗原型从角色、技能、Boss状态机开始到“炸空气”修复合集最后给出生产级战斗系统常见的工程实践。1. 背景与问题复盘1.1 标题里的场景到底在说什么先用大白话把一个梗拆成四个关键信息关键词实际含义工程映射鲨鱼玩家操控的角色可以释放高伤害技能的角色大招高伤害、长冷却技能代价较高的技能能力炸空气技能释放时目标已不可被命中伤害结算时目标状态与技能释放时机不一致章鱼老头一个会阶段切换的Boss带状态机的敌人角色破防误吃麦心态崩溃后误触消耗品UI误触、操作缺少二次确认回到战斗系统开发里这个梗其实描述了两类 bug玩家按下大招时Boss已经进入无敌/蓄力/换阶段状态但技能没有在结算前重新校验目标是否可被命中。玩家在情绪波动或快速操作时点击了道具按钮结果直接消耗了不该消耗的资源。前者属于“战斗判定时序”问题后者属于“输入与资源保护”问题。两者都很常见。1.2 为什么这个问题值得单独写一篇很多战斗原型在开发早期不会考虑“目标已经不可被攻击”这类边界情况。因为 Demo 阶段Boss基本站着挨打玩家只要按技能就能稳定造成伤害。一旦加入Boss状态机、技能动画、角色死亡、场景切换技能落空就变成高频问题。如果只在技能外层套一个“判断目标是否死亡”的 if仍然不够。Boss可能会进入锁定、瞬移、无敌、转阶段、读条引导等状态这些状态下表面看起来目标还活着但伤害结算应该被拒绝。所以这篇笔记会做成一个可运行的 Unity C# 示例重点解决“炸空气”和“误吃消耗品”两个问题顺便把状态机的写法梳理清楚。2. 环境准备与项目结构2.1 版本说明示例基于 Unity 引擎使用 C# 编写。Unity 版本不需要绑定到某一个具体版本只要使用 Unity 2021 之后的 LTS 版本即可。如果你本地正好是 Unity 2022 或 Unity 2023可以直接创建空项目按照下面的步骤操作。开发工具可以使用 Visual Studio、VS Code 或 Rider只要是能写 C# 的编辑器都行。2.2 示例项目命名为了统一表达我把示例项目命名为StarGuideCrystal谐音可以理解成“星导晶”。这个名字不必对应某个真实游戏或材料系统只是用来代指“本次要完成的小型战斗系统”。项目结构如下Assets/ ├── Scenes/ │ └── BattleDemo.unity ├── Scripts/ │ ├── Battle/ │ │ ├── Character.cs │ │ ├── OldOctopusBoss.cs │ │ ├── SharkUltimate.cs │ │ └── BattleManager.cs │ ├── UI/ │ │ └── UseItemButton.cs │ └── Data/ │ └── ItemDatabase.cs后面每个脚本会按编号逐步创建。2.3 场景布置建议在 Unity 场景中创建两个 GameObject一个取名叫Player_Shark挂上Character与SharkUltimate。一个取名叫Boss_OldOctopus挂上Character与OldOctopusBoss。再创建一个空物体叫BattleManager挂上BattleManager。还有一个 UI 画布用于演示道具误触问题。如果不想做复杂 UI可以先搭建一个按钮模拟道具使用入口。3. 核心机制拆解为什么大招会“炸空气”3.1 状态机是最基础的Boss设计在回合制或动作玩法里Boss不可能一直站在原地承受伤害。比较常见的设计是Boss先进入普通状态玩家可以输出。血量到达阈值或时间到达节点后Boss进入蓄力状态。蓄力结束后Boss进入无敌状态此时玩家无法打断。无敌结束后Boss进入虚弱状态玩家可以全力输出。这种流程很适合用状态机表达。C# 里最简单的方式是定义一个枚举再加上一个当前状态的字段。public enum BossState { Idle, ChargeUltimate, Invincible, Exhausted, }枚举可以直接作为状态机的状态标识。在Update里根据当前状态决定执行什么逻辑是入门阶段最容易理解的做法。3.2 伤害结算顺序是“炸空气”的关键先看一个很容易出低频 bug 的流程玩家按下大招按键。技能锁定当前目标。播放技能动画。动画播到伤害帧。对目标造成伤害。问题出在第 2 步和第 5 步之间。玩家按下技能的时候目标可能还处于正常状态。等伤害帧结算的时候目标可能已经切到无敌状态或者已经退场、走位离开范围。如果第 5 步不重新校验目标状态就会打在一个“看起来还在、实际上不能结算伤害”的目标上。简化代码很容易写if (target ! null) { target.TakeDamage(damage); }这个判断只解决了“目标对象是否存在”没有解决“目标现在是否允许被伤害”。所以要补上状态校验。3.3 无敌状态不一定等于“没血”很多新人的第一反应是既然目标无敌那我判断血量大于零不就行了吗不对。无敌状态往往是在目标“还有血量”的情况下发动的。此时目标血量明明是满的但游戏规则不允许你造成伤害。所以校验是否可被伤害不能只看血量还要看角色的当前状态。这个设计在代码里最好抽象成一个属性public virtual bool CanBeDamaged currentHealth 0f;子类Boss根据自己的状态机覆盖这个属性例如public override bool CanBeDamaged { get { if (currentState BossState.ChargeUltimate || currentState BossState.Invincible) { return false; } return base.CanBeDamaged; } }这样技能系统只要统一访问target.CanBeDamaged就能覆盖大量边界情况。3.4 UI误触怎么从工程上避免“破防误吃麦”本质是资源误消耗。单机原型中误触一个道具可能影响不大但在有稀有消耗品、联机同步、竞技玩法中误消耗会造成无法挽回的损失。工程上常见做法是消耗类按钮增加二次确认弹窗。按钮在释放后进入短暂冷却防止连点。使用道具前从服务端或本地存档校验数量。关键道具增加“锁定”或“保护”状态。4. 完整实战实现一个可运行的战斗原型下面会一步步创建一个最小可运行的战斗原型。代码基于 Unity 的MonoBehaviour体系可以直接复制到工程里运行。4.1 创建角色基类先写一个最基础的Character基类负责血量和伤害逻辑。文件路径Assets/Scripts/Battle/Character.csusing UnityEngine; namespace StarGuideCrystal.Battle { public abstract class Character : MonoBehaviour { [Header(基础属性)] [SerializeField] protected float maxHealth 1000f; protected float currentHealth; public float CurrentHealth currentHealth; public float MaxHealth maxHealth; /// summary /// 是否允许被造成伤害。 /// 子类可以覆盖该属性例如Boss在无敌状态下返回 false。 /// /summary public virtual bool CanBeDamaged currentHealth 0f; protected virtual void Awake() { currentHealth maxHealth; } /// summary /// 受到伤害。 /// /summary /// param namedamage伤害值/param public virtual void TakeDamage(float damage) { if (!CanBeDamaged) { Debug.Log(${name} 当前不可受击伤害被忽略); return; } currentHealth - damage; Debug.Log(${name} 受到 {damage} 点伤害剩余 HP {currentHealth}); if (currentHealth 0f) { Die(); } } protected virtual void Die() { currentHealth 0f; Debug.Log(${name} 退场); } } }这里把CanBeDamaged做成了虚属性默认只判断血量是否大于零。子类可以扩展出更严格的控制逻辑。4.2 编写“章鱼老头”状态机Boss接下来实现一个带状态机的Boss。它会经历四个状态Idle普通状态可以被攻击。ChargeUltimate蓄力中不可被攻击。Invincible无敌状态不可被攻击。Exhausted虚弱状态可以被攻击。文件路径Assets/Scripts/Battle/OldOctopusBoss.csusing System.Collections; using UnityEngine; namespace StarGuideCrystal.Battle { public class OldOctopusBoss : Character { public enum BossState { Idle, ChargeUltimate, Invincible, Exhausted, } [Header(Boss状态参数)] [SerializeField] private float chargeDuration 1f; [SerializeField] private float invincibleDuration 2f; [SerializeField] private float exhaustedDuration 3f; private BossState currentState BossState.Idle; public BossState CurrentState currentState; /// summary /// 重写可受击判断Boss在蓄力和无敌状态下不能受伤。 /// /summary public override bool CanBeDamaged { get { if (currentState BossState.ChargeUltimate || currentState BossState.Invincible) { return false; } return base.CanBeDamaged; } } /// summary /// 启动一次完整的“蓄力 - 无敌 - 虚弱”循环。 /// /summary public void StartUltimateSequence() { if (currentState ! BossState.Idle) { Debug.LogWarning(Boss当前状态不允许再次触发无敌流程); return; } StartCoroutine(UltimateSequenceRoutine()); } private IEnumerator UltimateSequenceRoutine() { currentState BossState.ChargeUltimate; Debug.Log(章鱼老头开始蓄力进入不可受击状态); yield return new WaitForSeconds(chargeDuration); currentState BossState.Invincible; Debug.Log(章鱼老头进入无敌状态); yield return new WaitForSeconds(invincibleDuration); currentState BossState.Exhausted; Debug.Log(章鱼老头进入虚弱状态可以输出); yield return new WaitForSeconds(exhaustedDuration); currentState BossState.Idle; Debug.Log(章鱼老头恢复正常状态); } } }这个状态机的核心价值在于把“什么状态下能被打”集中到一个属性上而不是散落在各个技能代码里。4.3 写出“鲨鱼大招”先保留bug版本先写一个会“炸空气”的版本。这个版本只判断目标是否为空不判断目标当前是否可被攻击。文件路径Assets/Scripts/Battle/SharkUltimate.csusing UnityEngine; namespace StarGuideCrystal.Battle { public class SharkUltimate : MonoBehaviour { [Header(大招伤害与范围)] [SerializeField] private float damage 500f; [SerializeField] private float range 15f; [Header(角色位置)] [SerializeField] private Transform characterTransform; /// summary /// 释放大招。 /// /summary /// param nametarget目标角色/param public void CastSkill(Character target) { if (target null) { Debug.LogWarning(鲨鱼大招打空了没有目标对象); return; } float distance Vector3.Distance(characterTransform.position, target.transform.position); if (distance range) { Debug.LogWarning(鲨鱼大招打空了目标距离太远); return; } target.TakeDamage(damage); } } }这个版本会在特定情况下出现“炸空气”玩家按下大招时目标还在射程内等CastSkill真正执行时目标已经进入无敌状态TakeDamage里的CanBeDamaged会拒绝伤害于是大招打上去没有伤害反馈。新手看到的现象就是“明明奶中了大招结果完全没有伤害Boss还像章鱼一样在那里乱动”。4.4 修复“炸空气”修复方法很简单在真正造成伤害之前再检查一次target.CanBeDamaged。文件路径Assets/Scripts/Battle/SharkUltimate.cs修改后的核心方法public void CastSkill(Character target) { if (target null) { Debug.LogWarning(鲨鱼大招打空了没有目标对象); return; } float distance Vector3.Distance(characterTransform.position, target.transform.position); if (distance range) { Debug.LogWarning(鲨鱼大招打空了目标距离太远); return; } if (!target.CanBeDamaged) { Debug.LogWarning(鲨鱼大招打空了目标当前处于不可受击状态); return; } target.TakeDamage(damage); }这里最关键的一行是if (!target.CanBeDamaged) { return; }这行代码代表了战斗判定中的一个重要原则技能释放时的锁定不等于伤害结算时的锁定结算前必须重新校验目标状态。4.5 修复“破防误吃麦”“误吃麦”对应的是UI层误操作。这里写一个简化版的道具使用按钮。文件路径Assets/Scripts/Data/ItemDatabase.csusing System.Collections.Generic; using UnityEngine; namespace StarGuideCrystal.UI { public class ItemDatabase : MonoBehaviour { private Dictionarystring, int itemCounts new Dictionarystring, int(); private void Awake() { // 初始化时给几个测试道具 itemCounts[meal_box] 3; itemCounts[health_potion] 5; } /// summary /// 尝试消耗指定道具返回是否消耗成功。 /// /summary public bool ConsumeItem(string itemId) { if (!itemCounts.ContainsKey(itemId)) { Debug.LogWarning($道具 {itemId} 不存在); return false; } if (itemCounts[itemId] 0) { Debug.LogWarning($道具 {itemId} 数量不足); return false; } itemCounts[itemId]--; Debug.Log($消耗道具 {itemId}剩余 {itemCounts[itemId]}); return true; } } }然后写按钮逻辑使用道具前必须二次确认。文件路径Assets/Scripts/UI/UseItemButton.csusing UnityEngine; using UnityEngine.UI; namespace StarGuideCrystal.UI { public class UseItemButton : MonoBehaviour { [SerializeField] private Button useButton; [SerializeField] private Button confirmButton; [SerializeField] private GameObject confirmPanel; [SerializeField] private ItemDatabase itemDatabase; [SerializeField] private string itemId meal_box; private bool isCoolingDown false; private void Start() { if (useButton ! null) { useButton.onClick.AddListener(OnClickUse); } if (confirmButton ! null) { confirmButton.onClick.AddListener(OnClickConfirm); } if (confirmPanel ! null) { confirmPanel.SetActive(false); } } private void OnClickUse() { if (isCoolingDown) { Debug.Log(按钮冷却中不能连续点击); return; } if (confirmPanel ! null) { confirmPanel.SetActive(true); } } private void OnClickConfirm() { bool success itemDatabase.ConsumeItem(itemId); if (success) { StartCoolDown(); } if (confirmPanel ! null) { confirmPanel.SetActive(false); } } private void StartCoolDown() { isCoolingDown true; // 简单演示冷却实际项目中可以配合 UnscaledDeltaTime 做更稳定的倒计时 Invoke(nameof(ResetCoolDown), 0.5f); } private void ResetCoolDown() { isCoolingDown false; } } }这样玩家疯狂点击“使用”并不会立刻消耗道具必须先在弹窗里点确认。确认之后进入短暂的冷却避免重复消耗。4.6 串联整体流程最后写一个BattleManager用于模拟整个操作序列。文件路径Assets/Scripts/Battle/BattleManager.csusing UnityEngine; namespace StarGuideCrystal.Battle { public class BattleManager : MonoBehaviour { [SerializeField] private SharkUltimate sharkUltimate; [SerializeField] private OldOctopusBoss boss; private void Start() { // 第一次放技能Boss处于 Idle 状态正常伤害 Debug.Log( 第一次大招Boss普通状态 ); sharkUltimate.CastSkill(boss); // Boss进入蓄力 无敌流程 boss.StartUltimateSequence(); // 这里故意模拟“玩家在Boss无敌期间按下大招” Debug.Log( 第二次大招Boss已经进入蓄力/无敌状态 ); sharkUltimate.CastSkill(boss); } private void Update() { // 测试时按空格触发一次大招 if (Input.GetKeyDown(KeyCode.Space)) { sharkUltimate.CastSkill(boss); } // 测试时按 U 触发Boss无敌流程 if (Input.GetKeyDown(KeyCode.U)) { boss.StartUltimateSequence(); } } } }运行后注意 Console 输出的顺序第一次大招正常命中。第二次大招会提示“目标当前处于不可受击状态”。此时不会扣血也不会出现“Boss在无敌状态下突然掉血”的诡异表现。5. 常见问题与排查思路5.1 技能没有伤害问题现象常见原因解决思路技能命中但没有扣血目标处于无敌/蓄力状态检查CanBeDamaged是否被状态机覆盖技能未命中距离判定超出范围使用Vector3.Distance或Collider范围检测技能命中但伤害很低没有计算防御减伤或抗性在TakeDamage中补充防御、抗性公式5.2 Boss状态切换混乱如果Boss在无敌状态下仍然受到伤害优先排查下面几点CanBeDamaged是否在每次伤害结算时被读取。Boss状态枚举是否在正确的时机切换。技能系统是否缓存了之前的判定结果。尤其是技能动画播放比较长的时候不要把“释放时”的状态当成“结算时”的状态。5.3 UI误触消耗道具问题现象常见原因解决思路点了按钮立刻消耗道具没有二次确认增加确认弹窗连点导致消耗多个道具没有防连点保护消耗成功后进入冷却道具数量为负没有做数量校验在ConsumeItem中必须判断数量5.4 调试建议如果怀疑是大招“炸空气”可以按下面顺序排查先加日志在CastSkill中分别打印“距离”“目标状态”“是否可被伤害”。在Boss的CanBeDamaged属性中打印当前状态。对比日志顺序确定是距离问题、状态问题还是伤害结算问题。6. 工程最佳实践与扩展建议完成上面的最小原型之后如果要做成一个真正的战斗系统还需要考虑下面几点。6.1 状态机的单一数据源不要让每个技能自己判断Boss状态而是把“是否可被伤害”收敛到角色身上。这样以后Boss增加新状态只要修改Boss类本身不需要把所有技能都翻出来改一遍。6.2 伤害结算与表现分离示例中直接调用TakeDamage是纯逻辑层面的做法。实际项目里伤害之前可能还有碰撞盒、弹道、动画事件、特效触发等表现层内容。推荐做法是表现层负责播放动画、特效、音效。逻辑层负责伤害计算、状态切换。表现层通过事件通知逻辑层。6.3 资源消耗操作必须有保护消耗品、货币、体力、抽卡道具这类资源一旦被误操作消耗玩家很难接受。所以重要消耗需要二次确认。稀有道具可以增加“锁定”功能。客户端只负责展示真正扣减必须经过服务端校验。6.4 善用日志与事件日志在开发期日志可以帮助定位“炸空气”这类问题。但有些日志不能直接发布到正式环境。可以做一个日志系统分等级输出线上只保留错误和警告日志。6.5 边界条件要重点测试战斗系统里最容易出问题的地方Boss濒死瞬间被攻击。Boss无敌状态最后一帧被攻击。技能动画播放期间Boss死亡。玩家按下攻击键的同时目标传送离开。网络延迟、断线重连后的状态同步。建议把这些场景整理成一份测试用例清单每次修改状态机后都跑一遍。6.6 从枚举状态机向动画状态机过渡当Boss行为增多单一的枚举状态机会变得很难维护。后续可以考虑使用 UnityAnimator作为视觉状态。使用StateMachineBehaviour驱动行为切换。使用行为树或决策树管理复杂AI。使用ScriptableObject配置技能数据。示例项目中为了保持最简单结构使用的是枚举加协程。实际项目可以逐步抽象不要在原型阶段就上复杂框架。7. 总结与后续学习方向到现在为止我们已经把“鲨鱼大招炸空气之后破防误吃麦的章鱼老头”这个梗拆成了一个可以运行、可以复现、也可以修复的 Unity 战斗原型。核心收获有四个方面技能释放与伤害结算不是同一个时刻结算前必须检查目标状态。Boss的无敌状态不能只用“血量大于零”判断要用状态机控制。资源消耗需要二次确认和防连点保护。开发期日志和边界测试用例能大幅减少“打空气”和“误吃麦”这类低级事故。如果你正好在写回合制RPG或动作游戏原型可以在此基础上继续扩展把技能数据改成ScriptableObject配置把Boss行为从协程改成更稳定的状态机框架再补充一个简单的对象池用来管理技能特效。这样整个项目会更接近可上线的工程结构。下一次如果再看到类似“某角色把大招打在空气上”的梗希望你第一反应已经不是“哈哈哈哈”而是“这个伤害结算前没做状态校验吧”。如果这篇笔记对你有帮助可以收藏备用也欢迎在评论区聊聊你在战斗系统里踩过哪些类似的坑。
返回列表