尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity技能系统架构设计:策略模式与事件驱动在类银河城RPG中的应用

Unity技能系统架构设计:策略模式与事件驱动在类银河城RPG中的应用 1. 项目概述与核心设计思路最近在做一个类银河战士恶魔城Metroidvania风格的Unity RPG项目其中Skill模块的设计与实现是战斗系统的核心骨架。这类游戏对技能的要求非常特殊它既需要像传统RPG一样有丰富的成长性和策略搭配又需要像动作游戏一样有精准的判定、流畅的连招和即时的反馈。如果简单套用MMO或卡牌游戏的技能框架很容易做出手感僵硬、缺乏探索感的“站桩”战斗完全违背了银河城的精髓。我的核心设计目标是构建一个高内聚、低耦合、易扩展且手感扎实的技能系统。它不仅要能处理火球术、治疗术这类传统技能更要能优雅地支持“冲刺斩”、“二段跳”、“蓄力射击”、“钩锁移动”这些极具银河城特色的能力。经过几轮迭代我最终确定了一套以策略模式Strategy Pattern为骨骼、以事件驱动Event-driven为神经、以状态机State Machine控制流程的混合架构。这套架构让新技能的添加像搭积木一样简单同时也保证了战斗逻辑的清晰和性能的可靠。简单来说一个技能在系统中会经历“配置 - 触发 - 执行 - 结束”的生命周期。我们通过ScriptableObject来静态定义技能的所有属性伤害、冷却、动画、特效等在运行时一个Skill组件负责管理这个生命周期而具体的技能效果则由一个个独立的SkillStrategy子类去实现。当玩家按下按键时输入系统发出事件技能管理器接收并检查条件如冷却、资源然后创建并执行对应的技能策略。策略执行过程中会通过事件总线Event Bus通知其他模块如UI更新冷却、敌人受击、摄像机震动整个过程高度解耦。2. 技能系统核心架构解析2.1 基于策略模式Strategy Pattern的技能逻辑封装为什么选择策略模式这是对抗“技能屎山”代码最有效的武器。想象一下如果用一个大大的switch或一堆if-else来处理所有技能代码很快就会变得无法维护。策略模式将每个技能的具体算法即效果逻辑封装在独立的类中使得它们可以相互替换。这样Skill这个上下文类就只需要关心流程控制而不需要知道火球术到底是怎么飞出去的。我定义了一个抽象基类SkillStrategypublic abstract class SkillStrategy : ScriptableObject { [Header(基础配置)] public string skillName; public Sprite icon; public float cooldown; public float castTime; public AnimationClip castAnimation; [Header(消耗)] public int mpCost; public int hpCost; // 核心方法检查释放条件 public virtual bool CheckCastConditions(Entity caster) { return caster.CurrentMp mpCost caster.CurrentHp hpCost; } // 核心方法执行技能效果 public abstract void Execute(Entity caster, Vector3 targetPosition); // 可选方法技能持续效果或结束处理 public virtual void OnUpdate(Entity caster) {} public virtual void OnFinish(Entity caster) {} }所有具体的技能比如FireballStrategy、DashAttackStrategy、DoubleJumpStrategy都继承自这个类并实现各自的Execute方法。SkillStrategy本身也继承ScriptableObject这意味着我们可以在Unity编辑器里像配置资源一样配置每一个技能可视化地填写伤害值、投射物预制体、buff持续时间等参数这对策划和设计来说极其友好。实操心得将CheckCastConditions放在策略基类里是个关键设计。这允许不同的技能有不同的前置条件。比如二段跳技能可能要求“角色当前处于跳跃状态且尚未使用二段跳”这个检查逻辑就可以在DoubleJumpStrategy中重写。这样条件检查的逻辑也实现了策略化非常灵活。2.2 事件驱动Event-driven架构实现模块解耦技能系统绝不是孤岛。一个技能释放出去需要触发一连串的连锁反应角色要播放动画和音效UI要刷新冷却倒计时敌人要计算伤害并播放受击反馈场景中可能要生成一个可破坏的物件成就系统可能要记录“火球术命中100次”……如果让Skill类直接去调用UIManager.Instance、EnemyManager.Instance那就成了紧密耦合的噩梦测试和调试会异常痛苦。我的解决方案是引入一个轻量级的事件总线Event Bus。它本质上是一个观察者模式的中心化管理器。任何模块都可以发布Publish事件或订阅Subscribe事件。首先定义一些通用的事件类// 技能相关事件 public struct SkillCastEvent { public Entity Caster; public SkillStrategy Skill; public Vector3 Target; } public struct SkillHitEvent { public Entity Caster; public Entity Target; public SkillStrategy Skill; public float DamageDealt; } // 资源变化事件 public struct MpChangedEvent { public Entity Entity; public int CurrentMp; public int MaxMp; }然后在技能策略的Execute方法中我们不再直接调用其他系统而是发布事件public class FireballStrategy : SkillStrategy { public GameObject projectilePrefab; public float projectileSpeed; public float explosionRadius; public override void Execute(Entity caster, Vector3 targetPosition) { // 1. 发布技能释放事件UI、音效、成就系统监听 EventBus.Publish(new SkillCastEvent { Caster caster, Skill this }); // 2. 创建投射物 GameObject proj Instantiate(projectilePrefab, caster.FirePoint.position, Quaternion.identity); // ... 设置速度、方向 // 3. 投射物命中时发布命中事件伤害计算、受击反馈、镜头震动监听 // 通常在投射物的碰撞检测脚本中发布 SkillHitEvent } }而其他模块比如UIManager只需要在初始化时订阅它关心的事件void Start() { EventBus.SubscribeSkillCastEvent(OnSkillCast); } void OnSkillCast(SkillCastEvent evt) { // 更新UI上对应技能的冷却图标 cooldownUI.StartCooldown(evt.Skill.icon, evt.Skill.cooldown); }注意事项事件驱动虽好但也要警惕“事件风暴”。不要滥用事件尤其是高频事件如Update中每帧发布。对于技能系统在关键节点开始、命中、结束发布事件是合适的。同时务必记得在对象销毁时如OnDestroy取消订阅否则会导致内存泄漏和空引用错误。2.3 技能状态机Skill State Machine管理生命周期一个技能从按下按键到效果结束会经历多个状态就绪Ready、吟唱Casting、释放Executing、冷却Cooldown。对于有持续时间的技能如引导型法术、持续光环还需要一个持续Channeling状态。用一堆布尔变量isCasting,isCooldown来控制这些状态很容易出错。我为每个Skill实例配备了一个简单的状态机。这里我没有使用完整的FSM框架而是用一个枚举和状态切换方法来管理public class Skill : MonoBehaviour { public SkillStrategy strategy; private SkillState _currentState SkillState.Ready; private float _cooldownTimer; private Entity _owner; enum SkillState { Ready, // 可释放 Casting, // 吟唱中 Executing, // 效果执行中如冲锋过程 Channeling, // 引导中 Cooldown // 冷却中 } public bool TryCast(Entity caster, Vector3 target) { if (_currentState ! SkillState.Ready) return false; if (!strategy.CheckCastConditions(caster)) return false; // 切换到吟唱状态 ChangeState(SkillState.Casting); // 播放吟唱动画启动吟唱计时器... // 吟唱结束后调用 OnCastFinished return true; } private void OnCastFinished() { ChangeState(SkillState.Executing); strategy.Execute(_owner, _lastTarget); // 根据技能类型可能立即进入Cooldown或进入Channeling } private void ChangeState(SkillState newState) { // 处理退出旧状态的逻辑 switch (_currentState) { case SkillState.Channeling: strategy.OnFinish(_owner); // 通知策略引导结束 break; } _currentState newState; // 处理进入新状态的逻辑 switch (_currentState) { case SkillState.Casting: // 触发吟唱动画、UI表现 break; case SkillState.Cooldown: _cooldownTimer strategy.cooldown; break; } } void Update() { switch (_currentState) { case SkillState.Channeling: strategy.OnUpdate(_owner); // 每帧执行持续效果 break; case SkillState.Cooldown: _cooldownTimer - Time.deltaTime; if (_cooldownTimer 0) ChangeState(SkillState.Ready); break; } } }这个状态机确保了技能生命周期的清晰和可控。例如在“吟唱”状态下角色无法移动或释放其他技能除非有特殊天赋在“引导”状态下技能效果每帧生效且可以被中断。3. 银河城特色技能的实现细节类银河战士恶魔城游戏的核心乐趣在于通过获得新技能来解锁新的地图区域和能力成长。这意味着我们的技能系统必须能很好地支持那些改变角色移动规则、与环境交互的能力。3.1 移动类技能冲刺与二段跳这类技能的关键在于与角色控制器Character Controller或刚体Rigidbody的协同工作并处理好状态冲突。冲刺Dash实现 冲刺不是简单的瞬间位移它通常包含无敌帧i-frame、拖尾特效、可穿越敌人、冷却时间短等特点。我将其实现为一个DashStrategy。public class DashStrategy : SkillStrategy { public float dashDistance; public float dashDuration; public GameObject dashTrailEffect; public override void Execute(Entity caster, Vector3 targetPosition) { // 计算冲刺方向通常为角色面朝方向或输入方向 Vector3 dashDirection caster.GetMoveInput().normalized; if (dashDirection Vector3.zero) dashDirection caster.transform.forward; // 启动一个协程来处理冲刺过程 caster.StartCoroutine(PerformDash(caster, dashDirection)); } IEnumerator PerformDash(Entity caster, Vector3 direction) { // 1. 设置状态防止其他移动输入干扰 caster.IsDashing true; caster.SetInvincible(true); // 开启无敌帧 // 2. 生成拖尾特效 GameObject trail Instantiate(dashTrailEffect, caster.transform); // 3. 在固定时间内每帧移动固定距离 float timer 0; Vector3 startPos caster.transform.position; Vector3 endPos startPos direction * dashDistance; while (timer dashDuration) { timer Time.deltaTime; float t timer / dashDuration; // 使用平滑曲线如EaseOutCubic让冲刺更有质感 t 1 - Mathf.Pow(1 - t, 3); caster.transform.position Vector3.Lerp(startPos, endPos, t); yield return null; } // 4. 清理 Destroy(trail); caster.IsDashing false; caster.SetInvincible(false); } }踩过的坑最初我直接在Update里用Translate移动但发现如果帧率波动冲刺距离会不一致。后来改为基于时间的Lerp并使用了物理碰撞检测CharacterController.Move以确保不会穿墙但要注意给CharacterController设置合适的slopeLimit和stepOffset否则在斜坡或台阶上冲刺会卡住。二段跳Double Jump实现 二段跳的关键是准确判断“何时可以跳”。我通过扩展CheckCastConditions来实现。public class DoubleJumpStrategy : SkillStrategy { public float jumpForce; public override bool CheckCastConditions(Entity caster) { // 基础条件蓝量够 if (!base.CheckCastConditions(caster)) return false; // 特殊条件角色处于跳跃中且尚未使用二段跳 PlayerController pc caster as PlayerController; return pc ! null pc.IsJumping !pc.HasDoubleJumped; } public override void Execute(Entity caster, Vector3 targetPosition) { PlayerController pc (PlayerController)caster; // 重置Y轴速度然后施加一个向上的力 pc.ResetVerticalVelocity(); pc.ApplyVerticalImpulse(jumpForce); pc.HasDoubleJumped true; // 标记已使用 // 播放二段跳特效和音效 EventBus.Publish(new SkillCastEvent { Caster caster, Skill this }); } }这里PlayerController需要暴露IsJumping和HasDoubleJumped状态并在角色落地时将HasDoubleJumped重置为false。3.2 环境交互类技能钩锁与下砸攻击这类技能需要与游戏世界的物理和碰撞系统深度交互。钩锁Grappling Hook实现 钩锁的逻辑相对复杂涉及射线检测、弹簧关节Spring Joint或向量插值移动。public class GrappleHookStrategy : SkillStrategy { public float maxDistance; public LayerMask grappleLayer; public float grappleSpeed; public LineRenderer lineRendererPrefab; public override void Execute(Entity caster, Vector3 targetPosition) { // 1. 发射射线检测可钩挂点 RaycastHit hit; if (Physics.Raycast(caster.AimPoint.position, caster.AimPoint.forward, out hit, maxDistance, grappleLayer)) { // 2. 创建LineRenderer显示钩锁线 LineRenderer lr Instantiate(lineRendererPrefab); lr.SetPosition(0, caster.GrappleStartPoint.position); lr.SetPosition(1, hit.point); // 3. 启动协程将角色拉向目标点 caster.StartCoroutine(PullToPoint(caster, hit.point, lr)); } } IEnumerator PullToPoint(Entity caster, Vector3 targetPoint, LineRenderer lr) { caster.IsGrappling true; Vector3 startPos caster.transform.position; while (Vector3.Distance(caster.transform.position, targetPoint) 0.5f) { // 更新钩锁线 lr.SetPosition(0, caster.GrappleStartPoint.position); // 使用Vector3.MoveTowards平滑移动 caster.transform.position Vector3.MoveTowards(caster.transform.position, targetPoint, grappleSpeed * Time.deltaTime); yield return null; } // 到达目标点或玩家取消 Destroy(lr.gameObject); caster.IsGrappling false; } }注意事项钩锁的体验优化点很多。比如可以加入一个轻微的“拉扯”初速度模拟惯性在钩锁过程中允许玩家进行小幅度的方向调整钩锁命中时可以有一个短暂的慢动作特效Time.timeScale来增强打击感。同时务必处理好钩锁中断的情况如撞到墙壁、主动取消。下砸攻击Ground Slam实现 这是一个兼具移动和攻击的技能。角色跃起后向下猛击对落点区域造成范围伤害和冲击波。public class GroundSlamStrategy : SkillStrategy { public float slamDownForce; public float impactRadius; public float damage; public GameObject impactEffect; public override void Execute(Entity caster, Vector3 targetPosition) { // 此技能通常在空中释放 if (caster.IsGrounded) return; // 1. 给角色一个巨大的向下的力或直接设置速度 Rigidbody rb caster.GetComponentRigidbody(); if (rb ! null) { rb.velocity new Vector3(rb.velocity.x, -slamDownForce, rb.velocity.z); } // 2. 启动检测协程当角色落地时触发效果 caster.StartCoroutine(CheckForLanding(caster)); } IEnumerator CheckForLanding(Entity caster) { while (!caster.IsGrounded) { yield return null; } // 3. 角色落地触发冲击波 Vector3 slamPoint caster.transform.position; // 球形检测范围内的所有敌人 Collider[] hits Physics.OverlapSphere(slamPoint, impactRadius, LayerMask.GetMask(Enemy)); foreach (var hit in hits) { Entity enemy hit.GetComponentEntity(); if (enemy ! null) { // 计算并施加伤害 enemy.TakeDamage(damage, caster); // 发布命中事件 EventBus.Publish(new SkillHitEvent { Caster caster, Target enemy, Skill this, DamageDealt damage }); } } // 4. 播放落地特效和镜头震动 Instantiate(impactEffect, slamPoint, Quaternion.identity); EventBus.Publish(new CameraShakeEvent { Intensity 0.7f, Duration 0.3f }); } }实操心得下砸攻击的伤害判定时机很重要。如果只在落地那一帧检测可能会因为帧率问题错过。我的做法是在落地后的几帧内持续检测例如0.1秒或者像上面这样在落地瞬间进行一次范围检测。为了更好的手感可以在角色开始下砸时就播放一个蓄力下砸的动画和音效并在落地时有一个短暂的“卡帧”Time.timeScale微调来强调打击感。4. Skill模块的配置、管理与性能优化4.1 使用ScriptableObject进行数据驱动配置将所有技能属性配置在ScriptableObject资产中是项目可维护性的基石。我为每个SkillStrategy子类创建对应的SO资产菜单。[CreateAssetMenu(fileName NewFireballSkill, menuName RPG Skills/Fireball)] public class FireballStrategy : SkillStrategy { /* ... */ } [CreateAssetMenu(fileName NewDashSkill, menuName RPG Skills/Dash)] public class DashStrategy : SkillStrategy { /* ... */ }在编辑器中策划可以创建一堆FireballStrategy、DashStrategy等资产并像配置材质球一样配置它们的属性。然后在玩家的SkillManager组件上有一个SkillSlot列表每个槽位关联一个SO资产和对应的快捷键。这样调整技能数值、效果、甚至替换整个技能逻辑都无需修改代码重启游戏即可生效极大地支持了快速迭代。配置表示例技能名称技能类型 (SO)冷却时间魔法消耗关键参数1关键参数2火球术FireballStrategy2.0s15 MP投射物速度: 15爆炸半径: 3.0闪电链ChainLightningStrategy4.0s30 MP跳跃次数: 4伤害衰减: 0.7治疗术HealStrategy8.0s20 MP治疗量: 50施法距离: 10.0英勇冲锋ChargeStrategy6.0s25 MP冲锋距离: 8.0眩晕时长: 1.5s4.2 技能管理器SkillManager与输入处理SkillManager挂载在玩家角色上负责管理所有技能槽位并桥接输入系统。public class SkillManager : MonoBehaviour { public Entity owner; public SkillSlot[] skillSlots; // 对应1,2,3,4键位 void Update() { HandleSkillInput(); } void HandleSkillInput() { // 示例数字键1-4触发技能 for (int i 0; i skillSlots.Length; i) { if (Input.GetKeyDown(KeyCode.Alpha1 i)) { skillSlots[i].TryCast(owner, GetMouseWorldPosition()); } } } } [System.Serializable] public class SkillSlot { public SkillStrategy strategy; // 拖入ScriptableObject public Skill runtimeInstance; // 运行时创建的Skill组件管理状态和冷却 public KeyCode shortcutKey; public bool TryCast(Entity caster, Vector3 target) { if (runtimeInstance null) { // 懒加载初始化 GameObject go new GameObject($Skill_{strategy.skillName}); runtimeInstance go.AddComponentSkill(); runtimeInstance.strategy strategy; } return runtimeInstance.TryCast(caster, target); } }输入处理进阶对于动作性强的银河城游戏输入缓冲Input Buffer和连招Combo系统很重要。输入缓冲可以让玩家在技能后摇结束前按下下一个技能键系统将其暂存并在可释放时自动释放使连招更顺畅。这可以在SkillManager中维护一个短暂的输入缓冲区来实现。4.3 对象池Object Pooling优化技能特效技能系统是游戏特效VFX和音效SFX的生成大户。频繁的Instantiate和Destroy是性能杀手尤其是在移动平台或低端PC上。对于所有频繁生成的物体如投射物、命中火花、Buff图标等必须使用对象池。我实现了一个简单的通用对象池管理器public class ObjectPool : MonoBehaviour { public static ObjectPool Instance; private DictionaryGameObject, QueueGameObject poolDictionary new DictionaryGameObject, QueueGameObject(); void Awake() { Instance this; } public GameObject SpawnFromPool(GameObject prefab, Vector3 position, Quaternion rotation) { if (!poolDictionary.ContainsKey(prefab)) { poolDictionary[prefab] new QueueGameObject(); } QueueGameObject pool poolDictionary[prefab]; GameObject obj; if (pool.Count 0) { obj pool.Dequeue(); obj.transform.position position; obj.transform.rotation rotation; obj.SetActive(true); } else { obj Instantiate(prefab, position, rotation); PooledObject pooledObj obj.AddComponentPooledObject(); pooledObj.prefab prefab; } return obj; } public void ReturnToPool(GameObject obj) { PooledObject pooledObj obj.GetComponentPooledObject(); if (pooledObj null) { Destroy(obj); return; } obj.SetActive(false); poolDictionary[pooledObj.prefab].Enqueue(obj); } } public class PooledObject : MonoBehaviour { public GameObject prefab; void OnDisable() { ObjectPool.Instance?.ReturnToPool(this.gameObject); } }在技能策略中生成投射物时改为从对象池获取// 替换 Instantiate(projectilePrefab, ...); GameObject proj ObjectPool.Instance.SpawnFromPool(projectilePrefab, caster.FirePoint.position, Quaternion.identity); // 投射物逻辑结束后会自动SetActive(false)并回池无需手动Destroy性能监控在Unity Profiler中要特别关注Instantiate和Destroy的调用次数。通过对象池这两项应该基本降为0除了初始化和场景切换。同时也要注意技能触发时GetComponent的调用对于高频调用的组件如Entity上的Health可以考虑缓存引用。5. 常见问题、调试技巧与扩展方向5.1 开发与调试中遇到的典型问题在开发Skill模块时我踩过不少坑这里记录几个最有代表性的问题1技能释放后角色状态异常如一直处于“施法中”无法移动。排查思路这是技能状态机没有正确切换或重置的典型症状。首先在Skill.ChangeState方法中加入Debug.Log打印每次状态切换的信息。检查技能执行流程的每个分支正常结束、被中断、条件不满足提前结束是否都正确调用了切换到Ready或Cooldown状态的方法。特别注意协程Coroutine中的流程。如果协程因为异常或条件判断提前yield break可能会跳过状态清理的代码。确保在协程中使用try...finally块或在所有退出路径上都清理状态。IEnumerator PerformSomeChanneling() { try { ChangeState(SkillState.Channeling); while (channelCondition) { // ... 每帧引导逻辑 yield return null; } } finally { // 无论因何退出都确保状态被清理 if (_currentState SkillState.Channeling) ChangeState(SkillState.Cooldown); } }问题2事件监听者收不到事件或者收到多次。排查思路这是事件总线订阅/发布逻辑的问题。收不到检查监听者的订阅时机是否晚于事件发布。确保监听者在Start或OnEnable中订阅并且对象是激活的。检查事件的结构体字段是否匹配。收到多次这是典型的重复订阅问题。确保在OnDisable或OnDestroy中取消订阅。一个常见的错误是在Update中每帧都订阅导致订阅数量爆炸。我的事件总线在发布时会打印当前所有监听者的数量这在调试时很有用。使用Unity的Debug.Log在事件的发布和订阅方法里打印关键信息跟踪事件流。问题3技能特效或投射物不显示或者位置不对。排查思路这通常是坐标空间和父子关系问题。不显示检查预制体是否被正确加载实例化时是否传入了正确的position和rotation。检查对象的layer是否被摄像机渲染。如果是对象池回收的物体确保在SpawnFromPool时正确重置了它的位置、旋转、缩放以及所有必要的组件状态如粒子系统Play()、碰撞体enabled true。位置不对区分世界坐标World Space和本地坐标Local Space。技能释放点如caster.FirePoint通常是一个子Transform。在实例化时如果使用caster.FirePoint.position这是世界坐标。如果希望特效跟随角色某个部位移动可能需要将其设为该部位如右手的子物体并设置其本地坐标为Vector3.zero。5.2 技能系统的扩展方向一个健壮的基础技能系统是创新的土壤。在完成核心功能后可以考虑以下扩展来增加游戏的深度和趣味性技能树与符文系统每个基础技能可以关联一个技能树。玩家通过技能点解锁分支例如火球术可以升级为“大火球”范围增加或“连发火球”数量增加。符文系统则允许玩家为技能槽镶嵌符文动态修改技能属性如“寒冰符文”让火球术附加减速效果。实现思路为SkillStrategy增加一个ListSkillUpgrade字段每个SkillUpgrade是一个ScriptableObject定义修改技能属性的方法如ModifyDamage,AddStatusEffect。在技能执行时遍历并应用所有已激活的升级和符文效果。状态效果Buff/Debuff系统这是技能系统的自然延伸。技能命中后除了造成伤害还可能附加中毒、灼烧、眩晕、加速等状态效果。实现思路创建一个StatusEffect基类包含持续时间、每帧效果、结束效果等。Entity类维护一个当前状态效果的列表。在SkillHitEvent的处理中除了计算伤害还可以附加相应的StatusEffect。状态效果本身也可以通过事件总线来驱动图标显示、计时等。技能连招与取消为了提升动作性可以设计技能之间的连招Combo逻辑以及技能前摇、后摇的取消Cancel机制。实现思路在Skill状态机中明确划分“前摇”、“伤害判定帧”、“后摇”等子状态。在“前摇”状态可以监听输入如果按下了特定连招技能键则取消当前技能后摇立即释放下一个技能。这需要一套精确的动画事件Animation Events和状态标签系统来配合。网络同步如果做多人游戏这是最大的挑战。技能系统需要完全在服务端进行权威计算客户端只负责表现和输入预测。实现思路采用指令Command模式。客户端发送“释放技能X目标位置Y”的指令到服务器。服务器验证后执行技能逻辑然后将结果伤害、命中、新状态广播给所有客户端。客户端的技能表现需要处理插值Interpolation和预测回滚Prediction Rollback来保证流畅性。事件总线在客户端-服务器架构下可以演变为网络消息总线。
返回列表