From a1b4e629aae95a949be21598169d9d5f1353675b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 23 May 2026 19:10:29 +0800 Subject: [PATCH] feat: Implement Room Streaming System - Add RoomStreamingManager to manage room loading and unloading based on player proximity. - Create StreamingBudgetConfigSO for memory and performance budgeting of the streaming system. - Introduce TransitionDirector to handle seamless and atmospheric fade transitions between rooms. - Develop WorldGraph to represent room connectivity and facilitate neighbor queries and distance calculations. - Implement RoomNode and RoomEdge classes to structure room data and connections. --- .../Data/UI/InputIcons/ICN_Keyboard.asset | 66 ++- Assets/_Game/Scripts/Combat/CombatEnums.cs | 2 + .../_Game/Scripts/Core/Assets/AddressKeys.cs | 5 +- .../Scripts/Core/Events/TransitionType.cs | 12 +- .../_Game/Scripts/Core/ITransitionDirector.cs | 28 + Assets/_Game/Scripts/Core/SceneService.cs | 24 +- .../Editor/Addressables/AddressableRules.cs | 4 + .../Scripts/Editor/BaseGames.Editor.asmdef | 1 + .../_Game/Scripts/Editor/Hub/DataHubWindow.cs | 1 + .../Scripts/Editor/Modules/StreamingModule.cs | 155 ++++++ .../Editor/Scene/SceneScaffoldTools.cs | 78 +++ .../Scripts/Enemies/AI/BDTaskAttributes.cs | 37 ++ Assets/_Game/Scripts/Enemies/AI/BD_Attack.cs | 10 +- .../Enemies/AI/BD_BossPhaseTransition.cs | 54 ++ .../Enemies/AI/BD_BossPhaseTransition.cs.meta | 11 + .../Scripts/Enemies/AI/BD_BroadcastAlert.cs | 39 ++ .../Enemies/AI/BD_BroadcastAlert.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_CanAttack.cs | 10 +- .../Scripts/Enemies/AI/BD_CanReachTarget.cs | 35 ++ .../Enemies/AI/BD_CanReachTarget.cs.meta | 11 + .../Scripts/Enemies/AI/BD_CanUseAbility.cs | 71 +++ .../Enemies/AI/BD_CanUseAbility.cs.meta | 11 + .../Scripts/Enemies/AI/BD_CanUseBossSkill.cs | 41 ++ .../Enemies/AI/BD_CanUseBossSkill.cs.meta | 11 + .../Scripts/Enemies/AI/BD_ChasePlayer.cs | 140 +++++ .../Scripts/Enemies/AI/BD_ChasePlayer.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_EnterPhase.cs | 14 +- .../_Game/Scripts/Enemies/AI/BD_FaceTarget.cs | 17 +- .../Scripts/Enemies/AI/BD_InterruptAbility.cs | 32 ++ .../Enemies/AI/BD_InterruptAbility.cs.meta | 11 + .../Enemies/AI/BD_InvestigateLastKnown.cs | 184 +++++++ .../AI/BD_InvestigateLastKnown.cs.meta | 11 + .../Scripts/Enemies/AI/BD_IsAbilityRunning.cs | 34 ++ .../Enemies/AI/BD_IsAbilityRunning.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs | 36 ++ .../Scripts/Enemies/AI/BD_IsAiPhase.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_IsGrounded.cs | 10 +- .../_Game/Scripts/Enemies/AI/BD_IsHPBelow.cs | 14 +- .../_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs | 10 +- .../Scripts/Enemies/AI/BD_IsOnNavLink.cs | 35 ++ .../Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta | 11 + .../Scripts/Enemies/AI/BD_IsPlayerInRange.cs | 10 +- .../Scripts/Enemies/AI/BD_IsPlayerVisible.cs | 10 +- .../Enemies/AI/BD_IsSensorDetecting.cs | 41 ++ .../Enemies/AI/BD_IsSensorDetecting.cs.meta | 11 + .../Scripts/Enemies/AI/BD_IsStateMatch.cs | 29 +- Assets/_Game/Scripts/Enemies/AI/BD_JumpTo.cs | 8 +- .../Enemies/AI/BD_MaintainCombatDistance.cs | 96 ++++ .../AI/BD_MaintainCombatDistance.cs.meta | 11 + Assets/_Game/Scripts/Enemies/AI/BD_MoveTo.cs | 10 +- .../Scripts/Enemies/AI/BD_MoveToAndWait.cs | 58 ++ .../Enemies/AI/BD_MoveToAndWait.cs.meta | 11 + .../Scripts/Enemies/AI/BD_MoveToPlayer.cs | 10 +- .../_Game/Scripts/Enemies/AI/BD_OnParried.cs | 30 + .../Scripts/Enemies/AI/BD_OnParried.cs.meta | 11 + Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs | 77 ++- .../Scripts/Enemies/AI/BD_PatrolWaypoints.cs | 185 +++++++ .../Enemies/AI/BD_PatrolWaypoints.cs.meta | 11 + .../Scripts/Enemies/AI/BD_PlayAnimation.cs | 10 +- .../Scripts/Enemies/AI/BD_ReturnToHome.cs | 103 ++++ .../Enemies/AI/BD_ReturnToHome.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs | 34 ++ .../Scripts/Enemies/AI/BD_SetAiPhase.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_SetAlert.cs | 10 +- .../_Game/Scripts/Enemies/AI/BD_SetState.cs | 45 ++ .../Scripts/Enemies/AI/BD_SetState.cs.meta | 11 + .../Scripts/Enemies/AI/BD_SpawnProjectile.cs | 5 +- .../Scripts/Enemies/AI/BD_StopMovement.cs | 10 +- .../Scripts/Enemies/AI/BD_SummonMinions.cs | 28 +- .../Scripts/Enemies/AI/BD_TelegraphAttack.cs | 24 +- .../_Game/Scripts/Enemies/AI/BD_TeleportTo.cs | 5 +- .../_Game/Scripts/Enemies/AI/BD_UseAbility.cs | 58 ++ .../Scripts/Enemies/AI/BD_UseAbility.cs.meta | 11 + .../Scripts/Enemies/AI/BD_UseBossSkill.cs | 53 ++ .../Enemies/AI/BD_UseBossSkill.cs.meta | 11 + .../Enemies/AI/BD_UseBossSkillWeighted.cs | 54 ++ .../AI/BD_UseBossSkillWeighted.cs.meta | 11 + Assets/_Game/Scripts/Enemies/AI/BD_Wait.cs | 5 +- .../Scripts/Enemies/AI/BD_WaitForAnimation.cs | 22 +- .../_Game/Scripts/Enemies/AI/BD_WaitRandom.cs | 5 +- .../Enemies/AI/BD_WaitUntilAbilityEnd.cs | 68 +++ .../Enemies/AI/BD_WaitUntilAbilityEnd.cs.meta | 11 + .../_Game/Scripts/Enemies/AI/BD_WalkRandom.cs | 44 ++ .../Scripts/Enemies/AI/BD_WalkRandom.cs.meta | 11 + .../Enemies/AI/BaseGames.Enemies.AI.asmdef | 5 +- Assets/_Game/Scripts/Enemies/Abilities.meta | 8 + .../Enemies/Abilities/AbilityRunState.cs | 26 + .../Enemies/Abilities/AbilityRunState.cs.meta | 11 + .../Enemies/Abilities/BlinkStrikeAbility.cs | 193 +++++++ .../Abilities/BlinkStrikeAbility.cs.meta | 11 + .../Enemies/Abilities/CeilingDropAbility.cs | 91 +++ .../Abilities/CeilingDropAbility.cs.meta | 11 + .../Enemies/Abilities/ChargeAbility.cs | 95 ++++ .../Enemies/Abilities/ChargeAbility.cs.meta | 11 + .../Enemies/Abilities/EnemyAbilityBase.cs | 189 +++++++ .../Abilities/EnemyAbilityBase.cs.meta | 11 + .../Enemies/Abilities/EnemyAbilityRegistry.cs | 79 +++ .../Abilities/EnemyAbilityRegistry.cs.meta | 11 + .../Enemies/Abilities/EnemyAbilitySO.cs | 46 ++ .../Enemies/Abilities/EnemyAbilitySO.cs.meta | 11 + .../Enemies/Abilities/EnemyAttackSO.cs | 48 ++ .../Enemies/Abilities/EnemyAttackSO.cs.meta | 11 + .../Enemies/Abilities/LeapAttackAbility.cs | 98 ++++ .../Abilities/LeapAttackAbility.cs.meta | 11 + .../Enemies/Abilities/MeleeAttackAbility.cs | 119 ++++ .../Abilities/MeleeAttackAbility.cs.meta | 11 + .../Enemies/Abilities/MultiDashAbility.cs | 49 ++ .../Abilities/MultiDashAbility.cs.meta | 11 + .../Abilities/ProjectileAttackAbility.cs | 126 +++++ .../Abilities/ProjectileAttackAbility.cs.meta | 11 + Assets/_Game/Scripts/Enemies/AiPhase.cs | 45 ++ Assets/_Game/Scripts/Enemies/AiPhase.cs.meta | 11 + .../Scripts/Enemies/BaseGames.Enemies.asmdef | 50 +- Assets/_Game/Scripts/Enemies/Boss/BossBase.cs | 319 ++++++++++- .../Scripts/Enemies/Boss/BossResource.cs | 104 ++++ .../Scripts/Enemies/Boss/BossResource.cs.meta | 11 + .../Scripts/Enemies/Boss/BossSkillExecutor.cs | 258 ++++++++- .../_Game/Scripts/Enemies/Boss/BossSkillSO.cs | 8 + .../Scripts/Enemies/EnemyAnimationConfigSO.cs | 15 +- Assets/_Game/Scripts/Enemies/EnemyBase.cs | 518 ++++++++++++++++-- .../Scripts/Enemies/EnemyDebugOverlay.cs | 149 +++++ .../Scripts/Enemies/EnemyDebugOverlay.cs.meta | 11 + Assets/_Game/Scripts/Enemies/EnemyMovement.cs | 118 +++- .../Scripts/Enemies/EnemyQuotaManager.cs | 4 + Assets/_Game/Scripts/Enemies/EnemyStats.cs | 16 +- Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs | 66 ++- Assets/_Game/Scripts/Enemies/FlyingEnemy.cs | 39 +- .../_Game/Scripts/Enemies/INavLinkHandler.cs | 51 ++ .../Scripts/Enemies/INavLinkHandler.cs.meta | 11 + Assets/_Game/Scripts/Enemies/IPathAgent.cs | 55 +- .../Enemies/Navigation/EnemyNavAgent.cs | 202 ++++++- .../Navigation/FlyingDirectNavigator.cs | 165 ++++++ .../Navigation/FlyingDirectNavigator.cs.meta | 11 + Assets/_Game/Scripts/Enemies/Perception.meta | 8 + .../Enemies/Perception/EnemySensorHub.cs | 89 +++ .../Enemies/Perception/EnemySensorHub.cs.meta | 11 + .../Enemies/Perception/EnemyThreatAssessor.cs | 54 ++ .../Enemies/Perception/IPerceptionSystem.cs | 23 + .../Perception/IPerceptionSystem.cs.meta | 11 + .../Enemies/Perception/SensorSlotNames.cs | 43 ++ .../Perception/SensorSlotNames.cs.meta | 11 + .../Enemies/States/EnemyKnockUpState.cs | 40 ++ .../Enemies/States/EnemyKnockUpState.cs.meta | 11 + .../_Game/Scripts/Enemies/StatusEffects.meta | 8 + .../StatusEffects/EnemyStatusEffectManager.cs | 87 +++ .../EnemyStatusEffectManager.cs.meta | 11 + .../Enemies/StatusEffects/IStatusEffect.cs | 38 ++ .../StatusEffects/IStatusEffect.cs.meta | 11 + .../Enemies/StatusEffects/StatusEffects.cs | 128 +++++ .../StatusEffects/StatusEffects.cs.meta | 11 + Assets/_Game/Scripts/World/IRoomLifecycle.cs | 24 + .../Scripts/World/IRoomStreamingManager.cs | 49 ++ .../_Game/Scripts/World/Map/MapRoomDataSO.cs | 16 + Assets/_Game/Scripts/World/RoomController.cs | 41 +- Assets/_Game/Scripts/World/RoomState.cs | 29 + Assets/_Game/Scripts/World/RoomTransition.cs | 8 +- Assets/_Game/Scripts/World/SpawnContext.cs | 24 + .../BaseGames.World.Streaming.asmdef | 21 + .../Scripts/World/Streaming/RoomHandle.cs | 294 ++++++++++ .../World/Streaming/RoomStreamingManager.cs | 388 +++++++++++++ .../Streaming/StreamingBudgetConfigSO.cs | 71 +++ .../World/Streaming/TransitionDirector.cs | 236 ++++++++ .../Scripts/World/Streaming/WorldGraph.cs | 241 ++++++++ Docs/Standards/AssetFolderSpec.md | 5 +- zeling_v2.sln | 24 +- 165 files changed, 7904 insertions(+), 313 deletions(-) create mode 100644 Assets/_Game/Scripts/Core/ITransitionDirector.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs create mode 100644 Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AiPhase.cs create mode 100644 Assets/_Game/Scripts/Enemies/AiPhase.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Boss/BossResource.cs create mode 100644 Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs create mode 100644 Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/INavLinkHandler.cs create mode 100644 Assets/_Game/Scripts/Enemies/INavLinkHandler.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs create mode 100644 Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Perception.meta create mode 100644 Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs create mode 100644 Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs create mode 100644 Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs create mode 100644 Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs create mode 100644 Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs create mode 100644 Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects.meta create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs create mode 100644 Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs.meta create mode 100644 Assets/_Game/Scripts/World/IRoomLifecycle.cs create mode 100644 Assets/_Game/Scripts/World/IRoomStreamingManager.cs create mode 100644 Assets/_Game/Scripts/World/RoomState.cs create mode 100644 Assets/_Game/Scripts/World/SpawnContext.cs create mode 100644 Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef create mode 100644 Assets/_Game/Scripts/World/Streaming/RoomHandle.cs create mode 100644 Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs create mode 100644 Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs create mode 100644 Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs create mode 100644 Assets/_Game/Scripts/World/Streaming/WorldGraph.cs diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset index e4dcb16..eddfffd 100644 --- a/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset +++ b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset @@ -13,4 +13,68 @@ MonoBehaviour: m_Name: ICN_Keyboard m_EditorClassIdentifier: _deviceType: 0 - _entries: [] + _entries: + - BindingPath: /w + Icon: {fileID: 0} + - BindingPath: /s + Icon: {fileID: 0} + - BindingPath: /a + Icon: {fileID: 0} + - BindingPath: /d + Icon: {fileID: 0} + - BindingPath: /space + Icon: {fileID: 0} + - BindingPath: /j + Icon: {fileID: 0} + - BindingPath: /k + Icon: {fileID: 0} + - BindingPath: /i + Icon: {fileID: 0} + - BindingPath: /l + Icon: {fileID: 0} + - BindingPath: /shift + Icon: {fileID: 0} + - BindingPath: /e + Icon: {fileID: 0} + - BindingPath: /1 + Icon: {fileID: 0} + - BindingPath: /2 + Icon: {fileID: 0} + - BindingPath: /3 + Icon: {fileID: 0} + - BindingPath: /q + Icon: {fileID: 0} + - BindingPath: /z + Icon: {fileID: 0} + - BindingPath: /x + Icon: {fileID: 0} + - BindingPath: /f + Icon: {fileID: 0} + - BindingPath: /escape + Icon: {fileID: 0} + - BindingPath: /upArrow + Icon: {fileID: 0} + - BindingPath: /downArrow + Icon: {fileID: 0} + - BindingPath: /leftArrow + Icon: {fileID: 0} + - BindingPath: /rightArrow + Icon: {fileID: 0} + - BindingPath: '*/{Submit}' + Icon: {fileID: 0} + - BindingPath: '*/{Cancel}' + Icon: {fileID: 0} + - BindingPath: /position + Icon: {fileID: 0} + - BindingPath: /position + Icon: {fileID: 0} + - BindingPath: /leftButton + Icon: {fileID: 0} + - BindingPath: /tip + Icon: {fileID: 0} + - BindingPath: /scroll + Icon: {fileID: 0} + - BindingPath: /middleButton + Icon: {fileID: 0} + - BindingPath: /rightButton + Icon: {fileID: 0} diff --git a/Assets/_Game/Scripts/Combat/CombatEnums.cs b/Assets/_Game/Scripts/Combat/CombatEnums.cs index cc0e09b..c94b0e3 100644 --- a/Assets/_Game/Scripts/Combat/CombatEnums.cs +++ b/Assets/_Game/Scripts/Combat/CombatEnums.cs @@ -31,6 +31,8 @@ namespace BaseGames.Combat CanClash = 1 << 5, ForceBreak = 1 << 6, NoKnockback = 1 << 7, + /// 击飞:使敌人进入 KnockUp 状态(腾空 + 落地)。仅在伤害量 >= HitTierConfig.launchThreshold 时生效。 + Launch = 1 << 8, } // ── 交互标签 ──────────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs index 96380db..3f351ae 100644 --- a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs +++ b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs @@ -51,7 +51,10 @@ namespace BaseGames.Core.Assets public const string PrefabWeaponSoulStaff = "WPN_SoulStaff"; // ── Config ScriptableObjects ───────────────────────────────────── - public const string DataFootstepCatalog = "Config/FootstepCatalog"; + public const string DataFootstepCatalog = "Config/FootstepCatalog"; + + /// 流式加载预算配置 SO,RoomStreamingManager 与 TransitionDirector 均依赖此资产。 + public const string DataStreamingBudgetConfig = "Config/StreamingBudgetConfig"; /// /// Addressable Label 常量(用于批量加载与预热)。 diff --git a/Assets/_Game/Scripts/Core/Events/TransitionType.cs b/Assets/_Game/Scripts/Core/Events/TransitionType.cs index 223b780..9dbff9c 100644 --- a/Assets/_Game/Scripts/Core/Events/TransitionType.cs +++ b/Assets/_Game/Scripts/Core/Events/TransitionType.cs @@ -1,7 +1,8 @@ namespace BaseGames.Core.Events { /// - /// 场景过渡类型,决定 的演出行为。 + /// 场景过渡类型,决定 或 + /// 的演出行为。 /// public enum TransitionType { @@ -12,5 +13,14 @@ namespace BaseGames.Core.Events /// 跨大区域切换。完整淡出,显示加载画面。 /// 适用于地图间传送、返回标题、大区域入口等有明显空间跳跃感的切换。 Scene, + + /// 无缝切换。无任何遮挡,目标房间必须已预加载(Dormant 状态)。 + /// 相机跟随玩家越过边界,视觉上无任何打断感。 + /// 若目标房间尚未就绪,TransitionDirector 将等待预加载完成后再执行切换(有超时保护)。 + Seamless, + + /// 氛围淡入淡出切换。短暂淡出(≈0.25 s)+ 显示新区域名称 + 淡入。 + /// 适用于跨大区域边界、目标房间已预加载的情况,比 Room 有更强的"抵达感"。 + AtmosphericFade, } } diff --git a/Assets/_Game/Scripts/Core/ITransitionDirector.cs b/Assets/_Game/Scripts/Core/ITransitionDirector.cs new file mode 100644 index 0000000..b66366b --- /dev/null +++ b/Assets/_Game/Scripts/Core/ITransitionDirector.cs @@ -0,0 +1,28 @@ +using BaseGames.Core.Events; + +namespace BaseGames.Core +{ + /// + /// 过渡导演接口。 + /// + /// 在处理 时, + /// 若过渡类型为 , + /// 则通过 ServiceLocator 查找此接口并委托处理。 + /// 若未找到实现(非流式模式),则退回原有淡出加载流程。 + /// + /// + public interface ITransitionDirector + { + /// + /// 处理过渡请求。由 SceneService 在确认过渡类型后调用。 + /// 实现方负责完整的过渡流程(激活目标房间、相机切换、播放演出等)。 + /// + void HandleTransition(SceneLoadRequest request); + + /// + /// 查询目标场景是否已预加载完毕(处于 Dormant 状态),可执行无缝切换。 + /// 若返回 false,SceneService 将退回带黑屏的 Room 过渡。 + /// + bool CanHandleSeamless(string targetSceneName); + } +} diff --git a/Assets/_Game/Scripts/Core/SceneService.cs b/Assets/_Game/Scripts/Core/SceneService.cs index bf98551..60934b1 100644 --- a/Assets/_Game/Scripts/Core/SceneService.cs +++ b/Assets/_Game/Scripts/Core/SceneService.cs @@ -70,7 +70,29 @@ namespace BaseGames.Core private void OnDisable() => _subscriptions.Clear(); private void HandleSceneLoadRequest(SceneLoadRequest request) - => StartCoroutine(LoadSceneCoroutine(request)); + { + // Seamless / AtmosphericFade 由 ITransitionDirector 处理(需要预加载支持) + if (request.TransitionType == TransitionType.Seamless || + request.TransitionType == TransitionType.AtmosphericFade) + { + var director = ServiceLocator.GetOrDefault(); + if (director != null) + { + // TransitionDirector 内部处理"立即切换"与"等待预加载后切换"两条路径 + director.HandleTransition(request); + return; + } + + // 未注册 ITransitionDirector(非流式模式):降级为 Room 过渡 + Debug.LogWarning($"[SceneService] 未找到 ITransitionDirector,{request.TransitionType} 降级为 Room 过渡。"); + var degraded = request; + degraded.TransitionType = TransitionType.Room; + StartCoroutine(LoadSceneCoroutine(degraded)); + return; + } + + StartCoroutine(LoadSceneCoroutine(request)); + } public IEnumerator LoadSceneCoroutine(SceneLoadRequest request) { diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs index d4255f9..c437c16 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs @@ -57,6 +57,7 @@ namespace BaseGames.Editor ("SPL_", "Config"), // 法术配置 SO ("ABL_", "Config"), // 能力配置 SO ("MAP_", "Config"), // 地图数据 SO(AssetFolderSpec §4) + ("STR_", "Config"), // 流式加载配置 SO(StreamingBudgetConfigSO) ("Config/", "Config"), // 路径前缀配置(AssetFolderSpec §8.2) // ── 音频(AUD_BGM_ / AUD_SFX_ 必须在通配 AUD_ 之前)───────────── ("AUD_BGM_", "Audio_Music"), // BGM 流式音频 @@ -79,6 +80,8 @@ namespace BaseGames.Editor { AddressKeys.PrefabUIFloatingDmgText, new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload } }, // FootstepCatalog 是首帧必须可用的配置 { AddressKeys.DataFootstepCatalog, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } }, + // 流式加载预算配置,运行时初始化前必须可用 + { AddressKeys.DataStreamingBudgetConfig, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } }, }; // ── 前缀 → 标签列表 ───────────────────────────────────────────────────── @@ -105,6 +108,7 @@ namespace BaseGames.Editor // ── 配置数据 ───────────────────────────────────────────────────── ("CHM_", new[] { AddressKeys.Labels.Charms }), ("MAP_", new[] { AddressKeys.Labels.Config }), // 地图数据 SO 为动态加载配置 + ("STR_", new[] { AddressKeys.Labels.Config }), // 流式加载配置 SO(StreamingBudgetConfigSO) ("Config/", new[] { AddressKeys.Labels.Config }), // ── 技能 / 法术 / 能力 / 世界物件 / 持久化:无批量加载需求,不加 Label ── ("SKL_", Array.Empty()), diff --git a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef index b526c35..c42d499 100644 --- a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef +++ b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef @@ -30,6 +30,7 @@ "BaseGames.Parry", "BaseGames.Skills", "BaseGames.World.Map", + "BaseGames.World.Streaming", "BaseGames.EventChain", "BaseGames.VFX", "Unity.InputSystem" diff --git a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs index 7045fd9..13892a4 100644 --- a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs +++ b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs @@ -76,6 +76,7 @@ namespace BaseGames.Editor _modules.Add(new FormModule()); _modules.Add(new BossSkillModule()); _modules.Add(new CharmModule()); + _modules.Add(new StreamingModule()); } // ── 布局 ───────────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs new file mode 100644 index 0000000..90b8b4f --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs @@ -0,0 +1,155 @@ +using System; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.World.Streaming; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 流式加载模块 —— 管理 资产。 + /// + public class StreamingModule : IDataModule + { + private const string Folder = "Assets/_Game/Data/Streaming"; + private const string Prefix = "STR_"; + + public string ModuleId => "streaming"; + public string DisplayName => "流式加载"; + public string IconName => "d_RectTransformBlueprint"; + + private SoListPane _listPane; + private DetailHeader _header; + private StreamingBudgetConfigSO _selected; + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + cfg => $"休眠上限 {cfg.MaxDormantRooms} 预加载深度 {cfg.PreloadLookaheadHops}跳"); + _listPane.SelectionChanged = sel => + { + _selected = sel; + }; + } + + public void BuildListPane(VisualElement container, Action onSelected) + { + _listPane.SelectionChanged = sel => + { + _selected = sel; + onSelected?.Invoke(sel); + }; + + // 顶部操作栏(新建) + var topBar = new VisualElement(); + topBar.style.flexDirection = FlexDirection.Row; + topBar.style.paddingLeft = 8; + topBar.style.paddingRight = 8; + topBar.style.paddingTop = 6; + topBar.style.paddingBottom = 6; + topBar.style.borderBottomWidth = 1; + topBar.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f)); + container.Add(topBar); + + var createBtn = new Button(() => + { + var created = AssetOperations.Create(Folder, "STR_BudgetConfig_New"); + if (created != null) _listPane.Refresh(created); + }) { text = "+ 新建配置" }; + createBtn.style.flexGrow = 1; + topBar.Add(createBtn); + + container.Add(_listPane); + _listPane.style.flexGrow = 1; + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as StreamingBudgetConfigSO; + + _header = new DetailHeader(); + _header.SetAsset(_selected); + _header.RenameRequested += newName => OnRenameRequested(selected, newName); + container.Add(_header); + + if (_selected == null) return; + + container.Add(BuildStatsCard(_selected)); + container.Add(BuildActionBar(_selected)); + container.Add(SkillModule.MakeDivider()); + container.Add(new InspectorElement(_selected)); + } + + public void OnActivated() => _listPane?.Refresh(); + + // ── Stats Card ──────────────────────────────────────────────────────── + + private static VisualElement BuildStatsCard(StreamingBudgetConfigSO cfg) + { + var card = SkillModule.MakeCard(); + SkillModule.AddChip(card, "最大休眠房间", $"{cfg.MaxDormantRooms}"); + SkillModule.AddChip(card, "内存上限", $"{cfg.MaxMemoryMB} MB"); + SkillModule.AddChip(card, "并发加载数", $"{cfg.MaxConcurrentLoads}"); + SkillModule.AddChip(card, "预加载跳数", $"{cfg.PreloadLookaheadHops}"); + SkillModule.AddChip(card, "冷却时长", $"{cfg.CoolingDuration:F1}s"); + SkillModule.AddChip(card, "每帧激活数", $"{cfg.LifecycleActivatePerFrame}"); + return card; + } + + // ── Action Bar ──────────────────────────────────────────────────────── + + private VisualElement BuildActionBar(StreamingBudgetConfigSO cfg) + { + var bar = SkillModule.MakeActionBar(); + + new Button(() => + { + EditorGUIUtility.PingObject(cfg); + Selection.activeObject = cfg; + }) { text = "定位" }.AlsoAddTo(bar); + + new Button(() => + { + var c = AssetOperations.Clone(cfg, Folder); + if (c != null) _listPane.Refresh(c); + }) { text = "克隆..." }.AlsoAddTo(bar); + + var del = new Button(() => + { + if (AssetOperations.Delete(cfg)) _listPane.Refresh(null); + }) { text = "删除" }; + ApplyDeleteStyle(del); + del.AlsoAddTo(bar); + + return bar; + } + + // ── 重命名 ──────────────────────────────────────────────────────────── + + private void OnRenameRequested(UnityEngine.Object asset, string newName) + { + var (ok, err) = AssetOperations.Rename(asset, newName); + if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定"); + else { _header.SetAsset(asset); _listPane.Invalidate(); } + } + + // ── 共用 ───────────────────────────────────────────────────────────── + + private static void ApplyDeleteStyle(Button btn) + { + var c = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); + btn.style.borderLeftColor = c; + btn.style.borderRightColor = c; + btn.style.borderTopColor = c; + btn.style.borderBottomColor = c; + btn.style.borderLeftWidth = 1; + btn.style.borderRightWidth = 1; + btn.style.borderTopWidth = 1; + btn.style.borderBottomWidth = 1; + btn.style.marginLeft = 8; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs index 28bbdcb..29c9449 100644 --- a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs +++ b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs @@ -12,6 +12,8 @@ using BaseGames.UI.MainMenu; using BaseGames.UI.Menus; using BaseGames.UI.Splash; using BaseGames.World; +using BaseGames.World.Map; +using BaseGames.World.Streaming; using PathBerserker2d; using Unity.Cinemachine; using UnityEditor; @@ -230,6 +232,9 @@ namespace BaseGames.Editor AddScaffoldNote(hudRootGo, "HUDController 已挂载。其内部图片/文本/图标 Prefab 依赖较多,需后续手工补 UI 资源与事件频道。", report); + // ── 流式加载系统 ────────────────────────────────────────────────── + ScaffoldStreamingSystem(services, report); + MarkDirtyAndLog("Persistent 场景脚手架", root, report); } @@ -392,6 +397,79 @@ namespace BaseGames.Editor MarkDirtyAndLog("Game Room 脚手架", root, report); } + // ───────────────────────────────────────────────────────────────────── + // 流式加载系统(RoomStreamingManager + TransitionDirector) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 在 [Services] 下创建或更新 SYS_RoomStreamingManager, + /// 挂载 , + /// 并自动绑定已存在的事件频道与配置资产。 + /// + private static void ScaffoldStreamingSystem(Transform services, List report) + { + // 预算配置 SO(不存在时自动创建) + StreamingBudgetConfigSO budgetConfig = EnsureStreamingBudgetConfigAsset(report); + + // MapDatabaseSO(查找已存在的资产) + Object mapDbAsset = FindFirstAssetByType("MapDatabase", "MAP_Database", "MapDatabaseSO"); + if (mapDbAsset == null) + report.Add("未找到 MapDatabaseSO 资产。请将 MapDatabaseSO 手工赋给 RoomStreamingManager._mapDatabase 与 TransitionDirector._mapDatabase。"); + + // ── SYS_RoomStreamingManager GameObject ────────────────────────── + GameObject streamingGo = GetOrCreateChild(services, "SYS_RoomStreamingManager").gameObject; + + RoomStreamingManager streamingMgr = GetOrAddComponent(streamingGo); + TransitionDirector transitionDir = GetOrAddComponent(streamingGo); + + // ── RoomStreamingManager 字段 ───────────────────────────────────── + AssignReference(streamingMgr, "_mapDatabase", mapDbAsset); + AssignReference(streamingMgr, "_budget", budgetConfig); + AssignAsset(streamingMgr, "_onRoomEntered", report, false, "EVT_RoomEntered"); + AssignAsset(streamingMgr, "_onRoomPreloaded", report, false, "EVT_RoomPreloaded"); + + // ── TransitionDirector 字段 ─────────────────────────────────────── + AssignReference(transitionDir, "_streamingManager", streamingMgr); + AssignReference(transitionDir, "_mapDatabase", mapDbAsset); + AssignReference(transitionDir, "_budget", budgetConfig); + AssignAsset(transitionDir, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest"); + AssignAsset(transitionDir, "_onFadeInRequest", report, false, "EVT_FadeInRequest"); + AssignAsset(transitionDir, "_onRegionNameDisplay", report, false, "EVT_RegionNameDisplay"); + AssignAsset(transitionDir, "_onSceneWorldStateRestored", report, false, "EVT_SceneWorldStateRestored"); + + report.Add("SYS_RoomStreamingManager:流式加载系统已创建。如 EVT_RoomEntered / EVT_RoomPreloaded 频道尚未存在,请通过 DataHub > Streaming 创建后重新运行脚手架。"); + } + + /// + /// 在 Assets/_Game/Data/Streaming/ 下确保默认预算配置 SO 存在。 + /// 已存在时直接返回;不存在时自动创建 STR_BudgetConfig_Default.asset。 + /// + private static StreamingBudgetConfigSO EnsureStreamingBudgetConfigAsset(List report) + { + // 先查找已有资产 + string[] guids = AssetDatabase.FindAssets("t:StreamingBudgetConfigSO"); + if (guids != null && guids.Length > 0) + { + string path = AssetDatabase.GUIDToAssetPath(guids[0]); + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null) + return existing; + } + + // 没有则创建默认资产 + const string folder = "Assets/_Game/Data/Streaming"; + const string assetPath = folder + "/STR_BudgetConfig_Default.asset"; + EnsureFolder(folder); + + var created = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(created, assetPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + report.Add($"已自动创建流式加载预算配置:{assetPath}。可在 DataHub > 流式加载 中编辑默认参数。"); + return created; + } + private static void AssignString(Object target, string propertyName, string value, List report = null) { SerializedObject serializedObject = new SerializedObject(target); diff --git a/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs b/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs new file mode 100644 index 0000000..06aed3b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs @@ -0,0 +1,37 @@ +#if GRAPH_DESIGNER +using System; + +namespace BaseGames.Enemies.AI +{ + /// + /// 指定 BD Task 在编辑器任务面板中显示的名称。 + /// 本版本 BehaviorDesigner 不内置此特性,由项目自行定义以保持代码可读性与前向兼容性。 + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class TaskNameAttribute : Attribute + { + public string Name { get; } + public TaskNameAttribute(string name) => Name = name; + } + + /// + /// 指定 BD Task 在编辑器任务面板中所属分类(路径形式,如 "BaseGames/Enemy/Combat")。 + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class TaskCategoryAttribute : Attribute + { + public string Category { get; } + public TaskCategoryAttribute(string category) => Category = category; + } + + /// + /// 为 BD Task 提供编辑器 Tooltip 描述文本。 + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class TaskDescriptionAttribute : Attribute + { + public string Description { get; } + public TaskDescriptionAttribute(string description) => Description = description; + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_Attack.cs b/Assets/_Game/Scripts/Enemies/AI/BD_Attack.cs index 814cf2b..5868f64 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_Attack.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_Attack.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; @@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI /// BD Action:发动攻击。 /// CanAttack() 检查通过后调用 BeginAttack,立即返回 Success。 /// + [TaskName("Attack")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("执行近战攻击(单段或连击序列)")] public class BD_Attack : Action { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs b/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs new file mode 100644 index 0000000..9f67d90 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs @@ -0,0 +1,54 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:启动 Boss 阶段过渡演出(无敌帧 + 可选定格),等待过渡完成后返回 Success。 + /// + /// 返回 Running:过渡演出进行中(BossBase.IsPhaseTransitioning = true)。 + /// 返回 Success:过渡完成,已切换到目标阶段。 + /// 返回 Failure:BossBase 组件不存在。 + /// + /// 典型 BT 用法: + /// Sequence [ BD_IsHPBelow(0.5) → BD_BossPhaseTransition(Phase=1, Duration=1.5) → ... ] + /// + [TaskName("Boss Phase Transition")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("触发 Boss 阶段过渡演出(无敌 + 动画 + 广播事件)")] + public class BD_BossPhaseTransition : Action + { + [Tooltip("切换到的目标阶段索引")] + [SerializeField] private int m_TargetPhase = 1; + + [Tooltip("无敌帧 + 过渡演出持续时间(秒)")] + [SerializeField, Min(0f)] private float m_InvincibleDuration = 1.5f; + + private BossBase _boss; + private bool _started; + + public override void OnAwake() => _boss = GetComponent(); + + public override void OnStart() + { + _started = false; + } + + public override TaskStatus OnUpdate() + { + if (_boss == null) return TaskStatus.Failure; + + if (!_started) + { + _boss.BeginPhaseTransition(m_TargetPhase, m_InvincibleDuration); + _started = true; + } + + return _boss.IsPhaseTransitioning ? TaskStatus.Running : TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs.meta new file mode 100644 index 0000000..c155ef5 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e86991de79acdb3498c9187ea96a8c3c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs b/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs new file mode 100644 index 0000000..52c38be --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs @@ -0,0 +1,39 @@ +#if GRAPH_DESIGNER +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using UnityEngine; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:向半径内其他敌人广播警戒(Group Alert)。 + /// + /// 返回 Success:广播完成(不保证命中任何敌人)。 + /// + /// 典型用法:在 BD_SetAiPhase(Chase) 之后立即调用此节点, + /// 使发现玩家的敌人将警报传播给周围同伴,实现群体索敌效果。 + /// + /// 半径优先使用 m_OverrideRadius;若为 0 则读取 EnemyStatsSO.AlertBroadcastRadius。 + /// + [TaskName("Broadcast Alert")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("向附近的友方广播警报,使其进入 Alert 状态")] + public class BD_BroadcastAlert : Action + { + [Tooltip("广播半径(m);0 = 使用 EnemyStatsSO.AlertBroadcastRadius")] + [SerializeField, Min(0f)] private float m_OverrideRadius = 0f; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + float r = m_OverrideRadius > 0f ? m_OverrideRadius : (_enemy.StatsSO?.AlertBroadcastRadius ?? 0f); + _enemy.AlertNearby(r); + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta new file mode 100644 index 0000000..039cb62 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93ffcd4596213bf4d8499aa565712213 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanAttack.cs b/Assets/_Game/Scripts/Enemies/AI/BD_CanAttack.cs index 2462b6a..5fabe82 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_CanAttack.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanAttack.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI /// /// BD Conditional:攻击冷却是否完毕(可以发动攻击)。 /// + [TaskName("Can Attack?")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("检查当前是否满足近战攻击条件(距离 + 视线)")] public class BD_CanAttack : Conditional { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs b/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs new file mode 100644 index 0000000..e9e1fb3 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs @@ -0,0 +1,35 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// 条件:目标坐标从当前位置是否可到达(调用 NavAgent.CanReach,无法寻路时 Failure)。 + /// 用于 Boss 阶段切换或小怪决策能否追击到跨平台目标。 + /// + [TaskName("Can Reach Target?")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("检查 NavAgent 是否可以规划到目标的有效路径")] + public sealed class BD_CanReachTarget : Conditional + { + [Tooltip("检查目标(留空则使用玩家位置)")] + [SerializeField] private Transform m_Target; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy?.Nav == null) return TaskStatus.Failure; + Vector2 pos = m_Target != null + ? (Vector2)m_Target.position + : _enemy.PlayerTransform != null ? (Vector2)_enemy.PlayerTransform.position : Vector2.zero; + return _enemy.Nav.CanReach(pos) ? TaskStatus.Success : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta new file mode 100644 index 0000000..962a6fa --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 436d3fd564880ae46a899b347f9a3494 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs new file mode 100644 index 0000000..6a76d6b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs @@ -0,0 +1,71 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// + /// 条件:abilityId 当前可用(冷却完毕且未在运行)。 + /// 可选开启范围、视线、脚踏地面等调度提示检查; + /// 仅当所有启用的条件均满足时才返回 Success。 + /// + [TaskName("Can Use Ability?")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("检查能力是否存在且冷却就绪,支持范围/视线/地面等调度提示检查")] + public sealed class BD_CanUseAbility : Conditional + { + [Tooltip("能力 ScriptableObject(优先级高于下方字符串 ID)")] + [SerializeField] private EnemyAbilitySO m_AbilitySO; + + [Tooltip("能力 ID(当 AbilitySO 未赋值时使用)")] + [SerializeField] private string m_AbilityId = ""; + + [Header("调度提示(可选)")] + [Tooltip("启用后,检查玩家是否在 AbilitySO.preferredRange 范围内")] + [SerializeField] private bool m_CheckRange = false; + + [Tooltip("启用后,检查 AbilitySO.requiresLineOfSight 是否满足视线条件")] + [SerializeField] private bool m_CheckLineOfSight = false; + + [Tooltip("启用后,检查 AbilitySO.requiresGrounded 是否满足落地条件")] + [SerializeField] private bool m_CheckGrounded = false; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + + string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId; + var ab = _enemy.Abilities.Get(id); + if (ab == null || !ab.CanUse) return TaskStatus.Failure; + + // ── 调度提示检查(均可在 Inspector 独立开关)────────────────────────── + var config = ab.Config; + if (config != null) + { + if (m_CheckGrounded && config.requiresGrounded && !(_enemy.Movement?.IsGrounded ?? false)) + return TaskStatus.Failure; + + if (m_CheckLineOfSight && config.requiresLineOfSight && !_enemy.IsPlayerVisible()) + return TaskStatus.Failure; + + if (m_CheckRange && _enemy.Stats != null) + { + float sqrDist = _enemy.Stats.SqrDistanceToPlayer; + float minSqr = config.preferredMinRange * config.preferredMinRange; + float maxSqr = config.preferredMaxRange * config.preferredMaxRange; + if (sqrDist < minSqr || sqrDist > maxSqr) + return TaskStatus.Failure; + } + } + + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta new file mode 100644 index 0000000..68538c6 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fbc6b3634e77bf40bfeb918c7e45e5f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs new file mode 100644 index 0000000..a840617 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs @@ -0,0 +1,41 @@ +#if GRAPH_DESIGNER +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using UnityEngine; +using BaseGames.Boss; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Conditional:检查 Boss 技能是否冷却就绪。 + /// 支持拖拽 BossSkillSO 或直接填写 skillId 字符串(SO 优先)。 + /// + [TaskName("Can Use Boss Skill?")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("检查指定 Boss 技能是否在当前阶段可用且冷却就绪")] + public class BD_CanUseBossSkill : Conditional + { + [SerializeField] private BossSkillSO m_SkillSO; + [SerializeField] private string m_SkillId; + + private BossBase _boss; + private BossSkillExecutor _executor; + + public override void OnAwake() + { + _boss = GetComponent(); + _executor = GetComponentInChildren(); + } + + public override TaskStatus OnUpdate() + { + if (_executor == null) return TaskStatus.Failure; + + string id = m_SkillSO != null ? m_SkillSO.skillId : m_SkillId; + if (string.IsNullOrEmpty(id)) return TaskStatus.Failure; + + return _executor.CanUseSkill(id) ? TaskStatus.Success : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta new file mode 100644 index 0000000..28c903c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb7c971f5193f174189337814487bd7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs new file mode 100644 index 0000000..5d8a95d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs @@ -0,0 +1,140 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:追击玩家(三阶段视线丢失模型)。 + /// + /// + /// Tracking:持有视线,以奔跑速度追击并更新 LastKnownPlayerPosition。 + /// Searching:视线丢失超过 LostSightBuffer(~0.3s)后进入。速度降低,向最后可见位置行进;若恢复视线即回到 Tracking。 + /// Lost:Searching 持续超过 LoseLinkTimeout → 返回 Failure,让 BD 树切入 BD_InvestigateLastKnown。 + /// + /// + /// 短暂遮挡(如绕过柱子)在 LostSightBuffer 内不会改变速度,避免追击手感割裂。 + /// + [TaskName("Chase Player")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("三段式追击(Tracking→Searching→Lost);丢失视线后进入缓冲期")] + public sealed class BD_ChasePlayer : Action + { + [Header("距离限制")] + [Tooltip("追击距离上限(m);0 = 使用 EnemyStatsSO.MaxChaseDistance")] + [SerializeField] [Min(0f)] private float m_MaxChaseDistance = 0f; + + [Header("视线丢失")] + [Tooltip("视线丢失判定超时(s);0 = 使用 EnemyStatsSO.LoseLinkTimeout")] + [SerializeField] [Min(0f)] private float m_LoseLinkTimeout = 0f; + + [Tooltip("视线刚丢失后的缓冲期(s):此期间保持追击速度,短暂遮挡不触发搜索模式")] + [SerializeField] [Min(0f)] private float m_LostSightBuffer = 0.3f; + + [Header("路径规划")] + [Tooltip("路径重规划阈值(m):玩家移动超过此距离后才重新规划,避免每帧请求")] + [SerializeField] [Min(0.1f)] private float m_ReplanThreshold = 1.5f; + + [Header("搜索阶段")] + [Tooltip("Searching 阶段速度倍率(相对于行走速度;<1 = 减速搜索)")] + [SerializeField] [UnityEngine.Range(0.3f, 1.5f)] private float m_SearchSpeedMultiplier = 0.7f; + + private enum ChaseSubState { Tracking, Searching } + + private EnemyBase _enemy; + private float _losTimer; + private Vector2 _lastReplanPos; + private ChaseSubState _subState; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() + { + _enemy.SetAiPhase(AiPhase.Chase); + _losTimer = 0f; + _lastReplanPos = Vector2.positiveInfinity; + _subState = ChaseSubState.Tracking; + + float runSpeed = _enemy.Stats?.RunSpeed ?? 4f; + _enemy.Nav?.SetSpeed(runSpeed); + + var ac = _enemy.AnimConfig; + if (_enemy.Animancer != null && ac?.Run != null) + _enemy.Animancer.Play(ac.Run); + } + + public override TaskStatus OnUpdate() + { + if (_enemy == null || _enemy.PlayerTransform == null) + return TaskStatus.Failure; + + float maxDist = m_MaxChaseDistance > 0f ? m_MaxChaseDistance : (_enemy.Stats?.MaxChaseDistance ?? 15f); + float loseTime = m_LoseLinkTimeout > 0f ? m_LoseLinkTimeout : (_enemy.Stats?.LoseLinkTimeout ?? 2f); + + // 超出最大追击距离 → 放弃追击 + if (_enemy.Stats != null && _enemy.Stats.SqrDistanceToPlayer > maxDist * maxDist) + return TaskStatus.Failure; + + Vector2 playerPos = _enemy.PlayerTransform.position; + + if (_enemy.IsPlayerVisible()) + { + // 视线恢复:Searching → Tracking,恢复奔跑速度 + if (_subState == ChaseSubState.Searching) + { + _subState = ChaseSubState.Tracking; + _enemy.Nav?.SetSpeed(_enemy.Stats?.RunSpeed ?? 4f); + } + _losTimer = 0f; + _enemy.LastKnownPlayerPosition = playerPos; + } + else + { + _losTimer += Time.deltaTime; + + if (_subState == ChaseSubState.Tracking) + { + // 缓冲期结束 → 切入 Searching:减速并向最后可见位置行进 + if (_losTimer >= m_LostSightBuffer) + { + _subState = ChaseSubState.Searching; + float searchSpeed = (_enemy.Stats?.WalkSpeed ?? 2f) * m_SearchSpeedMultiplier; + _enemy.Nav?.SetSpeed(searchSpeed); + _enemy.MoveTo(_enemy.LastKnownPlayerPosition); + _lastReplanPos = _enemy.LastKnownPlayerPosition; + } + } + else // Searching + { + if (_losTimer >= loseTime) + return TaskStatus.Failure; // 搜索超时 → 进入 BD_InvestigateLastKnown + } + } + + // Tracking 阶段按阈值重规划路径 + if (_subState == ChaseSubState.Tracking) + { + float sqrReplan = m_ReplanThreshold * m_ReplanThreshold; + if ((playerPos - _lastReplanPos).sqrMagnitude > sqrReplan) + { + _enemy.MoveTo(playerPos); + _lastReplanPos = playerPos; + } + } + + _enemy.FacePlayer(); + return TaskStatus.Running; + } + + public override void OnEnd() + { + float walkSpeed = _enemy?.Stats?.WalkSpeed ?? 2f; + _enemy?.Nav?.SetSpeed(walkSpeed); + _enemy?.StopMovement(); + _subState = ChaseSubState.Tracking; // 重置子状态,防止下次激活时以错误状态重入 + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta new file mode 100644 index 0000000..1926539 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a7ecfbdf1fee6a141a18c72d36fcfbf2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_EnterPhase.cs b/Assets/_Game/Scripts/Enemies/AI/BD_EnterPhase.cs index f285580..609b7ef 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_EnterPhase.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_EnterPhase.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; @@ -9,22 +9,26 @@ namespace BaseGames.Enemies.AI /// BD Action:Boss 切换阶段(Phase)。 /// 调用 BossBase.EnterPhase(PhaseIndex),单帧完成,返回 Success。 /// + [TaskName("Enter Boss Phase")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("立即切换 Boss 到目标阶段序号(单帧完成)")] public class BD_EnterPhase : Action { [UnityEngine.SerializeField] private int m_PhaseIndex = 1; private BossBase _boss; + public override void OnAwake() => _boss = GetComponent(); + public override void OnStart() { - _boss = GetComponent(); + // 阶段切换是单帧操作,在 OnStart 完成;OnUpdate 仅汇报结果 + _boss?.EnterPhase(m_PhaseIndex); } public override TaskStatus OnUpdate() { - if (_boss == null) return TaskStatus.Failure; - _boss.EnterPhase(m_PhaseIndex); - return TaskStatus.Success; + return _boss == null ? TaskStatus.Failure : TaskStatus.Success; } } } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_FaceTarget.cs b/Assets/_Game/Scripts/Enemies/AI/BD_FaceTarget.cs index 161f326..4a08e12 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_FaceTarget.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_FaceTarget.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -9,21 +9,18 @@ namespace BaseGames.Enemies.AI /// /// BD Action:立即朝向玩家,单帧完成,返回 Success。 /// + [TaskName("Face Target")] + [TaskCategory("BaseGames/Enemy/Animation")] + [TaskDescription("立即朝向目标 Transform 或玩家,单帧返回 Success")] public class BD_FaceTarget : Action { - private EnemyBase _enemy; - private Transform _player; + private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - var go = GameObject.FindWithTag("Player"); - _player = go != null ? go.transform : null; - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { - if (_enemy == null || _player == null) return TaskStatus.Failure; + if (_enemy == null) return TaskStatus.Failure; _enemy.FacePlayer(); return TaskStatus.Success; } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs b/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs new file mode 100644 index 0000000..062a6af --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs @@ -0,0 +1,32 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// 动作:中断指定能力。 + [TaskName("Interrupt Ability")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("立即中断指定能力或全部能力")] + public sealed class BD_InterruptAbility : Action + { + [SerializeField] private string m_AbilityId = ""; + [SerializeField] private InterruptReason m_Reason = InterruptReason.ExternalRequest; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + var ab = _enemy.Abilities.Get(m_AbilityId); + if (ab == null) return TaskStatus.Failure; + ab.Interrupt(m_Reason); + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta new file mode 100644 index 0000000..4db9b64 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3db7ca634cc20574dba7f11ab018b23a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs b/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs new file mode 100644 index 0000000..7392666 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs @@ -0,0 +1,184 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:多步搜查(Navigate → LookAround → RandomWalk × N)。 + /// + /// + /// Navigate:导航至最后可见位置。 + /// LookAround:到达后原地环顾(播放 Look 动画,持续 LookAroundDuration)。 + /// WalkRandom:向搜查半径内的随机点移动,重复 RandomStepCount 次。 + /// 任意阶段重新发现玩家 → 返回 Failure,BD 树重入追击。 + /// 所有步骤完成仍未发现 → 返回 Success,BD 树归位/恢复巡逻。 + /// + /// + [TaskName("Investigate Last Known Pos")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("多步搜查:导航到最后已知位置 → 环顾四周 → 随机游走 N 次")] + public sealed class BD_InvestigateLastKnown : Action + { + [Tooltip("到达搜查点后环顾的时长(s)")] + [SerializeField] private float m_LookAroundDuration = 0.8f; + + [Tooltip("随机行走步数(完成环顾后随机游荡的次数)")] + [SerializeField] [Min(0)] private int m_RandomStepCount = 2; + + [Tooltip("随机行走半径(m)")] + [SerializeField] private float m_SearchRadius = 2.5f; + + [Tooltip("到达目标点的判定半径(m)")] + [SerializeField] private float m_ArriveRadius = 0.6f; + + private enum InvestigateSubStep { Navigate, LookAround, WalkRandom } + + private EnemyBase _enemy; + private InvestigateSubStep _step; + private float _stepTimer; + private int _stepsRemaining; + private Vector2 _randomTarget; + private bool _pathFailed; + private bool _subscribed; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() + { + _enemy.SetAiPhase(AiPhase.Investigate); + _step = InvestigateSubStep.Navigate; + _stepTimer = 0f; + _stepsRemaining = m_RandomStepCount; + _pathFailed = false; + + var ac = _enemy.AnimConfig; + if (_enemy.Animancer != null) + { + var clip = ac?.Investigate ?? ac?.Walk; + if (clip != null) _enemy.Animancer.Play(clip); + } + + if (!_subscribed && _enemy.Nav != null) + { + _enemy.Nav.OnNavPathFailed += HandlePathFailed; + _subscribed = true; + } + + _enemy.MoveTo(_enemy.LastKnownPlayerPosition); + } + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + + if (_enemy.IsPlayerVisible()) return TaskStatus.Failure; + + switch (_step) + { + case InvestigateSubStep.Navigate: return UpdateNavigate(); + case InvestigateSubStep.LookAround: return UpdateLookAround(); + case InvestigateSubStep.WalkRandom: return UpdateWalkRandom(); + default: return TaskStatus.Success; + } + } + + public override void OnEnd() + { + if (_subscribed && _enemy?.Nav != null) + { + _enemy.Nav.OnNavPathFailed -= HandlePathFailed; + _subscribed = false; + } + _enemy?.StopMovement(); + } + + // ── 阶段逻辑 ──────────────────────────────────────────────────── + private TaskStatus UpdateNavigate() + { + Vector2 self = _enemy.transform.position; + bool arrived = _pathFailed || + (self - _enemy.LastKnownPlayerPosition).sqrMagnitude <= m_ArriveRadius * m_ArriveRadius; + + if (!arrived) return TaskStatus.Running; + + _enemy.StopMovement(); + EnterLookAround(); + return TaskStatus.Running; + } + + private TaskStatus UpdateLookAround() + { + _stepTimer += Time.deltaTime; + if (_stepTimer < m_LookAroundDuration) return TaskStatus.Running; + + if (_stepsRemaining > 0) + EnterRandomWalk(); + else + return TaskStatus.Success; + + return TaskStatus.Running; + } + + private TaskStatus UpdateWalkRandom() + { + Vector2 self = _enemy.transform.position; + bool arrived = _pathFailed || + (self - _randomTarget).sqrMagnitude <= m_ArriveRadius * m_ArriveRadius; + + if (!arrived) return TaskStatus.Running; + + _enemy.StopMovement(); + _stepsRemaining--; + _pathFailed = false; + + if (_stepsRemaining > 0) + EnterRandomWalk(); + else + return TaskStatus.Success; + + return TaskStatus.Running; + } + + // ── 辅助方法 ──────────────────────────────────────────────────── + private void EnterLookAround() + { + _step = InvestigateSubStep.LookAround; + _stepTimer = 0f; + + var ac = _enemy.AnimConfig; + if (_enemy.Animancer != null) + { + var clip = ac?.Investigate ?? ac?.Idle; + if (clip != null) _enemy.Animancer.Play(clip); + } + } + + private void EnterRandomWalk() + { + _step = InvestigateSubStep.WalkRandom; + _pathFailed = false; + + Vector2 origin = _enemy.LastKnownPlayerPosition; + // 横板地形中只在水平方向随机偏移,保留 origin.y; + // PathBerserker2d 寻路负责处理跨平台的纵向路径规划。 + float dir = Random.value > 0.5f ? 1f : -1f; + float dist = Random.Range(0.5f, m_SearchRadius); + _randomTarget = new Vector2(origin.x + dir * dist, origin.y); + + _enemy.MoveTo(_randomTarget); + + var ac = _enemy.AnimConfig; + if (_enemy.Animancer != null) + { + var clip = ac?.Walk; + if (clip != null) _enemy.Animancer.Play(clip); + } + } + + private void HandlePathFailed() => _pathFailed = true; + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs.meta new file mode 100644 index 0000000..1c7d3eb --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2e43db9982a49864e95812919b0efd10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs new file mode 100644 index 0000000..7c1c690 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs @@ -0,0 +1,34 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// 条件:abilityId 当前是否正在运行。 + [TaskName("Is Ability Running?")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("检查指定能力当前是否正在执行中")] + public sealed class BD_IsAbilityRunning : Conditional + { + [Tooltip("能力 ScriptableObject(优先级高于下方字符串 ID)")] + [SerializeField] private EnemyAbilitySO m_AbilitySO; + + [Tooltip("能力 ID(当 AbilitySO 未赋值时使用)")] + [SerializeField] private string m_AbilityId = ""; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId; + var ab = _enemy.Abilities.Get(id); + return ab != null && ab.IsRunning ? TaskStatus.Success : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta new file mode 100644 index 0000000..d6b38a4 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 254e8e3d6b56229498e94e24e7e53393 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs new file mode 100644 index 0000000..4691e11 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs @@ -0,0 +1,36 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Conditional:当前 AI 行为阶段是否与目标匹配。 + /// + /// 常用于 Decorator Conditional Abort 或 Sequence 头部保护子树, + /// 例如只有在 阶段时才执行巡逻序列。 + /// + [TaskName("Is Ai Phase?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查当前 AI 行为阶段是否与目标枚举值匹配")] + public sealed class BD_IsAiPhase : Conditional + { + [Tooltip("目标 AI 行为阶段")] + [SerializeField] private AiPhase m_Phase = AiPhase.Patrol; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + return _enemy.CurrentAiPhase == m_Phase + ? TaskStatus.Success + : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta new file mode 100644 index 0000000..3761d88 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5d65c8544c21a146a5f30140acdb5ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsGrounded.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsGrounded.cs index 0050283..1ec23fb 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsGrounded.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsGrounded.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI /// /// BD Conditional:敌人是否处于地面。 /// + [TaskName("Is Grounded?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查敌人是否接地(适用于触发跳跃或落地逻辑的条件保护)")] public class BD_IsGrounded : Conditional { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsHPBelow.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsHPBelow.cs index 2502f16..b9249d6 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsHPBelow.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsHPBelow.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -9,22 +9,24 @@ namespace BaseGames.Enemies.AI /// BD Conditional:Boss HP 是否低于阈值(用于触发阶段切换)。 /// HPThreshold 为 0~1 归一化比例(如 0.5 = HP ≤ 50%)。 /// + [TaskName("Is HP Below Threshold?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查当前 HP 是否低于指定百分比阈值")] public class BD_IsHPBelow : Conditional { + [UnityEngine.Range(0f, 1f)] [UnityEngine.SerializeField] private float m_HPThreshold = 0.5f; private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { if (_enemy == null || _enemy.Stats == null) return TaskStatus.Failure; - float ratio = (float)_enemy.Stats.CurrentHP / _enemy.Stats.MaxHP; + float maxHP = UnityEngine.Mathf.Max(1f, _enemy.Stats.MaxHP); + float ratio = (float)_enemy.Stats.CurrentHP / maxHP; return ratio <= m_HPThreshold ? TaskStatus.Success : TaskStatus.Failure; } } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs index 0fd568f..d4f2934 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI /// BD Conditional:敌人是否靠近平台边缘。 /// 通过 EnemyNavAgent.IsNearEdge() 检测(双射线检测脚下/前方地面)。 /// + [TaskName("Is Near Edge?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查前方是否有悬崖边缘(基于 SensorToolkit 或 Raycast)")] public class BD_IsNearEdge : Conditional { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs new file mode 100644 index 0000000..979c182 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs @@ -0,0 +1,35 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// 条件:NavAgent 当前是否正在穿越连接段(跳跃/下落/爬梯/传送等)。 + /// 可选过滤具体连接类型(填 None = 任意类型均匹配)。 + /// + [TaskName("Is On NavLink?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查 NavAgent 当前是否正在穿越 NavLink(如跳跃传送门)")] + public sealed class BD_IsOnNavLink : Conditional + { + [Tooltip("填 None 匹配任意类型;填具体类型则只在该类型时 Success。")] + [SerializeField] private NavLinkType m_FilterType = NavLinkType.None; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy?.Nav == null) return TaskStatus.Failure; + if (!_enemy.Nav.IsOnLink) return TaskStatus.Failure; + if (m_FilterType != NavLinkType.None && _enemy.Nav.CurrentLinkType != m_FilterType) + return TaskStatus.Failure; + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta new file mode 100644 index 0000000..04213a6 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44406bb7e06320746950682adf59f59c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerInRange.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerInRange.cs index 1b7e5b5..bdf010d 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerInRange.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerInRange.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI /// BD Conditional:检查玩家是否在指定范围内。 /// 成功/失败直接驱动 BT 分支选择(Selector / Sequence 节点)。 /// + [TaskName("Is Player In Range?")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("检查玩家是否在指定距离内")] public class BD_IsPlayerInRange : Conditional { /// 检测范围(Inspector 可配置,默认 6 米)。 @@ -16,10 +19,7 @@ namespace BaseGames.Enemies.AI private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs index 3d0b751..d2ada2e 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; using BaseGames.Enemies; @@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI /// BD Conditional:玩家是否可见(LOS 检测)。 /// 读取 EnemyBase._losResult 字段(由 BatchLOSSystem 每帧写入,或降级 3 帧节流 Raycast)。 /// + [TaskName("Is Player Visible?")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("检查是否有视线到达玩家(通过 EnemyBase.HasLineOfSight)")] public class BD_IsPlayerVisible : Conditional { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs new file mode 100644 index 0000000..0681028 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs @@ -0,0 +1,41 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies.Perception; + +namespace BaseGames.Enemies.AI +{ + /// + /// 条件:EnemySensorHub 中名为 slotName 的 Sensor 是否检测到目标。 + /// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。 + /// + [TaskName("Is Sensor Detecting?")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("检查 EnemySensorHub 中指定 Sensor 槽是否检测到目标")] + public sealed class BD_IsSensorDetecting : Conditional + { + [SerializeField] private string m_SlotName = "aggro"; + [SerializeField] private bool m_AnyTarget = false; + + private EnemySensorHub _hub; + private EnemyBase _enemy; + + public override void OnAwake() + { + _hub = gameObject.GetComponent(); + _enemy = gameObject.GetComponent(); + } + + public override TaskStatus OnUpdate() + { + if (_hub == null) return TaskStatus.Failure; + if (m_AnyTarget) + return _hub.HasAnyDetection(m_SlotName) ? TaskStatus.Success : TaskStatus.Failure; + var tgt = _enemy != null ? _enemy.PlayerTransform : null; + if (tgt == null) return TaskStatus.Failure; + return _hub.IsDetecting(m_SlotName, tgt.gameObject) ? TaskStatus.Success : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta new file mode 100644 index 0000000..bc54b49 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a1015b63dbb3da4aa877d7e898b394a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsStateMatch.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsStateMatch.cs index 8c572f8..ba38ab9 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsStateMatch.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsStateMatch.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; @@ -7,36 +7,23 @@ using BaseGames.Enemies; namespace BaseGames.Enemies.AI { /// - /// BD Conditional:敌人当前 EnemyStateType 是否与目标状态名称匹配。 - /// TargetStateName 直接输入枚举名称字符串(Controlled / Hurt / Stagger / Dead), - /// 枚举值顺序变化时 BD 图不会静默失效。 + /// BD Conditional:敌人当前 EnemyStateType 是否与目标状态匹配。 /// + [TaskName("Is State Match?")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("检查当前物理/战斗状态是否与目标枚举值匹配")] public class BD_IsStateMatch : Conditional { - /// 目标状态名称(Controlled / Hurt / Stagger / Dead)。 - [SerializeField] private string m_TargetStateName = "Controlled"; + [SerializeField] private EnemyStateType m_TargetState = EnemyStateType.Controlled; private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { if (_enemy == null) return TaskStatus.Failure; - - if (!System.Enum.TryParse(m_TargetStateName, out var target)) - { - Debug.LogError($"[BD_IsStateMatch] 未知状态名: '{m_TargetStateName}'," + - "有效值为 Controlled / Hurt / Stagger / Dead", gameObject); - return TaskStatus.Failure; - } - - return _enemy.CurrentState == target - ? TaskStatus.Success - : TaskStatus.Failure; + return _enemy.CurrentState == m_TargetState ? TaskStatus.Success : TaskStatus.Failure; } } } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_JumpTo.cs b/Assets/_Game/Scripts/Enemies/AI/BD_JumpTo.cs index 03447d3..d46d2b3 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_JumpTo.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_JumpTo.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI /// BD Action:跳跃至目标坐标(抛物线跳跃)。 /// 调用 EnemyBase.JumpTo,待落地(IsGrounded)后返回 Success。 /// + [TaskName("Jump To")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("执行跳跃动作到达目标高度或 NavLink 对接点")] public class BD_JumpTo : Action { [SerializeField] private Vector2 m_Target; @@ -17,9 +20,10 @@ namespace BaseGames.Enemies.AI private EnemyBase _enemy; private bool _jumped; + public override void OnAwake() => _enemy = GetComponent(); + public override void OnStart() { - _enemy = GetComponent(); _jumped = false; } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs b/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs new file mode 100644 index 0000000..8358442 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs @@ -0,0 +1,96 @@ +#if GRAPH_DESIGNER +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using UnityEngine; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:战斗站位控制。 + /// 在 [m_PreferredMinDist, m_PreferredMaxDist] 范围内保持与玩家的水平距离: + /// - 距离过近 → 后退 + /// - 距离过远 → 靠近 + /// - 在范围内 → 停止移动,持续朝向玩家 + /// 始终返回 Running,上层 BT 通过 Abort 或条件终止该 Task。 + /// + [TaskName("Maintain Combat Distance")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("在战斗中维持与目标的理想距离范围")] + public class BD_MaintainCombatDistance : Action + { + [Tooltip("期望最小距离(m);小于此值开始后退")] + [SerializeField] private float m_PreferredMinDist = 2f; + + [Tooltip("期望最大距离(m);大于此值开始靠近")] + [SerializeField] private float m_PreferredMaxDist = 4f; + + [Tooltip("后退速度(m/s),默认使用 WalkSpeed")] + [SerializeField] private float m_BackpedaleSpeed = 0f; + + [Tooltip("每帧重新规划路径的阈值(m);玩家移动超过此距离才重规划,降低频率")] + [SerializeField] private float m_ReplanThreshold = 0.5f; + + private EnemyBase _enemy; + private Vector2 _lastPlayerPos; + private float _sqrMin; + private float _sqrMax; + + public override void OnAwake() => _enemy = GetComponent(); + + public override void OnStart() + { + _sqrMin = m_PreferredMinDist * m_PreferredMinDist; + _sqrMax = m_PreferredMaxDist * m_PreferredMaxDist; + _lastPlayerPos = Vector2.positiveInfinity; + } + + public override TaskStatus OnUpdate() + { + if (_enemy == null || _enemy.PlayerTransform == null) + return TaskStatus.Failure; + + if (_enemy.Stats == null) + return TaskStatus.Failure; + + _enemy.FacePlayer(); + + float sqrDist = _enemy.Stats.SqrDistanceToPlayer; + + if (sqrDist < _sqrMin) + { + // 距离过近 → 后退(向远离玩家方向移动) + _enemy.Nav?.StopNavigation(); + Vector2 toPlayer = ((Vector2)_enemy.PlayerTransform.position - (Vector2)_enemy.transform.position).normalized; + float backDir = -Mathf.Sign(toPlayer.x); + float speed = m_BackpedaleSpeed > 0f ? m_BackpedaleSpeed : _enemy.Stats.WalkSpeed; + _enemy.Movement?.MoveWithSpeed(backDir, speed); + } + else if (sqrDist > _sqrMax) + { + // 距离过远 → 靠近 + Vector2 playerPos = _enemy.PlayerTransform.position; + float moved = ((Vector2)playerPos - _lastPlayerPos).sqrMagnitude; + if (moved > m_ReplanThreshold * m_ReplanThreshold) + { + _lastPlayerPos = playerPos; + _enemy.MoveTo(playerPos); + } + } + else + { + // 在最优范围内 → 停止导航,原地保持朝向 + _enemy.Nav?.StopNavigation(); + _enemy.Movement?.StopHorizontal(); + } + + return TaskStatus.Running; + } + + public override void OnEnd() + { + _enemy?.Movement?.StopHorizontal(); + _enemy?.Nav?.StopNavigation(); + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs.meta new file mode 100644 index 0000000..855cb92 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 480875a7bad333140a3c46e637eb7bc6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MoveTo.cs b/Assets/_Game/Scripts/Enemies/AI/BD_MoveTo.cs index 168aee5..6cc71bb 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_MoveTo.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MoveTo.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -10,16 +10,16 @@ namespace BaseGames.Enemies.AI /// BD Action:移动到指定世界坐标 Target。 /// 到达目标(IsAtDestination)后返回 Success。 /// + [TaskName("Move To")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("导航到目标 Transform 或世界坐标点;到达返回 Success")] public class BD_MoveTo : Action { [SerializeField] private Vector2 m_Target; private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs new file mode 100644 index 0000000..add3c6f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs @@ -0,0 +1,58 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// 动作:移动到目标并等待 OnGoalReached 事件(事件驱动,替代轮询版 BD_MoveTo)。 + /// OnGoalReached 触发后返回 Success;路径失败返回 Failure。 + /// + [TaskName("Move To And Wait")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("导航到目标点,到达后原地等待指定时长,然后返回 Success")] + public sealed class BD_MoveToAndWait : Action + { + [SerializeField] private Transform m_Target; + + private EnemyBase _enemy; + private bool _reached; + private bool _failed; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() + { + _reached = _failed = false; + if (_enemy?.Nav == null) { _failed = true; return; } + _enemy.Nav.OnGoalReached += HandleReached; + _enemy.Nav.OnNavPathFailed += HandleFailed; + var pos = m_Target != null ? (Vector2)m_Target.position + : _enemy.PlayerTransform != null ? (Vector2)_enemy.PlayerTransform.position + : (Vector2)transform.position; + _enemy.Nav.RequestMoveTo(pos); + } + + public override TaskStatus OnUpdate() + { + if (_failed) return TaskStatus.Failure; + if (_reached) return TaskStatus.Success; + return TaskStatus.Running; + } + + public override void OnEnd() + { + if (_enemy?.Nav != null) + { + _enemy.Nav.OnGoalReached -= HandleReached; + _enemy.Nav.OnNavPathFailed -= HandleFailed; + } + } + + private void HandleReached() => _reached = true; + private void HandleFailed() => _failed = true; + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta new file mode 100644 index 0000000..8ee544e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df51ca671ebccea47a45c1e1bca3caa3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_MoveToPlayer.cs b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToPlayer.cs index 90643de..33b775e 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_MoveToPlayer.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_MoveToPlayer.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; @@ -10,14 +10,14 @@ namespace BaseGames.Enemies.AI /// OnStart 从 EnemyBase.PlayerTransform 获取玩家引用(由 _onPlayerSpawned 事件缓存, /// 避免 FindWithTag 全场景扫描)。OnUpdate 每帧更新导航目标。 /// + [TaskName("Move To Player")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("持续向玩家方向移动,失去视线或超出追踪距离返回 Failure")] public class BD_MoveToPlayer : Action { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs b/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs new file mode 100644 index 0000000..3483f2c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs @@ -0,0 +1,30 @@ +#if GRAPH_DESIGNER +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Conditional Task:检查敌人本次 BT Tick 内是否发生了弹反事件。 + /// + /// 返回 Success 时同时清除标志(每次弹反只触发一次响应分支)。 + /// 典型用法:在 BD 树根部优先分支放置此条件,触发时执行受击硬直 / 反制动画等子树。 + /// + [TaskName("On Parried?")] + [TaskCategory("BaseGames/Enemy/Utility")] + [TaskDescription("检查上一次攻击是否被玩家格挡(用于 Boss 反制逻辑)")] + public class BD_OnParried : Conditional + { + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + return _enemy.ConsumeParryEvent() ? TaskStatus.Success : TaskStatus.Failure; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta new file mode 100644 index 0000000..be1a6b9 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fdae429711fc46d41a7b025eed43784f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs b/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs index f992062..5f88924 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs @@ -1,32 +1,57 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER +using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; -using UnityEngine; +using BaseGames.Enemies.Perception; namespace BaseGames.Enemies.AI { /// - /// BD Action:敌人巡逻行为。 - /// 持续令敌人向当前朝向移动,遇墙/边缘时自动转向。 + /// BD Action:来回踱步巡逻——持续向当前方向移动,遇墙或悬崖时自动翻转方向。 + /// + /// 若需要按预设路点顺序巡逻,请使用 (支持 Transform 引用和内联坐标)。 + /// + /// 转向检测优先级: + /// + /// EnemySensorHub "wall_ahead" / "ledge" 槽(SensorToolkit,已配置时使用) + /// Physics2D Raycast 兜底(Prefab 未配置 Sensor 时自动启用) + /// /// + [TaskName("Patrol (Pace)")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(SensorToolkit 优先)")] public class BD_Patrol : Action { - [Tooltip("检测地面边缘的向下射线长度")] + [Tooltip("(兜底)检测地面边缘的向下射线长度(m)")] public float edgeCheckLength = 1.2f; - [Tooltip("检测障碍物的水平射线长度")] + [Tooltip("(兜底)检测障碍物的水平射线长度(m)")] public float wallCheckLength = 0.4f; - [Tooltip("地面/墙壁 LayerMask")] + [Tooltip("(兜底)边缘检测射线起点相对角色的前向偏移(m)")] + public float edgeCheckFwdOffset = 0.3f; + [Tooltip("(兜底)边缘检测射线起点相对角色的向下偏移(m)")] + public float edgeCheckDownOffset = 0.1f; + [Tooltip("(兜底)地面/墙壁 LayerMask")] public LayerMask groundLayer; - private EnemyBase _enemy; - private float _dir = 1f; + private EnemyBase _enemy; + private EnemySensorHub _hub; + private float _dir = 1f; - public override void OnStart() + // 缓存:SensorHub 中对应槽位是否已配置(Awake 时查询一次,避免每帧 Dictionary 查找) + private bool _hasWallSensor; + private bool _hasEdgeSensor; + + public override void OnAwake() { - _enemy = GetComponent(); + _enemy = GetComponent(); + _hub = GetComponent(); + _hasWallSensor = _hub != null && _hub.Get(SensorSlotNames.WallAhead) != null; + _hasEdgeSensor = _hub != null && _hub.Get(SensorSlotNames.Ledge) != null; } + public override void OnStart() => _enemy?.SetAiPhase(AiPhase.Patrol); + public override TaskStatus OnUpdate() { if (_enemy == null) return TaskStatus.Failure; @@ -38,27 +63,33 @@ namespace BaseGames.Enemies.AI return TaskStatus.Running; } - public override void OnEnd() - { - _enemy?.StopMovement(); - } + public override void OnEnd() => _enemy?.StopMovement(); private bool ShouldFlip() { - Transform t = _enemy.transform; - Vector2 pos = t.position; + if (_hub != null) + { + // 有传感器配置:用传感器结果,完全跳过 Raycast + if (_hasWallSensor || _hasEdgeSensor) + { + bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead); + bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge); + return wallHit || edgeHit; + } + } - // 前方边缘检测:在脚前方向下射线,若无地面则转向 - Vector2 edgeOrigin = pos + Vector2.right * (_dir * 0.3f) + Vector2.down * 0.1f; + // Raycast 兜底:仅在未配置 Sensor 时执行 + Transform t = _enemy.transform; + Vector2 pos = t.position; + + Vector2 edgeOrigin = pos + Vector2.right * (_dir * edgeCheckFwdOffset) + Vector2.down * edgeCheckDownOffset; bool hasGround = Physics2D.Raycast(edgeOrigin, Vector2.down, edgeCheckLength, groundLayer); if (!hasGround) return true; - // 前方障碍检测:水平射线,若撞墙则转向 bool hitWall = Physics2D.Raycast(pos, Vector2.right * _dir, wallCheckLength, groundLayer); - if (hitWall) return true; - - return false; + return hitWall; } } } #endif + diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs b/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs new file mode 100644 index 0000000..b45d1f5 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs @@ -0,0 +1,185 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:按预设路点顺序巡逻(支持 Transform 引用或内联 Vector2 坐标两种模式)。 + /// + /// + /// m_Waypoints(Transform[]):拖入场景中的路点对象;适合动态路点(可在运行时移动)。 + /// m_InlineWaypoints(Vector2[]):直接填写世界坐标;无需在场景中放置对象,编辑器中以 Gizmo 可视化路径。 + /// 两者同时设置时 m_Waypoints 优先。 + /// + /// + /// 到达最后一个路点后可循环(Loop)或往返(PingPong)。 + /// 与 PathBerserker2d 集成:通过 IPathAgent.RequestMoveTo 导航,支持跨平台跳跃 NavLink。 + /// + [TaskName("Patrol (Waypoints)")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("按预设路点顺序巡逻;支持 Transform 引用或内联 Vector2 坐标,可循环或折返")] + public sealed class BD_PatrolWaypoints : Action + { + [Tooltip("路点列表(世界空间 Transform);与 m_InlineWaypoints 同时设置时此项优先")] + [SerializeField] private Transform[] m_Waypoints; + + [Tooltip("内联路点坐标(世界空间 Vector2);m_Waypoints 为空时使用;在 Scene 视图中以绿色 Gizmo 可视化")] + [SerializeField] private Vector2[] m_InlineWaypoints; + + [Tooltip("到达路点的判定半径(m)")] + [SerializeField] private float m_ArriveRadius = 0.3f; + + [Tooltip("true = 往返; false = 循环")] + [SerializeField] private bool m_PingPong = false; + + [Tooltip("每个路点到达后等待时长(s)")] + [SerializeField] private float m_WaitAtWaypoint = 0f; + + private EnemyBase _enemy; + private int _index = 0; + private int _dir = 1; + private float _waitTimer = 0f; + private bool _waiting = false; + + // ── 统一路点访问 ──────────────────────────────────────────────────── + private int WaypointCount => + m_Waypoints != null && m_Waypoints.Length > 0 + ? m_Waypoints.Length + : m_InlineWaypoints?.Length ?? 0; + + private Vector2 GetWaypoint(int index) + { + if (m_Waypoints != null && m_Waypoints.Length > 0) + { + var t = m_Waypoints[index]; + return t != null ? (Vector2)t.position : (Vector2)transform.position; + } + return m_InlineWaypoints != null && index < m_InlineWaypoints.Length + ? m_InlineWaypoints[index] + : (Vector2)transform.position; + } + + // ── BD 生命周期 ───────────────────────────────────────────────────── + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() + { + if (WaypointCount == 0) return; + _waiting = false; + _waitTimer = 0f; + _enemy?.SetAiPhase(AiPhase.Patrol); + RequestCurrent(); + } + + public override TaskStatus OnUpdate() + { + if (_enemy == null || WaypointCount == 0) + return TaskStatus.Failure; + + if (_waiting) + { + _waitTimer -= Time.deltaTime; + if (_waitTimer > 0f) return TaskStatus.Running; + _waiting = false; + Advance(); + RequestCurrent(); + } + else + { + Vector2 wp = GetWaypoint(_index); + float sqrDist = ((Vector2)_enemy.transform.position - wp).sqrMagnitude; + + if (sqrDist <= m_ArriveRadius * m_ArriveRadius) + { + if (m_WaitAtWaypoint > 0f) + { + _waiting = true; + _waitTimer = m_WaitAtWaypoint; + _enemy.StopMovement(); + } + else + { + Advance(); + RequestCurrent(); + } + } + else + { + _enemy.MoveTo(wp); + _enemy.Movement?.FaceTarget(wp); + } + } + return TaskStatus.Running; + } + + public override void OnEnd() => _enemy?.StopMovement(); + + // ── 内部辅助 ──────────────────────────────────────────────────────── + private void RequestCurrent() + { + if (WaypointCount == 0) return; + _enemy.MoveTo(GetWaypoint(_index)); + } + + private void Advance() + { + if (WaypointCount <= 1) return; + if (m_PingPong) + { + _index += _dir; + if (_index >= WaypointCount) { _index = WaypointCount - 2; _dir = -1; } + else if (_index < 0) { _index = 1; _dir = 1; } + } + else + { + _index = (_index + 1) % WaypointCount; + } + } + + // ── Gizmo 可视化(仅 m_InlineWaypoints 模式)──────────────────────── +#if UNITY_EDITOR + private new void OnDrawGizmos() + { + if (m_InlineWaypoints == null || m_InlineWaypoints.Length < 1) return; + // m_Waypoints 存在时不绘制,避免与场景路点重叠 + if (m_Waypoints != null && m_Waypoints.Length > 0) return; + + Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.8f); + for (int i = 0; i < m_InlineWaypoints.Length; i++) + { + Gizmos.DrawWireSphere(m_InlineWaypoints[i], m_ArriveRadius); + + if (i < m_InlineWaypoints.Length - 1) + Gizmos.DrawLine(m_InlineWaypoints[i], m_InlineWaypoints[i + 1]); + } + + // PingPong 时也画返回线;Loop 时画首尾连接线 + if (m_InlineWaypoints.Length >= 2) + { + if (m_PingPong) + { + // 往返:虚线效果(半透明线) + Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.35f); + Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]); + } + else + { + // 循环:画首尾连接 + Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.5f); + Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]); + } + } + + // 编辑器标签(路点序号) + UnityEditor.Handles.color = new Color(0.2f, 0.85f, 0.2f, 1f); + for (int i = 0; i < m_InlineWaypoints.Length; i++) + UnityEditor.Handles.Label(m_InlineWaypoints[i] + Vector2.up * 0.25f, $"WP{i}"); + } +#endif + } +} +#endif + diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta new file mode 100644 index 0000000..305f455 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e7a2cb1e940ff92499a0ebb9d3063e21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_PlayAnimation.cs b/Assets/_Game/Scripts/Enemies/AI/BD_PlayAnimation.cs index 566487c..2399f40 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_PlayAnimation.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_PlayAnimation.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI /// BD Action:通过 AnimationClip 名称播放 Animancer 动画,立即返回 Success。 /// ClipName 需与 EnemyAnimationConfigSO 中字段名一致。 /// + [TaskName("Play Animation")] + [TaskCategory("BaseGames/Enemy/Animation")] + [TaskDescription("通过 Animancer 播放指定动画 Clip;支持等待动画结束后返回")] public class BD_PlayAnimation : Action { /// EnemyAnimationConfigSO 中的 AnimationClip 字段名(如 "Attack_Melee")。 @@ -17,10 +20,7 @@ namespace BaseGames.Enemies.AI private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs new file mode 100644 index 0000000..cc9c7a1 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs @@ -0,0 +1,103 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:归位到出生点()。 + /// + /// + /// 切换 ,使用行走速度导航回初始位置。 + /// 到达(距离 ≤ homeRadius)或路径失败时:切换 ,返回 Success。 + /// 路径失败也返回 Success,避免 BD 树卡死;上层节点继续恢复巡逻。 + /// + /// + /// 典型 BD 用法: + /// + /// Sequence + /// BD_InvestigateLastKnown → Success + /// BD_ReturnToHome → Success + /// BD_SetAiPhase(Patrol) + /// + /// + [TaskName("Return To Home")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("导航回出生点;到达后切换 Idle 并返回 Success")] + public sealed class BD_ReturnToHome : Action + { + [Tooltip("到达判定半径(m);0 = 使用 EnemyStatsSO.HomeRadius")] + [SerializeField] private float m_ArriveRadius = 0f; + + private EnemyBase _enemy; + private bool _reached; + private bool _pathFailed; + private bool _subscribed; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() + { + _enemy.SetAiPhase(AiPhase.ReturnHome); + _reached = false; + _pathFailed = false; + + if (!_subscribed && _enemy.Nav != null) + { + _enemy.Nav.OnGoalReached += HandleReached; + _enemy.Nav.OnNavPathFailed += HandleFailed; + _subscribed = true; + } + + // 切换为行走速度 + float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f; + _enemy.Nav?.SetSpeed(walkSpeed); + + // 播放行走动画 + var ac = _enemy.AnimConfig; + if (_enemy.Animancer != null && ac?.Walk != null) + _enemy.Animancer.Play(ac.Walk); + + _enemy.MoveTo(_enemy.HomePosition); + } + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + + if (_pathFailed) return CompleteReturn(); + if (_reached) return CompleteReturn(); + + // 兜底距离判断(事件可能因帧序问题延迟一帧) + float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f); + float sqr = ((Vector2)_enemy.transform.position - _enemy.HomePosition).sqrMagnitude; + if (sqr <= radius * radius) + return CompleteReturn(); + + return TaskStatus.Running; + } + + public override void OnEnd() + { + if (_subscribed && _enemy?.Nav != null) + { + _enemy.Nav.OnGoalReached -= HandleReached; + _enemy.Nav.OnNavPathFailed -= HandleFailed; + _subscribed = false; + } + } + + private TaskStatus CompleteReturn() + { + _enemy.StopMovement(); + _enemy.SetAiPhase(AiPhase.Idle); + return TaskStatus.Success; + } + + private void HandleReached() => _reached = true; + private void HandleFailed() => _pathFailed = true; + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta new file mode 100644 index 0000000..836fd1b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0555fce4edd236847b580a2b436bd22f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs b/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs new file mode 100644 index 0000000..7f99938 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs @@ -0,0 +1,34 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:设置 AI 行为阶段()。 + /// 单帧完成,返回 Success。常用于行为树中作为阶段过渡的标记节点, + /// 例如在 Selector 分支开头标记当前进入了哪个阶段。 + /// + [TaskName("Set Ai Phase")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("立即切换 AI 行为阶段(AiPhase),单帧返回 Success")] + public sealed class BD_SetAiPhase : Action + { + [Tooltip("目标 AI 行为阶段")] + [SerializeField] private AiPhase m_Phase = AiPhase.Idle; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + _enemy.SetAiPhase(m_Phase); + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta new file mode 100644 index 0000000..3a4a4e5 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f70a0ea7dc70bb4cbf300d19eacfba0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SetAlert.cs b/Assets/_Game/Scripts/Enemies/AI/BD_SetAlert.cs index d3ec200..970cad9 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_SetAlert.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SetAlert.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; @@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI /// /// BD Action:设置警觉状态,并通知 EnemyBase 调整 BT Tick 频率。 /// + [TaskName("Set Alert Tick Rate")] + [TaskCategory("BaseGames/Enemy/Perception")] + [TaskDescription("切换行为树 Tick 频率:警觉时高频,巡逻时低频")] public class BD_SetAlert : Action { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs b/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs new file mode 100644 index 0000000..c698d1f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs @@ -0,0 +1,45 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:强制切换敌人到指定 EnemyStateType,并中断所有正在执行的能力。 + /// 单帧完成,立即返回 Success。 + /// + /// 典型用法: + /// - 进入 Stagger 状态后重置为 Idle(替代手动停止动画) + /// - 技能打断后显式回到 Controlled 状态 + /// + [TaskName("Set State")] + [TaskCategory("BaseGames/Enemy/State")] + [TaskDescription("强制切换敌人物理/战斗状态(ForceState)")] + public class BD_SetState : Action + { + [Tooltip("切换到的目标状态")] + [SerializeField] private EnemyStateType m_TargetState = EnemyStateType.Controlled; + + [Tooltip("切换状态时同时中断所有正在执行的能力")] + [SerializeField] private bool m_InterruptAbilities = true; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + + if (m_InterruptAbilities) + _enemy.Abilities?.InterruptAll(InterruptReason.ExternalRequest); + + _enemy.ForceState(m_TargetState); + return TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta new file mode 100644 index 0000000..8c64bff --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3406f00433d44e449c24b4340b0c49a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SpawnProjectile.cs b/Assets/_Game/Scripts/Enemies/AI/BD_SpawnProjectile.cs index 2f72e76..d28f3d3 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_SpawnProjectile.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SpawnProjectile.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -11,6 +11,9 @@ namespace BaseGames.Enemies.AI /// BD Action:在敌人当前位置生成弹射物。 /// ProjectileKey 为 Addressable 键,通过 GlobalObjectPool 实例化。 /// + [TaskName("Spawn Projectile")] + [TaskCategory("BaseGames/Enemy/Utility")] + [TaskDescription("在指定位置生成投射物并赋予初速度")] public class BD_SpawnProjectile : Action { [SerializeField] private Vector2 m_Direction = Vector2.right; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_StopMovement.cs b/Assets/_Game/Scripts/Enemies/AI/BD_StopMovement.cs index 03ba8fa..14b2459 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_StopMovement.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_StopMovement.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; @@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI /// /// BD Action:立即停止移动,单帧完成,返回 Success。 /// + [TaskName("Stop Movement")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("立即停止 NavAgent 移动,单帧返回 Success")] public class BD_StopMovement : Action { private EnemyBase _enemy; - public override void OnStart() - { - _enemy = GetComponent(); - } + public override void OnAwake() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_SummonMinions.cs b/Assets/_Game/Scripts/Enemies/AI/BD_SummonMinions.cs index 229df5e..f4fc8c1 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_SummonMinions.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_SummonMinions.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -12,28 +12,40 @@ namespace BaseGames.Enemies.AI /// BD Action:Boss 专用召唤小兵。 /// 通过 GlobalObjectPool 在 Boss 周围生成 MinionPrefabKey 对应的敌人。 /// + [TaskName("Summon Minions")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("在指定位置生成小怪预制体(支持延迟和数量配置)")] public class BD_SummonMinions : Action { + [Header("召唤配置")] + [Tooltip("对象池 Key,对应 PoolRegistry 中注册的小怪预制体。")] [SerializeField] private string m_MinionPrefabKey = ""; + [Tooltip("单次召唤的小兵数量。")] + [Min(1)] [SerializeField] private int m_Count = 2; + [Tooltip("小兵生成位置距 Boss 中心的横向散开半径(m)。")] + [Min(0.1f)] [SerializeField] private float m_SpawnRadius = 3f; + // 延迟缓存:首次调用时解析,避免每帧服务定位开销 + private IObjectPoolService _pool; + public override TaskStatus OnUpdate() { if (string.IsNullOrEmpty(m_MinionPrefabKey)) return TaskStatus.Failure; - var pool = ServiceLocator.GetOrDefault(); - if (pool == null) return TaskStatus.Failure; + _pool ??= ServiceLocator.GetOrDefault(); + if (_pool == null) return TaskStatus.Failure; for (int i = 0; i < m_Count; i++) { - var angle = Random.Range(0f, 360f) * Mathf.Deg2Rad; - var offset = new Vector2(Mathf.Cos(angle), 0f) * m_SpawnRadius; - var spawnPos = new Vector3( + float angleRad = (i / (float)m_Count) * Mathf.PI * 2f; + var offset = new Vector2(Mathf.Cos(angleRad), 0f) * m_SpawnRadius; + var spawnPos = new Vector3( transform.position.x + offset.x, - transform.position.y + offset.y, + transform.position.y, 0f); - pool.Spawn(m_MinionPrefabKey, spawnPos, Quaternion.identity); + _pool.Spawn(m_MinionPrefabKey, spawnPos, Quaternion.identity); } return TaskStatus.Success; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_TelegraphAttack.cs b/Assets/_Game/Scripts/Enemies/AI/BD_TelegraphAttack.cs index 7179f8b..34b4a2f 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_TelegraphAttack.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_TelegraphAttack.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI /// BD Action:触发攻击预警(TelegraphSystem),等待 Duration 秒后返回 Success。 /// 对应架构 07_EnemyModule §8 BD_TelegraphAttack;与 TelegraphSystem 协作。 /// + [TaskName("Telegraph Attack")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("播放攻击前摇动画并等待结束后返回 Success")] public class BD_TelegraphAttack : Action { [SerializeField] private float m_Duration = 1f; @@ -17,15 +20,18 @@ namespace BaseGames.Enemies.AI private TelegraphSystem _telegraph; private float _elapsed; + private Coroutine _telegraphCoroutine; + + public override void OnAwake() => _telegraph = GetComponent(); public override void OnStart() { - _telegraph = GetComponent(); - _elapsed = 0f; + _elapsed = 0f; + _telegraphCoroutine = null; if (_telegraph != null && !string.IsNullOrEmpty(m_VfxKey)) { - _telegraph.StartCoroutine( + _telegraphCoroutine = _telegraph.StartCoroutine( _telegraph.ShowTelegraph(m_VfxKey, m_Duration, transform.position)); } } @@ -35,6 +41,16 @@ namespace BaseGames.Enemies.AI _elapsed += Time.deltaTime; return _elapsed >= m_Duration ? TaskStatus.Success : TaskStatus.Running; } + + public override void OnEnd() + { + // BD 任务被 Abort 时停止仍在播放的预警协程,避免孤立 VFX + if (_telegraphCoroutine != null) + { + _telegraph?.StopCoroutine(_telegraphCoroutine); + _telegraphCoroutine = null; + } + } } } #endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_TeleportTo.cs b/Assets/_Game/Scripts/Enemies/AI/BD_TeleportTo.cs index 6cbf24a..4bd0cc4 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_TeleportTo.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_TeleportTo.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI /// BD Action:瞬移(传送)到目标坐标,Boss 专用。 /// 单帧直接修改 transform.position,不使用导航。 /// + [TaskName("Teleport To")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("瞬移到目标位置(配合粒子/闪烁能力)")] public class BD_TeleportTo : Action { [SerializeField] private Vector2 m_Target; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs b/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs new file mode 100644 index 0000000..cb73236 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs @@ -0,0 +1,58 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// + /// 通用:触发能力。OnStart 调用 ability.Execute(),OnUpdate 等待结束。 + /// 失败条件:能力不存在、能力 CanUse=false 或 Execute 返回 false。 + /// + [TaskName("Use Ability")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("触发指定能力(拖拽 EnemyAbilitySO 或填写 ID),等待执行结束")] + public sealed class BD_UseAbility : Action + { + [Tooltip("可直接拖拽 EnemyAbilitySO 资产(推荐),或填写裸字符串 ID 作为兜底")] + [SerializeField] private EnemyAbilitySO m_AbilitySO; + [SerializeField] private string m_AbilityId = ""; + + private EnemyBase _enemy; + private EnemyAbilityBase _ability; + private bool _startedSuccessfully; + + public override void OnAwake() + { + _enemy = gameObject.GetComponent(); + } + + public override void OnStart() + { + _startedSuccessfully = false; + if (_enemy == null) return; + + // SO 拖拽优先;裸字符串兜底 + string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId; + if (string.IsNullOrEmpty(id)) return; + + _ability = _enemy.Abilities.Get(id); + if (_ability == null) return; + _startedSuccessfully = _ability.Execute(); + } + + public override TaskStatus OnUpdate() + { + if (!_startedSuccessfully || _ability == null) return TaskStatus.Failure; + return _ability.IsRunning ? TaskStatus.Running : TaskStatus.Success; + } + + public override void OnEnd() + { + _ability = null; + _startedSuccessfully = false; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta new file mode 100644 index 0000000..e581b0a --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4865a86627f84a54292736e6dc2b9e15 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs new file mode 100644 index 0000000..0c9daed --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs @@ -0,0 +1,53 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Boss; + +namespace BaseGames.Enemies.AI +{ + /// + /// 触发 Boss 技能。OnStart 调用 BossBase.UseBossSkill(skillId),OnUpdate 等待技能结束。 + /// 失败条件:非 Boss 敌人、技能 ID 无效、BossSkillExecutor 未就绪或技能执行中。 + /// + [TaskName("Use Boss Skill")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("通过技能 ID 执行 Boss 指定技能")] + public sealed class BD_UseBossSkill : Action + { + [SerializeField] private string m_SkillId = ""; + [Tooltip("可选:直接拖拽 BossSkillSO 资产以替代裸字符串(优先于 m_SkillId)")] + [SerializeField] private BossSkillSO m_SkillSO; + + private BossBase _boss; + private bool _startedSuccessfully; + + public override void OnAwake() + { + _boss = gameObject.GetComponent(); + } + + public override void OnStart() + { + _startedSuccessfully = false; + if (_boss == null) return; + + string id = m_SkillSO != null ? m_SkillSO.skillId : m_SkillId; + if (string.IsNullOrEmpty(id)) return; + + _startedSuccessfully = _boss.UseBossSkill(id); + } + + public override TaskStatus OnUpdate() + { + if (!_startedSuccessfully || _boss == null) return TaskStatus.Failure; + return _boss.IsBossSkillExecuting ? TaskStatus.Running : TaskStatus.Success; + } + + public override void OnEnd() + { + _startedSuccessfully = false; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta new file mode 100644 index 0000000..5a25b4e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f86542f8dede80438ab28ec682758b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs new file mode 100644 index 0000000..13d2ef6 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs @@ -0,0 +1,54 @@ +#if GRAPH_DESIGNER +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using UnityEngine; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:在当前阶段可用且冷却就绪的技能中,按 weight 加权随机选择一个技能并执行。 + /// + /// 返回 Running:技能正在执行。 + /// 返回 Success:技能执行完成。 + /// 返回 Failure:无可用技能,或执行器忙。 + /// + /// 用法:在 Boss BT 的 Combat 阶段,替代 BD_UseBossSkill(固定 ID)实现随机化技能组合。 + /// 每个 BossSkillSO 设置 weight 字段控制出现概率:越大越常见。 + /// + [TaskName("Use Boss Skill (Weighted)")] + [TaskCategory("BaseGames/Enemy/Boss")] + [TaskDescription("加权随机选择并执行可用技能;对上一次使用的技能施加权重惩罚")] + public class BD_UseBossSkillWeighted : Action + { + [Tooltip("等待技能执行完成后才返回 Success。关闭后执行开始即返回 Success,BT 继续运行其他节点。")] + [SerializeField] private bool m_WaitForCompletion = true; + + private BossBase _boss; + private bool _started; + + public override void OnAwake() => _boss = GetComponent(); + + public override void OnStart() + { + _started = false; + } + + public override TaskStatus OnUpdate() + { + if (_boss == null) return TaskStatus.Failure; + + if (!_started) + { + bool ok = _boss.UseBossSkillWeighted(); + if (!ok) return TaskStatus.Failure; + _started = true; + + if (!m_WaitForCompletion) return TaskStatus.Success; + } + + // 等待技能执行完成 + return _boss.IsBossSkillExecuting ? TaskStatus.Running : TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs.meta new file mode 100644 index 0000000..134f9e8 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5cf18a1d81f80c646947ea0475c31108 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_Wait.cs b/Assets/_Game/Scripts/Enemies/AI/BD_Wait.cs index 0fc4a6e..03cc78d 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_Wait.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_Wait.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI /// BD Action:等待固定 Duration 秒后返回 Success。 /// 适用于攻击前摇、冷却间隔等固定等待。 /// + [TaskName("Wait")] + [TaskCategory("BaseGames/Enemy/Utility")] + [TaskDescription("等待固定 Duration 秒后返回 Success")] public class BD_Wait : Action { [UnityEngine.SerializeField] private float m_Duration = 1f; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WaitForAnimation.cs b/Assets/_Game/Scripts/Enemies/AI/BD_WaitForAnimation.cs index ca84d32..a3ae84f 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_WaitForAnimation.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WaitForAnimation.cs @@ -1,4 +1,5 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER +using UnityEngine; using Opsive.BehaviorDesigner.Runtime; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -8,23 +9,36 @@ namespace BaseGames.Enemies.AI { /// /// BD Action:等待当前 Animancer 动画播放完毕再返回 Success。 - /// 通过 EnemyBase.IsAnimationComplete 轮询(Animancer CurrentState.NormalizedTime >= 1)。 + /// 通过 Animancer CurrentState.NormalizedTime >= 1 轮询; + /// 若动画为循环动画(NormalizedTime 每帧 wrap 到 0),超时后强制返回 Success,防止永久挂死。 /// + [TaskName("Wait For Animation")] + [TaskCategory("BaseGames/Enemy/Animation")] + [TaskDescription("等待 Animancer 当前动画播放完毕后返回 Success。循环动画请设置超时时间。")] public class BD_WaitForAnimation : Action { + [Tooltip("安全超时(秒)。若动画是循环型,超时后自动返回 Success。0 = 不启用超时(仅适合非循环动画)。")] + [SerializeField, Min(0f)] private float m_Timeout = 5f; + private EnemyBase _enemy; + private float _deadline; + + public override void OnAwake() => _enemy = GetComponent(); public override void OnStart() { - _enemy = GetComponent(); + _deadline = m_Timeout > 0f ? Time.time + m_Timeout : float.MaxValue; } public override TaskStatus OnUpdate() { if (_enemy == null) return TaskStatus.Failure; + // 超时保护:防止循环动画导致永久阻塞 + if (Time.time >= _deadline) return TaskStatus.Success; + var state = _enemy.Animancer?.States?.Current; - if (state == null) return TaskStatus.Success; // 无动画直接继续 + if (state == null) return TaskStatus.Success; // 无动画直接继续 if (state.NormalizedTime >= 1f) return TaskStatus.Success; return TaskStatus.Running; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WaitRandom.cs b/Assets/_Game/Scripts/Enemies/AI/BD_WaitRandom.cs index a673aa9..5b4c53b 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_WaitRandom.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WaitRandom.cs @@ -1,4 +1,4 @@ -#if GRAPH_DESIGNER +#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; @@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI /// BD Action:在 [Min, Max] 范围内随机等待后返回 Success。 /// 适用于巡逻间隔、随机攻击延迟等自然行为。 /// + [TaskName("Wait (Random)")] + [TaskCategory("BaseGames/Enemy/Utility")] + [TaskDescription("在 Min~Max 范围内随机等待后返回 Success")] public class BD_WaitRandom : Action { [UnityEngine.SerializeField] private float m_Min = 0.5f; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs b/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs new file mode 100644 index 0000000..eca5514 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs @@ -0,0 +1,68 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Action:等待指定能力执行结束后返回 Success。 + /// 适用于需要同步等待某个由外部触发的能力(而非本节点触发)的场景。 + /// + /// 返回 Running:能力正在执行。 + /// 返回 Success:能力执行完毕(IsRunning = false)。 + /// 返回 Failure:能力不存在,或超时保护触发。 + /// + /// 典型用法:Sequence [ BD_UseAbility(A) → BD_WaitUntilAbilityEnd(B) ] 等待 B 被 A 内部链式触发后结束。 + /// + [TaskName("Wait Until Ability End")] + [TaskCategory("BaseGames/Enemy/Combat")] + [TaskDescription("等待指定能力执行完成后返回 Success")] + public sealed class BD_WaitUntilAbilityEnd : Action + { + [Tooltip("可直接拖拽 EnemyAbilitySO 资产(推荐),或填写裸字符串 ID 作为兜底")] + [SerializeField] private EnemyAbilitySO m_AbilitySO; + [SerializeField] private string m_AbilityId = ""; + + [Tooltip("超时保护(秒);0 = 永不超时")] + [SerializeField, Min(0f)] private float m_Timeout = 5f; + + private EnemyBase _enemy; + private EnemyAbilityBase _ability; + private float _deadline; + + public override void OnAwake() => _enemy = GetComponent(); + + public override void OnStart() + { + _ability = null; + _deadline = m_Timeout > 0f ? Time.time + m_Timeout : float.MaxValue; + + if (_enemy == null) return; + string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId; + if (!string.IsNullOrEmpty(id)) + _ability = _enemy.Abilities?.Get(id); + } + + public override TaskStatus OnUpdate() + { + if (_ability == null) return TaskStatus.Failure; + + if (Time.time >= _deadline) + { + Debug.LogWarning($"[BD_WaitUntilAbilityEnd] 能力 '{_ability.Config?.abilityId}' 超时,强制中断", gameObject); + // 强制中断仍在运行的能力,防止 HitBox/动画协程泄漏 + if (_ability.IsRunning) + _ability.Interrupt(InterruptReason.ExternalRequest); + return TaskStatus.Failure; + } + + return _ability.IsRunning ? TaskStatus.Running : TaskStatus.Success; + } + + public override void OnEnd() => _ability = null; + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs.meta new file mode 100644 index 0000000..84818ba --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9311a809097042f4b8b5de9681d51d0d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs b/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs new file mode 100644 index 0000000..a2c4b1f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs @@ -0,0 +1,44 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// 动作:随机游走(调用 NavAgent.SetRandomDestination)。 + /// 到达目的地后立即选下一个随机点,持续 Running。 + /// 常用于巡逻 fallback 或无路点的小怪。 + /// + [TaskName("Walk Random")] + [TaskCategory("BaseGames/Enemy/Movement")] + [TaskDescription("随机游走:持续选取随机目的地导航(常用于待机巡逻兜底)")] + public sealed class BD_WalkRandom : Action + { + [Tooltip("尝试选取随机点的次数")] + [SerializeField] private int m_RetryCount = 5; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override void OnStart() => TryWalk(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + if (_enemy.Nav.IsAtDestination()) TryWalk(); + return TaskStatus.Running; + } + + public override void OnEnd() => _enemy?.StopMovement(); + + private void TryWalk() + { + for (int i = 0; i < m_RetryCount; i++) + if (_enemy.Nav.WalkToRandom()) break; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta new file mode 100644 index 0000000..2ad3cea --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a55d2b270a2c1d34a9b08771feedba62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef b/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef index 4250ca3..9a4c8c7 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef +++ b/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef @@ -7,13 +7,14 @@ "noEngineReferences": false, "versionDefines": [], "rootNamespace": "BaseGames.Enemies.AI", - "references": [ + "references": [ "BaseGames.Core", "BaseGames.Core.Events", "BaseGames.Enemies", "BaseGames.Enemies.Boss.Patterns", "Opsive.BehaviorDesigner.Runtime", - "Kybernetik.Animancer" + "Kybernetik.Animancer", + "Micosmo.SensorToolkit" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Enemies/Abilities.meta b/Assets/_Game/Scripts/Enemies/Abilities.meta new file mode 100644 index 0000000..9c3c5dc --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d5d161f28bc68404ead8b5d52e9ad335 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs new file mode 100644 index 0000000..710241c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs @@ -0,0 +1,26 @@ +namespace BaseGames.Enemies.Abilities +{ + /// + /// 能力执行的阶段枚举(架构 07_EnemyModule §8)。 + /// 用于 BD/Animator/UI 查询能力当前在哪个阶段(telegraph/出招/恢复)。 + /// + public enum AbilityRunState + { + Idle, + Telegraph, + Windup, + Active, + Recovery, + Interrupted + } + + /// 能力中断原因。供 BD 判断是否需要重新调度。 + public enum InterruptReason + { + ExternalRequest, + Hurt, + Stagger, + KnockUp, + Dead + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs.meta new file mode 100644 index 0000000..2eff839 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82020b408d70f45448de169667bc80a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs new file mode 100644 index 0000000..2d496e0 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections; +using UnityEngine; +using BaseGames.Core; +using BaseGames.Core.Pool; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 闪烁突袭能力:消失后瞬移到目标侧后方/上方/侧翼,现身后衔接出手。 + /// + /// 流程: + /// 1. 选择闪烁目标点(玩家侧后 / 上方 / 侧翼,依 ) + /// 2. 隐身(隐藏 SpriteRenderer、关闭碰撞、播放消失 VFX) + /// 3. 等待 disappearDuration 营造"消失"感 + /// 4. 瞬移到目标点 + /// 5. 现身 + 出现 VFX + 预警闪光(reappearTelegraph) + /// 6. 调用 followUpAbilityId 指定的能力作为出手(若为空则播放 attackSequence[0]) + /// + /// 同时实现 for : + /// 当 PathBerserker2d 路径包含 teleport NavLink 时,本能力接管穿越逻辑, + /// 播放消失/出现动画后告知 NavAgent 继续路径,而不触发战斗出手。 + /// + public sealed class BlinkStrikeAbility : EnemyAbilityBase, INavLinkHandler + { + public enum BlinkPosition { BehindTarget, FrontTarget, AboveTarget, FlankTarget } + + [Header("闪烁参数")] + [SerializeField] private BlinkPosition _blinkPosition = BlinkPosition.BehindTarget; + [SerializeField] private float _offsetDistance = 1.8f; + [SerializeField] private float _verticalOffsetAbove = 3.0f; + [SerializeField] private float _disappearDuration = 0.25f; + [SerializeField] private float _reappearTelegraph = 0.20f; + [SerializeField] private LayerMask _groundMask; + [SerializeField] private float _groundSnapMaxDistance = 3f; + + [Header("视觉")] + [SerializeField] private SpriteRenderer[] _renderers; + [SerializeField] private Collider2D[] _disableDuringBlink; + [SerializeField] private string _disappearVfxKey = ""; + [SerializeField] private string _appearVfxKey = ""; + + [Header("出手能力(在现身后触发,若为空则播放第一段攻击动画)")] + [SerializeField] private string _followUpAbilityId = ""; + + private Rigidbody2D _rb; + private IObjectPoolService _pool; + private Coroutine _navLinkCoroutine; + + // ── INavLinkHandler(Teleport 连接段穿越)───────────────────── + private static readonly NavLinkType[] _handledTypes = new[] { NavLinkType.Teleport }; + public NavLinkType[] HandledLinkTypes => _handledTypes; + + public bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd) => true; + + public void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete) + { + if (_navLinkCoroutine != null) StopCoroutine(_navLinkCoroutine); + _navLinkCoroutine = StartCoroutine(TeleportNavLinkCoroutine(linkEnd, onComplete)); + } + + public void AbortLinkTraversal() + { + if (_navLinkCoroutine != null) { StopCoroutine(_navLinkCoroutine); _navLinkCoroutine = null; } + SetVisible(true); + } + + /// 纯导航用传送:消失 → 移动到连接终点 → 出现,不触发战斗出手。 + private IEnumerator TeleportNavLinkCoroutine(Vector2 destination, Action onComplete) + { + _pool ??= ServiceLocator.GetOrDefault(); + + if (!string.IsNullOrEmpty(_disappearVfxKey)) + _pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity); + SetVisible(false); + if (_rb != null) _rb.velocity = Vector2.zero; + yield return EnemyAbilityWaits.Get(_disappearDuration); + + if (_rb != null) _rb.position = destination; + else _transform.position = destination; + + SetVisible(true); + if (!string.IsNullOrEmpty(_appearVfxKey)) + _pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity); + + _navLinkCoroutine = null; + onComplete?.Invoke(); // 通知 EnemyNavAgent → CompleteLinkTraversal + } + + protected override void Awake() + { + base.Awake(); + _rb = GetComponentInParent(); + if (_renderers == null || _renderers.Length == 0) + _renderers = GetComponentsInChildren(true); + } + + protected override IEnumerator ExecuteCoroutine() + { + _pool ??= ServiceLocator.GetOrDefault(); + var target = _enemy != null ? _enemy.PlayerTransform : null; + if (target == null) yield break; + + // 1. 消失 + if (!string.IsNullOrEmpty(_disappearVfxKey)) + _pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity); + SetVisible(false); + if (_rb != null) _rb.velocity = Vector2.zero; + yield return EnemyAbilityWaits.Get(_disappearDuration); + + // 2. 选目标点 + 瞬移 + Vector2 dest = ComputeBlinkPosition(target); + if (_rb != null) _rb.position = dest; + else _transform.position = dest; + FaceTarget(target); + + // 3. 现身 + 预警 + SetVisible(true); + if (!string.IsNullOrEmpty(_appearVfxKey)) + _pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity); + Phase = AbilityRunState.Telegraph; + yield return EnemyAbilityWaits.Get(_reappearTelegraph); + + // 4. 出手 + Phase = AbilityRunState.Active; + if (!string.IsNullOrEmpty(_followUpAbilityId) && _enemy != null) + { + var follow = _enemy.Abilities.Get(_followUpAbilityId); + if (follow != null) + { + follow.Execute(); + while (follow.IsRunning) yield return null; + yield break; + } + } + // 退化路径:播放第一段动画 + var seq = _config != null ? _config.attackSequence : null; + if (seq != null && seq.Length > 0) + { + var atk = seq[0]; + float dur = atk.fallbackDuration; + if (atk.clip != null && _animancer != null) + { + var st = _animancer.Play(atk.clip); + if (st != null && st.Length > 0f) dur = st.Length; + } + yield return EnemyAbilityWaits.Get(dur); + } + } + + protected override void OnInterrupted(InterruptReason reason) + { + // 中断时同时终止 NavLink 穿越协程(若正在进行),防止 onComplete 回调污染导航状态 + AbortLinkTraversal(); + } + + private Vector2 ComputeBlinkPosition(Transform target) + { + Vector2 t = target.position; + Vector2 selfDir = (t - (Vector2)_transform.position); + float facing = selfDir.x >= 0f ? 1f : -1f; + Vector2 raw; + switch (_blinkPosition) + { + case BlinkPosition.FrontTarget: raw = t + new Vector2(facing * _offsetDistance, 0f); break; + case BlinkPosition.AboveTarget: raw = t + new Vector2(0f, _verticalOffsetAbove); break; + case BlinkPosition.FlankTarget: + raw = t + new Vector2((UnityEngine.Random.value < 0.5f ? -1f : 1f) * _offsetDistance, 0f); + break; + case BlinkPosition.BehindTarget: + default: + float tFacing = target.localScale.x >= 0f ? 1f : -1f; + raw = t - new Vector2(tFacing * _offsetDistance, 0f); + break; + } + // 贴地(避免悬空) + var hit = Physics2D.Raycast(raw + Vector2.up * 0.5f, Vector2.down, + _groundSnapMaxDistance + 0.5f, _groundMask); + if (hit.collider != null) raw.y = hit.point.y + 0.05f; + return raw; + } + + private void SetVisible(bool visible) + { + if (_renderers != null) + for (int i = 0; i < _renderers.Length; i++) + if (_renderers[i] != null) _renderers[i].enabled = visible; + if (_disableDuringBlink != null) + for (int i = 0; i < _disableDuringBlink.Length; i++) + if (_disableDuringBlink[i] != null) _disableDuringBlink[i].enabled = visible; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs.meta new file mode 100644 index 0000000..e76f7c8 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8fca68990cbd3b1428ba2c45bbf87d86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs new file mode 100644 index 0000000..1888c5d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs @@ -0,0 +1,91 @@ +using System.Collections; +using UnityEngine; +using BaseGames.Combat; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 挂顶下落能力:怪物初始挂在天花板(kinematic,重力 0)。能力触发后: + /// 1. 切换为 dynamic + 恢复重力 + /// 2. 自由落体到接触地面 + /// 3. 落地播放 AoE HitBox + 砸地反馈 + /// + [RequireComponent(typeof(Rigidbody2D))] + public sealed class CeilingDropAbility : EnemyAbilityBase + { + [Header("下落")] + [SerializeField] private float _fallGravityScale = 3.5f; + [SerializeField] private float _maxFallTime = 3f; + [SerializeField] private LayerMask _groundMask; + + [Header("落地")] + [SerializeField] private HitBox _landingHitBox; + [SerializeField] private float _hitBoxActiveTime = 0.2f; + [SerializeField] private float _recoveryTime = 0.4f; + + private Rigidbody2D _rb; + private RigidbodyType2D _origBodyType; + private float _origGravityScale; + + protected override void Awake() + { + base.Awake(); + _rb = GetComponentInParent(); + } + + protected override IEnumerator ExecuteCoroutine() + { + if (_rb == null) yield break; + var atk = (_config.attackSequence != null && _config.attackSequence.Length > 0) + ? _config.attackSequence[0] : null; + + Phase = AbilityRunState.Active; + + // 切换到动态 + 恢复重力 + _origBodyType = _rb.bodyType; + _origGravityScale = _rb.gravityScale; + _rb.bodyType = RigidbodyType2D.Dynamic; + _rb.gravityScale = _fallGravityScale; + _rb.velocity = Vector2.zero; + + float t = 0f; + while (t < _maxFallTime) + { + t += Time.fixedDeltaTime; + yield return new WaitForFixedUpdate(); + if (t > 0.05f && IsGrounded()) break; + } + + _rb.velocity = Vector2.zero; + + if (_landingHitBox != null) + { + _landingHitBox.Activate(atk != null ? atk.damageSource : null, _transform); + yield return EnemyAbilityWaits.Get(_hitBoxActiveTime); + _landingHitBox.Deactivate(); + } + + yield return EnemyAbilityWaits.Get(_recoveryTime); + + // 落地后不恢复挂顶状态(一般转为地面行为),保持动态 Rigidbody + } + + private bool IsGrounded() + { + var hit = Physics2D.Raycast(_rb.position, Vector2.down, 0.6f, _groundMask); + return hit.collider != null; + } + + protected override void OnInterrupted(InterruptReason reason) + { + if (_landingHitBox != null && _landingHitBox.IsActive) _landingHitBox.Deactivate(); + // 中断时恢复 Rigidbody 原始状态,防止物理参数泄漏 + if (_rb != null) + { + _rb.velocity = Vector2.zero; + _rb.bodyType = _origBodyType; + _rb.gravityScale = _origGravityScale; + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs.meta new file mode 100644 index 0000000..5ed6d9b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e91a8a04726e4144ab7c4d87064ec2f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs new file mode 100644 index 0000000..e132138 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs @@ -0,0 +1,95 @@ +using System.Collections; +using UnityEngine; +using BaseGames.Combat; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 直线冲锋能力:朝目标方向高速直线冲锋,撞墙时进入硬直恢复。 + /// 流程:朝向目标 → 蓄力 → 高速直线冲锋直到撞墙或超距 → 恢复。 + /// 冲锋期间 HitBox 持续激活;可选撞墙时硬直。 + /// + [RequireComponent(typeof(Rigidbody2D))] + public sealed class ChargeAbility : EnemyAbilityBase + { + [Header("冲锋参数")] + [SerializeField] [Min(0.1f)] private float _chargeSpeed = 14f; + [SerializeField] [Min(0.1f)] private float _maxDistance = 12f; + [SerializeField] private float _windupTime = 0.4f; + [SerializeField] private float _recoveryTime = 0.6f; + [SerializeField] private float _wallCheckDist = 0.4f; + [SerializeField] private LayerMask _wallMask; + [SerializeField] private bool _stunOnWallHit = true; + [SerializeField] private float _wallStunTime = 0.8f; + + [Header("HitBox")] + [SerializeField] private HitBox _chargeHitBox; + + [Header("动画 Key")] + [SerializeField] private string _windupAnimSlot = ""; + [SerializeField] private string _chargeAnimSlot = ""; + + private Rigidbody2D _rb; + private float _direction; + + protected override void Awake() + { + base.Awake(); + _rb = GetComponentInParent(); + } + + protected override IEnumerator ExecuteCoroutine() + { + if (_rb == null) yield break; + + FaceTarget(_enemy != null ? _enemy.PlayerTransform : null); + _direction = _transform.localScale.x >= 0f ? 1f : -1f; + + var seq = _config != null ? _config.attackSequence : null; + var windupAtk = (seq != null && seq.Length > 0) ? seq[0] : null; + var chargeAtk = (seq != null && seq.Length > 1) ? seq[1] : windupAtk; + + // Windup + if (windupAtk != null && windupAtk.clip != null && _animancer != null) + _animancer.Play(windupAtk.clip); + _rb.velocity = new Vector2(0f, _rb.velocity.y); + yield return EnemyAbilityWaits.Get(_windupTime); + + // Charge + Phase = AbilityRunState.Active; + if (chargeAtk != null && chargeAtk.clip != null && _animancer != null) + _animancer.Play(chargeAtk.clip); + if (_chargeHitBox != null) + _chargeHitBox.Activate(chargeAtk != null ? chargeAtk.damageSource : null, _transform); + + Vector2 start = _rb.position; + bool wallHit = false; + float maxTime = (_maxDistance / _chargeSpeed) * 3f; // 3 倍容差兜底,防止极端物理情况下死循环 + float elapsed = 0f; + while (true) + { + _rb.velocity = new Vector2(_chargeSpeed * _direction, _rb.velocity.y); + yield return new WaitForFixedUpdate(); + elapsed += Time.fixedDeltaTime; + if (elapsed >= maxTime) break; // 超时保护 + float traveled = Mathf.Abs(_rb.position.x - start.x); + if (traveled >= _maxDistance) break; + var hit = Physics2D.Raycast(_rb.position, new Vector2(_direction, 0f), _wallCheckDist, _wallMask); + if (hit.collider != null) { wallHit = true; break; } + } + + _rb.velocity = new Vector2(0f, _rb.velocity.y); + if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate(); + + // Recovery + float recover = wallHit && _stunOnWallHit ? _wallStunTime : _recoveryTime; + yield return EnemyAbilityWaits.Get(recover); + } + + protected override void OnInterrupted(InterruptReason reason) + { + if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate(); + if (_rb != null) _rb.velocity = Vector2.zero; // 完整清零速度,包含 y 轴 + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta new file mode 100644 index 0000000..2d001ff --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 66784dd946e412049ad4425aa7be8a47 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs new file mode 100644 index 0000000..e354bbf --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -0,0 +1,189 @@ +using System.Collections; +using UnityEngine; +using Animancer; +using BaseGames.Core; +using BaseGames.Core.Pool; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 敌人能力抽象基类(架构 07_EnemyModule §8.4)。 + /// 责任:生命周期管理、冷却计时、中断分发。子类只实现 。 + /// + /// 设计要点: + /// - 单一执行实例:同一能力同时只有一个协程在跑。 + /// - 协程内 yield WaitForSeconds 复用 (无 GC)。 + /// - 受击/死亡时由 调用 。 + /// + [DisallowMultipleComponent] + public abstract class EnemyAbilityBase : MonoBehaviour + { + [Header("配置 SO")] + [SerializeField] protected EnemyAbilitySO _config; + + // 缓存依赖(Awake 填入,热路径无 GetComponent) + protected EnemyBase _enemy; + protected AnimancerComponent _animancer; + protected Transform _transform; + + private Coroutine _runner; + private float _cooldownEndTime = -1f; + private bool _isRunning; + + // ── 公共状态 ───────────────────────────────────────────────────── + public EnemyAbilitySO Config => _config; + public bool IsRunning => _isRunning; + public AbilityRunState Phase { get; protected set; } = AbilityRunState.Idle; + public float CooldownRemaining => Mathf.Max(0f, _cooldownEndTime - Time.time); + public bool IsOnCooldown => CooldownRemaining > 0f; + + /// 能力被外部中断时触发(BD Task / 状态机订阅用)。 + public event System.Action Interrupted; + + /// BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。 + public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive; + + protected virtual void Awake() + { + _enemy = GetComponentInParent(); + _animancer = _enemy != null ? _enemy.Animancer : GetComponentInParent(); + _transform = transform; + if (_enemy == null) + Debug.LogError($"[EnemyAbilityBase] {GetType().Name} 找不到 EnemyBase。", this); + if (_animancer == null) + Debug.LogWarning($"[EnemyAbilityBase] {GetType().Name} 找不到 AnimancerComponent,动画能力将无法播放动画。", this); + } + + protected virtual void OnDisable() + { + if (_isRunning) Interrupt(InterruptReason.ExternalRequest); + } + + // ── 执行 ───────────────────────────────────────────────────────── + /// + /// 启动能力。重复调用、冷却中或已运行将返回 false。 + /// 若 非空,会先中断同组其他能力(互斥)。 + /// + public bool Execute() + { + if (!CanUse) return false; + + // 互斥组:启动前中断同组正在运行的其他能力 + if (_config != null && !string.IsNullOrEmpty(_config.exclusionGroup)) + _enemy?.Abilities.InterruptGroup(_config.exclusionGroup, InterruptReason.ExternalRequest); + + _runner = StartCoroutine(RunInternal()); + return true; + } + + /// + /// 强制启动能力,忽略冷却检查(连段语义:外部组合技调用子能力时使用)。 + /// 若能力正在运行则先中断再重启。 + /// + public bool ForceExecute() + { + if (_enemy == null || !_enemy.IsAlive) return false; + if (_isRunning) Interrupt(InterruptReason.ExternalRequest); + _runner = StartCoroutine(RunInternal()); + return true; + } + + private IEnumerator RunInternal() + { + _isRunning = true; + Phase = AbilityRunState.Telegraph; + try + { + if (_config != null && _config.telegraphDuration > 0f) + yield return TelegraphRoutine(); + + Phase = AbilityRunState.Windup; + yield return ExecuteCoroutine(); + Phase = AbilityRunState.Recovery; + } + finally + { + _isRunning = false; + _runner = null; + _cooldownEndTime = Time.time + (_config != null ? _config.cooldown : 0f); + if (Phase != AbilityRunState.Interrupted) Phase = AbilityRunState.Idle; + OnAbilityEnded(); + } + } + + /// 子类实现:能力主体。可分多段、含 HitBox 激活/弹幕生成/物理推进等。 + protected abstract IEnumerator ExecuteCoroutine(); + + /// 预警阶段(默认生成 VFX 后等待 telegraphDuration)。子类可重写。 + protected virtual IEnumerator TelegraphRoutine() + { + if (!string.IsNullOrEmpty(_config.telegraphVfxKey)) + { + var pool = ServiceLocator.GetOrDefault(); + pool?.Spawn(_config.telegraphVfxKey, _transform.position, Quaternion.identity); + } + yield return EnemyAbilityWaits.Get(_config.telegraphDuration); + } + + /// 能力结束钩子(被中断或正常结束都会调用)。 + protected virtual void OnAbilityEnded() { } + + /// 中断当前执行。冷却仍会按配置计入。 + public void Interrupt(InterruptReason reason) + { + if (!_isRunning) return; + if (_config != null) + { + if (reason == InterruptReason.Hurt && !_config.interruptOnHurt) return; + if (reason == InterruptReason.Stagger && !_config.interruptOnStagger) return; + } + if (_runner != null) StopCoroutine(_runner); + _runner = null; + _isRunning = false; + Phase = AbilityRunState.Interrupted; + OnInterrupted(reason); + Interrupted?.Invoke(reason); + OnAbilityEnded(); + _cooldownEndTime = Time.time + (_config != null ? _config.cooldown * 0.5f : 0f); + } + + protected virtual void OnInterrupted(InterruptReason reason) { } + + /// 子类辅助:朝向目标。 + protected void FaceTarget(Transform target) + { + if (target == null || _enemy == null) return; + float dx = target.position.x - _transform.position.x; + if (Mathf.Abs(dx) < 0.001f) return; + var s = _transform.localScale; + s.x = Mathf.Abs(s.x) * Mathf.Sign(dx); + _transform.localScale = s; + } + } + + /// WaitForSeconds 池(架构 §10 GC 优化)。能力协程统一通过此获取等待指令。 + internal static class EnemyAbilityWaits + { + private const int MaxCacheSize = 64; + + private static readonly System.Collections.Generic.Dictionary _cache + = new System.Collections.Generic.Dictionary(32); + public static WaitForSeconds Get(float seconds) + { + if (seconds <= 0f) return null; + if (!_cache.TryGetValue(seconds, out var w)) + { + if (_cache.Count < MaxCacheSize) + { + w = new WaitForSeconds(seconds); + _cache[seconds] = w; + } + else + { + return new WaitForSeconds(seconds); + } + } + return w; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs.meta new file mode 100644 index 0000000..66e317c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d701244b04517a34d8f9214a606ba46e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs new file mode 100644 index 0000000..15e9a29 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 能力注册表(架构 07_EnemyModule §8.3)。 + /// EnemyBase.Awake 时调用 自动扫描子物体上所有 + /// 组件,按 abilityId 建立 O(1) 查询字典。 + /// + public sealed class EnemyAbilityRegistry + { + private readonly Dictionary _byId + = new Dictionary(8); + private readonly List _all = new List(8); + + /// 所有已注册能力(只读)。 + public IReadOnlyList All => _all; + + public void CollectFrom(GameObject root) + { + _byId.Clear(); + _all.Clear(); + root.GetComponentsInChildren(true, _all); + for (int i = 0; i < _all.Count; i++) + { + var ab = _all[i]; + if (ab == null || ab.Config == null || string.IsNullOrEmpty(ab.Config.abilityId)) + continue; + if (_byId.ContainsKey(ab.Config.abilityId)) + { + Debug.LogError($"[EnemyAbilityRegistry] 重复 abilityId='{ab.Config.abilityId}' 于 {ab.gameObject.name},后注册者被忽略。请检查配置。", ab); + continue; + } + _byId.Add(ab.Config.abilityId, ab); + } + } + + public EnemyAbilityBase Get(string abilityId) + { + if (string.IsNullOrEmpty(abilityId)) return null; + _byId.TryGetValue(abilityId, out var ab); + return ab; + } + + public T Get() where T : EnemyAbilityBase + { + for (int i = 0; i < _all.Count; i++) + if (_all[i] is T t) return t; + return null; + } + + public bool Has(string abilityId) => _byId.ContainsKey(abilityId); + + public void InterruptAll(InterruptReason reason) + { + for (int i = 0; i < _all.Count; i++) + { + var ab = _all[i]; + if (ab != null && ab.IsRunning) ab.Interrupt(reason); + } + } + + /// + /// 中断指定互斥组内所有正在执行的能力。 + /// 由 在 Execute 开始时调用,确保同组互斥。 + /// + public void InterruptGroup(string group, InterruptReason reason = InterruptReason.ExternalRequest) + { + if (string.IsNullOrEmpty(group)) return; + for (int i = 0; i < _all.Count; i++) + { + var ab = _all[i]; + if (ab != null && ab.IsRunning && ab.Config?.exclusionGroup == group) + ab.Interrupt(reason); + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs.meta new file mode 100644 index 0000000..d4f13d3 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7542012f25c120f4aad1914db8852b59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs new file mode 100644 index 0000000..6f8a599 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs @@ -0,0 +1,46 @@ +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 能力配置包(架构 07_EnemyModule §8.2)。 + /// 一个能力 = 一组攻击段 + 公共参数(冷却/预警/中断规则)。 + /// 由对应 EnemyAbilityBase 子类组件读取并执行。 + /// + [CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")] + public class EnemyAbilitySO : ScriptableObject + { + [Header("标识")] + [Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\")")] + public string abilityId = "ability_id"; + + [Header("攻击序列")] + public EnemyAttackSO[] attackSequence; + + [Header("冷却(秒,从能力执行结束开始计)")] + [Min(0f)] public float cooldown = 1.5f; + + [Header("预警(Telegraph)")] + [Tooltip("预警 VFX key(IObjectPoolService.Spawn 的池 key),为空则跳过")] + public string telegraphVfxKey = ""; + [Min(0f)] public float telegraphDuration = 0f; + + [Header("中断规则")] + [Tooltip("受击时是否打断能力(false=能力具霸体)")] + public bool interruptOnHurt = true; + [Tooltip("Stagger 时是否打断(一般为 true)")] + public bool interruptOnStagger = true; + + [Header("调度提示(供 AI 选择参考,不强制)")] + [Min(0f)] public float preferredMinRange = 0f; + [Min(0f)] public float preferredMaxRange = 5f; + public bool requiresLineOfSight = true; + public bool requiresGrounded = true; + + [Header("互斥组与调度优先级")] + [Tooltip("互斥组名:同组能力只能有一个同时执行,执行时自动中断同组其他能力;为空 = 无互斥")] + public string exclusionGroup = ""; + [Tooltip("AI 调度优先级(值越高越优先被选择,冷却就绪时对比)")] + [Min(0)] public int priority = 0; + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs.meta new file mode 100644 index 0000000..f60595d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9050afa76362dff469c64fbb48c9ff8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs new file mode 100644 index 0000000..07ad05b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs @@ -0,0 +1,48 @@ +namespace BaseGames.Enemies.Abilities +{ + /// + /// 单段攻击数据(架构 07_EnemyModule §8.1)。 + /// 描述"一次攻击"的所有要素:动画、HitBox 时机、伤害源、可选射弹。 + /// EnemyAbilitySO.attackSequence 按顺序组合多段为完整能力。 + /// + [UnityEngine.CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Attack", fileName = "EATK_")] + public class EnemyAttackSO : UnityEngine.ScriptableObject + { + [UnityEngine.Header("标识(仅调试用)")] + public string attackName = "Attack"; + + [UnityEngine.Header("动画")] + [UnityEngine.Tooltip("动画片段。若为空则跳过动画,按 fallbackDuration 推进。")] + public Animancer.ClipTransition clip; + [UnityEngine.Tooltip("clip 为空时本段总时长(秒)")] + public float fallbackDuration = 0.6f; + + [UnityEngine.Header("HitBox 时机(归一化 0-1,相对动画长度)")] + [UnityEngine.Tooltip("HitBox 槽位名(在 MeleeAttackAbility 的 hitBoxSlots 中索引),为空表示无近战 HitBox")] + public string hitBoxSlot = ""; + [UnityEngine.Range(0f, 1f)] public float hitBoxEnterT = 0.30f; + [UnityEngine.Range(0f, 1f)] public float hitBoxExitT = 0.55f; + + [UnityEngine.Header("伤害源")] + public BaseGames.Combat.DamageSourceSO damageSource; + + [UnityEngine.Header("射弹(远程能力使用)")] + public BaseGames.Combat.ProjectileConfigSO projectileConfig; + [UnityEngine.Min(1)] public int projectileCount = 1; + [UnityEngine.Range(0f, 180f)] public float spreadAngleDeg = 0f; + [UnityEngine.Tooltip("射弹生成时机(归一化)")] + [UnityEngine.Range(0f, 1f)] public float projectileFireT = 0.40f; + + [UnityEngine.Header("段间衔接")] + [UnityEngine.Tooltip("本段结束后到下一段开始的延迟(秒)")] + public float postDelay = 0f; + [UnityEngine.Tooltip("本段是否锁定移动(设速度为0)")] + public bool lockMovement = true; + + [UnityEngine.Header("可选霸体窗口")] + public bool hasPoiseWindow = false; + public BaseGames.Combat.PoiseLevel poiseLevel = BaseGames.Combat.PoiseLevel.Light; + [UnityEngine.Range(0f, 1f)] public float poiseStartT = 0.10f; + [UnityEngine.Range(0f, 1f)] public float poiseEndT = 0.55f; + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta new file mode 100644 index 0000000..8a86775 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 33dae93853f55b34c95cb12fb235c8b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs new file mode 100644 index 0000000..f231b2f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs @@ -0,0 +1,98 @@ +using System.Collections; +using UnityEngine; +using BaseGames.Combat; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 跳跃俯冲能力:起跳后以抛物线轨迹扑向目标,落地触发 AoE。 + /// 流程:起跳预备 → 抛物线移动到目标 → 落地 AoE。 + /// 使用 Rigidbody2D 直接物理推动;NavAgent 在执行期间被禁用以避免冲突。 + /// + [RequireComponent(typeof(Rigidbody2D))] + public sealed class LeapAttackAbility : EnemyAbilityBase + { + [Header("跳跃参数")] + [SerializeField] private float _jumpHeight = 4f; + [SerializeField] private float _maxRange = 8f; + [SerializeField] private float _windupTime = 0.35f; + [SerializeField] private float _recoveryTime = 0.4f; + [SerializeField] private LayerMask _groundMask; + + [Header("落地 AoE")] + [SerializeField] private HitBox _landingHitBox; + [SerializeField] private float _hitBoxActiveTime = 0.15f; + + private Rigidbody2D _rb; + private float _origGravity; + + protected override void Awake() + { + base.Awake(); + _rb = GetComponentInParent(); + if (_rb != null) _origGravity = _rb.gravityScale; + } + + protected override IEnumerator ExecuteCoroutine() + { + if (_rb == null || _enemy == null || _enemy.PlayerTransform == null) yield break; + var atk = _config.attackSequence != null && _config.attackSequence.Length > 0 + ? _config.attackSequence[0] : null; + + FaceTarget(_enemy.PlayerTransform); + + // Windup(可选动画) + if (atk != null && atk.clip != null && _animancer != null) + _animancer.Play(atk.clip); + yield return EnemyAbilityWaits.Get(_windupTime); + + // 计算抛物线初速度 + Vector2 from = _rb.position; + Vector2 to = _enemy.PlayerTransform.position; + float dx = Mathf.Clamp(to.x - from.x, -_maxRange, _maxRange); + float g = Mathf.Abs(Physics2D.gravity.y) * _origGravity; + float vy = Mathf.Sqrt(2f * g * Mathf.Max(0.1f, _jumpHeight)); + float tUp = vy / g; + float dyDown = (from.y + _jumpHeight) - to.y; + float tDown = Mathf.Sqrt(2f * Mathf.Max(0.01f, dyDown) / g); + float total = tUp + tDown; + float vx = dx / Mathf.Max(0.1f, total); + + Phase = AbilityRunState.Active; + _rb.velocity = new Vector2(vx, vy); + + // 空中飞行直到接触地面 + yield return null; + float airTimer = 0f; + while (airTimer < total + 0.5f) + { + airTimer += Time.fixedDeltaTime; + yield return new WaitForFixedUpdate(); + if (airTimer > 0.1f && IsGrounded()) break; + } + _rb.velocity = new Vector2(0f, _rb.velocity.y); + + // 落地 AoE + if (_landingHitBox != null) + { + var src = atk != null ? atk.damageSource : null; + _landingHitBox.Activate(src, _transform); + yield return EnemyAbilityWaits.Get(_hitBoxActiveTime); + _landingHitBox.Deactivate(); + } + + yield return EnemyAbilityWaits.Get(_recoveryTime); + } + + private bool IsGrounded() + { + var hit = Physics2D.Raycast(_rb.position, Vector2.down, 0.6f, _groundMask); + return hit.collider != null; + } + + protected override void OnInterrupted(InterruptReason reason) + { + if (_landingHitBox != null && _landingHitBox.IsActive) _landingHitBox.Deactivate(); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs.meta new file mode 100644 index 0000000..cbe3c78 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f6cb37d9690ce647ae1e3385d86eb96 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs new file mode 100644 index 0000000..06c1540 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs @@ -0,0 +1,119 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using BaseGames.Combat; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 近战连击能力。 + /// 按 EnemyAbilitySO.attackSequence 顺序播放动画段,每段在 hitBoxEnterT~hitBoxExitT + /// 之间激活对应槽位的 HitBox。 + /// + /// 设计:HitBox 通过命名槽位绑定,避免对 EnemyCombat 的硬依赖;同一敌人多个近战 + /// 能力可共享同一 HitBox 槽位(如不同段使用同一把武器)。 + /// + public sealed class MeleeAttackAbility : EnemyAbilityBase + { + [System.Serializable] + public struct HitBoxSlot + { + public string slotName; + public HitBox hitBox; + } + + [Header("HitBox 槽位(按名字索引)")] + [SerializeField] private HitBoxSlot[] _hitBoxSlots; + + [Header("行为")] + [SerializeField] private bool _faceTargetOnStart = true; + + private Dictionary _slotMap; + + protected override void Awake() + { + base.Awake(); + _slotMap = new Dictionary(_hitBoxSlots?.Length ?? 0); + if (_hitBoxSlots != null) + { + for (int i = 0; i < _hitBoxSlots.Length; i++) + { + var s = _hitBoxSlots[i]; + if (s.hitBox != null && !string.IsNullOrEmpty(s.slotName)) + _slotMap[s.slotName] = s.hitBox; + } + } + } + + protected override IEnumerator ExecuteCoroutine() + { + var seq = _config != null ? _config.attackSequence : null; + if (seq == null || seq.Length == 0) yield break; + + if (_faceTargetOnStart && _enemy != null && _enemy.PlayerTransform != null) + FaceTarget(_enemy.PlayerTransform); + + for (int i = 0; i < seq.Length; i++) + { + var atk = seq[i]; + if (atk == null) continue; + yield return PlayAttackStep(atk); + if (atk.postDelay > 0f) + yield return EnemyAbilityWaits.Get(atk.postDelay); + } + } + + private IEnumerator PlayAttackStep(EnemyAttackSO atk) + { + Phase = AbilityRunState.Active; + + float duration = atk.fallbackDuration; + if (atk.clip != null && _animancer != null) + { + var state = _animancer.Play(atk.clip); + if (state != null && state.Length > 0f) duration = state.Length; + } + + HitBox hb = null; + if (!string.IsNullOrEmpty(atk.hitBoxSlot)) + _slotMap.TryGetValue(atk.hitBoxSlot, out hb); + + float enterAbs = atk.hitBoxEnterT * duration; + float exitAbs = atk.hitBoxExitT * duration; + float t = 0f; + bool active = false; + + while (t < duration) + { + t += Time.deltaTime; + if (hb != null) + { + if (!active && t >= enterAbs) + { + hb.Activate(atk.damageSource, _transform); + active = true; + } + else if (active && t >= exitAbs) + { + hb.Deactivate(); + active = false; + } + } + yield return null; + } + + if (active && hb != null) hb.Deactivate(); + } + + protected override void OnInterrupted(InterruptReason reason) + { + // 确保 HitBox 被关闭,防止中断时残留激活 + if (_hitBoxSlots == null) return; + for (int i = 0; i < _hitBoxSlots.Length; i++) + { + var hb = _hitBoxSlots[i].hitBox; + if (hb != null && hb.IsActive) hb.Deactivate(); + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs.meta new file mode 100644 index 0000000..dfd2efd --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 971ba82e05d87234e8b944760542e47c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs new file mode 100644 index 0000000..12515c4 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs @@ -0,0 +1,49 @@ +using System.Collections; +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 连续冲刺能力:将另一个 ChargeAbility 作为单次冲刺单元顺序触发 dashCount 次。 + /// 每次冲刺之间间隔 pauseBetweenDashes 秒,每次冲刺前可重新朝向目标。 + /// + public sealed class MultiDashAbility : EnemyAbilityBase + { + [Header("冲刺单元")] + [Tooltip("作为冲刺单元的 ChargeAbility 的 abilityId")] + [SerializeField] private string _dashAbilityId = "charge"; + + [Header("节奏")] + [SerializeField, Min(1)] private int _dashCount = 3; + [SerializeField, Min(0f)] private float _pauseBetweenDashes = 0.25f; + [SerializeField] private bool _refaceTargetEachDash = true; + + protected override IEnumerator ExecuteCoroutine() + { + if (_enemy == null) yield break; + var dash = _enemy.Abilities.Get(_dashAbilityId); + if (dash == null) + { + Debug.LogWarning($"[MultiDashAbility] 找不到冲刺单元 abilityId='{_dashAbilityId}'", this); + yield break; + } + + for (int i = 0; i < _dashCount; i++) + { + if (_refaceTargetEachDash && _enemy.PlayerTransform != null) + FaceTarget(_enemy.PlayerTransform); + + // 强制执行子冲刺(连段语义,忽略冷却) + if (!dash.ForceExecute()) + { + Debug.LogWarning($"[MultiDashAbility] ForceExecute 失败(第 {i + 1} 次)", this); + yield break; + } + while (dash.IsRunning) yield return null; + + if (i < _dashCount - 1 && _pauseBetweenDashes > 0f) + yield return EnemyAbilityWaits.Get(_pauseBetweenDashes); + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs.meta new file mode 100644 index 0000000..fa8d57f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10880cfeb05429946854cce4b0e24626 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs new file mode 100644 index 0000000..71950d2 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs @@ -0,0 +1,126 @@ +using System.Collections; +using UnityEngine; +using BaseGames.Combat; +using BaseGames.Core; +using BaseGames.Core.Pool; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 远程射弹能力(扇形 / 弹幕)。 + /// 每段攻击按 projectileFireT 触发,从池中生成 projectileCount 个抛射物, + /// 按 spreadAngleDeg 均布角度。 + /// + public sealed class ProjectileAttackAbility : EnemyAbilityBase + { + [Header("发射点(为空则使用本物体位置)")] + [SerializeField] private Transform _muzzle; + + [Header("行为")] + [SerializeField] private bool _faceTargetOnStart = true; + + private IObjectPoolService _pool; + + protected override void Awake() + { + base.Awake(); + if (_muzzle == null) _muzzle = _transform; + } + + protected override IEnumerator ExecuteCoroutine() + { + _pool ??= ServiceLocator.GetOrDefault(); + var seq = _config != null ? _config.attackSequence : null; + if (seq == null || seq.Length == 0) yield break; + + if (_faceTargetOnStart && _enemy != null && _enemy.PlayerTransform != null) + FaceTarget(_enemy.PlayerTransform); + + for (int i = 0; i < seq.Length; i++) + { + var atk = seq[i]; + if (atk == null) continue; + yield return PlayShot(atk); + if (atk.postDelay > 0f) yield return EnemyAbilityWaits.Get(atk.postDelay); + } + } + + private IEnumerator PlayShot(EnemyAttackSO atk) + { + Phase = AbilityRunState.Active; + float duration = atk.fallbackDuration; + if (atk.clip != null && _animancer != null) + { + var state = _animancer.Play(atk.clip); + if (state != null && state.Length > 0f) duration = state.Length; + } + + float fireAbs = atk.projectileFireT * duration; + float t = 0f; + bool fired = false; + + while (t < duration) + { + t += Time.deltaTime; + if (!fired && t >= fireAbs) + { + SpawnVolley(atk); + fired = true; + } + yield return null; + } + if (!fired) SpawnVolley(atk); + } + + private void SpawnVolley(EnemyAttackSO atk) + { + if (_pool == null || atk.projectileConfig == null) return; + var cfg = atk.projectileConfig; + if (string.IsNullOrEmpty(cfg.PoolKey)) return; + + Vector2 baseDir = GetAimDirection(); + int count = Mathf.Max(1, atk.projectileCount); + float spread = atk.spreadAngleDeg; + float startAngle = -spread * 0.5f; + float step = count > 1 ? spread / (count - 1) : 0f; + + var src = atk.damageSource != null ? atk.damageSource : cfg.DamageSource; + int layer = gameObject.layer; + + for (int i = 0; i < count; i++) + { + float angle = startAngle + step * i; + Vector2 dir = Rotate(baseDir, angle); + + var go = _pool.Spawn(cfg.PoolKey, _muzzle.position, Quaternion.identity); + if (go == null) + { + Debug.LogWarning($"[ProjectileAttackAbility] 对象池 '{cfg.PoolKey}' 无法获取弹体,请检查池配置或容量。", this); + continue; + } + var proj = go.GetComponent(); + if (proj == null) + { + Debug.LogWarning($"[ProjectileAttackAbility] 弹体 '{go.name}' 缺少 Projectile 组件,请检查 Prefab 配置。", this); + continue; + } + var info = DamageInfo.From(src, dir, _muzzle.position, layer, proj); + proj.Initialize(cfg, info, dir, layer); + } + } + + private Vector2 GetAimDirection() + { + if (_enemy != null && _enemy.PlayerTransform != null) + return ((Vector2)_enemy.PlayerTransform.position - (Vector2)_muzzle.position).normalized; + return _transform.localScale.x >= 0f ? Vector2.right : Vector2.left; + } + + private static Vector2 Rotate(Vector2 v, float degrees) + { + float rad = degrees * Mathf.Deg2Rad; + float c = Mathf.Cos(rad), s = Mathf.Sin(rad); + return new Vector2(v.x * c - v.y * s, v.x * s + v.y * c); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs.meta new file mode 100644 index 0000000..f598656 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/ProjectileAttackAbility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a80eb7827a2ec3b44bc7ad651e86dbce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AiPhase.cs b/Assets/_Game/Scripts/Enemies/AiPhase.cs new file mode 100644 index 0000000..b60a9b0 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AiPhase.cs @@ -0,0 +1,45 @@ +namespace BaseGames.Enemies +{ + /// + /// 敌人 AI 行为阶段枚举(独立于 EnemyStateType 的物理/战斗状态)。 + /// + /// 两套状态正交关系: + /// - :受击/击飞/死亡等物理事件驱动的瞬时状态。 + /// - :行为树主动切换的持续阶段,反映 AI 当前的战术意图。 + /// + /// 典型切换路径: + /// + /// Idle / Patrol + /// → [感知触发] → Alert(短暂警觉动作) + /// → Chase(追击) + /// → Combat(攻击范围内) + /// ↓ 丢失视线超时 + /// → Investigate(前往最后可见位置搜查) + /// ↓ 搜查完成或超出范围 + /// → ReturnHome(归位)→ Patrol + /// + /// + public enum AiPhase + { + /// 未激活,静止或播放待机循环动画。 + Idle, + + /// 沿路点或随机目的地正常巡逻,无感知威胁。 + Patrol, + + /// 感知到威胁后的短暂警觉过渡阶段,通常播放抬头/发现动作后进入 Chase。 + Alert, + + /// 全力追击玩家,使用奔跑速度。 + Chase, + + /// 玩家在攻击距离内,准备或正在执行攻击。 + Combat, + + /// 丢失目标后前往最后可见坐标搜查,若无发现则切 ReturnHome 或 Patrol。 + Investigate, + + /// 超出追击范围或搜查结束后归位到初始位置。 + ReturnHome, + } +} diff --git a/Assets/_Game/Scripts/Enemies/AiPhase.cs.meta b/Assets/_Game/Scripts/Enemies/AiPhase.cs.meta new file mode 100644 index 0000000..fd8a252 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AiPhase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2390565562baf84fb61b592c2dade24 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef b/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef index a41bcaa..8806378 100644 --- a/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef +++ b/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef @@ -1,25 +1,27 @@ { - "excludePlatforms": [], - "allowUnsafeCode": false, - "precompiledReferences": [], - "name": "BaseGames.Enemies", - "defineConstraints": [], - "noEngineReferences": false, - "versionDefines": [], - "rootNamespace": "BaseGames.Enemies", - "references": [ - "BaseGames.Core", - "BaseGames.Core.Events", - "BaseGames.Combat", - "BaseGames.Feedback", - "BaseGames.World", - "MoreMountains.Tools", - "Kybernetik.Animancer", - "Opsive.BehaviorDesigner.Runtime", - "Unity.Addressables", - "Unity.ResourceManager" - ], - "autoReferenced": true, - "overrideReferences": false, - "includePlatforms": [] -} + "name": "BaseGames.Enemies", + "rootNamespace": "BaseGames.Enemies", + "references": [ + "BaseGames.Core", + "BaseGames.Core.Events", + "BaseGames.Combat", + "BaseGames.Feedback", + "BaseGames.World", + "MoreMountains.Tools", + "Kybernetik.Animancer", + "Opsive.BehaviorDesigner.Runtime", + "Unity.Addressables", + "Unity.ResourceManager", + "Micosmo.SensorToolkit", + "BaseGames.Parry" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index f4db6df..a445ee8 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -1,10 +1,15 @@ +using System.Collections; +using System.Collections.Generic; using UnityEngine; +using BaseGames.Boss; +using BaseGames.Combat; using BaseGames.Core.Events; +using BaseGames.Parry; namespace BaseGames.Enemies { /// - /// Boss 敌人基类。扩展 以支持多阶段切换与战斗结束广播。 + /// Boss 敌人基类。扩展 以支持多阶段切换、技能执行与战斗结束广播。 /// 具体 Boss 继承此类并重写 。 /// public class BossBase : EnemyBase @@ -14,17 +19,171 @@ namespace BaseGames.Enemies [SerializeField] private BoolEventChannelSO _onBossFightEnded; [SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged; + [Header("技能执行器")] + [SerializeField] private BossSkillExecutor _skillExecutor; + + [Header("资源组件(可选)")] + [SerializeField] private BossResource _bossResource; + + [Header("玩家反制事件(可选)")] + [Tooltip("订阅此频道以响应玩家弹反成功事件")] + [SerializeField] private ParryInfoEventChannelSO _onParrySuccess; + public string BossId => _bossId; + /// 当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。 + public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting; + protected int _currentPhase = 0; + /// 当前 Boss 阶段索引(BD Task 可直接查询)。 + public int CurrentPhase => _currentPhase; + private Coroutine _counterStaggerCoroutine; + + // 缓存加权候选列表,避免 UseBossSkillWeighted() 每次 new List → GC 分配 + private readonly List<(BossSkillSO skill, float w)> _weightedCandidates = new(8); + + // 单元素缓冲数组,供 ApplyCounterResponse 缓存当前技能,避免 new[] 分配 + private readonly BossSkillSO[] _singleSkillBuf = new BossSkillSO[1]; + + protected override void Awake() + { + base.Awake(); + // includeInactive:true 确保禁用状态的子组件也能被发现(如分阶段按需启用的执行器) + if (_skillExecutor == null) _skillExecutor = GetComponentInChildren(true); + if (_bossResource == null) _bossResource = GetComponentInChildren(true); + } + + protected override void OnEnable() + { + base.OnEnable(); + _onParrySuccess?.Subscribe(HandleParrySuccess).AddTo(_subs); + } /// - /// 进入指定阶段。广播 供 UI / 音乐系统响应。 + /// 阶段过渡期间完全无敌( 的 IsInvincible 检查由此路由)。 + /// + public override bool IsInvincible => IsPhaseTransitioning || base.IsInvincible; + + /// + /// 上一次成功执行的技能 ID。 对其施加权重惩罚,防止相同技能连续重复。 + /// + public string LastUsedSkillId { get; private set; } + + // ── 技能执行(BD Task 调用入口)───────────────────────────────────── + + /// + /// 通过技能 ID 执行 Boss 技能。 + /// 若技能未找到、执行器忙或冷却中则返回 false,否则返回 true。 + /// + public bool UseBossSkill(string skillId) + { + if (_skillExecutor == null || string.IsNullOrEmpty(skillId)) return false; + if (IsPhaseTransitioning) return false; + var skill = _skillExecutor.FindSkill(skillId); + if (skill == null) + { + Debug.LogWarning($"[BossBase] 未找到技能 '{skillId}'(Boss: {_bossId})", this); + return false; + } + if (!_skillExecutor.CanUseSkill(skillId)) + return false; + if (!CheckResourceCost(skill)) + return false; + + _skillExecutor.ExecuteSkill(skill); + _bossResource?.OnBossUseSkill(); + return true; + } + + /// + /// 在当前阶段可用且冷却就绪的技能中,按 加权随机选择一个并执行。 + /// 若上一次已使用某技能,则对该技能施加 0.3× 权重惩罚,降低连续重复的概率。 + /// 若无可用技能或执行器忙则返回 false。 + /// + public bool UseBossSkillWeighted() + { + if (_skillExecutor == null || _skillExecutor.IsExecuting) return false; + if (IsPhaseTransitioning) return false; + + var skills = _skillExecutor.Skills; + if (skills == null || skills.Length == 0) return false; + + // 筛选:在当前阶段可用 + 冷却就绪 + weight > 0 + _weightedCandidates.Clear(); + float totalWeight = 0f; + foreach (var s in skills) + { + if (s == null || s.weight <= 0f) continue; + if (!_skillExecutor.CanUseSkill(s.skillId)) continue; + if (!IsSkillAvailableInPhase(s)) continue; + + // 防重复:上一个技能权重打折 + float w = s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight; + _weightedCandidates.Add((s, w)); + totalWeight += w; + } + + if (_weightedCandidates.Count == 0 || totalWeight <= 0f) return false; + + // 加权随机抽取 + float roll = UnityEngine.Random.Range(0f, totalWeight); + BossSkillSO selected = null; + float accum = 0f; + foreach (var (skill, w) in _weightedCandidates) + { + accum += w; + if (roll <= accum) { selected = skill; break; } + } + selected ??= _weightedCandidates[_weightedCandidates.Count - 1].skill; + + if (!CheckResourceCost(selected)) return false; + + _skillExecutor.ExecuteSkill(selected); + LastUsedSkillId = selected.skillId; + _bossResource?.OnBossUseSkill(); + return true; + } + + /// 检查技能的 availablePhaseIndices 是否包含当前阶段(空数组 = 全阶段可用)。 + private bool IsSkillAvailableInPhase(BossSkillSO skill) + { + if (skill.availablePhaseIndices == null || skill.availablePhaseIndices.Length == 0) + return true; + foreach (int p in skill.availablePhaseIndices) + if (p == _currentPhase) return true; + return false; + } + + /// + /// 检查 Boss 资源是否满足技能的 minRequired 门槛。 + /// 未配置资源组件或 minRequired <= 0 时视为通过。 + /// + private bool CheckResourceCost(BossSkillSO skill) + { + if (_bossResource == null) return true; + float min = skill.resourceCost.minRequired; + if (min <= 0f) return true; + return _bossResource.CurrentValue >= min; + } + + // ── 阶段 ────────────────────────────────────────────────────────────── + + /// 当前是否处于阶段过渡(无敌帧 + 过渡演出)期间。 + public bool IsPhaseTransitioning { get; private set; } + + private Coroutine _phaseTransitionCoroutine; + + /// + /// 进入指定阶段。自动打断当前执行中的技能,广播 供 UI / 音乐系统响应。 /// 子类可重写以添加额外过渡逻辑(动画、无敌帧等)。 /// public virtual void EnterPhase(int phase) { - _currentPhase = phase; + // 阶段切换必须先打断正在执行的技能,确保原子性 + _skillExecutor?.InterruptCurrentSkill(); + + _currentPhase = phase; + LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移 _onBossPhaseChanged?.Raise(new BossPhaseEvent { BossId = _bossId, @@ -32,6 +191,61 @@ namespace BaseGames.Enemies }); } + /// + /// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 。 + /// BD_BossPhaseTransition 检查 来等待过渡完成。 + /// + /// 过渡目标阶段索引。 + /// 无敌帧持续时间(秒)。 + public void BeginPhaseTransition(int targetPhase, float invincibleDuration = 1.5f) + { + if (IsPhaseTransitioning) + { + Debug.LogWarning( + $"[BossBase] '{_bossId}' 已在阶段过渡中(当前阶段 {_currentPhase})," + + $"忽略跳转至阶段 {targetPhase} 的请求。请检查行为树逻辑是否重复触发阶段切换。", + this); + return; + } + if (_phaseTransitionCoroutine != null) StopCoroutine(_phaseTransitionCoroutine); + _phaseTransitionCoroutine = StartCoroutine(PhaseTransitionCoroutine(targetPhase, invincibleDuration)); + } + + private IEnumerator PhaseTransitionCoroutine(int targetPhase, float duration) + { + IsPhaseTransitioning = true; + + // 打断技能 + 停止移动 + _skillExecutor?.InterruptCurrentSkill(); + StopMovement(); + + // 无敌帧期间接受的伤害由 IsInvincible 属性屏蔽(子类重写 IsInvincible 或在此处理) + float elapsed = 0f; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + yield return null; + } + + EnterPhase(targetPhase); + IsPhaseTransitioning = false; + _phaseTransitionCoroutine = null; + } + + /// + /// 立即终止阶段过渡协程并清除标志位。 + /// 死亡时调用,防止 IsPhaseTransitioning 永久为 true 影响对象池复用。 + /// + private void AbortPhaseTransition() + { + if (_phaseTransitionCoroutine != null) + { + StopCoroutine(_phaseTransitionCoroutine); + _phaseTransitionCoroutine = null; + } + IsPhaseTransitioning = false; + } + /// 检查当前 HP 是否低于指定百分比(0~1)。 public bool IsHPBelow(float ratio) { @@ -39,10 +253,109 @@ namespace BaseGames.Enemies return (float)_stats.CurrentHP / _stats.MaxHP < ratio; } + protected override void OnDamageTaken(DamageInfo info) + { + _bossResource?.OnBossTakeDamage(); + } + protected override void Die() { + // 死亡时立即中止阶段过渡,防止 IsPhaseTransitioning 标志永久锁死(影响对象池复用) + AbortPhaseTransition(); base.Die(); _onBossFightEnded?.Raise(true); } + + // ── 玩家反制响应 ────────────────────────────────────────────────────── + + private void HandleParrySuccess(ParryInfo info) + { + if (!IsAlive) return; + var counterType = info.IsPerfect ? CounterType.PerfectParry : CounterType.Parry; + ApplyCounterResponse(counterType, string.Empty); + } + + /// + /// 根据 counterType 查找当前技能(或所有技能)的 PlayerCounterResponse 并应用效果。 + /// 可由外部系统(闪避穿越、弱点命中等)直接调用。 + /// + public void ApplyCounterResponse(CounterType counterType, string requiredSkillId) + { + if (_skillExecutor == null) return; + + // 优先检查当前正在执行的技能的反制规则 + BossSkillSO activeSkill = _skillExecutor.IsExecuting + ? _skillExecutor.FindCurrentSkill() + : null; + + BossSkillSO[] candidates; + if (activeSkill != null) + { + _singleSkillBuf[0] = activeSkill; + candidates = _singleSkillBuf; + } + else + { + candidates = _skillExecutor.Skills; + } + + if (candidates == null) return; + + foreach (var skill in candidates) + { + if (skill?.counterResponses == null) continue; + foreach (var resp in skill.counterResponses) + { + if (resp.counterType != counterType) continue; + if (!string.IsNullOrEmpty(resp.requiredSkillId) && + !string.IsNullOrEmpty(requiredSkillId) && + resp.requiredSkillId != requiredSkillId) + continue; + + ExecuteCounterEffect(resp); + return; // 每次反制只触发第一条匹配规则 + } + } + } + + private void ExecuteCounterEffect(in PlayerCounterResponse resp) + { + if (resp.interruptSkill) + _skillExecutor?.InterruptCurrentSkill(); + + if (resp.bossStaggerDuration > 0f) + { + if (_counterStaggerCoroutine != null) + StopCoroutine(_counterStaggerCoroutine); + _counterStaggerCoroutine = StartCoroutine(CounterStaggerCoroutine(resp.bossStaggerDuration)); + } + + if (resp.openVulnWindow) + { + float duration = Mathf.Max(resp.bossStaggerDuration, 1f); + float multiplier = 1f + resp.bossDamageBonus; + _skillExecutor?.OpenVulnerabilityWindow(duration, multiplier); + } + + resp.counterFeedback?.Play(); + } + + private IEnumerator CounterStaggerCoroutine(float duration) + { + ForceState(EnemyStateType.Stagger); + // 时长固定且较短,直接 new WFY 即可;若需优化可接入 WFS 缓存 + yield return new WaitForSeconds(duration); + if (IsAlive && CurrentState == EnemyStateType.Stagger) + ForceState(EnemyStateType.Controlled); + _counterStaggerCoroutine = null; + } + + public override void OnSpawn() + { + base.OnSpawn(); + LastUsedSkillId = null; + _currentPhase = 0; + _skillExecutor?.ResetAllCooldowns(); + } } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs new file mode 100644 index 0000000..681ac2b --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs @@ -0,0 +1,104 @@ +using UnityEngine; +using BaseGames.Enemies; + +namespace BaseGames.Boss +{ + /// + /// Boss 自身资源(如愤怒值)运行时组件。 + /// 根据 配置: + /// - 每帧以 自动积累或消耗。 + /// - 受击时增加 。 + /// - 技能使用时增加 。 + /// - 满值时若 ,自动让 BossBase 执行配置的技能。 + /// + public sealed class BossResource : MonoBehaviour + { + [SerializeField] private BossResourceConfigSO _config; + [SerializeField] private BossBase _boss; + + private float _currentValue; + private bool _fullTriggered; + + /// 当前资源值(0 ~ config.maxValue)。 + public float CurrentValue => _currentValue; + + /// 当前资源值归一化(0~1)。 + public float NormalizedValue => _config != null && _config.maxValue > 0f + ? _currentValue / _config.maxValue : 0f; + + private void Awake() + { + if (_boss == null) _boss = GetComponentInParent(); + if (_config == null) + { + Debug.LogError("[BossResource] 未配置 BossResourceConfigSO。", this); + enabled = false; + return; + } + _currentValue = _config.startValue; + } + + private void Update() + { + if (_config == null || !_boss.IsAlive) return; + + if (_config.passiveRate != 0f) + AddValue(_config.passiveRate * Time.deltaTime); + } + + // ── 外部触发 ────────────────────────────────────────────────────────── + + /// Boss 受击时调用。由 BossBase.TakeDamage 覆写钩子触发。 + public void OnBossTakeDamage() + { + if (_config == null) return; + AddValue(_config.onTakeDamageGain); + } + + /// Boss 使用技能时调用。由 BossBase.UseBossSkill 触发。 + public void OnBossUseSkill() + { + if (_config == null) return; + AddValue(_config.onSkillUseGain); + } + + /// 直接设置资源值(外部强制赋值,跳过满值触发)。 + public void SetValue(float value) + { + _currentValue = Mathf.Clamp(value, 0f, _config != null ? _config.maxValue : float.MaxValue); + } + + // ── 内部 ────────────────────────────────────────────────────────────── + + private void AddValue(float delta) + { + float prev = _currentValue; + _currentValue = Mathf.Clamp(_currentValue + delta, 0f, _config.maxValue); + + // 满值触发(从未满→满时只触发一次) + if (_config.autoTriggerOnFull && + _currentValue >= _config.maxValue && + prev < _config.maxValue && + !_fullTriggered) + { + _fullTriggered = true; + OnReachFull(); + } + + if (_currentValue < _config.maxValue) + _fullTriggered = false; + } + + private void OnReachFull() + { + if (_config.fullTriggerSkill == null || _boss == null) return; + + _boss.UseBossSkill(_config.fullTriggerSkill.skillId); + + if (_config.resetValueAfterTrigger > 0f) + _currentValue = _config.resetValueAfterTrigger; + else + _currentValue = 0f; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta new file mode 100644 index 0000000..39432c9 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f53bc0514e6b1143bb5ec17c25ee2c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs index a74d0a6..5543ac2 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs @@ -22,12 +22,68 @@ namespace BaseGames.Boss /// PlayerController 无 Instance(架构 05 §2),由 Inspector 指定。 [SerializeField] private Transform _playerTransform; + [SerializeField] private BossSkillSO[] _skills; + + [Header("技能重复检测范围")] + [Tooltip("SkillSequence RepeatIfPlayerInRange 的检测半径(m)")] + [SerializeField, Min(1f)] private float _repeatRangeCheck = 8f; + private BossSkillSO _currentSkill; private bool _isExecuting; private Coroutine _activeCoroutine; + private Coroutine _vulnCoroutine; // 弱点窗口协程(中断时需同步停止) + private bool _patternHitConfirmed; // 本次技能执行期间是否有 HitBox 命中 + + // 技能冷却:skillId → 冷却结束的 Time.time 时刻 + private readonly Dictionary _skillCooldownEndTimes = new(); public bool IsExecuting => _isExecuting; + /// 检查指定技能是否冷却就绪(无冷却记录或已过冷却时间)。 + public bool CanUseSkill(string skillId) + { + if (string.IsNullOrEmpty(skillId)) return false; + if (_skillCooldownEndTimes.TryGetValue(skillId, out float endTime)) + return Time.time >= endTime; + return true; + } + + /// 强制重置指定技能的冷却(阶段切换、复活等场景使用)。 + public void ResetSkillCooldown(string skillId) + { + _skillCooldownEndTimes.Remove(skillId); + } + + /// 重置所有技能冷却。 + public void ResetAllCooldowns() => _skillCooldownEndTimes.Clear(); + + private void Awake() + { +#if UNITY_EDITOR + ValidateSkillConfig(); +#endif + } + +#if UNITY_EDITOR + private void OnValidate() => ValidateSkillConfig(); + + private void ValidateSkillConfig() + { + if (_skills == null || _skills.Length == 0) + { + Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 未配置任何技能 SO。", this); + return; + } + foreach (var skill in _skills) + { + if (skill == null) + Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) _skills 数组含 null 元素。", this); + else if (string.IsNullOrEmpty(skill.skillId)) + Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 技能 '{skill.name}' 缺少 skillId。", this); + } + } +#endif + /// /// 按 float 值复用 WaitForSeconds 实例,消除协程中每次 new WaitForSeconds 的 GC 分配。 /// Domain Reload 禁用时静态缓存跨 PlayMode 会话保留,但 WaitForSeconds 是幂等值对象, @@ -38,21 +94,83 @@ namespace BaseGames.Boss [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] private static void ClearWFSCache() => _wfsCache.Clear(); + private const int MaxWFSCacheSize = 64; + private static WaitForSeconds GetWFS(float t) { if (!_wfsCache.TryGetValue(t, out var wfs)) - _wfsCache[t] = wfs = new WaitForSeconds(t); + { + if (_wfsCache.Count < MaxWFSCacheSize) + _wfsCache[t] = wfs = new WaitForSeconds(t); + else + return new WaitForSeconds(t); + } return wfs; } // ── 公共 API ─────────────────────────────────────────────────────────── /// - /// 执行一个 Boss 技能。若当前已在执行中则直接返回。 + /// 按 skillId 查找已在 Inspector 注册的技能 SO。未找到返回 null。 + /// + public BossSkillSO FindSkill(string skillId) + { + if (_skills == null) return null; + foreach (var s in _skills) + if (s != null && s.skillId == skillId) return s; + return null; + } + + /// 返回当前正在执行的技能 SO,未执行时返回 null。 + public BossSkillSO FindCurrentSkill() => _isExecuting ? _currentSkill : null; + + /// Inspector 中注册的全部技能 SO(只读)。 + public BossSkillSO[] Skills => _skills; + + /// + /// 从候选技能列表中按 weight 加权随机选择一个技能。 + /// 权重为 0 的技能不参与选择;所有候选权重均为 0 时返回 null。 + /// + public BossSkillSO SelectWeightedSkill(System.Collections.Generic.IList candidates) + { + if (candidates == null || candidates.Count == 0) return null; + + float totalWeight = 0f; + for (int i = 0; i < candidates.Count; i++) + { + var s = candidates[i]; + if (s != null && s.weight > 0f) totalWeight += s.weight; + } + if (totalWeight <= 0f) return null; + + float roll = UnityEngine.Random.value * totalWeight; + float acc = 0f; + for (int i = 0; i < candidates.Count; i++) + { + var s = candidates[i]; + if (s == null || s.weight <= 0f) continue; + acc += s.weight; + if (roll <= acc) return s; + } + // 浮点精度兜底:返回最后一个有效候选 + for (int i = candidates.Count - 1; i >= 0; i--) + if (candidates[i]?.weight > 0f) return candidates[i]; + return null; + } + + /// + /// 执行一个 Boss 技能。若当前正在执行或技能冷却未就绪则返回。 /// public void ExecuteSkill(BossSkillSO skill) { if (_isExecuting || skill == null) return; + if (!CanUseSkill(skill.skillId)) + { + Debug.Log($"[BossSkillExecutor] 技能 '{skill.skillId}' 冷却中,无法执行。", this); + return; + } + // 提前订阅,确保 InterruptCurrentSkill() 中断时 FinishExecution() 能正常取消 + SubscribeHitCallbacks(); _activeCoroutine = StartCoroutine(ExecuteSkillCoroutine(skill)); } @@ -61,6 +179,12 @@ namespace BaseGames.Boss /// public void InterruptCurrentSkill() { + // 同步停止弱点窗口协程,防止中断后继续激活 WeakPointSystem + if (_vulnCoroutine != null) + { + StopCoroutine(_vulnCoroutine); + _vulnCoroutine = null; + } if (_activeCoroutine != null) { StopCoroutine(_activeCoroutine); @@ -69,12 +193,49 @@ namespace BaseGames.Boss FinishExecution(); } - // ── 主协程 ───────────────────────────────────────────────────────────── + // 等待事件触发的 VulnWindow(事件驱动类型,存储后由 NotifyVulnTrigger 逐个激活) + private readonly List _pendingEventWindows = new(); + + /// + /// 通知执行器某一外部事件已发生(如格挡成功、反制命中等), + /// 激活所有注册该触发类型的弱点窗口。 + /// 由 BossBase.HandleParrySuccess / ApplyCounterResponse 等调用。 + /// + public void NotifyVulnTrigger(VulnTriggerType triggerType) + { + for (int i = _pendingEventWindows.Count - 1; i >= 0; i--) + { + var w = _pendingEventWindows[i]; + if (w.TriggerType == triggerType) + { + _pendingEventWindows.RemoveAt(i); + StartCoroutine(OpenWindowCoroutine(w)); + } + } + } + + private void OnHitConfirmedCallback(DamageInfo _) => _patternHitConfirmed = true; + + private void SubscribeHitCallbacks() + { + if (_hitBoxes == null) return; + foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed += OnHitConfirmedCallback; + } + + private void UnsubscribeHitCallbacks() + { + if (_hitBoxes == null) return; + foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed -= OnHitConfirmedCallback; + } private IEnumerator ExecuteSkillCoroutine(BossSkillSO skill) { - _isExecuting = true; - _currentSkill = skill; + _isExecuting = true; + _currentSkill = skill; + _patternHitConfirmed = false; + + // HitBox 订阅已在 ExecuteSkill() 入口完成(确保 Interrupt 中断时能在 FinishExecution 取消) + _onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId }); // 播放技能动画 @@ -82,26 +243,37 @@ namespace BaseGames.Boss _animancer.Play(skill.skillAnimation); // 启动 VulnerabilityWindow 协程(与主序列并行) - Coroutine vulnCoroutine = null; + _vulnCoroutine = null; if (skill.vulnerabilityWindows != null && skill.vulnerabilityWindows.Length > 0) - vulnCoroutine = StartCoroutine(ActivateVulnerabilityWindowsCoroutine(skill)); + _vulnCoroutine = StartCoroutine(ActivateVulnerabilityWindowsCoroutine(skill)); - // 执行攻击序列(优先 sequenceOnMiss 作为默认序列) + // 执行主攻击序列(始终执行 sequenceOnMiss;sequenceOnHit 是命中后的追加序列) if (skill.sequenceOnMiss != null) yield return ExecuteSequenceCoroutine(skill.sequenceOnMiss); + // 若本次有命中确认且配置了 sequenceOnHit,执行追加序列(连段、击倒追击等) + if (_patternHitConfirmed && skill.sequenceOnHit != null) + yield return ExecuteSequenceCoroutine(skill.sequenceOnHit); + // 若弱点协程还在运行则等待其结束(避免孤立协程) - if (vulnCoroutine != null) - yield return vulnCoroutine; + if (_vulnCoroutine != null) + yield return _vulnCoroutine; FinishExecution(); } private void FinishExecution() { + UnsubscribeHitCallbacks(); // 无论正常结束还是被 Interrupt,均在此取消订阅 + _pendingEventWindows.Clear(); // 清除未触发的事件驱动弱点窗口,防止跨技能积压 + _vulnCoroutine = null; // 正常结束时已自然结束,仅清除引用 _isExecuting = false; if (_currentSkill != null) { + // 记录冷却结束时刻 + if (_currentSkill.cooldown > 0f) + _skillCooldownEndTimes[_currentSkill.skillId] = Time.time + _currentSkill.cooldown; + _onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = _currentSkill.skillId }); _currentSkill = null; } @@ -140,15 +312,17 @@ namespace BaseGames.Boss yield return GetWFS(pattern.WindupDuration); // 激活 HitBox(架构 06 §4:Activate(DamageSourceSO, Transform)) - foreach (var hb in _hitBoxes) - hb.Activate(pattern.DamageSource, transform); + if (_hitBoxes != null && _hitBoxes.Length > 0) + foreach (var hb in _hitBoxes) + if (hb != null) hb.Activate(pattern.DamageSource, transform); if (pattern.ActiveDuration > 0f) yield return GetWFS(pattern.ActiveDuration); // 关闭 HitBox - foreach (var hb in _hitBoxes) - hb.Deactivate(); + if (_hitBoxes != null && _hitBoxes.Length > 0) + foreach (var hb in _hitBoxes) + if (hb != null) hb.Deactivate(); // 后摇 if (pattern.RecoveryDuration > 0f) @@ -159,26 +333,58 @@ namespace BaseGames.Boss private IEnumerator ActivateVulnerabilityWindowsCoroutine(BossSkillSO skill) { + _pendingEventWindows.Clear(); + foreach (var window in skill.vulnerabilityWindows) { - if (window.TriggerDelay > 0f) - yield return GetWFS(window.TriggerDelay); - - bool activateSpecific = window.ActivateWeakPointHurtBox; - _weakPointSystem?.SetActive(true, window.DamageMultiplier, activateSpecific); - window.OpenFeedback?.Play(); - - yield return GetWFS(window.Duration); - - _weakPointSystem?.SetActive(false, 1f, activateSpecific); - window.CloseFeedback?.Play(); + if (window.TriggerType == VulnTriggerType.OnAttackRecovery) + { + // 时间驱动:按 TriggerDelay 延迟后自动激活 + if (window.TriggerDelay > 0f) + yield return GetWFS(window.TriggerDelay); + StartCoroutine(OpenWindowCoroutine(window)); + } + else + { + // 事件驱动(OnParriedSuccess / Manual 等):注册到待触发列表 + _pendingEventWindows.Add(window); + } } } + /// 实际开启并持续弱点窗口,支持独立并行运行。 + private IEnumerator OpenWindowCoroutine(VulnerabilityWindow window) + { + bool activateSpecific = window.ActivateWeakPointHurtBox; + _weakPointSystem?.SetActive(true, window.DamageMultiplier, activateSpecific); + window.OpenFeedback?.Play(); + + yield return GetWFS(window.Duration); + + _weakPointSystem?.SetActive(false, 1f, activateSpecific); + window.CloseFeedback?.Play(); + } + // ── 工具 ─────────────────────────────────────────────────────────────── + /// + /// 在指定时长内开启弱点窗口(格挡/闪避反制时调用,独立于技能 VulnerabilityWindow 序列)。 + /// + public void OpenVulnerabilityWindow(float duration, float damageMultiplier) + { + if (_weakPointSystem == null || duration <= 0f) return; + StartCoroutine(VulnWindowOverride(duration, damageMultiplier)); + } + + private IEnumerator VulnWindowOverride(float duration, float multiplier) + { + _weakPointSystem.SetActive(true, multiplier, false); + yield return GetWFS(duration); + _weakPointSystem.SetActive(false, 1f, false); + } + private bool IsPlayerInRange() => _playerTransform != null && - Vector2.Distance(transform.position, _playerTransform.position) < 8f; + Vector2.Distance(transform.position, _playerTransform.position) < _repeatRangeCheck; } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs index 9ae3632..4c67271 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs @@ -12,9 +12,12 @@ namespace BaseGames.Boss public class BossSkillSO : ScriptableObject { [Header("元信息")] + [Tooltip("技能唯一标识符(全小写英文 + 下划线,如 'slash_combo'、'phase_dash')。BD_UseBossSkill 通过此 Id 引用")] public string skillId; + [Tooltip("编辑器中显示的可读名称,不影响运行逻辑")] public string displayName; [TextArea(1, 4)] + [Tooltip("设计备注(仅供编辑器参考,不影响运行)")] public string designNote; [Header("技能分类")] @@ -57,5 +60,10 @@ namespace BaseGames.Boss [Header("冷却")] [Min(0f)] public float cooldown; + + [Header("权重随机(UseBossSkillWeighted 使用)")] + [Tooltip("相对权重,数值越大被随机选中的概率越高;0 = 禁用随机选择")] + [Min(0f)] + public float weight = 1f; } } diff --git a/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs b/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs index 3b932f3..9e66322 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs @@ -20,8 +20,16 @@ namespace BaseGames.Enemies [Header("受击")] public AnimationClip Hurt; public AnimationClip Stagger; + [Tooltip("击飞腾空动画(可选);留空时由 EnemyBase.ScheduleStateRecovery 按时长自动恢复")] + public AnimationClip KnockUp; public AnimationClip Dead; + [Header("AI 阶段")] + [Tooltip("发现目标时的短暂警觉姿态(可选)")] + public AnimationClip Alert; + [Tooltip("搜查时的慢走或环顾动作(留空则回退到 Walk)")] + public AnimationClip Investigate; + /// /// 按字段名返回 Clip(BD_PlayAnimation 使用)。 /// 支持字段名和常见别名(如 Attack_Melee / Idle 等)。 @@ -32,11 +40,14 @@ namespace BaseGames.Enemies { "Idle" => Idle, "Walk" => Walk, - "Run" => Run, - "Attack" or "Attack_Melee" => Attack, + "Run" or "Chase" => Run, + "Attack" or "Attack_Melee" => Attack, "Hurt" => Hurt, "Stagger" => Stagger, + "KnockUp" => KnockUp, "Dead" or "Death" => Dead, + "Alert" => Alert, + "Investigate" => Investigate ?? Walk, _ => null, }; } diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index b43fe33..553b4f0 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -2,7 +2,9 @@ using UnityEngine; using Animancer; using BaseGames.Combat; using BaseGames.Core.Events; +using BaseGames.Core.Pool; using BaseGames.Enemies.States; +using BaseGames.Enemies.Abilities; #if GRAPH_DESIGNER using Opsive.BehaviorDesigner.Runtime; #endif @@ -14,8 +16,9 @@ namespace BaseGames.Enemies /// 实现 IDamageable,为 Behavior Designer 任务提供统一虚方法接口。 /// 包含:BD 接口、受击、死亡流程。 /// ⚠️ _nav 字段类型为 IPathAgent(在 BaseGames.Enemies.Navigation 中实现具体类)。 + /// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。 /// - public class EnemyBase : MonoBehaviour, IDamageable, ILOSRequester + public class EnemyBase : MonoBehaviour, IDamageable, ILOSRequester, IPoolable { [Header("标识")] [SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard" @@ -44,29 +47,71 @@ namespace BaseGames.Enemies /// [SerializeField] private BaseGames.Core.Events.TransformEventChannelSO _onPlayerSpawned; +#if GRAPH_DESIGNER + [Header("BT Tick 分级(LOD)")] + [Tooltip("Idle 阶段 BT Tick 最小间隔(s);0 = 每帧全速。")] + [SerializeField] private float _btIdleTickInterval = 0.30f; + [Tooltip("Patrol/ReturnHome 阶段 BT Tick 间隔(s)")] + [SerializeField] private float _btPatrolTickInterval = 0.15f; + [Tooltip("Alert/Investigate 阶段 BT Tick 间隔(s)")] + [SerializeField] private float _btAlertTickInterval = 0.08f; + [Tooltip("Chase 阶段 BT Tick 间隔(s)")] + [SerializeField] private float _btChaseTickInterval = 0.05f; + [Tooltip("Combat 阶段 BT Tick 间隔(s);0 = 每帧全速")] + [SerializeField] private float _btCombatTickInterval = 0f; +#endif + // ── 导航代理(IPathAgent;由 EnemyNavAgent 实现)─────────────────── // 通过接口引用,避免对 Navigation 程序集的直接依赖。 // 由子类 / Inspector 注入,或者运行时 GetComponent() 获取。 protected IPathAgent _nav; // 霸体来源(由 EnemyPoiseComponent.Awake() 自动注入,TakeDamage 时读取) private IPoiseSource _poiseSource; - private readonly CompositeDisposable _subs = new(); + protected readonly CompositeDisposable _subs = new(); + + // 碰撞体缓存:Awake 时收集一次,避免 Die()/OnSpawn() 中频繁 GetComponentsInChildren 分配 + private Collider2D[] _colliders; + + // ── 对象池支持 ───────────────────────────────────────────────────── + /// + /// 本 GameObject 上的 PooledObject 组件(可选)。 + /// Prefab 挂有此组件时,Die() 使用归还池取代 Destroy,实现对象池复用。 + /// + private PooledObject _pooledObject; // ── 状态 ────────────────────────────────────────────────────────── private EnemyStateType _currentState; public EnemyStateType CurrentState => _currentState; + + // ── AI 行为阶段(独立于物理/战斗状态)──────────────────────────────── + private AiPhase _currentAiPhase = AiPhase.Idle; + /// 当前 AI 行为阶段(BD 任务读取;ForceState 不会改变此值)。 + public AiPhase CurrentAiPhase => _currentAiPhase; + + /// AI 行为阶段变更时触发,可用于驱动外部系统(音效/视觉效果等)。 + public event System.Action OnAiPhaseChanged; + + // ── 导航语义(归位 / 搜查)──────────────────────────────────────────── + /// Awake/Start 时记录的初始世界坐标,供 BD_ReturnToHome 归位使用。 + public Vector2 HomePosition { get; private set; } + + /// + /// 玩家最后一次可见的世界坐标。 + /// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。 + /// + public Vector2 LastKnownPlayerPosition { get; set; } // POCO 状态对象字典:枚举保持对外 API 不变。 // 子类可在 Awake() 重写条目注入自定义状态对象。 protected readonly System.Collections.Generic.Dictionary _stateObjs = new System.Collections.Generic.Dictionary(); // ── IDamageable ─────────────────────────────────────────────────── public bool IsAlive => _currentState != EnemyStateType.Dead; - public bool IsInvincible => _currentState == EnemyStateType.Dead; + public virtual bool IsInvincible => _currentState == EnemyStateType.Dead; public int Defense => _stats != null ? _stats.Defense : 0; public void TakeDamage(DamageInfo info) { - if (_currentState == EnemyStateType.Dead) return; + if (IsInvincible) return; _stats?.TakeDamage(info.FinalDamage); _feedback?.OnHit(info); @@ -77,22 +122,93 @@ namespace BaseGames.Enemies return; } - // 根据霸体等级选择 Stagger(硬直)或 Hurt(受击)。 - // ForceBreak 标记或 BreakLevel 超过当前霸体等级时触发 Stagger,否则触发 Hurt。 - PoiseLevel curPoise = _poiseSource?.GetCurrentPoiseLevel() ?? PoiseLevel.None; - bool causesStagger = info.Flags.HasFlag(DamageFlags.ForceBreak) - || (int)info.Break > (int)curPoise; - ForceState(causesStagger ? EnemyStateType.Stagger : EnemyStateType.Hurt); + // ── 受击分级(KnockUp > Stagger > Hurt)────────────────────── + PoiseLevel curPoise = _poiseSource?.GetCurrentPoiseLevel() ?? PoiseLevel.None; + bool causesStagger = info.Flags.HasFlag(DamageFlags.ForceBreak) + || (int)info.Break > (int)curPoise; + + // KnockUp 判断:携带 Launch 标志,且(无阈值限制 或 伤害超过阈值) + bool causesKnockUp = false; + if (causesStagger && info.Flags.HasFlag(DamageFlags.Launch) && _statsSO != null) + { + int threshold = _statsSO.HitTiers.launchThreshold; + causesKnockUp = (threshold <= 0) || (info.FinalDamage >= threshold); + } + + EnemyStateType nextState; + InterruptReason reason; + if (causesKnockUp) + { + // 存储来袭方向供 EnemyKnockUpState 使用 + _pendingLaunchDir = info.KnockbackDirection; + nextState = EnemyStateType.KnockUp; + reason = InterruptReason.KnockUp; + } + else if (causesStagger) + { + nextState = EnemyStateType.Stagger; + reason = InterruptReason.Stagger; + } + else + { + nextState = EnemyStateType.Hurt; + reason = InterruptReason.Hurt; + } + + ForceState(nextState); + _abilities.InterruptAll(reason); + OnDamageTaken(info); + } + + /// + /// 受击后钩子(已确认未死亡时调用)。子类可重写以触发额外逻辑(如资源积累)。 + /// + protected virtual void OnDamageTaken(DamageInfo info) { } + + /// + /// 击飞来袭方向(由 TakeDamage 写入,供 EnemyKnockUpState.Enter() 读取)。 + /// + internal Vector2 PendingLaunchDir => _pendingLaunchDir; + private Vector2 _pendingLaunchDir; + + /// + /// 协程兜底:在无对应 Animancer 动画时按时长自动恢复到 Controlled 状态。 + /// 仅在 AnimConfig 对应 Clip 为 null 时由状态类调用。 + /// + public void ScheduleStateRecovery(EnemyStateType fromState, float delay) + { + StartCoroutine(StateRecoveryRoutine(fromState, delay)); + } + + private System.Collections.IEnumerator StateRecoveryRoutine(EnemyStateType fromState, float delay) + { + yield return new WaitForSeconds(delay); + if (_currentState == fromState) + ForceState(EnemyStateType.Controlled); } // BD 任务访问接口(公共只读属性)──────────────────────────────── public IPathAgent Nav => _nav; public EnemyMovement Movement => _movement; public EnemyStats Stats => _stats; + /// 敌人配置 SO,供 BD Task / 状态对象读取配置数据(如 knockUpDuration)。 + public EnemyStatsSO StatsSO => _statsSO; public AnimancerComponent Animancer => _animancer; public EnemyAnimationConfigSO AnimConfig => _animConfig; + /// 能力注册表(架构 §8.3)。Awake 时自动收集所有 EnemyAbilityBase 组件。 + public EnemyAbilityRegistry Abilities => _abilities; + private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry(); /// 由 _onPlayerSpawned 事件缓存的玩家 Transform,供 BD 任务读取。 public Transform PlayerTransform => _playerTransform; + /// 感知 Hub(SensorToolkit);供 QuotaManager 暂停/恢复 Sensor 使用。 + public Perception.IPerceptionSystem SensorHub => _sensorHub; + private Perception.EnemySensorHub _sensorHub; + /// 威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。 + public Perception.EnemyThreatAssessor ThreatAssessor => _threatAssessor; + private Perception.EnemyThreatAssessor _threatAssessor; + /// 状态效果管理器(冻结、灼烧、睡眠等)。 + public StatusEffects.EnemyStatusEffectManager StatusEffects => _statusEffects; + private StatusEffects.EnemyStatusEffectManager _statusEffects; #if GRAPH_DESIGNER public BehaviorTree BehaviorTree => _behaviorTree; #endif @@ -111,6 +227,18 @@ namespace BaseGames.Enemies _movement?.StopHorizontal(); } + /// 施加状态效果(需要 EnemyStatusEffectManager 组件)。同类型效果自动刷新。 + public void ApplyStatusEffect(StatusEffects.IStatusEffect effect) + => _statusEffects?.Apply(effect); + + /// 移除指定类型的状态效果(若存在)。 + public void RemoveStatusEffect(StatusEffects.StatusEffectType type) + => _statusEffects?.Remove(type); + + /// 查询指定类型状态效果是否激活。 + public bool HasStatusEffect(StatusEffects.StatusEffectType type) + => _statusEffects != null && _statusEffects.HasEffect(type); + public virtual void BeginAttack(AttackType type) { _combat?.StartAttack(type); @@ -123,8 +251,31 @@ namespace BaseGames.Enemies public virtual bool IsPlayerInRange(float range) => _stats != null && _stats.SqrDistanceToPlayer <= range * range; + /// + /// 检查玩家是否在感知范围内。若 > 0, + /// 同时验证玩家是否在自身朝向的扇形角度内。 + /// + public virtual bool IsPlayerInDetectRange() + { + if (_stats == null || _playerTransform == null) return false; + float detectRange = _statsSO != null ? _statsSO.DetectRange : 6f; + if (_stats.SqrDistanceToPlayer > detectRange * detectRange) return false; + + float angleDeg = _statsSO?.DetectAngleDeg ?? 0f; + if (angleDeg <= 0f) return true; // 0 = 关闭方向限制 + + Vector2 toPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).normalized; + float facingDir = _movement != null ? _movement.FacingDirection : 1f; + var forward = new Vector2(facingDir, 0f); + float angle = Vector2.Angle(forward, toPlayer); + return angle <= angleDeg; + } + + /// 原始视线检测结果(BatchLOSSystem 写入,无感知延迟修正)。 + public bool HasLineOfSight => _losResult; + public virtual bool IsPlayerVisible() - => _losResult; // BatchLOSSystem 写入;初始 false(未见玩家) + => _threatAssessor != null ? _threatAssessor.IsThreatDetected : _losResult; public virtual void FacePlayer() { @@ -136,25 +287,143 @@ namespace BaseGames.Enemies { if (info.Flags.HasFlag(DamageFlags.NoKnockback)) return; _movement?.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce); + // 统一路径:击退必须经过状态机,确保能力被中断且动画一致。 + ForceState(EnemyStateType.Hurt); + _abilities.InterruptAll(InterruptReason.Hurt); } public virtual void JumpTo(Vector2 target) => _movement?.JumpToTarget(target); /// - /// 调整 BehaviorTree Tick 频率(非警觉=2帧/次,警觉=每帧)。 + /// 调整 BehaviorTree Tick 频率(非警觉=降频,警觉=高频)。 /// 由 BD_SetAlert 调用(架构 07_EnemyModule §13.5)。 /// - public void SetAggroTickRate(bool isAggro) + public virtual void SetAggroTickRate(bool isAggro) { #if GRAPH_DESIGNER - // Opsive 运行时当前版本未直接暴露 frameInterval 字段。 - // 需升级 Opsive 包或通过自定义 Tick 次数属性实现此功能。 - Debug.LogWarning("[EnemyBase] SetAggroTickRate 当前无效:Opsive 运行时尚未暴露 frameInterval,请升级包后实现。", this); - _ = isAggro; + _btCurrentInterval = isAggro ? _btAlertTickInterval : _btIdleTickInterval; #endif } + // ── 警戒传播(Group Alert)──────────────────────────────────────── + private static readonly UnityEngine.Collider2D[] _alertBuffer = new UnityEngine.Collider2D[32]; + + // ── 弹反(Parry)响应 ────────────────────────────────────────────── + private bool _wasParried; + private float _parryTimestamp; + // BD 树因阶段切换/死亡未能消费弹反事件时,超过此时长自动过期 + private const float ParryEventTTL = 2f; + + /// + /// 消费弹反事件标志(读取并清除)。 + /// BD_OnParried Conditional Task 在每次 Tick 时调用此方法。 + /// 若 BD 树因阶段切换或死亡未能及时消费,超过 TTL 后自动过期返回 false。 + /// + public bool ConsumeParryEvent() + { + if (!_wasParried) return false; + if (Time.time - _parryTimestamp > ParryEventTTL) + { + _wasParried = false; + return false; + } + _wasParried = false; + return true; + } + + /// + /// 被弹反时调用:强制进入 Stagger 状态并在 秒后恢复。 + /// 由近战攻击碰到玩家弹反框时触发(例如 BossParryDetector 或通用 ParryDetector)。 + /// + public virtual void ReceiveParry(float staggerDuration = 0.5f) + { + if (!IsAlive) return; + _wasParried = true; + _parryTimestamp = Time.time; + ForceState(EnemyStateType.Stagger); + _abilities.InterruptAll(InterruptReason.Stagger); + ScheduleStateRecovery(EnemyStateType.Stagger, staggerDuration); + } + + /// + /// 通知半径内的其他敌人进入 Alert 阶段(Group Alert 广播)。 + /// 配合 BD_BroadcastAlert 在进入 Chase 阶段时调用。 + /// + public void AlertNearby(float radius) + { + if (radius <= 0f) return; + int count = Physics2D.OverlapCircleNonAlloc(transform.position, radius, _alertBuffer); + for (int i = 0; i < count; i++) + { + if (_alertBuffer[i] == null) continue; + var enemy = _alertBuffer[i].GetComponentInParent(); + if (enemy == null || enemy == this) continue; + enemy.ReceiveAlert(_playerTransform, LastKnownPlayerPosition); + } + } + + /// + /// 接收来自邻近敌人的警戒广播。 + /// 当前处于 Idle / Patrol 时切换到 Alert;Chase / Combat 中不降级。 + /// + public void ReceiveAlert(Transform sharedPlayerTransform, Vector2 lastKnownPos) + { + if (!IsAlive) return; + if (_currentAiPhase == AiPhase.Chase || _currentAiPhase == AiPhase.Combat) return; + + // 同步玩家引用(若本敌人尚未感知到玩家) + if (sharedPlayerTransform != null && _playerTransform == null) + _playerTransform = sharedPlayerTransform; + LastKnownPlayerPosition = lastKnownPos; + SetAiPhase(AiPhase.Alert); + } + + [Header("AI 行为阶段")] + [Tooltip("SetAiPhase 变更时是否自动播放 AnimConfig 中对应的阶段动画(如 Alert/Investigate)")] + [SerializeField] private bool _autoPlayPhaseAnimation = true; + + /// + /// 设置 AI 行为阶段,并广播 事件。 + /// 若 为 true,自动播放 AnimConfig 对应动画。 + /// 由 BD Task(BD_SetAiPhase / BD_ChasePlayer 等)调用; + /// 不触发受击/死亡等 EnemyStateType 流程。 + /// + public void SetAiPhase(AiPhase phase) + { + if (_currentAiPhase == phase) return; + _currentAiPhase = phase; + OnAiPhaseChanged?.Invoke(phase); + +#if GRAPH_DESIGNER + // 按行为阶段动态调整 BT Tick 频率(5 档 LOD) + _btCurrentInterval = phase switch + { + AiPhase.Patrol => _btPatrolTickInterval, + AiPhase.Alert => _btAlertTickInterval, + AiPhase.Investigate => _btAlertTickInterval, + AiPhase.Chase => _btChaseTickInterval, + AiPhase.Combat => _btCombatTickInterval, + AiPhase.ReturnHome => _btPatrolTickInterval, + _ => _btIdleTickInterval, // Idle + 未来新阶段兜底 + }; +#endif + + if (_autoPlayPhaseAnimation && _animancer != null && _animConfig != null) + { + var clip = phase switch + { + AiPhase.Alert => _animConfig.Alert, + AiPhase.Investigate => _animConfig.Investigate ?? _animConfig.Walk, + AiPhase.Patrol => _animConfig.Walk, + AiPhase.Chase => _animConfig.Run, + AiPhase.Idle => _animConfig.Idle, + _ => null, + }; + if (clip != null) _animancer.Play(clip); + } + } + // ── 动画事件钩子(由 EnemyAnimationEvents 调用)──────────────────── /// 生成弹幕 / 技能投射物。payload 为配置 Id,由子类查表实现。 @@ -169,9 +438,30 @@ namespace BaseGames.Enemies /// 设置嘶吼状态(影响 Blackboard / 状态机行为)。 public virtual void SetRoaring(bool isRoaring) { } + // 防止状态 Enter/Exit 内部再次调用 ForceState 造成无限递归 + private bool _isStateTransitioning; + // ── 状态控制 ────────────────────────────────────────────────────── + /// + /// 强制切换物理/战斗状态。 + /// ⚠️ Dead 是终态:进入后不允许外部再转换到其他状态。 + /// 对象池复用时请通过 重置,该方法调用 。 + /// public void ForceState(EnemyStateType newState) { + // Dead 是终态:阻止任何"复活"转换,防止死亡后协程意外恢复 + if (_currentState == EnemyStateType.Dead && newState != EnemyStateType.Dead) + return; + + // 防止 Enter/Exit 内嵌套调用 ForceState 导致无限递归 + if (_isStateTransitioning) + { + Debug.LogWarning($"[EnemyBase] ForceState({newState}) 在状态转换期间被递归调用,已忽略。", this); + return; + } + + _isStateTransitioning = true; + // Exit 当前状态 if (_stateObjs.TryGetValue(_currentState, out var prev)) prev.Exit(this); @@ -181,6 +471,21 @@ namespace BaseGames.Enemies // Enter 新状态 if (_stateObjs.TryGetValue(newState, out var next)) next.Enter(this); + + _isStateTransitioning = false; + } + + /// + /// 对象池复用重置专用(跳过 Dead 终态守卫)。 + /// 仅由 调用,不对外暴露。 + /// + private void ForceStateRespawn(EnemyStateType newState) + { + if (_stateObjs.TryGetValue(_currentState, out var prev)) + prev.Exit(this); + _currentState = newState; + if (_stateObjs.TryGetValue(newState, out var next)) + next.Enter(this); } // ── Unity 生命周期 ──────────────────────────────────────────────── @@ -190,10 +495,17 @@ namespace BaseGames.Enemies _stateObjs[EnemyStateType.Controlled] = new EnemyControlledState(); _stateObjs[EnemyStateType.Hurt] = new EnemyHurtState(); _stateObjs[EnemyStateType.Stagger] = new EnemyStaggerState(); + _stateObjs[EnemyStateType.KnockUp] = new EnemyKnockUpState(); _stateObjs[EnemyStateType.Dead] = new EnemyDeadState(); _nav = GetComponent() ?? new NullPathAgent(); _poiseSource = GetComponent(); + _sensorHub = GetComponentInChildren(); + _statusEffects = GetComponent(); + _threatAssessor = GetComponent(); + _pooledObject = GetComponent(); + _abilities.CollectFrom(gameObject); + _colliders = GetComponentsInChildren(true); Debug.Assert(_statsSO != null, "[EnemyBase] _statsSO 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this); Debug.Assert(_stats != null, "[EnemyBase] _stats 未绑定,请在 Prefab Inspector 中绑定 EnemyStats 组件。", this); @@ -206,8 +518,12 @@ namespace BaseGames.Enemies _behaviorTree = GetComponent(); if (_behaviorTree != null) { - _behaviorTree.StartWhenEnabled = false; - _behaviorTree.PauseWhenDisabled = true; + _behaviorTree.StartWhenEnabled = false; + _behaviorTree.PauseWhenDisabled = true; + // Manual 模式:由 EnemyBase.Update 按 AiPhase 分级节流 Tick。 + _behaviorTree.UpdateMode = Opsive.BehaviorDesigner.Runtime.Components.UpdateMode.Manual; + _btManualMode = true; + _btCurrentInterval = _btIdleTickInterval; _behaviorTree.StartBehavior(); } #endif @@ -220,10 +536,35 @@ namespace BaseGames.Enemies // 使用 sqrMagnitude 替代 Vector2.Distance,避免每帧开平方计算 if (_playerTransform != null && _stats != null) _stats.SqrDistanceToPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).sqrMagnitude; + +#if GRAPH_DESIGNER + // BT Tick LOD:按当前 AiPhase 分级节流,降低空闲状态的 BT 开销。 + if (_btManualMode && _behaviorTree != null) + { + float interval = _btCurrentInterval; + if (interval <= 0f) + { + _behaviorTree.Tick(); + } + else + { + _btTickTimer += Time.deltaTime; + if (_btTickTimer >= interval) + { + _btTickTimer -= interval; + _behaviorTree.Tick(); + } + } + } +#endif } protected virtual void Start() { + // 记录出生位置,供 BD_ReturnToHome 归位使用 + HomePosition = transform.position; + LastKnownPlayerPosition = transform.position; + // 若事件未配置或玩家尚未广播,匹降为一次性查找 if (_playerTransform == null) { @@ -251,6 +592,7 @@ namespace BaseGames.Enemies } protected virtual void OnDestroy() { } + // LOS 缓存(BatchLOSSystem 写入;降级时由 3 帧节流 Raycast 写入) private bool _losResult; @@ -268,7 +610,10 @@ namespace BaseGames.Enemies // BehaviorTree 引用(#if GRAPH_DESIGNER 保护,避免空引用) #if GRAPH_DESIGNER - private BehaviorTree _behaviorTree; + private BehaviorTree _behaviorTree; + private bool _btManualMode; + private float _btTickTimer; + private float _btCurrentInterval; #endif protected virtual void Die() @@ -276,19 +621,31 @@ namespace BaseGames.Enemies if (_currentState == EnemyStateType.Dead) return; ForceState(EnemyStateType.Dead); + // 死亡时清除所有状态效果 + _statusEffects?.Clear(); + + // 死亡时强制中断所有能力(忽略 interruptOnHurt 等过滤) + _abilities.InterruptAll(InterruptReason.Dead); + // 禁用所有碰撞体 - foreach (var col in GetComponentsInChildren()) - col.enabled = false; + if (_colliders != null) + foreach (var col in _colliders) if (col != null) col.enabled = false; // 播放死亡动画 if (_animancer != null && _animConfig != null && _animConfig.Dead != null) { var state = _animancer.Play(_animConfig.Dead); - state.Events(this).OnEnd = () => Destroy(gameObject); + if (_pooledObject != null) + state.Events(this).OnEnd = () => _pooledObject.ReturnToPool(); + else + state.Events(this).OnEnd = () => Destroy(gameObject); } else { - Destroy(gameObject, 1.5f); + if (_pooledObject != null) + _pooledObject.ReturnToPoolDelayed(1.5f); + else + Destroy(gameObject, 1.5f); } _feedback?.OnDeath(); @@ -296,27 +653,103 @@ namespace BaseGames.Enemies OnDied?.Invoke(); } + // ── IPoolable ───────────────────────────────────────────────────── + /// + /// 对象从池中取出时调用,重置运行时状态。 + /// 使用对象池时,须在 Prefab 根节点挂载 并确保 Awake 已缓存 。 + /// + public virtual void OnSpawn() + { + // 恢复碰撞体 + if (_colliders != null) + foreach (var col in _colliders) if (col != null) col.enabled = true; + + // 重置状态(对象池复用:跳过 Dead 终态守卫,强制恢复到 Controlled) + ForceStateRespawn(EnemyStateType.Controlled); + _currentAiPhase = AiPhase.Idle; + + // 重置对象池复用相关的运行时感知数据 + // 注意:_playerTransform 不重置(场景中玩家仍存在),只重置追踪历史 + LastKnownPlayerPosition = transform.position; + _wasParried = false; + + // 重置生命值 + if (_stats != null && _statsSO != null) + _stats.Initialize(_statsSO); + + // 重置能力冷却 + _abilities.InterruptAll(InterruptReason.Dead); + +#if GRAPH_DESIGNER + _behaviorTree?.StartBehavior(); +#endif + } + + /// + /// 对象归还到池时调用,清理临时状态,停止 BT。 + /// + public virtual void OnDespawn() + { + _abilities.InterruptAll(InterruptReason.Dead); + _nav?.StopNavigation(); + +#if GRAPH_DESIGNER + if (_behaviorTree != null) _behaviorTree.enabled = false; +#endif + } + +#if UNITY_EDITOR + protected virtual void OnValidate() + { + if (_statsSO == null) + Debug.LogError($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置,运行时会 NullRef。", this); + if (_stats == null) + Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 EnemyStats 组件引用。", this); + if (_animancer == null) + Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 AnimancerComponent 引用。", this); + } +#endif + private void OnDrawGizmos() { #if UNITY_EDITOR if (_statsSO == null) return; - // ── 侦测范围(淡橙填充圆,始终可见)+ 攻击范围(淡红填充圆)──────── + // ── 侦测范围(淡橙;若配置扇形角则绘制扇形弧)+ 攻击范围(淡红圆)──── { var c = new Vector3(transform.position.x, transform.position.y, 0f); var prevM = UnityEditor.Handles.matrix; UnityEditor.Handles.matrix = Matrix4x4.identity; - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.15f); - UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange); - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.55f); - UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange); - + // 攻击范围(全圆) UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.15f); UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange); UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.55f); UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange); + // 侦测范围 + float angleDeg = _statsSO.DetectAngleDeg; + if (angleDeg > 0f) + { + // 扇形感知:绘制弧形扇区 + float facing = Application.isPlaying && _movement != null ? _movement.FacingDirection : 1f; + Vector3 forward3 = new Vector3(facing, 0f, 0f); + UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.12f); + UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange); + UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange); + UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.6f); + UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange); + UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange); + } + else + { + // 全圆感知 + UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.15f); + UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange); + UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.55f); + UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange); + } + UnityEditor.Handles.matrix = prevM; } @@ -365,11 +798,34 @@ namespace BaseGames.Enemies UnityEditor.Handles.matrix = prevM; } + + // 运行时:AiPhase 彩色圆 + 状态标签 + if (Application.isPlaying) + { + Color phaseColor = _currentAiPhase switch + { + AiPhase.Idle => Color.gray, + AiPhase.Patrol => Color.green, + AiPhase.Alert => Color.yellow, + AiPhase.Chase => new Color(1f, 0.5f, 0f), + AiPhase.Combat => Color.red, + AiPhase.Investigate => Color.cyan, + AiPhase.ReturnHome => Color.blue, + _ => Color.white, + }; + Gizmos.color = phaseColor; + Gizmos.DrawWireSphere(transform.position, 0.5f); + + UnityEditor.Handles.color = phaseColor; + UnityEditor.Handles.Label( + transform.position + Vector3.up * 1.0f, + $"{_currentAiPhase} | {_currentState}"); + } #endif } } // ── 枚举(架构 07 §1)──────────────────────────────────────────────── - public enum EnemyStateType { Controlled, Hurt, Stagger, Dead } + public enum EnemyStateType { Controlled, Hurt, Stagger, KnockUp, Dead } public enum AttackType { Melee, Ranged, Special } } diff --git a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs new file mode 100644 index 0000000..df34f24 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs @@ -0,0 +1,149 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using UnityEngine; +using BaseGames.Enemies.Abilities; +using BaseGames.Enemies.StatusEffects; + +namespace BaseGames.Enemies +{ + /// + /// 运行时 AI 状态调试叠加层(仅 Editor)。 + /// 挂到敌人 Prefab 根节点,运行时在场景视图 / GameView 上显示: + /// - AiPhase / EnemyStateType + /// - 当前激活的能力名称及运行状态 + /// - LOS 状态 / 到玩家距离 + /// - Boss:技能执行状态 + 各技能冷却倒计时 + /// + [AddComponentMenu("BaseGames/Debug/Enemy Debug Overlay")] + public sealed class EnemyDebugOverlay : MonoBehaviour + { + [Header("显示选项")] + [Tooltip("在 Scene 视图中绘制 GUI 标签")] + [SerializeField] private bool _showInSceneView = true; + [Tooltip("在 Game 视图中绘制 GUI 标签(需要 OnGUI)")] + [SerializeField] private bool _showInGameView = false; + [Tooltip("文字背景透明度")] + [SerializeField, Range(0f, 1f)] private float _bgAlpha = 0.65f; + + private EnemyBase _enemy; + private BossBase _boss; + private EnemyStatusEffectManager _statusMgr; + + private GUIStyle _labelStyle; + private GUIStyle _boxStyle; + private bool _stylesInit; + + private void Awake() + { + _enemy = GetComponent(); + _boss = GetComponent(); + _statusMgr = GetComponent(); + } + + // ── Scene 视图标签(UnityEditor.Handles.Label)───────────────────── + private void OnDrawGizmos() + { + if (!_showInSceneView || !Application.isPlaying || _enemy == null) return; + + var lines = BuildLines(); + var label = string.Join("\n", lines); + var pos = transform.position + Vector3.up * 2.2f; + + UnityEditor.Handles.Label(pos, label); + } + + // ── Game 视图 GUI(OnGUI)────────────────────────────────────────── + private void OnGUI() + { + if (!_showInGameView || !Application.isPlaying || _enemy == null) return; + + InitStyles(); + + var cam = UnityEngine.Camera.main; + if (cam == null) return; + + Vector3 worldPos = transform.position + Vector3.up * 2.2f; + Vector3 screen = cam.WorldToScreenPoint(worldPos); + if (screen.z < 0f) return; + + float screenY = Screen.height - screen.y; + var lines = BuildLines(); + var text = string.Join("\n", lines); + var size = _labelStyle.CalcSize(new GUIContent(text)); + var rect = new Rect(screen.x - size.x * 0.5f, screenY - size.y, size.x + 8f, size.y + 4f); + + GUI.Box(rect, GUIContent.none, _boxStyle); + GUI.Label(rect, text, _labelStyle); + } + + // ── 内部 ────────────────────────────────────────────────────────── + + private System.Collections.Generic.List BuildLines() + { + var list = new System.Collections.Generic.List(8); + + // 名称 + list.Add($"{gameObject.name}"); + + // AiPhase + EnemyState + list.Add($"Phase: {_enemy.CurrentAiPhase} State: {_enemy.CurrentState}"); + + // 距离 + LOS + float dist = _enemy.Stats != null ? Mathf.Sqrt(_enemy.Stats.SqrDistanceToPlayer) : -1f; + bool los = _enemy.IsPlayerVisible(); + var losColor = los ? "#44ff88" : "#ff6644"; + list.Add($"Dist: {dist:F1}m LOS: {los}"); + + // 当前激活能力 + if (_enemy.Abilities != null) + { + foreach (var ab in _enemy.Abilities.All) + { + if (ab.IsRunning) + { + string abilityId = ab.Config?.abilityId ?? ab.GetType().Name; + list.Add($"Ability: {abilityId} [{ab.Phase}]"); + } + } + } + + // Boss 信息 + if (_boss != null) + { + list.Add($"BossSkillExec: {_boss.IsBossSkillExecuting}"); + } + + // 状态效果 + if (_statusMgr != null) + { + var effects = _statusMgr.ActiveEffects; + for (int i = 0; i < effects.Count; i++) + list.Add($"FX: {effects[i].Type}"); + } + + return list; + } + + private void InitStyles() + { + if (_stylesInit) return; + _stylesInit = true; + + _labelStyle = new GUIStyle(GUI.skin.label) + { + richText = true, + fontSize = 11, + alignment = TextAnchor.UpperLeft, + padding = new RectOffset(4, 4, 2, 2), + }; + _labelStyle.normal.textColor = Color.white; + + var bgTex = new Texture2D(1, 1); + bgTex.SetPixel(0, 0, new Color(0.05f, 0.05f, 0.1f, _bgAlpha)); + bgTex.Apply(); + + _boxStyle = new GUIStyle(GUI.skin.box); + _boxStyle.normal.background = bgTex; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs.meta b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs.meta new file mode 100644 index 0000000..1fc89be --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac1a007a8980d6944bd92a3c22c4b84b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs index 374da6c..00c9520 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs @@ -1,22 +1,117 @@ +using System; +using System.Collections; using UnityEngine; namespace BaseGames.Enemies { /// /// 敌人移动组件(架构 07_EnemyModule §3)。 - /// 实现:水平移动、面向目标、击退。 + /// 实现:水平移动、面向目标、击退,以及导航连接段穿越()。 + /// + /// 作为 处理 Jump / Fall 两种 NavLink 类型: + /// - 跳跃连接(Jump):调用 施加物理冲量,等待落地后通知完成 + /// - 下落连接(Fall) :水平对准目标X,让重力自然下坠,到达目标Y附近通知完成 + /// 没有 EnemyMovement 组件(或 Jump 能力被移除)的敌人将无法通过跳跃连接, + /// 路径代价保持 TransformBasedMovement 兜底(仍可跳,但无自定义动画/物理)。 + /// /// ⚠️ 使用 Rigidbody2D.velocity(Unity 2022 LTS)。 /// [RequireComponent(typeof(Rigidbody2D))] - public class EnemyMovement : MonoBehaviour + public class EnemyMovement : MonoBehaviour, INavLinkHandler { [SerializeField] private EnemyStatsSO _config; [SerializeField] private SpriteRenderer _spriteRenderer; + [Header("导航跳跃能力(INavLinkHandler)")] + [Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")] + [SerializeField] private float _navJumpMaxHeight = 6f; + [Tooltip("可处理的最大跳跃水平距离")] + [SerializeField] private float _navJumpMaxDist = 10f; + [Tooltip("地面检测射线长度(用于判断跳跃是否落地)")] + [SerializeField] private float _groundCheckDist = 0.35f; + [Tooltip("地面层 LayerMask")] + [SerializeField] private LayerMask _groundMask; + private Rigidbody2D _rb; private int _facingDir = 1; + private Coroutine _linkCoroutine; public bool IsGrounded { get; private set; } + /// 当前朝向:1 = 右,-1 = 左。 + public int FacingDirection => _facingDir; + + // ── INavLinkHandler ──────────────────────────────────────────── + private static readonly NavLinkType[] _handledTypes = + new[] { NavLinkType.Jump, NavLinkType.Fall }; + + public NavLinkType[] HandledLinkTypes => _handledTypes; + + public bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd) + { + if (type == NavLinkType.Jump) + { + float dy = Mathf.Abs(linkEnd.y - linkStart.y); + float dx = Mathf.Abs(linkEnd.x - linkStart.x); + return dy <= _navJumpMaxHeight && dx <= _navJumpMaxDist; + } + return true; // Fall 总是可以处理 + } + + public void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete) + { + if (_linkCoroutine != null) StopCoroutine(_linkCoroutine); + _linkCoroutine = type == NavLinkType.Jump + ? StartCoroutine(JumpLinkCoroutine(linkStart, linkEnd, onComplete)) + : StartCoroutine(FallLinkCoroutine(linkStart, linkEnd, onComplete)); + } + + public void AbortLinkTraversal() + { + if (_linkCoroutine != null) { StopCoroutine(_linkCoroutine); _linkCoroutine = null; } + StopHorizontal(); + } + + private IEnumerator JumpLinkCoroutine(Vector2 start, Vector2 end, Action onComplete) + { + JumpToTarget(end); + yield return null; // 等一帧让 velocity 生效 + + // 等待离地后落地(超时 3s 防死锁) + float timer = 0f; + bool leftGround = false; + while (timer < 3f) + { + timer += Time.fixedDeltaTime; + yield return new WaitForFixedUpdate(); + if (!leftGround && !IsGroundedCheck()) { leftGround = true; } + if (leftGround && IsGroundedCheck()) break; + } + StopHorizontal(); + _linkCoroutine = null; + onComplete?.Invoke(); + } + + private IEnumerator FallLinkCoroutine(Vector2 start, Vector2 end, Action onComplete) + { + // 水平对准目标 + float dx = end.x - (float)transform.position.x; + if (Mathf.Abs(dx) > 0.15f) MoveHorizontal(Mathf.Sign(dx)); + + // 等待接近目标Y(重力驱动下落) + float timer = 0f; + while (timer < 3f) + { + timer += Time.fixedDeltaTime; + yield return new WaitForFixedUpdate(); + if (IsGroundedCheck() && Mathf.Abs(_rb.position.y - end.y) < 0.6f) break; + } + StopHorizontal(); + _linkCoroutine = null; + onComplete?.Invoke(); + } + + private bool IsGroundedCheck() => + Physics2D.Raycast(_rb.position, Vector2.down, _groundCheckDist, _groundMask); private void Awake() { @@ -24,6 +119,11 @@ namespace BaseGames.Enemies _rb = GetComponent(); } + private void FixedUpdate() + { + IsGrounded = IsGroundedCheck(); + } + /// 按 SO 配置速度水平移动。dir: +1 右 / -1 左 / 0 停止。 public void MoveHorizontal(float dir) { @@ -53,6 +153,20 @@ namespace BaseGames.Enemies _rb.velocity = dir.normalized * force; } + /// + /// 击飞冲量:向上 + 沿受击反方向水平。 + /// sourceDir 为伤害来源朝向(通常是 DamageInfo.KnockbackDirection),横向取其反方向。 + /// + /// 来袭方向(已归一化) + /// 水平冲量大小 + /// 纵向冲量大小 + public void LaunchKnockup(Vector2 sourceDir, float horzForce, float upForce) + { + if (_rb == null) return; + float horzSign = sourceDir.x >= 0f ? -1f : 1f; // 反方向弹飞 + _rb.velocity = new Vector2(horzSign * horzForce, upForce); + } + public void StopHorizontal() { var vel = _rb.velocity; diff --git a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs index 25059cf..6c2fd51 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs @@ -111,7 +111,11 @@ namespace BaseGames.Enemies bool active = i < _maxActiveBehaviorTrees; if (bt != null && bt.enabled != active) + { bt.enabled = active; + // 同步暂停/恢复 SensorToolkit Sensor,避免远处敌人无效 tick + enemy.SensorHub?.SetSuspended(!active); + } } #endif } diff --git a/Assets/_Game/Scripts/Enemies/EnemyStats.cs b/Assets/_Game/Scripts/Enemies/EnemyStats.cs index 3d9c168..fad5f96 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStats.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStats.cs @@ -20,6 +20,17 @@ namespace BaseGames.Enemies public int Defense { get; private set; } public float AttackCooldownTimer { get; private set; } + // ── 移动速度(透传 SO,供 BD 任务运行时读取)──────────────────────── + public float WalkSpeed => _config?.WalkSpeed ?? 2f; + public float RunSpeed => _config?.RunSpeed ?? 4f; + + // ── AI 追击配置(透传 SO)────────────────────────────────────────── + public float MaxChaseDistance => _config?.MaxChaseDistance ?? 15f; + public float LoseLinkTimeout => _config?.LoseLinkTimeout ?? 2f; + public float AlertDuration => _config?.AlertDuration ?? 0.6f; + public float InvestigateDuration => _config?.InvestigateDuration ?? 3f; + public float HomeRadius => _config?.HomeRadius ?? 0.5f; + /// /// 每帧由 EnemyBase 更新(sqrMagnitude,避免 sqrt 开销)。 /// 使用方请与 range*range 比较,而非直接与 range 比较。 @@ -59,9 +70,10 @@ namespace BaseGames.Enemies { if (_config == null) return; var scaler = ServiceLocator.GetOrDefault()?.CurrentScaler; - MaxHP = scaler != null - ? Mathf.Max(1, Mathf.RoundToInt(_config.MaxHP * scaler.EnemyHPMultiplier)) + int raw = scaler != null + ? Mathf.RoundToInt(_config.MaxHP * scaler.EnemyHPMultiplier) : _config.MaxHP; + MaxHP = Mathf.Max(1, raw); // 防止 MaxHP=0 导致 HP 比例计算 NaN } public void TakeDamage(int amount) diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index 4d2b7ce..079dae1 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -1,7 +1,27 @@ +using System; using UnityEngine; namespace BaseGames.Enemies { + /// + /// 受击分级配置(KnockUp 击飞门槛 & 飞行参数)。 + /// 挂载在 EnemyStatsSO.HitTiers 中。 + /// + [Serializable] + public struct HitTierConfig + { + [Tooltip("重击(Stagger)触发伤害阈值;伤害 >= 此值进入 Stagger")] + [Min(0)] public int heavyHitThreshold; + [Tooltip("击飞(KnockUp)触发伤害阈值;需同时设置 DamageFlags.Launch;0 = 仅依赖 Launch 标志")] + [Min(0)] public int launchThreshold; + [Tooltip("击飞纵向冲量")] + [Min(0f)] public float launchUpForce; + [Tooltip("击飞横向冲量(沿受击反方向)")] + [Min(0f)] public float launchHorzForce; + [Tooltip("无 KnockUp 动画时的状态持续时长(s)")] + [Min(0f)] public float knockUpDuration; + } + /// /// 敌人属性配置 SO(架构 07_EnemyModule §2)。 /// @@ -9,29 +29,55 @@ namespace BaseGames.Enemies public class EnemyStatsSO : ScriptableObject { [Header("生命")] - public int MaxHP = 50; + [Min(1)] public int MaxHP = 50; [Header("防御")] - public int Defense = 0; + [Min(0)] public int Defense = 0; [Header("移动")] - public float WalkSpeed = 2f; - public float RunSpeed = 4f; + [Min(0f)] public float WalkSpeed = 2f; + [Min(0f)] public float RunSpeed = 4f; [Header("战斗")] - public int AttackDamage = 10; - public float AttackRange = 1.5f; - public float AttackCooldown = 1f; - public float DetectRange = 6f; + [Min(0)] public int AttackDamage = 10; + [Min(0f)] public float AttackRange = 1.5f; + [Min(0f)] public float AttackCooldown = 1f; + [Min(0f)] public float DetectRange = 6f; + + [Header("追击 & AI 阶段")] + [Tooltip("最大追击距离(m);超出后放弃追击切换搜查阶段")] + [Min(0f)] public float MaxChaseDistance = 15f; + [Tooltip("视线丢失判定超时(s);超过此时长无视线则切换搜查")] + [Min(0f)] public float LoseLinkTimeout = 2f; + [Tooltip("Alert 阶段持续时长(s);短暂警觉动作后进入追击")] + [Min(0f)] public float AlertDuration = 0.6f; + [Tooltip("搜查最后可见位置后的等待时长(s)")] + [Min(0f)] public float InvestigateDuration = 3f; + [Tooltip("归位到达判定半径(m)")] + [Min(0.01f)] public float HomeRadius = 0.5f; [Header("击退(作为来源时)")] - public float KnockbackForce = 5f; - public float HitStunDuration = 0.3f; + [Min(0f)] public float KnockbackForce = 5f; + [Min(0f)] public float HitStunDuration = 0.3f; + + [Header("受击分级")] + [Tooltip("Stagger / KnockUp 伤害阈值及击飞参数")] + public HitTierConfig HitTiers; [Header("视线检测(BatchLOSSystem)")] [Tooltip("相对 transform.position 的眼睛偏移量")] public Vector2 EyeOffset = new Vector2(0f, 0.8f); [Tooltip("遮挡 LOS 的物理图层")] public LayerMask LOSBlockingMask = 1; // Default layer + + [Header("感知角度")] + [Tooltip("前向感知扇形半角(度)。0 = 关闭方向检查,按完整圆形感知")] + [Range(0f, 180f)] + public float DetectAngleDeg = 0f; + + [Header("警戒传播(Group Alert)")] + [Tooltip("发现玩家时通知周围同伴进入 Alert 的半径(m);0 = 关闭")] + [Min(0f)] + public float AlertBroadcastRadius = 0f; } } diff --git a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs index 2b9455b..ace180a 100644 --- a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs +++ b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs @@ -4,12 +4,23 @@ using BaseGames.Combat; namespace BaseGames.Enemies { /// - /// 飞行敌人。不依赖 PathBerserker2d 导航,直接通过 Rigidbody2D 向玩家移动。 - /// 接触玩家时造成伤害。 + /// 飞行敌人基类。 + /// + /// 导航由 实现(IPathAgent), + /// AI 行为逻辑由挂载的 Behavior Designer 树驱动。 + /// 本类仅负责: + /// + /// Rigidbody2D 无重力初始化 + /// 接触伤害(OnTriggerStay2D) + /// StopMovement / MoveInDirection 的 Rigidbody2D 快速移动重写 + /// /// public class FlyingEnemy : EnemyBase { - [SerializeField] private float _chaseSpeed = 3f; + [Header("飞行移动(快速覆盖速度)")] + [SerializeField] private float _moveSpeed = 3f; + + [Header("接触伤害")] [SerializeField] private DamageSourceSO _contactDamageSource; private Rigidbody2D _rb; @@ -20,26 +31,11 @@ namespace BaseGames.Enemies _rb = GetComponent(); if (_rb != null) { - _rb.gravityScale = 0f; - _rb.constraints = RigidbodyConstraints2D.FreezeRotation; + _rb.gravityScale = 0f; + _rb.constraints = RigidbodyConstraints2D.FreezeRotation; } } - protected override void Update() - { - base.Update(); - - if (_playerTransform == null || _rb == null) return; - if (CurrentState == EnemyStateType.Dead || - CurrentState == EnemyStateType.Stagger) return; - - // 向玩家移动 - Vector2 targetPos = _playerTransform.position; - Vector2 myPos = _rb.position; - Vector2 newPos = Vector2.MoveTowards(myPos, targetPos, _chaseSpeed * Time.deltaTime); - _rb.MovePosition(newPos); - } - public override void StopMovement() { if (_rb != null) _rb.velocity = Vector2.zero; @@ -47,13 +43,14 @@ namespace BaseGames.Enemies public override void MoveInDirection(float dir) { - if (_rb != null) _rb.velocity = new Vector2(dir, 0f) * _chaseSpeed; + if (_rb != null) _rb.velocity = new Vector2(dir * _moveSpeed, 0f); } private void OnTriggerStay2D(Collider2D other) { if (_contactDamageSource == null) return; if (CurrentState == EnemyStateType.Dead) return; + if (_rb == null) return; var hurtBox = other.GetComponent(); if (hurtBox == null) return; diff --git a/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs b/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs new file mode 100644 index 0000000..9c79714 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs @@ -0,0 +1,51 @@ +using System; +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 连接段穿越能力接口(架构 07_EnemyModule §5.2)。 + /// + /// 实现此接口的组件(EnemyAbilityBase 子类 / EnemyMovement 等)可接管特定 + /// NavLink 类型的穿越逻辑,包括动画播放、物理推进、时序控制,完全封装在能力内部。 + /// + /// 注册:EnemyNavAgent.Awake 自动发现当前 GameObject 上实现此接口的所有组件。 + /// + /// ──────────────────────────────────────────────────────────────────── + /// 如何扩展自定义 LinkType(超出 PB2d 内置 6 种之外): + /// 1. 在 Edit → Project Settings → PathBerserker2d → NavLink Type Names 追加名字 + /// (索引从 6 开始,例如 index 6 = "wallhug") + /// 2. 在 枚举追加同名条目(ParseLinkType 会字符串匹配) + /// 3. 实现 INavLinkHandler,在 HandledLinkTypes 中声明该枚举值 + /// 4. EnemyNavAgent 会自动将其从 TransformBasedMovement 剥离并委托给能力 + /// + /// 如何使用 NavTag 控制地形通行性: + /// - 在 PathBerserker2dSettings 中为各 NavSurface 配置 NavTag(如"water"、"lava") + /// - 通过 NavAgent.GetNavTagTraversalMultiplier(int) 配置或查询通行成本(≤0 = 禁止) + /// - 无需在此接口扩展,PB2d 在寻路阶段自动过滤 + /// ──────────────────────────────────────────────────────────────────── + /// + public interface INavLinkHandler + { + /// 该处理器负责的所有连接段类型(允许一个组件处理多种类型)。 + NavLinkType[] HandledLinkTypes { get; } + + /// + /// 运行时可行性检查:能否穿越此具体连接段(距离/高度/当前状态等)。 + /// 返回 false 时 EnemyNavAgent 记录警告并让 TransformBasedMovement 兜底。 + /// + bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd); + + /// + /// 开始穿越。能力自行驱动动画 + 物理。 + /// onComplete 必须且只能调用一次;未调用将导致 NavAgent 永远卡在连接段。 + /// + void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete); + + /// + /// 强制中断穿越(敌人死亡 / 被击飞 / 场景切换时调用)。 + /// 调用后不得再调用 onComplete。 + /// + void AbortLinkTraversal(); + } +} diff --git a/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs.meta b/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs.meta new file mode 100644 index 0000000..2ccaaac --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/INavLinkHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2fba1151274a1ca46a0eefe9f3add050 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/IPathAgent.cs b/Assets/_Game/Scripts/Enemies/IPathAgent.cs index e5cf61d..a0368b6 100644 --- a/Assets/_Game/Scripts/Enemies/IPathAgent.cs +++ b/Assets/_Game/Scripts/Enemies/IPathAgent.cs @@ -3,6 +3,20 @@ using UnityEngine; namespace BaseGames.Enemies { + // ── 连接段类型(映射 PB2d LinkTypeName 字符串)─────────────────────── + public enum NavLinkType + { + None, + Segment, // 普通地面/平台段 + Jump, + Fall, + Corner, + Climb, + Elevator, + Teleport, + Custom + } + /// /// 导航代理抽象接口(架构 07_EnemyModule §5)。 /// EnemyBase 和 BD Task 只依赖此接口,不依赖具体导航库。 @@ -10,6 +24,7 @@ namespace BaseGames.Enemies /// public interface IPathAgent { + // ── 核心移动 ──────────────────────────────────────────────────── /// 请求移动到世界坐标 target。 void RequestMoveTo(Vector2 target); @@ -28,8 +43,37 @@ namespace BaseGames.Enemies /// 是否接近平台边缘(脚下或前方无地面时为 true)。 bool IsNearEdge(); + // ── 连接段感知(NavLink 平台跳跃/爬梯/传送)───────────────────── + /// 当前是否正在穿越连接段(跳跃/下落/爬梯等)。 + bool IsOnLink { get; } + + /// 当前正在穿越的连接段类型;不在连接段时为 None。 + NavLinkType CurrentLinkType { get; } + + /// 当前连接段的起始世界坐标;不在连接段时为 Vector2.zero。 + Vector2 CurrentLinkStart { get; } + + /// 当前连接段的目标世界坐标;不在连接段时为 Vector2.zero。 + Vector2 CurrentLinkEnd { get; } + + /// 检查目标是否可以从当前位置导航到达(同步轻量查询)。 + bool CanReach(Vector2 target); + + /// 移动到随机可到达点(巡逻 fallback)。 + bool WalkToRandom(); + + // ── 连接段事件 ────────────────────────────────────────────────── + /// 开始穿越连接段时触发(传入连接段类型)。 + event Action OnLinkStarted; + + /// 连接段穿越完成时触发。 + event Action OnLinkCompleted; + /// 路径寻路失败事件(目标不可达时触发)。 event Action OnNavPathFailed; + + /// 到达目标事件(替代轮询 IsAtDestination)。 + event Action OnGoalReached; } // ── 无导航 / 测试用空实现 ───────────────────────────────────────────── @@ -41,6 +85,15 @@ namespace BaseGames.Enemies public void SetSpeed(float _) { } public bool IsMoving => false; public bool IsNearEdge() => false; - public event Action OnNavPathFailed { add { } remove { } } + public bool IsOnLink => false; + public NavLinkType CurrentLinkType => NavLinkType.None; + public Vector2 CurrentLinkStart => Vector2.zero; + public Vector2 CurrentLinkEnd => Vector2.zero; + public bool CanReach(Vector2 _) => false; + public bool WalkToRandom() => false; + public event Action OnLinkStarted { add { } remove { } } + public event Action OnLinkCompleted{ add { } remove { } } + public event Action OnNavPathFailed { add { } remove { } } + public event Action OnGoalReached { add { } remove { } } } } diff --git a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs index c0128a3..2d6eca1 100644 --- a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs +++ b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs @@ -1,73 +1,225 @@ using UnityEngine; using PathBerserker2d; using BaseGames.Enemies; +using System; +using System.Collections.Generic; namespace BaseGames.Enemies.Navigation { /// - /// PathBerserker2d 导航代理包装器(架构 07_EnemyModule §5)。 - /// 实现 IPathAgent 接口,使 EnemyBase 和 BD Task 无需直接依赖 PB2d 类型。 - /// PB2d API:UpdatePath(Vector2)、Stop()、TransformBasedMovement.movementSpeed、IsFollowingAPath。 + /// PathBerserker2d 导航代理包装器(架构 07_EnemyModule §5 能力驱动版)。 + /// + /// 设计原则: + /// + /// 连接段穿越(跳跃/攀爬/传送等)委托给 能力组件执行 + /// 能力自行驱动动画 + 物理,本组件只做"调度 → 等回调 → 通知 PB2d 继续" + /// 无对应能力时自动回退到 TransformBasedMovement(兜底行为) + /// Awake 自动发现 GameObject 上的全部 INavLinkHandler,并禁用 TBM 对应 FeatureFlag + /// /// [RequireComponent(typeof(NavAgent))] [RequireComponent(typeof(TransformBasedMovement))] public class EnemyNavAgent : MonoBehaviour, IPathAgent { - private NavAgent _navAgent; + // ── 序列化 ────────────────────────────────────────────────────── + [Tooltip("边缘检测前向距离(m)")] + [SerializeField] private float _edgeCheckFwdOffset = 0.3f; + [Tooltip("边缘检测向下射线长度(m)")] + [SerializeField] private float _edgeCheckDownLen = 0.6f; + [Tooltip("边缘检测 LayerMask(留空 = 所有层)")] + [SerializeField] private LayerMask _groundMask = ~0; + + // ── IPathAgent 公开状态 ──────────────────────────────────────── + public bool IsMoving => _navAgent != null && _navAgent.IsFollowingAPath; + public bool IsOnLink => _navAgent != null && _navAgent.IsOnLink; + public NavLinkType CurrentLinkType => _currentLinkType; + public Vector2 CurrentLinkStart => _currentLinkStart; + public Vector2 CurrentLinkEnd => _currentLinkEnd; + + // ── IPathAgent 事件 ──────────────────────────────────────────── + public event Action OnLinkStarted; + public event Action OnLinkCompleted; + public event Action OnNavPathFailed; + public event Action OnGoalReached; + + // ── 私有 ──────────────────────────────────────────────────────── + private NavAgent _navAgent; private TransformBasedMovement _movement; - /// 正在沿路径移动时为 true。 - public bool IsMoving => _navAgent != null && _navAgent.IsFollowingAPath; + // 能力 handler 注册表(NavLinkType → INavLinkHandler) + private readonly Dictionary _handlers + = new Dictionary(); + private INavLinkHandler _activeHandler; - public event System.Action OnNavPathFailed; + // 连接段状态缓存 + private NavLinkType _currentLinkType = NavLinkType.None; + private Vector2 _currentLinkStart; + private Vector2 _currentLinkEnd; + // ── 初始化 ───────────────────────────────────────────────────── private void Awake() { _navAgent = GetComponent(); _movement = GetComponent(); - _navAgent.OnFailedToFindPath += HandlePathFailed; + + // 自动发现 INavLinkHandler 组件并注册(包含子对象) + foreach (var handler in GetComponentsInChildren(true)) + RegisterLinkHandler(handler); + + _navAgent.OnStartLinkTraversal += HandleLinkStart; + _navAgent.OnSegmentTraversal += HandleSegmentTraversal; + _navAgent.OnFailedToFindPath += HandlePathFailed; + _navAgent.OnReachedGoal += HandleGoalReached; } private void OnDestroy() { - if (_navAgent != null) - _navAgent.OnFailedToFindPath -= HandlePathFailed; + if (_navAgent == null) return; + _navAgent.OnStartLinkTraversal -= HandleLinkStart; + _navAgent.OnSegmentTraversal -= HandleSegmentTraversal; + _navAgent.OnFailedToFindPath -= HandlePathFailed; + _navAgent.OnReachedGoal -= HandleGoalReached; } - private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke(); - - public void RequestMoveTo(Vector2 target) + // ── Handler 注册 ─────────────────────────────────────────────── + /// + /// 注册能力为某连接段类型的处理器。 + /// 注册后 TransformBasedMovement 中对应 FeatureFlag 将被禁用,由能力全权负责该类型穿越。 + /// + public void RegisterLinkHandler(INavLinkHandler handler) { - _navAgent?.UpdatePath(target); + if (handler == null) return; + foreach (var type in handler.HandledLinkTypes) + { + _handlers[type] = handler; + // 禁用 TBM 对该类型的处理,完全交给能力 + var flag = TypeToFeatureFlag(type); + if (flag != 0 && _movement != null) + _movement.enabledFeatures &= ~flag; + } } + /// 移除某处理器(例如能力被永久禁用时)。TBM FeatureFlag 自动恢复。 + public void UnregisterLinkHandler(INavLinkHandler handler) + { + if (handler == null) return; + foreach (var type in handler.HandledLinkTypes) + { + if (_handlers.TryGetValue(type, out var registered) && ReferenceEquals(registered, handler)) + { + _handlers.Remove(type); + // 恢复 TBM 对该类型的处理 + var flag = TypeToFeatureFlag(type); + if (flag != 0 && _movement != null) + _movement.enabledFeatures |= flag; + } + } + } + + // ── IPathAgent ───────────────────────────────────────────────── + public void RequestMoveTo(Vector2 target) => _navAgent?.UpdatePath(target); + public void StopNavigation() { + _activeHandler?.AbortLinkTraversal(); + _activeHandler = null; _navAgent?.Stop(); } - public bool IsAtDestination() - { - if (_navAgent == null) return true; - // 已停止 OR 在目标线段上且不再跟随路径 - return _navAgent.IsIdle; - } + public bool IsAtDestination() => _navAgent == null || _navAgent.IsIdle; public void SetSpeed(float speed) { if (_movement != null) _movement.movementSpeed = speed; } + public bool CanReach(Vector2 target) => _navAgent?.CanReach(target) ?? false; + + public bool WalkToRandom() => _navAgent?.SetRandomDestination() ?? false; + public bool IsNearEdge() { - // 双射线检测:脚下前方是否有地面 - if (_navAgent == null) return false; var origin = (Vector2)transform.position; var facing = transform.localScale.x >= 0f ? Vector2.right : Vector2.left; - var groundMask = ~0; // 检测所有层;可收窄至 Ground 层 - bool groundAhead = Physics2D.Raycast(origin + facing * 0.3f, Vector2.down, 0.5f, groundMask); - return !groundAhead; + return !Physics2D.Raycast(origin + facing * _edgeCheckFwdOffset, + Vector2.down, _edgeCheckDownLen, _groundMask); } + + // ── 底层 NavAgent 直接访问 ────────────────────────────────────── + /// 原始 PB2d NavAgent,供需要 PB2d 高级功能的能力直接使用。 + public NavAgent RawNavAgent => _navAgent; + + // ── 连接段调度核心 ───────────────────────────────────────────── + private void HandleLinkStart(NavAgent agent) + { + var seg = agent.CurrentPathSegment; + _currentLinkType = ParseLinkType(seg?.link?.LinkTypeName); + _currentLinkStart = seg?.LinkStart ?? Vector2.zero; + _currentLinkEnd = seg?.LinkEnd ?? Vector2.zero; + + OnLinkStarted?.Invoke(_currentLinkType); + + if (_handlers.TryGetValue(_currentLinkType, out var handler)) + { + if (handler.CanHandleLink(_currentLinkType, _currentLinkStart, _currentLinkEnd)) + { + _activeHandler = handler; + handler.BeginLinkTraversal(_currentLinkType, _currentLinkStart, _currentLinkEnd, + onComplete: () => + { + _activeHandler = null; + _navAgent?.CompleteLinkTraversal(); + }); + return; + } + + // CanHandleLink 返回 false 且 TBM 已禁用 → NavAgent 会卡住,警告设计者 + Debug.LogWarning($"[EnemyNavAgent] '{handler.GetType().Name}' reported CanHandleLink=false " + + $"for {_currentLinkType} (TBM disabled). NavAgent may stall. " + + $"Check link distance/height vs. ability parameters.", this); + } + // 无 handler → TBM 兜底(其 FeatureFlag 仍开启) + } + + private void HandleSegmentTraversal(NavAgent agent) + { + if (_currentLinkType != NavLinkType.None && !agent.IsOnLink) + { + var finished = _currentLinkType; + _currentLinkType = NavLinkType.None; + _currentLinkStart = Vector2.zero; + _currentLinkEnd = Vector2.zero; + OnLinkCompleted?.Invoke(finished); + } + } + + private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke(); + private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke(); + + // ── 工具 ─────────────────────────────────────────────────────── + private static NavLinkType ParseLinkType(string name) => name switch + { + "corner" => NavLinkType.Corner, + "jump" => NavLinkType.Jump, + "fall" => NavLinkType.Fall, + "teleport" => NavLinkType.Teleport, + "climb" => NavLinkType.Climb, + "elevator" => NavLinkType.Elevator, + null or "" => NavLinkType.Segment, + _ => NavLinkType.Custom + }; + + /// NavLinkType → TransformBasedMovement.FeatureFlags 映射。 + private static TransformBasedMovement.FeatureFlags TypeToFeatureFlag(NavLinkType type) => type switch + { + NavLinkType.Jump => TransformBasedMovement.FeatureFlags.JumpLinks, + NavLinkType.Fall => TransformBasedMovement.FeatureFlags.FallLinks, + NavLinkType.Corner => TransformBasedMovement.FeatureFlags.CornerLinks, + NavLinkType.Climb => TransformBasedMovement.FeatureFlags.ClimbLinks, + NavLinkType.Elevator => TransformBasedMovement.FeatureFlags.ElevatorLinks, + NavLinkType.Teleport => TransformBasedMovement.FeatureFlags.TeleportLinks, + _ => (TransformBasedMovement.FeatureFlags)0 + }; } } diff --git a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs new file mode 100644 index 0000000..6099b9a --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs @@ -0,0 +1,165 @@ +using System; +using UnityEngine; + +namespace BaseGames.Enemies.Navigation +{ + /// + /// 飞行单位直线导航代理。 + /// 不依赖 PathBerserker2d 寻路,直接通过 Rigidbody2D.MovePosition 向目标点直飞。 + /// + /// 特性: + /// + /// 空闲时施加正弦波形悬停偏移,产生飘浮感。 + /// NavLink 相关成员均返回安全默认值(飞行单位不使用平台连接段)。 + /// 目标到达后触发 事件(替代轮询 IsAtDestination)。 + /// + /// + /// 使用方式:挂载到飞行怪 Prefab 根节点,替代 EnemyNavAgent; + /// 的 GetComponent<IPathAgent>() 自动发现此组件。 + /// + [RequireComponent(typeof(Rigidbody2D))] + public sealed class FlyingDirectNavigator : MonoBehaviour, IPathAgent + { + [Header("移动参数")] + [Tooltip("直飞速度(m/s)")] + [SerializeField] private float _moveSpeed = 3f; + [Tooltip("到达目标的判定距离(m)")] + [SerializeField] private float _stoppingDist = 0.15f; + + [Header("游荡参数(WalkToRandom)")] + [Tooltip("随机游荡目标点距当前位置的最大半径(m)。")] + [Min(0.1f)] + [SerializeField] private float _randomWalkRadius = 3f; + + [Header("悬停参数(空闲 / 无目标时)")] + [Tooltip("悬停横向摆动速度(m/s)")] + [SerializeField] private float _hoverLateralSpeed = 0.5f; + [Tooltip("悬停纵向正弦频率(Hz)")] + [SerializeField] private float _hoverSineFrequency = 1.2f; + [Tooltip("悬停纵向正弦振幅(m/s)")] + [SerializeField] private float _hoverSineAmplitude = 0.25f; + [Tooltip("横向方向翻转周期(s)")] + [SerializeField] private float _hoverFlipInterval = 1.5f; + + // ── IPathAgent 事件 ──────────────────────────────────────────── + public event Action OnLinkStarted { add { } remove { } } + public event Action OnLinkCompleted { add { } remove { } } + public event Action OnNavPathFailed { add { } remove { } } + public event Action OnGoalReached; + + // ── 状态 ─────────────────────────────────────────────────────── + private Rigidbody2D _rb; + private Vector2? _destination; + private bool _isMoving; + private bool _goalFired; + + private float _hoverTimer; + private float _hoverFlipTimer; + private float _hoverDir = 1f; + + // ── IPathAgent 属性 ──────────────────────────────────────────── + public bool IsMoving => _isMoving; + public bool IsOnLink => false; + public NavLinkType CurrentLinkType => NavLinkType.None; + public Vector2 CurrentLinkStart => Vector2.zero; + public Vector2 CurrentLinkEnd => Vector2.zero; + + // ── Unity 生命周期 ───────────────────────────────────────────── + private void Awake() + { + _rb = GetComponent(); + _rb.gravityScale = 0f; + _rb.constraints = RigidbodyConstraints2D.FreezeRotation; + } + + private void FixedUpdate() + { + if (_destination.HasValue) + UpdateChase(); + else + UpdateHover(); + } + + // ── IPathAgent 方法 ──────────────────────────────────────────── + public void RequestMoveTo(Vector2 target) + { + _destination = target; + _isMoving = true; + _goalFired = false; + } + + public void StopNavigation() + { + _destination = null; + _isMoving = false; + _rb.velocity = Vector2.zero; + } + + public bool IsAtDestination() + { + if (!_destination.HasValue) return true; + return ((Vector2)transform.position - _destination.Value).sqrMagnitude + <= _stoppingDist * _stoppingDist; + } + + public void SetSpeed(float speed) => _moveSpeed = speed; + + public bool IsNearEdge() => false; // 飞行单位不检测平台边缘 + + public bool CanReach(Vector2 target) => true; // 飞行单位直飞,始终可达 + + public bool WalkToRandom() + { + // 在当前位置随机偏移一个 2D 方向(供 BD_WalkRandom 调用) + Vector2 offset = UnityEngine.Random.insideUnitCircle.normalized * _randomWalkRadius; + RequestMoveTo((Vector2)transform.position + offset); + return true; + } + + // ── 内部移动逻辑 ─────────────────────────────────────────────── + private void UpdateChase() + { + Vector2 myPos = _rb.position; + Vector2 target = _destination.Value; + float sqrDist = (target - myPos).sqrMagnitude; + + if (sqrDist <= _stoppingDist * _stoppingDist) + { + _rb.velocity = Vector2.zero; + _isMoving = false; + _destination = null; + if (!_goalFired) + { + _goalFired = true; + OnGoalReached?.Invoke(); + } + return; + } + + Vector2 newPos = Vector2.MoveTowards(myPos, target, _moveSpeed * Time.fixedDeltaTime); + _rb.MovePosition(newPos); + + // 面向移动方向 + float dx = target.x - myPos.x; + if (Mathf.Abs(dx) > 0.05f) + { + var s = transform.localScale; + s.x = Mathf.Abs(s.x) * Mathf.Sign(dx); + transform.localScale = s; + } + } + + private void UpdateHover() + { + _hoverFlipTimer += Time.fixedDeltaTime; + if (_hoverFlipTimer >= _hoverFlipInterval) + { + _hoverFlipTimer = 0f; + _hoverDir = -_hoverDir; + } + + float sineY = Mathf.Sin(Time.time * _hoverSineFrequency * Mathf.PI * 2f) * _hoverSineAmplitude; + _rb.velocity = new Vector2(_hoverDir * _hoverLateralSpeed, sineY); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs.meta b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs.meta new file mode 100644 index 0000000..d6a0de9 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 17b018161daf99846969142e6184b858 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Perception.meta b/Assets/_Game/Scripts/Enemies/Perception.meta new file mode 100644 index 0000000..d462c84 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 940cf84a70550a84a9c68aa457ce6a1d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs b/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs new file mode 100644 index 0000000..688b56c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using UnityEngine; +using Micosmo.SensorToolkit; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 敌人感知 Hub(架构 07_EnemyModule §9)。 + /// 集中暴露挂载在敌人 Prefab 上的各种 SensorToolkit Sensor,BD 任务通过 + /// 字符串槽位查询,避免在 BD 任务 Inspector 中拖具体 Sensor 引用。 + /// + /// 典型槽位命名约定: + /// - "aggro" : RangeSensor2D,玩家入侵警戒圈 + /// - "attack_melee" : RangeSensor2D,近战触发距离 + /// - "attack_range" : RangeSensor2D,远程触发距离 + /// - "los" : LOSSensor2D,视线 + /// - "wall_ahead" : RaySensor2D,前方墙体检测 + /// - "ledge" : RaySensor2D,前方悬崖检测 + /// + [DisallowMultipleComponent] + public sealed class EnemySensorHub : MonoBehaviour, IPerceptionSystem + { + [System.Serializable] + public struct SensorSlot + { + public string slotName; + public Sensor sensor; + } + + [SerializeField] private SensorSlot[] _slots; + + private Dictionary _map; + + private void Awake() + { + _map = new Dictionary(_slots?.Length ?? 0); + if (_slots == null) return; + for (int i = 0; i < _slots.Length; i++) + { + var s = _slots[i]; + if (s.sensor != null && !string.IsNullOrEmpty(s.slotName)) + _map[s.slotName] = s.sensor; + } + } + + public Sensor Get(string slotName) + { + if (_map == null || string.IsNullOrEmpty(slotName)) return null; + _map.TryGetValue(slotName, out var s); + return s; + } + + public bool IsDetecting(string slotName, GameObject target) + { + var s = Get(slotName); + return s != null && target != null && s.IsDetected(target); + } + + public bool HasAnyDetection(string slotName) + { + var s = Get(slotName); + if (s == null) return false; + foreach (var _ in s.Detections) return true; + return false; + } + + public GameObject GetFirstDetection(string slotName) + { + var s = Get(slotName); + if (s == null) return null; + foreach (var go in s.Detections) return go; + return null; + } + + /// + /// 暂停或恢复所有插槽的 Sensor。 + /// 当敌人超出 QuotaManager 活跃范围时调用(关闭),归入活跃范围时恢复(开启)。 + /// + public void SetSuspended(bool suspended) + { + if (_slots == null) return; + for (int i = 0; i < _slots.Length; i++) + { + var sensor = _slots[i].sensor; + if (sensor != null) sensor.enabled = !suspended; + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta new file mode 100644 index 0000000..fc47010 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58cb3ac0e49c151429cad39d3e164a3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs new file mode 100644 index 0000000..b34f9cd --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs @@ -0,0 +1,54 @@ +using UnityEngine; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 威胁评估器:在原始 LOS 结果上叠加感知反应延迟,使敌人不会瞬间发现玩家。 + /// + /// 工作原理: + /// + /// 读取 (原始 LOS,BatchLOSSystem 写入)。 + /// 连续感知到玩家超过 秒后, 才变为 true。 + /// 一旦丢失 LOS, 立即重置为 false(保持对"躲起来"的快速响应)。 + /// + /// + /// 挂载:添加到与 相同的 GameObject。 + /// 会自动路由至此组件(如果存在)。 + /// + [RequireComponent(typeof(EnemyBase))] + public class EnemyThreatAssessor : MonoBehaviour + { + [Tooltip("持续感知此时长(s)后判定为威胁;模拟敌人的反应时间")] + [SerializeField] [Min(0f)] private float reactionDelay = 0.15f; + + private EnemyBase _enemy; + private float _losAccumulator; + + /// 是否已将玩家判定为当前威胁(含反应延迟)。 + public bool IsThreatDetected { get; private set; } + + private void Awake() + { + _enemy = GetComponent(); + } + + private void Update() + { + if (_enemy == null) return; + + bool hasLos = _enemy.HasLineOfSight; + + if (hasLos) + { + _losAccumulator += Time.deltaTime; + if (_losAccumulator >= reactionDelay) + IsThreatDetected = true; + } + else + { + _losAccumulator = 0f; + IsThreatDetected = false; + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs new file mode 100644 index 0000000..7bad8aa --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs @@ -0,0 +1,23 @@ +using UnityEngine; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 敌人感知系统接口。 + /// EnemyBase 通过此接口与感知实现解耦,支持运行时替换(SensorToolkit / 自定义实现)。 + /// + public interface IPerceptionSystem + { + /// 指定槽位是否检测到任意目标。 + bool HasAnyDetection(string slotName); + + /// 指定槽位是否正在检测 target 对象。 + bool IsDetecting(string slotName, GameObject target); + + /// 返回指定槽位第一个检测到的对象,无检测则返回 null。 + GameObject GetFirstDetection(string slotName); + + /// 暂停或恢复感知系统(LOD / 超出活跃范围时调用)。 + void SetSuspended(bool suspended); + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs.meta new file mode 100644 index 0000000..581a775 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e0f4d9ac2fab74409dfa127cc6a67d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs new file mode 100644 index 0000000..1dbf0c1 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs @@ -0,0 +1,43 @@ +namespace BaseGames.Enemies.Perception +{ + /// + /// 槽位名称常量。 + /// + /// 统一定义字符串键,避免在 BD Task Inspector 和代码中散布魔法字符串。 + /// Prefab 上 EnemySensorHub 组件的 slotName 字段必须与此处常量保持一致。 + /// + public static class SensorSlotNames + { + /// + /// 警戒范围(RangeSensor2D):玩家进入此圈触发 Alert 阶段。 + /// 通常半径大于攻击范围,小于视线检测范围。 + /// + public const string Aggro = "aggro"; + + /// + /// 视线检测(LOSSensor2D):敌我之间无遮挡时持续为 true。 + /// 由 BatchLOSSystem 批量计算,BD_IsPlayerVisible 读取结果。 + /// + public const string LOS = "los"; + + /// + /// 近战攻击范围(RangeSensor2D):玩家进入时触发近战攻击条件。 + /// + public const string AttackMelee = "attack_melee"; + + /// + /// 远程攻击范围(RangeSensor2D):玩家进入时触发远程攻击条件。 + /// + public const string AttackRange = "attack_range"; + + /// + /// 前方墙体(RaySensor2D):水平方向检测,用于巡逻转向。 + /// + public const string WallAhead = "wall_ahead"; + + /// + /// 前方悬崖(RaySensor2D):斜向下检测地面是否存在,用于巡逻转向。 + /// + public const string Ledge = "ledge"; + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs.meta new file mode 100644 index 0000000..9ec31d2 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f449ccbbaeb13f468dd74ca202c4937 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs new file mode 100644 index 0000000..7fbcaf4 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs @@ -0,0 +1,40 @@ +namespace BaseGames.Enemies.States +{ + /// + /// 击飞腾空状态:施加击飞冲量,播放 KnockUp 动画直至落地/动画结束后恢复 Controlled。 + /// + /// 触发条件:TakeDamage 检测到 DamageFlags.Launch + 伤害 >= launchThreshold。 + /// 来袭方向由 提供。 + /// 若 AnimConfig 无 KnockUp Clip,则通过 按时长兜底恢复。 + /// + public sealed class EnemyKnockUpState : IEnemyState + { + public EnemyStateType StateType => EnemyStateType.KnockUp; + + public void Enter(EnemyBase owner) + { + var cfg = owner.StatsSO?.HitTiers ?? default; + + // 施加击飞冲量 + owner.Movement?.LaunchKnockup(owner.PendingLaunchDir, cfg.launchHorzForce, cfg.launchUpForce); + + if (owner.Animancer != null && owner.AnimConfig?.KnockUp != null) + { + var animState = owner.Animancer.Play(owner.AnimConfig.KnockUp); + animState.Events(owner).OnEnd = () => + { + if (owner.CurrentState == EnemyStateType.KnockUp) + owner.ForceState(EnemyStateType.Controlled); + }; + } + else + { + // 无 KnockUp 动画:按配置时长自动恢复 + float duration = cfg.knockUpDuration > 0f ? cfg.knockUpDuration : 0.5f; + owner.ScheduleStateRecovery(EnemyStateType.KnockUp, duration); + } + } + + public void Exit(EnemyBase owner) { } + } +} diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs.meta b/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs.meta new file mode 100644 index 0000000..79b8eb0 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/States/EnemyKnockUpState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 916cc14ff8ec787438042a7982768b9b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects.meta b/Assets/_Game/Scripts/Enemies/StatusEffects.meta new file mode 100644 index 0000000..315cb8f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6f4279ac662718d43bf6903197f35e80 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs b/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs new file mode 100644 index 0000000..3d6df84 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.StatusEffects +{ + /// + /// 状态效果管理器。 + /// 挂载在 EnemyBase 同 GameObject,负责激活效果的 Tick、叠加规则与移除。 + /// + /// 设计原则: + /// - 同类型效果只保留一个(新效果重置计时)。 + /// - 每帧在 EnemyBase.Update 中由 Tick 驱动。 + /// - 敌人死亡时由 EnemyBase.Die 调用 Clear。 + /// + [DisallowMultipleComponent] + public sealed class EnemyStatusEffectManager : MonoBehaviour + { + private EnemyBase _enemy; + + // 用 List 而非 Dictionary 避免 GC(通常同时激活效果 < 4 个) + private readonly List _active = new List(4); + + private void Awake() => _enemy = GetComponent(); + + // ── 外部 API ────────────────────────────────────────────────────── + + /// 施加效果。同类型效果已存在时先移除旧的再挂新的(刷新)。 + public void Apply(IStatusEffect effect) + { + if (effect == null) return; + Remove(effect.Type); // 移除同类旧效果(刷新逻辑) + _active.Add(effect); + effect.OnApplied(_enemy); + } + + /// 移除指定类型效果(若存在)。 + public void Remove(StatusEffectType type) + { + for (int i = _active.Count - 1; i >= 0; i--) + { + if (_active[i].Type == type) + { + _active[i].OnRemoved(_enemy); + _active.RemoveAt(i); + break; + } + } + } + + /// 查询指定类型效果是否激活。 + public bool HasEffect(StatusEffectType type) + { + for (int i = 0; i < _active.Count; i++) + if (_active[i].Type == type) return true; + return false; + } + + /// 激活效果的只读视图(调试叠加层 / 存档用,非热路径)。 + public System.Collections.Generic.IReadOnlyList ActiveEffects => _active; + + /// 移除全部效果(死亡 / 重生时调用)。 + public void Clear() + { + for (int i = _active.Count - 1; i >= 0; i--) + _active[i].OnRemoved(_enemy); + _active.Clear(); + } + + // ── 每帧驱动 ───────────────────────────────────────────────────── + + private void Update() + { + if (_active.Count == 0) return; + float dt = Time.deltaTime; + for (int i = _active.Count - 1; i >= 0; i--) + { + var fx = _active[i]; + fx.Tick(_enemy, dt); + if (fx.IsFinished) + { + fx.OnRemoved(_enemy); + _active.RemoveAt(i); + } + } + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs.meta b/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs.meta new file mode 100644 index 0000000..5c190fa --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/EnemyStatusEffectManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d13e62de549907545bb4295ce1e0f089 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs b/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs new file mode 100644 index 0000000..0f26aa3 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace BaseGames.Enemies.StatusEffects +{ + /// + /// 状态效果类型枚举。 + /// + public enum StatusEffectType + { + Frozen, // 冻结:停止移动、降低 BT Tick 频率 + Sleep, // 睡眠:受击即唤醒 + Burning, // 灼烧:持续扣血 + } + + /// + /// 状态效果接口。 + /// 每种效果实现此接口,由 统一管理生命周期。 + /// + public interface IStatusEffect + { + StatusEffectType Type { get; } + + /// 持续时间(秒),负值 = 永久(直到手动移除)。 + float Duration { get; } + + /// 效果是否已自然结束(超时或条件不满足)。 + bool IsFinished { get; } + + /// 效果挂载到 enemy 时调用一次。 + void OnApplied(EnemyBase enemy); + + /// 效果每帧 Tick(由 EnemyStatusEffectManager 驱动)。 + void Tick(EnemyBase enemy, float deltaTime); + + /// 效果移除(超时 / 手动 / 死亡)时调用一次。 + void OnRemoved(EnemyBase enemy); + } +} diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs.meta b/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs.meta new file mode 100644 index 0000000..d650fc2 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/IStatusEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 471af430ac587384ebcd225024de6dab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs b/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs new file mode 100644 index 0000000..e8b5163 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs @@ -0,0 +1,128 @@ +using UnityEngine; +using BaseGames.Combat; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies.StatusEffects +{ + /// + /// 冻结效果:停止敌人移动,中断所有能力,降低 BT Tick 频率。 + /// 效果持续期间每帧强制停止水平移动。 + /// + public sealed class FrozenEffect : IStatusEffect + { + public StatusEffectType Type => StatusEffectType.Frozen; + public float Duration { get; } + public bool IsFinished => _elapsed >= Duration; + + private float _elapsed; + + public FrozenEffect(float duration) => Duration = duration; + + public void OnApplied(EnemyBase enemy) + { + _elapsed = 0f; + enemy.Abilities?.InterruptAll(InterruptReason.ExternalRequest); + enemy.StopMovement(); + enemy.SetAggroTickRate(false); + } + + public void Tick(EnemyBase enemy, float deltaTime) + { + _elapsed += deltaTime; + enemy.Movement?.StopHorizontal(); + } + + public void OnRemoved(EnemyBase enemy) { } + } + + /// + /// 睡眠效果:敌人进入休眠状态,受到任意伤害立即唤醒。 + /// 持续时间可为负值(永久,直到被击醒)。 + /// + public sealed class SleepEffect : IStatusEffect + { + public StatusEffectType Type => StatusEffectType.Sleep; + public float Duration { get; } + public bool IsFinished => _awoken || (Duration >= 0f && _elapsed >= Duration); + + private float _elapsed; + private bool _awoken; + private int _lastHP; + + public SleepEffect(float duration = -1f) => Duration = duration; + + public void OnApplied(EnemyBase enemy) + { + _elapsed = 0f; + _awoken = false; + _lastHP = enemy.Stats != null ? enemy.Stats.CurrentHP : int.MaxValue; + enemy.Abilities?.InterruptAll(InterruptReason.ExternalRequest); + enemy.StopMovement(); + enemy.SetAggroTickRate(false); + } + + public void Tick(EnemyBase enemy, float deltaTime) + { + _elapsed += deltaTime; + enemy.Movement?.StopHorizontal(); + + // 受击检测:HP 减少则唤醒 + if (enemy.Stats != null && enemy.Stats.CurrentHP < _lastHP) + _awoken = true; + if (enemy.Stats != null) + _lastHP = enemy.Stats.CurrentHP; + } + + public void OnRemoved(EnemyBase enemy) { } + } + + /// + /// 灼烧效果:每 tickInterval 秒对敌人造成 damagePerTick 点持续伤害。 + /// 不触发霸体判定(无 DamageFlags.ForceBreak),允许受击动作打断。 + /// + public sealed class BurningEffect : IStatusEffect + { + public StatusEffectType Type => StatusEffectType.Burning; + public float Duration { get; } + public bool IsFinished => _elapsed >= Duration; + + private readonly float _damagePerTick; + private readonly float _tickInterval; + private float _elapsed; + private float _nextTick; + + public BurningEffect(float duration, float damagePerTick, float tickInterval = 0.5f) + { + Duration = duration; + _damagePerTick = damagePerTick; + _tickInterval = tickInterval; + } + + public void OnApplied(EnemyBase enemy) + { + _elapsed = 0f; + _nextTick = _tickInterval; + } + + public void Tick(EnemyBase enemy, float deltaTime) + { + _elapsed += deltaTime; + if (_elapsed >= _nextTick) + { + _nextTick += _tickInterval; + var info = new DamageInfo + { + RawDamage = (int)_damagePerTick, + Amount = (int)_damagePerTick, + FinalDamage = (int)_damagePerTick, + Type = DamageType.Fire, + Flags = DamageFlags.None, + }; + enemy.TakeDamage(info); + } + } + + public void OnRemoved(EnemyBase enemy) { } + } +} + diff --git a/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs.meta b/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs.meta new file mode 100644 index 0000000..bfcef81 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/StatusEffects/StatusEffects.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc0498227f923c74281ecb8f7abaaf10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/World/IRoomLifecycle.cs b/Assets/_Game/Scripts/World/IRoomLifecycle.cs new file mode 100644 index 0000000..2b73663 --- /dev/null +++ b/Assets/_Game/Scripts/World/IRoomLifecycle.cs @@ -0,0 +1,24 @@ +namespace BaseGames.World +{ + /// + /// 实现此接口的组件可感知房间的 Dormant / Active 生命周期切换。 + /// + /// 在休眠或激活房间时, + /// 会调用场景内所有实现了此接口的组件,使其做出相应响应(关闭 AI、保存状态等)。 + /// + /// + public interface IRoomLifecycle + { + /// + /// 房间进入休眠时调用。实现方应关闭 AI、暂停动画、停止音效, + /// 以避免 Dormant 房间消耗不必要的 CPU。 + /// + void OnRoomDormant(); + + /// + /// 房间被激活时调用。实现方应恢复 AI、重置状态、播放入场效果等。 + /// + /// 出生上下文,含出生点 ID 和是否为复活流程。 + void OnRoomActivate(SpawnContext context); + } +} diff --git a/Assets/_Game/Scripts/World/IRoomStreamingManager.cs b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs new file mode 100644 index 0000000..937642c --- /dev/null +++ b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs @@ -0,0 +1,49 @@ +using System.Collections; + +namespace BaseGames.World +{ + /// + /// 房间流式加载管理器接口。 + /// + /// 在 Start() 中通过 ServiceLocator 查找此接口, + /// 若存在则将自身注册,由管理器控制相机初始化时机(避免 Dormant 房间抢占相机)。 + /// 若不存在(非流式模式)则退回原有行为。 + /// + /// + /// 通过此接口操作流式管理器, + /// 完全解耦于具体实现类,便于测试和替换。 + /// + /// + public interface IRoomStreamingManager + { + /// 房间加载完成后,RoomController.Start() 调用此方法将自身注册到流式管理器。 + void RegisterRoomController(RoomController controller); + + /// 当前处于 Active 状态的房间 ID。 + string CurrentRoomId { get; } + + /// + /// 立即将指定房间加入预加载队列。 + /// 若房间已加载或已在队列中则忽略。 + /// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。 + /// + void PreloadRoom(string roomId); + + /// + /// 查询目标房间是否已处于 Dormant 状态,可立即执行无等待的激活。 + /// + bool IsRoomDormant(string roomId); + + /// + /// 激活目标房间(Dormant → Active)并将前一个房间置于冷却状态。 + /// 目标房间必须已处于 Dormant 状态。由 在过渡时调用。 + /// + IEnumerator ActivateRoomCoroutine(string targetRoomId, SpawnContext context); + + /// + /// 若房间尚未加载则先加载,完成后激活。 + /// 用于非流式冷启动路径(如游戏初始化、快速传送落地)。 + /// + IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context); + } +} diff --git a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs index c9591bf..671312b 100644 --- a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs +++ b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using UnityEngine; +using BaseGames.Core.Events; namespace BaseGames.World.Map { @@ -32,6 +33,13 @@ namespace BaseGames.World.Map public bool IsSavePoint; public bool IsShop; public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标 + + [Header("流式加载")] + [Tooltip("此房间场景资产的预估内存(KB)。\n" + + "在 Profiler 中测量场景实际内存后填入,供流式管理器执行内存预算检查使用。\n" + + "建议在关卡内容基本定型后更新此值。0 = 未填写,将跳过内存预算检查。")] + [Min(0)] + public int EstimatedMemoryKB; } [Serializable] @@ -40,6 +48,11 @@ namespace BaseGames.World.Map public string TargetRoomId; // 连接的目标房间 ID public Vector2Int ExitGridPos; // 出口在格子地图上的位置 public ExitDirection Direction; // 出口方向 + + [Tooltip("此出口触发的过渡类型。\n" + + "Seamless:无缝切换(同区域相邻房间首选);\n" + + "AtmosphericFade:短暂淡出 + 区域名提示(跨大区域边界首选)。")] + public TransitionType PreferredTransitionType; } public enum ExitDirection { Up, Down, Left, Right } @@ -61,8 +74,11 @@ namespace BaseGames.World.Map public MapRoomDataSO GetRoom(string roomId) { if (_index == null) + { + if (AllRooms == null) return null; _index = AllRooms.Where(r => r != null) .ToDictionary(r => r.RoomId); + } _index.TryGetValue(roomId, out var r); return r; } diff --git a/Assets/_Game/Scripts/World/RoomController.cs b/Assets/_Game/Scripts/World/RoomController.cs index fb0f7d8..c335c1e 100644 --- a/Assets/_Game/Scripts/World/RoomController.cs +++ b/Assets/_Game/Scripts/World/RoomController.cs @@ -6,11 +6,15 @@ namespace BaseGames.World { /// /// 房间控制器。挂在每个房间场景的 [RoomRoot] 下。 - /// Start 时切换摄像机到玩家当前所在的 CameraArea,并提供出生点查询。 + /// + /// 在流式加载模式下( 已注册),Start() 将自身注册到 + /// 流式管理器,由管理器控制相机初始化时机,避免 Dormant 房间抢占相机。 + /// 非流式模式(无管理器注册)则退回原有行为,在 Start() 中立即初始化相机。 + /// /// 支持房间内存在多个 CameraArea 的情况:动态检测玩家位于哪个触发区域, /// 无匹配时回退到场景内第一个 CameraArea。 /// - public class RoomController : MonoBehaviour + public class RoomController : MonoBehaviour, IRoomLifecycle { [SerializeField] private string _roomId; [SerializeField] private PlayerSpawnPoint[] _spawnPoints; @@ -23,6 +27,39 @@ namespace BaseGames.World public string RoomId => _roomId; private void Start() + { + // 流式模式:注册到管理器,由管理器控制相机初始化时机(避免 Dormant 房间抢占相机) + var streaming = ServiceLocator.GetOrDefault(); + if (streaming != null) + { + streaming.RegisterRoomController(this); + return; + } + + // 非流式模式(管理器未注册):立即初始化相机,保持原有行为 + SetupCamera(); + } + + // ── IRoomLifecycle ──────────────────────────────────────────────────────── + + /// + /// 房间进入休眠时由 调用。 + /// 相机无需额外操作,视觉隐藏由 RoomHandle 批量关闭 Renderer 完成。 + /// + public void OnRoomDormant() { } + + /// + /// 房间被激活时由 调用,重新初始化相机区域。 + /// + public void OnRoomActivate(SpawnContext context) + { + SetupCamera(); + } + + // ── 相机初始化 ──────────────────────────────────────────────────────────── + + /// 初始化相机区域。可由 Start() 或 OnRoomActivate() 调用。 + public void SetupCamera() { // 显式覆盖优先:直接使用编辑器/工具指定的基线区域 CameraArea area = _cameraArea != null ? _cameraArea : FindAreaForPlayer(); diff --git a/Assets/_Game/Scripts/World/RoomState.cs b/Assets/_Game/Scripts/World/RoomState.cs new file mode 100644 index 0000000..386bd7f --- /dev/null +++ b/Assets/_Game/Scripts/World/RoomState.cs @@ -0,0 +1,29 @@ +namespace BaseGames.World +{ + /// + /// 房间在流式加载系统中的生命周期状态。 + /// + public enum RoomState + { + /// 未加载,资产不在内存中。 + Unloaded, + + /// 正在后台异步加载中。 + Loading, + + /// 已加载并初始化,处于休眠状态:渲染器、AI、物理均已关闭,不消耗 CPU。 + Dormant, + + /// 正在激活中(分帧启用 AI)。 + Activating, + + /// 完全激活,玩家当前所在的房间。 + Active, + + /// 玩家已离开,等待冷却计时后转为 Dormant 或 Unloaded。 + Cooling, + + /// 正在后台异步卸载中。 + Unloading, + } +} diff --git a/Assets/_Game/Scripts/World/RoomTransition.cs b/Assets/_Game/Scripts/World/RoomTransition.cs index 5359447..3b7bf4a 100644 --- a/Assets/_Game/Scripts/World/RoomTransition.cs +++ b/Assets/_Game/Scripts/World/RoomTransition.cs @@ -25,9 +25,11 @@ namespace BaseGames.World [SerializeField] private string _targetTransitionId; // 目标房间出生点 ID [Header("过渡类型")] - [Tooltip("Room:极短淡出,无加载画面,相邻房间边界专用。\n" + - "Scene:完整淡出 + 加载画面,大区域/地图间切换专用。")] - [SerializeField] private TransitionType _transitionType = TransitionType.Room; + [Tooltip("Seamless:无缝切换,流式系统标准选项,绝大多数房间门使用此类型。\n" + + "AtmosphericFade:短暂淡出 + 区域名提示,适合跨大区域边界。\n" + + "Room:极短淡出,不走流式系统(仅用于非流式独立场景切换)。\n" + + "Scene:完整淡出 + 加载画面,大区域/地图间传送专用。")] + [SerializeField] private TransitionType _transitionType = TransitionType.Seamless; [Header("触发方式")] [SerializeField] private bool _autoTrigger = true; // true = 玩家进入触发器自动触发 diff --git a/Assets/_Game/Scripts/World/SpawnContext.cs b/Assets/_Game/Scripts/World/SpawnContext.cs new file mode 100644 index 0000000..1f2d1d3 --- /dev/null +++ b/Assets/_Game/Scripts/World/SpawnContext.cs @@ -0,0 +1,24 @@ +namespace BaseGames.World +{ + /// + /// 玩家在房间激活时的出生上下文。 + /// 由 传递给 , + /// 用于确定出生点位置和方向。 + /// + public readonly struct SpawnContext + { + /// 目标出生点 ID,对应 。null 时使用默认出生点。 + public readonly string EntryTransitionId; + + /// true = 死亡复活流程,不播放入场动画。 + public readonly bool IsRespawn; + + public SpawnContext(string entryTransitionId, bool isRespawn = false) + { + EntryTransitionId = entryTransitionId; + IsRespawn = isRespawn; + } + + public static readonly SpawnContext Default = new SpawnContext(null, false); + } +} diff --git a/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef b/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef new file mode 100644 index 0000000..057aa1f --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef @@ -0,0 +1,21 @@ +{ + "name": "BaseGames.World.Streaming", + "rootNamespace": "BaseGames.World.Streaming", + "references": [ + "BaseGames.Core", + "BaseGames.Core.Events", + "BaseGames.World", + "BaseGames.World.Map", + "Unity.Addressables", + "Unity.ResourceManager" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs new file mode 100644 index 0000000..007984f --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs @@ -0,0 +1,294 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; +using UnityEngine.ResourceManagement.ResourceProviders; +using UnityEngine.SceneManagement; + +namespace BaseGames.World.Streaming +{ + /// + /// 单个房间的流式加载状态包装器。 + /// + /// 由 创建和管理,封装以下职责: + /// + /// Addressables 加载 / 卸载句柄 + /// Dormantize:批量关闭 Renderer、暂停 IRoomLifecycle(AI、音效等) + /// Activate:批量恢复渲染,分帧唤醒 IRoomLifecycle + /// Deactivate:玩家离开后关闭本房间内容 + /// + /// + /// + public class RoomHandle + { + // ── 公开属性 ────────────────────────────────────────────────────────────── + + public string RoomId { get; } + public RoomState State { get; private set; } = RoomState.Unloaded; + public int EstimatedMemKB { get; set; } + + /// 上次处于 Active 状态的时间(Time.time)。用于 LRU 卸载优先级计算。 + public float LastActiveTime { get; private set; } + + /// 加载完成后由 RoomController.Start() 通过 IRoomStreamingManager.RegisterRoomController 注入。 + public RoomController RoomController { get; set; } + + // ── 私有字段 ────────────────────────────────────────────────────────────── + + private AsyncOperationHandle _sceneHandle; + + // 缓存的场景组件集合,Dormantize 后可快速 Activate + private Renderer[] _renderers; + private IRoomLifecycle[] _lifecycles; + private AudioSource[] _audioSources; + private Light[] _lights; + private ParticleSystem[] _particleSystems; + + // 分帧激活的协程宿主(由 RoomStreamingManager 提供) + private MonoBehaviour _coroutineRunner; + private int _lifecycleActivatePerFrame; + + // ── 构造 ────────────────────────────────────────────────────────────────── + + public RoomHandle(string roomId, MonoBehaviour coroutineRunner, int lifecycleActivatePerFrame) + { + RoomId = roomId; + _coroutineRunner = coroutineRunner; + _lifecycleActivatePerFrame = lifecycleActivatePerFrame; + } + + // ── 加载 / 卸载 ─────────────────────────────────────────────────────────── + + /// + /// 开始后台异步加载(Additive)。 + /// 加载完成后自动调用 将房间置于休眠状态。 + /// + public IEnumerator LoadAsync(string addressableKey) + { + if (State != RoomState.Unloaded) + { + Debug.LogWarning($"[RoomHandle] {RoomId} 非 Unloaded 状态,忽略重复加载请求。"); + yield break; + } + + State = RoomState.Loading; + + _sceneHandle = Addressables.LoadSceneAsync(addressableKey, LoadSceneMode.Additive); + + while (!_sceneHandle.IsDone) + yield return null; + + if (_sceneHandle.Status != AsyncOperationStatus.Succeeded) + { + Debug.LogError($"[RoomHandle] {RoomId} 加载失败:{addressableKey}"); + State = RoomState.Unloaded; + yield break; + } + + // 收集场景内组件引用,然后置于休眠 + CollectSceneComponents(); + Dormantize(); + } + + /// 异步卸载本房间。完成后 State → Unloaded。 + public IEnumerator UnloadAsync() + { + if (State == RoomState.Unloaded || State == RoomState.Unloading || !_sceneHandle.IsValid()) yield break; + + // Active 状态先走 Deactivate,再设置 Unloading + bool needsDeactivate = State == RoomState.Active || + State == RoomState.Activating || + State == RoomState.Cooling; + if (needsDeactivate) + Deactivate(); + + State = RoomState.Unloading; + + var op = Addressables.UnloadSceneAsync(_sceneHandle); + yield return op; + + _renderers = null; + _lifecycles = null; + _audioSources = null; + _lights = null; + _particleSystems = null; + RoomController = null; + State = RoomState.Unloaded; + } + + // ── Dormantize ──────────────────────────────────────────────────────────── + + /// + /// 将已加载的房间置于休眠状态。 + /// 关闭所有 Renderer 和 AudioSource,通知所有 IRoomLifecycle 组件进入休眠。 + /// 场景内 GameObject 保持激活(便于快速恢复),但不消耗渲染和 AI 开销。 + /// + public void Dormantize() + { + // 允许从 Loading(初次加载完成后)、Active、Activating、Cooling 进入 + if (State != RoomState.Loading && + State != RoomState.Active && + State != RoomState.Activating && + State != RoomState.Cooling) + { + Debug.LogWarning($"[RoomHandle] {RoomId} 在 {State} 状态下调用 Dormantize,忽略。"); + return; + } + + if (_renderers != null) + foreach (var r in _renderers) if (r != null) r.enabled = false; + + if (_audioSources != null) + foreach (var a in _audioSources) if (a != null) { a.Stop(); a.enabled = false; } + + if (_lights != null) + foreach (var l in _lights) if (l != null) l.enabled = false; + + if (_particleSystems != null) + foreach (var ps in _particleSystems) if (ps != null) ps.Pause(); + + if (_lifecycles != null) + foreach (var l in _lifecycles) l?.OnRoomDormant(); + + State = RoomState.Dormant; + } + + // ── Activate ────────────────────────────────────────────────────────────── + + /// + /// 激活此房间(Dormant → Active)。 + /// 恢复 Renderer、AudioSource,分帧唤醒 IRoomLifecycle,并设置玩家出生点。 + /// 目标房间必须已处于 状态。 + /// + public IEnumerator Activate(SpawnContext context) + { + if (State != RoomState.Dormant) + { + Debug.LogError($"[RoomHandle] {RoomId} 不是 Dormant 状态(当前:{State}),无法激活。"); + yield break; + } + + State = RoomState.Activating; + + // 先恢复渲染(让玩家立刻能看到新房间) + if (_renderers != null) + foreach (var r in _renderers) if (r != null) r.enabled = true; + + if (_audioSources != null) + foreach (var a in _audioSources) if (a != null) a.enabled = true; + + if (_lights != null) + foreach (var l in _lights) if (l != null) l.enabled = true; + + if (_particleSystems != null) + foreach (var ps in _particleSystems) if (ps != null) ps.Play(); + + // 相机 + 出生点(由 RoomController 处理) + RoomController?.OnRoomActivate(context); + + // 分帧激活 IRoomLifecycle(AI、特效等) + yield return _coroutineRunner.StartCoroutine(ActivateLifecyclesGradually(context)); + + State = RoomState.Active; + LastActiveTime = Time.time; + } + + /// 将 IRoomLifecycle 组件分帧激活,避免单帧 CPU 峰值。 + private IEnumerator ActivateLifecyclesGradually(SpawnContext context) + { + if (_lifecycles == null) yield break; + + int activatedThisFrame = 0; + foreach (var lc in _lifecycles) + { + if (lc == null) continue; + lc.OnRoomActivate(context); + activatedThisFrame++; + if (activatedThisFrame >= _lifecycleActivatePerFrame) + { + activatedThisFrame = 0; + yield return null; + } + } + } + + // ── Deactivate ──────────────────────────────────────────────────────────── + + /// + /// 玩家离开后关闭此房间(Active / Activating → Dormant)。 + /// 与 相同,但语义上表示从 Active 退出。 + /// + public void Deactivate() + { + if (State != RoomState.Active && State != RoomState.Activating && State != RoomState.Cooling) + { + Debug.LogWarning($"[RoomHandle] {RoomId} 在 {State} 状态下调用 Deactivate,忽略。"); + return; + } + + Dormantize(); + } + + // ── 冷却 ────────────────────────────────────────────────────────────────── + + /// + /// 标记为冷却中,同时休眠渲染和 AI 开销。 + /// 玩家离开后不立即卸载,等待冷却计时结束后再卸载。 + /// + public void BeginCooling() + { + if (State != RoomState.Active && State != RoomState.Activating) return; + // 先休眠渲染和 AI(与 Dormantize 相同,但最终 State 设为 Cooling 而非 Dormant) + Dormantize(); + State = RoomState.Cooling; + } + + /// + /// 冷却期间玩家返回,将状态从 Cooling 重置为 Dormant。 + /// 渲染已在 BeginCooling 时关闭,无需重复操作。 + /// + public void ResetToDormant() + { + if (State == RoomState.Cooling) + State = RoomState.Dormant; + } + + // ── 内部工具 ────────────────────────────────────────────────────────────── + + /// + /// 收集加载完成的场景中所有需要被管理的组件引用。 + /// 在 LoadAsync 完成后、Dormantize 之前调用一次。 + /// + private void CollectSceneComponents() + { + if (!_sceneHandle.IsValid()) return; + + Scene scene = _sceneHandle.Result.Scene; + GameObject[] roots = scene.GetRootGameObjects(); + + var rendererList = new List(); + var lifecycleList = new List(); + var audioSourceList = new List(); + var lightList = new List(); + var particleList = new List(); + + foreach (var root in roots) + { + rendererList.AddRange(root.GetComponentsInChildren(true)); + lifecycleList.AddRange(root.GetComponentsInChildren(true)); + audioSourceList.AddRange(root.GetComponentsInChildren(true)); + lightList.AddRange(root.GetComponentsInChildren(true)); + particleList.AddRange(root.GetComponentsInChildren(true)); + } + + _renderers = rendererList.ToArray(); + _lifecycles = lifecycleList.ToArray(); + _audioSources = audioSourceList.ToArray(); + _lights = lightList.ToArray(); + _particleSystems = particleList.ToArray(); + } + + public override string ToString() => $"RoomHandle[{RoomId}, {State}]"; + } +} diff --git a/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs new file mode 100644 index 0000000..89e9835 --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs @@ -0,0 +1,388 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using BaseGames.Core; +using BaseGames.Core.Events; +using BaseGames.World.Map; + +namespace BaseGames.World.Streaming +{ + /// + /// 核心流式加载调度器。挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上。 + /// + /// 职责: + /// + /// 在 Persistent 场景初始化时从 MapDatabaseSO 构建 + /// 监听 事件,重新计算 StreamingSet + /// 后台异步预加载邻居房间(),并发数由预算配置限制 + /// 执行内存预算检查,通过 LRU 策略卸载距离最远、最久未访问的 Dormant 房间 + /// 提供 ActivateRoom / DeactivateRoom 接口供 调用 + /// + /// + /// + [DefaultExecutionOrder(-800)] + public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager + { + [Header("配置")] + [SerializeField] private MapDatabaseSO _mapDatabase; + [SerializeField] private StreamingBudgetConfigSO _budget; + + [Header("事件频道 - 监听")] + [Tooltip("玩家进入新房间时发布(携带 RoomId 字符串)。")] + [SerializeField] private StringEventChannelSO _onRoomEntered; + + [Header("事件频道 - 发布")] + [Tooltip("某个房间加载并进入 Dormant 状态后发布(携带 RoomId)。\n" + + "供 TransitionDirector 检查是否可执行 Seamless 切换。")] + [SerializeField] private StringEventChannelSO _onRoomPreloaded; + + [Header("格子单位(世界坐标)")] + [Tooltip("每个格子对应的 Unity 世界坐标单位数,与关卡设计网格对齐。")] + [SerializeField] private float _unitsPerGrid = 16f; + + // ── 运行时状态 ───────────────────────────────────────────────────────────── + + private WorldGraph _graph; + private Dictionary _handles = new(); + private readonly Queue _loadQueue = new(); + /// O(1) Contains 镜像,与 _loadQueue 保持同步,避免 Queue.Contains 的 O(n) 开销。 + private readonly HashSet _queuedRoomIds = new(); + /// 当前正在运行 CoolingCoroutine 的房间,防止竞态下重复启动冷却协程。 + private readonly HashSet _roomsInCooling = new(); + /// 单次 BFS 预算:从当前房间到所有已加载房间的跳数缓存,供 LRU 排序使用。 + private Dictionary _hopCache = new(); + private int _activeLoads; + private string _currentRoomId; + + private readonly CompositeDisposable _subscriptions = new(); + + // ── IRoomStreamingManager ───────────────────────────────────────────────── + + public string CurrentRoomId => _currentRoomId; + + public void RegisterRoomController(RoomController controller) + { + if (controller == null) return; + if (_handles.TryGetValue(controller.RoomId, out var handle)) + { + handle.RoomController = controller; + // Active 房间直接初始化相机(正常过渡流程,非后台预加载) + if (handle.State == RoomState.Active) + controller.SetupCamera(); + } + else + { + // 首次进入(非流式路径也可能触发),直接初始化相机 + controller.SetupCamera(); + } + } + + /// + /// 立即将指定房间加入预加载队列。 + /// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。 + /// 若已加载或已在队列中则忽略。 + /// + public void PreloadRoom(string roomId) + { + if (_handles.ContainsKey(roomId)) return; + EnqueuePreload(roomId); + } + + // ── 生命周期 ────────────────────────────────────────────────────────────── + + private void Awake() + { + ServiceLocator.Register(this); + _graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid); + } + + private void OnEnable() + { + _onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subscriptions); + } + + private void OnDisable() + { + _subscriptions.Clear(); + } + + private void OnDestroy() + { + ServiceLocator.Unregister(this); + } + + private void Update() + { + DrainLoadQueue(); + } + + // ── 房间进入事件 ─────────────────────────────────────────────────────────── + + private void OnRoomEntered(string roomId) + { + if (roomId == _currentRoomId) return; + _currentRoomId = roomId; + RecalculateStreamingSet(roomId); + } + + // ── StreamingSet 计算 ────────────────────────────────────────────────────── + + /// + /// 根据当前房间重新计算需要保持 Dormant 的邻居集合, + /// 并调度加载和卸载操作。单次 BFS 完成所有计算,结果缓存供 LRU 排序复用。 + /// + private void RecalculateStreamingSet(string currentRoomId) + { + if (_graph == null) return; + + int hops = _budget != null ? _budget.PreloadLookaheadHops : 2; + + // 单次 BFS:同时服务于 keepIds 构建、预加载候选、LRU 排序三个用途 + _hopCache = _graph.GetAllHopDistances(currentRoomId); + + // 从跳数缓存派生保留集(跳数 ≤ hops 的所有房间 + 当前房间) + var keepIds = new HashSet { currentRoomId }; + foreach (var kv in _hopCache) + { + if (kv.Value <= hops) + keepIds.Add(kv.Key); + } + + // 不在保留集内的 Dormant 房间 → 开始冷却(防重复:_roomsInCooling 守卫) + foreach (var handle in _handles.Values) + { + if (!keepIds.Contains(handle.RoomId) && + handle.State == RoomState.Dormant && + !_roomsInCooling.Contains(handle.RoomId)) + { + StartCoroutine(CoolingCoroutine(handle)); + } + } + + // 保留集内尚未加载的房间 → 加入预加载队列 + foreach (var kv in _hopCache) + { + if (kv.Value > hops) continue; + if (!_handles.ContainsKey(kv.Key)) + EnqueuePreload(kv.Key); + } + + EnforceMemoryBudget(); + } + + // ── 预加载队列 ──────────────────────────────────────────────────────────── + + private void EnqueuePreload(string roomId) + { + if (_queuedRoomIds.Contains(roomId)) return; // O(1) 查重 + if (_handles.ContainsKey(roomId)) return; + _loadQueue.Enqueue(roomId); + _queuedRoomIds.Add(roomId); + } + + /// 每帧尝试从队列中启动新的加载,受 MaxConcurrentLoads 限制。 + private void DrainLoadQueue() + { + if (_budget == null) return; + while (_activeLoads < _budget.MaxConcurrentLoads && _loadQueue.Count > 0) + { + string roomId = _loadQueue.Dequeue(); + _queuedRoomIds.Remove(roomId); // 同步镜像 + if (_handles.ContainsKey(roomId)) continue; // 已被其他路径加载 + + var node = _graph?.GetNode(roomId); + if (node == null) continue; + + StartCoroutine(PreloadRoomCoroutine(roomId)); + } + } + + private IEnumerator PreloadRoomCoroutine(string roomId) + { + _activeLoads++; + + int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8; + var handle = new RoomHandle(roomId, this, perFrame); + // 从图节点读取内存估算(关卡设计师在 MapRoomDataSO 中填写) + handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0; + _handles[roomId] = handle; + + // RoomId 与 Addressable key 相同(规范:Room_{Region}_{Id}) + yield return handle.LoadAsync(roomId); + + _activeLoads--; + + if (handle.State == RoomState.Dormant) + { + _onRoomPreloaded?.Raise(roomId); + Debug.Log($"[RoomStreamingManager] 预加载完成:{roomId}"); + } + else + { + // 加载失败,从字典移除 + _handles.Remove(roomId); + } + } + + // ── 内存预算 / LRU 卸载 ─────────────────────────────────────────────────── + + private void EnforceMemoryBudget() + { + if (_budget == null) return; + + // 按距离降序 + LRU 时间升序排列所有 Dormant 房间 + var dormants = _handles.Values + .Where(h => h.State == RoomState.Dormant) + .OrderByDescending(h => _hopCache.TryGetValue(h.RoomId, out int d) ? d : int.MaxValue) + .ThenBy(h => h.LastActiveTime) + .ToList(); + + // 第一道:数量超限,从列表头部(距离最远/最久未访问)开始卸载 + int excess = Mathf.Max(0, dormants.Count - _budget.MaxDormantRooms); + for (int i = 0; i < excess; i++) + StartCoroutine(UnloadRoomCoroutine(dormants[i].RoomId)); + + // 第二道:内存超限,从数量截止处继续(避免与第一道循环重复调度同一批房间) + int totalKB = _handles.Values.Sum(h => h.EstimatedMemKB); + int budgetKB = _budget.MaxMemoryMB * 1024; + if (totalKB <= budgetKB) return; + + for (int i = excess; i < dormants.Count; i++) + { + if (totalKB <= budgetKB) break; + var h = dormants[i]; + StartCoroutine(UnloadRoomCoroutine(h.RoomId)); + totalKB -= h.EstimatedMemKB; + } + } + + // ── 冷却 / 卸载 ─────────────────────────────────────────────────────────── + + private IEnumerator CoolingCoroutine(RoomHandle handle) + { + // 防止同一房间同时运行多个 CoolingCoroutine + if (!_roomsInCooling.Add(handle.RoomId)) yield break; + + try + { + float duration = _budget != null ? _budget.CoolingDuration : 6f; + yield return new WaitForSeconds(duration); + + // 冷却结束后再次检查是否仍需保留 + if (_currentRoomId != null) + { + int hops = _budget != null ? _budget.PreloadLookaheadHops : 2; + var neighbors = _graph.GetNeighborsWithinHops(_currentRoomId, hops); + bool stillNeeded = neighbors.Any(n => n.RoomId == handle.RoomId) + || handle.RoomId == _currentRoomId; + if (stillNeeded) + { + // 玩家已回到本房间附近:重置为 Dormant,可再次被激活 + handle.ResetToDormant(); + yield break; + } + } + + StartCoroutine(UnloadRoomCoroutine(handle.RoomId)); + } + finally + { + _roomsInCooling.Remove(handle.RoomId); + } + } + + private IEnumerator UnloadRoomCoroutine(string roomId) + { + if (!_handles.TryGetValue(roomId, out var handle)) yield break; + yield return handle.UnloadAsync(); + _handles.Remove(roomId); + Debug.Log($"[RoomStreamingManager] 已卸载:{roomId}"); + } + + // ── TransitionDirector 调用接口 ──────────────────────────────────────────── + + /// + /// 查询目标房间是否已处于 Dormant 状态,可执行无等待的切换。 + /// + public bool IsRoomDormant(string roomId) + => _handles.TryGetValue(roomId, out var h) && h.State == RoomState.Dormant; + + /// + /// 激活目标房间(Dormant → Active)并停用前一个房间。 + /// 由 在过渡时调用。 + /// + public IEnumerator ActivateRoomCoroutine(string targetRoomId, SpawnContext context) + { + if (!_handles.TryGetValue(targetRoomId, out var targetHandle)) + { + Debug.LogError($"[RoomStreamingManager] 目标房间 {targetRoomId} 不在已加载集合中,无法激活。"); + yield break; + } + + string previousRoomId = _currentRoomId; + + // 激活新房间 + yield return targetHandle.Activate(context); + _currentRoomId = targetRoomId; + + // 旧房间进入冷却(不立即卸载;守卫防止 CoolingCoroutine 重复启动) + if (!string.IsNullOrEmpty(previousRoomId) && + _handles.TryGetValue(previousRoomId, out var prevHandle) && + prevHandle.State == RoomState.Active && + !_roomsInCooling.Contains(previousRoomId)) + { + prevHandle.BeginCooling(); + StartCoroutine(CoolingCoroutine(prevHandle)); + } + + // 重新计算新房间的 StreamingSet + RecalculateStreamingSet(targetRoomId); + + // 通知:视同进入新房间(触发地图探索更新等) + _onRoomEntered?.Raise(targetRoomId); + } + + /// + /// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。 + /// 完成后广播 EVT_RoomEntered 以触发 StreamingSet 重新计算。 + /// + public IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context) + { + if (!_handles.ContainsKey(roomId)) + { + int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8; + var handle = new RoomHandle(roomId, this, perFrame); + handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0; + _handles[roomId] = handle; + yield return handle.LoadAsync(roomId); + } + + yield return ActivateRoomCoroutine(roomId, context); + } + + // ── Gizmos ──────────────────────────────────────────────────────────────── + +#if UNITY_EDITOR + private void OnDrawGizmos() + { + if (!Application.isPlaying || _graph == null) return; + foreach (var kv in _handles) + { + var node = _graph.GetNode(kv.Key); + if (node == null) continue; + + Gizmos.color = kv.Value.State switch + { + RoomState.Active => new Color(0f, 1f, 0.3f, 0.3f), + RoomState.Dormant => new Color(0.2f, 0.6f, 1f, 0.2f), + RoomState.Loading => new Color(1f, 1f, 0f, 0.25f), + RoomState.Cooling => new Color(1f, 0.5f, 0f, 0.2f), + _ => new Color(0.5f, 0.5f, 0.5f, 0.1f), + }; + Gizmos.DrawCube(node.WorldBounds.center, node.WorldBounds.size); + } + } +#endif + } +} diff --git a/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs b/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs new file mode 100644 index 0000000..88bb90e --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs @@ -0,0 +1,71 @@ +using UnityEngine; + +namespace BaseGames.World.Streaming +{ + /// + /// 流式加载系统的内存与性能预算配置。 + /// 挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上,或通过 Inspector 注入。 + /// 可为不同目标平台(PC / 主机 / 移动端)准备多份配置资产。 + /// 资产路径:Assets/_Game/Data/Streaming/ + /// + [CreateAssetMenu(menuName = "BaseGames/World/Streaming/BudgetConfig")] + public class StreamingBudgetConfigSO : ScriptableObject + { + [Header("内存与容量")] + + [Tooltip("同时保持 Dormant 状态的最大房间数量(不含当前 Active 房间)。\n" + + "超出此限制后,LRU 策略将卸载距离最远、最久未访问的 Dormant 房间。")] + [Min(1)] + public int MaxDormantRooms = 6; + + [Tooltip("房间资产内存上限(MB)。仅用于超出时触发 LRU 卸载的第二道保障检查。\n" + + "需在编辑器 Profiler 中测量各房间实际内存后填入估算值。")] + [Min(64)] + public int MaxMemoryMB = 300; + + [Header("加载控制")] + + [Tooltip("同时进行的 Addressables 加载操作数量上限。\n" + + "过高会导致 I/O 竞争和帧率波动,建议 1-2。")] + [Range(1, 4)] + public int MaxConcurrentLoads = 2; + + [Tooltip("预加载的邻居跳数。\n" + + "1 = 仅预加载直接相邻出口的房间;2 = 再加载邻居的邻居。\n" + + "值越高预加载越激进,内存压力越大。")] + [Range(1, 3)] + public int PreloadLookaheadHops = 2; + + [Header("激活与冷却")] + + [Tooltip("玩家离开房间后,延迟多少秒才将该房间转为 Dormant/Unloaded。\n" + + "防止来回反复时频繁卸载重载。推荐 5-10 s。")] + [Min(0f)] + public float CoolingDuration = 6f; + + [Tooltip("房间 Activate 时每帧最多激活的 IRoomLifecycle 数量,分帧避免 CPU 峰值。")] + [Min(1)] + public int LifecycleActivatePerFrame = 8; + + [Header("AtmosphericFade 演出")] + + [Tooltip("AtmosphericFade 过渡的淡出时长(秒)。")] + [Range(0.1f, 1f)] + public float AtmosphericFadeOutDuration = 0.25f; + + [Tooltip("AtmosphericFade 过渡的淡入时长(秒)。")] + [Range(0.1f, 1f)] + public float AtmosphericFadeInDuration = 0.3f; + + [Tooltip("AtmosphericFade 过渡中,区域名称文本显示的最短持续时间(秒)。")] + [Range(0.5f, 3f)] + public float RegionNameDisplayDuration = 1.2f; + + [Header("Seamless 过渡等待")] + + [Tooltip("Seamless 过渡等待目标房间预加载完成的最长时间(秒)。\n" + + "超时后放弃本次切换,玩家可再次触发(届时房间通常已就绪)。")] + [Range(2f, 30f)] + public float SeamlessWaitTimeout = 10f; + } +} diff --git a/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs b/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs new file mode 100644 index 0000000..0ff0d41 --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs @@ -0,0 +1,236 @@ +using System.Collections; +using UnityEngine; +using BaseGames.Core; +using BaseGames.Core.Events; + +namespace BaseGames.World.Streaming +{ + /// + /// 过渡导演。挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上(与 RoomStreamingManager 同级)。 + /// + /// 实现 ,由 在收到 + /// 请求时调用。 + /// + /// + /// 三档过渡行为: + /// + /// Seamless + /// 无任何遮挡。目标房间已 Dormant → 同帧内激活,相机跟随,无等待感。 + /// AtmosphericFade + /// 短暂淡出 → 激活房间 → 显示区域名 → 淡入。适合跨大区域边界。 + /// Scene / Room + /// 不由本类处理,由 SceneService 走原有黑屏加载流程。 + /// + /// + /// + [DefaultExecutionOrder(-790)] + public class TransitionDirector : MonoBehaviour, ITransitionDirector + { + [Header("依赖")] + [SerializeField] private RoomStreamingManager _streamingManagerRef; + + [Header("事件频道 - 发布")] + [SerializeField] private VoidEventChannelSO _onFadeOutRequest; + [SerializeField] private VoidEventChannelSO _onFadeInRequest; + + [Tooltip("AtmosphericFade 过渡中,向 UI 发布区域名称字符串(可为空,则跳过显示)。")] + [SerializeField] private StringEventChannelSO _onRegionNameDisplay; + + [Tooltip("AtmosphericFade 过渡完成后发布,供 SceneService 同步淡入时序使用。")] + [SerializeField] private VoidEventChannelSO _onSceneWorldStateRestored; + + [Header("事件频道 - 监听(世界数据)")] + [Tooltip("MapManager 提供的地图数据库,用于读取区域名称。可为空,为空时跳过区域名显示。")] + [SerializeField] private BaseGames.World.Map.MapDatabaseSO _mapDatabase; + + [Header("预算配置")] + [SerializeField] private StreamingBudgetConfigSO _budget; + + private bool _isTransitioning; + /// 通过接口访问流式管理器,与具体实现类完全解耦。 + private IRoomStreamingManager _streaming; + + // ── 生命周期 ────────────────────────────────────────────────────────────── + + private void Awake() + { + _streaming = _streamingManagerRef; + ServiceLocator.Register(this); + } + + private void OnDestroy() + { + ServiceLocator.Unregister(this); + } + + // ── ITransitionDirector ─────────────────────────────────────────────────── + + public bool CanHandleSeamless(string targetSceneName) + => _streaming != null && _streaming.IsRoomDormant(targetSceneName); + + public void HandleTransition(SceneLoadRequest request) + { + if (_isTransitioning) + { + Debug.LogWarning($"[TransitionDirector] 过渡进行中,忽略新请求:{request.SceneName}"); + return; + } + + switch (request.TransitionType) + { + case TransitionType.Seamless: + // 房间已就绪:立即无缝切换;否则等待预加载完成后再切换(等待阶段不持有过渡锁) + if (_streaming.IsRoomDormant(request.SceneName)) + StartCoroutine(SeamlessTransitionCoroutine(request)); + else + StartCoroutine(WaitForRoomAndSeamlessCoroutine(request)); + break; + + case TransitionType.AtmosphericFade: + StartCoroutine(AtmosphericFadeCoroutine(request)); + break; + + default: + Debug.LogError($"[TransitionDirector] 不应处理的过渡类型:{request.TransitionType}"); + break; + } + } + + // ── Seamless 过渡 ───────────────────────────────────────────────────────── + + /// + /// 无遮挡无等待的无缝切换。 + /// 目标房间已 Dormant 且在内存中,本帧即可完成激活。 + /// + private IEnumerator SeamlessTransitionCoroutine(SceneLoadRequest request) + { + _isTransitioning = true; + try + { + var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn); + yield return _streaming.ActivateRoomCoroutine(request.SceneName, context); + + _onSceneWorldStateRestored?.Raise(); + yield return null; + } + finally + { + _isTransitioning = false; + } + } + + /// + /// 目标房间尚未就绪时:主动触发预加载,在等待阶段不持有过渡锁(允许后续请求覆盖), + /// 房间就绪后再锁定并执行激活。若超时则静默放弃,玩家可再次触发。 + /// + private IEnumerator WaitForRoomAndSeamlessCoroutine(SceneLoadRequest request) + { + // 等待阶段:不持有 _isTransitioning 锁,避免 10s 内所有门触发被丢弃 + _streaming.PreloadRoom(request.SceneName); + + float maxWait = _budget != null ? _budget.SeamlessWaitTimeout : 10f; + float elapsed = 0f; + + while (!_streaming.IsRoomDormant(request.SceneName) && elapsed < maxWait) + { + elapsed += Time.deltaTime; + yield return null; + } + + if (!_streaming.IsRoomDormant(request.SceneName)) + { + Debug.LogWarning($"[TransitionDirector] 等待房间 {request.SceneName} 预加载超时({maxWait:F1}s),放弃本次切换。"); + yield break; + } + + // 激活阶段:此时才持有锁(等待期间若另一次切换已发起则放弃本次) + if (_isTransitioning) yield break; + _isTransitioning = true; + try + { + var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn); + yield return _streaming.ActivateRoomCoroutine(request.SceneName, context); + _onSceneWorldStateRestored?.Raise(); + yield return null; + } + finally + { + _isTransitioning = false; + } + } + + // ── AtmosphericFade 过渡 ────────────────────────────────────────────────── + + /// + /// 短暂淡出 → 等待目标房间预加载(黑屏期间利用淡出时间预热)→ 激活 → 显示区域名称 → 淡入。 + /// 若房间在黑屏期间仍未就绪(超时),则取消切换并淡入还原画面。 + /// + private IEnumerator AtmosphericFadeCoroutine(SceneLoadRequest request) + { + _isTransitioning = true; + try + { + float fadeOut = _budget != null ? _budget.AtmosphericFadeOutDuration : 0.25f; + float fadeIn = _budget != null ? _budget.AtmosphericFadeInDuration : 0.3f; + float nameHold = _budget != null ? _budget.RegionNameDisplayDuration : 1.2f; + float maxWait = _budget != null ? _budget.SeamlessWaitTimeout : 10f; + + // 1. 触发预加载(若尚未开始),同步发起淡出——黑屏期间完成加载 + _streaming.PreloadRoom(request.SceneName); + _onFadeOutRequest?.Raise(); + yield return new WaitForSeconds(fadeOut); + + // 2. 等待目标房间进入 Dormant(黑屏中等待;淡出时间已消耗部分等待窗口) + float elapsed = 0f; + while (!_streaming.IsRoomDormant(request.SceneName) && elapsed < maxWait) + { + elapsed += Time.deltaTime; + yield return null; + } + + if (!_streaming.IsRoomDormant(request.SceneName)) + { + Debug.LogWarning($"[TransitionDirector] AtmosphericFade:等待房间 {request.SceneName} 超时({maxWait:F1}s),取消切换并还原画面。"); + // 黑屏中取消:淡入还原,玩家停在原房间 + _onFadeInRequest?.Raise(); + yield return new WaitForSeconds(fadeIn); + yield break; + } + + // 3. 激活目标房间(已 Dormant,立即完成) + var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn); + yield return _streaming.ActivateRoomCoroutine(request.SceneName, context); + + // 4. 通知状态恢复 + _onSceneWorldStateRestored?.Raise(); + yield return null; + + // 5. 显示区域名称 + string regionName = GetRegionName(request.SceneName); + if (!string.IsNullOrEmpty(regionName)) + { + _onRegionNameDisplay?.Raise(regionName); + yield return new WaitForSeconds(nameHold); + } + + // 6. 淡入 + _onFadeInRequest?.Raise(); + yield return new WaitForSeconds(fadeIn); + } + finally + { + _isTransitioning = false; + } + } + + // ── 工具方法 ────────────────────────────────────────────────────────────── + + /// 从 MapDatabase 获取目标房间所属区域的显示名称。 + private string GetRegionName(string roomId) + { + if (_mapDatabase == null) return null; + var roomData = _mapDatabase.GetRoom(roomId); + return roomData?.RegionId; // 可扩展为 RegionDisplayName + } + } +} diff --git a/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs new file mode 100644 index 0000000..5062b66 --- /dev/null +++ b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs @@ -0,0 +1,241 @@ +using System.Collections.Generic; +using UnityEngine; +using BaseGames.World.Map; + +namespace BaseGames.World.Streaming +{ + /// + /// 单个房间在运行时连通图中的节点。 + /// 由 构建。 + /// + public class RoomNode + { + public string RoomId; + public string ZoneId; + + /// + /// 房间在世界坐标中的范围(格子坐标乘以 )。 + /// 用于相机边界计算和出口坐标换算。 + /// + public Rect WorldBounds; + + /// + /// 此房间资产的预估内存(KB)。由关卡设计师在 MapRoomDataSO 中填写, + /// 供 执行内存预算检查使用。 + /// + public int EstimatedMemoryKB; + + /// 与此房间相连的所有出口(有向边)。 + public List Edges = new(); + } + + /// + /// 两个房间之间的有向连接(出口)。 + /// + public class RoomEdge + { + public RoomNode From; + public RoomNode To; + + /// 出口方向,用于预测加载优先级(与玩家移动方向对比)。 + public ExitDirection Direction; + + /// 出口在世界坐标中的近似位置,用于预测加载的距离计算。 + public Vector2 ExitWorldPos; + + /// 过渡关联的 ,由关卡设计师在 RoomTransition 组件中配置。 + public Core.Events.TransitionType PreferredTransitionType; + } + + /// + /// 运行时房间连通图。 + /// + /// 在 Persistent 场景初始化阶段由 从 + /// 构建,之后只读,线程安全(无写操作)。 + /// + /// 提供:邻居查询、BFS 跳数计算、预加载候选集合生成。 + /// + public class WorldGraph + { + /// 每个格子单元对应的 Unity 世界坐标单位数。应与关卡设计网格对齐。 + public readonly float UnitsPerGrid; + + private readonly Dictionary _nodes = new(); + + public WorldGraph(float unitsPerGrid = 16f) + { + UnitsPerGrid = unitsPerGrid; + } + + // ── 构建 ────────────────────────────────────────────────────────────────── + + /// + /// 从 构建运行时图。 + /// 调用一次,之后图为只读。 + /// + public static WorldGraph Build(MapDatabaseSO database, float unitsPerGrid = 16f) + { + var graph = new WorldGraph(unitsPerGrid); + + if (database == null || database.AllRooms == null) + { + Debug.LogError("[WorldGraph] MapDatabaseSO 为空,无法构建世界图。"); + return graph; + } + + // 第一遍:创建所有节点 + foreach (var roomData in database.AllRooms) + { + if (roomData == null) continue; + + var node = new RoomNode + { + RoomId = roomData.RoomId, + ZoneId = roomData.RegionId, + WorldBounds = new Rect( + roomData.GridPosition.x * unitsPerGrid, + roomData.GridPosition.y * unitsPerGrid, + roomData.GridSize.x * unitsPerGrid, + roomData.GridSize.y * unitsPerGrid), + EstimatedMemoryKB = roomData.EstimatedMemoryKB, + }; + graph._nodes[roomData.RoomId] = node; + } + + // 第二遍:建立有向边(双向,各方向独立记录便于方向性预测) + foreach (var roomData in database.AllRooms) + { + if (roomData?.Exits == null) continue; + if (!graph._nodes.TryGetValue(roomData.RoomId, out var fromNode)) continue; + + foreach (var exit in roomData.Exits) + { + if (string.IsNullOrEmpty(exit.TargetRoomId)) continue; + if (!graph._nodes.TryGetValue(exit.TargetRoomId, out var toNode)) continue; + + var edge = new RoomEdge + { + From = fromNode, + To = toNode, + Direction = exit.Direction, + ExitWorldPos = new Vector2( + exit.ExitGridPos.x * unitsPerGrid, + exit.ExitGridPos.y * unitsPerGrid), + PreferredTransitionType = exit.PreferredTransitionType, + }; + fromNode.Edges.Add(edge); + } + } + + Debug.Log($"[WorldGraph] 构建完成:{graph._nodes.Count} 个房间节点。"); + return graph; + } + + // ── 查询 ────────────────────────────────────────────────────────────────── + + /// 获取节点。若不存在返回 null。 + public RoomNode GetNode(string roomId) + { + _nodes.TryGetValue(roomId, out var node); + return node; + } + + /// 返回与指定房间直接相邻(1 跳)的所有邻居节点。 + public IEnumerable GetDirectNeighbors(string roomId) + { + if (!_nodes.TryGetValue(roomId, out var node)) yield break; + foreach (var edge in node.Edges) + yield return edge.To; + } + + /// + /// 通过 BFS 获取距离指定房间不超过 跳的所有房间节点集合。 + /// 结果不包含起始节点本身。 + /// + public HashSet GetNeighborsWithinHops(string startRoomId, int maxHops) + { + var result = new HashSet(); + var visited = new HashSet { startRoomId }; + var queue = new Queue<(string roomId, int depth)>(); + queue.Enqueue((startRoomId, 0)); + + while (queue.Count > 0) + { + var (current, depth) = queue.Dequeue(); + if (!_nodes.TryGetValue(current, out var node)) continue; + + foreach (var edge in node.Edges) + { + if (visited.Contains(edge.To.RoomId)) continue; + visited.Add(edge.To.RoomId); + result.Add(edge.To); + if (depth + 1 < maxHops) + queue.Enqueue((edge.To.RoomId, depth + 1)); + } + } + + return result; + } + + /// + /// 计算两个房间之间的最短跳数(BFS)。 + /// 若不可达返回 int.MaxValue。 + /// + public int GetHopDistance(string fromRoomId, string toRoomId) + { + if (fromRoomId == toRoomId) return 0; + + var visited = new HashSet { fromRoomId }; + var queue = new Queue<(string roomId, int depth)>(); + queue.Enqueue((fromRoomId, 0)); + + while (queue.Count > 0) + { + var (current, depth) = queue.Dequeue(); + if (!_nodes.TryGetValue(current, out var node)) continue; + + foreach (var edge in node.Edges) + { + if (edge.To.RoomId == toRoomId) return depth + 1; + if (visited.Contains(edge.To.RoomId)) continue; + visited.Add(edge.To.RoomId); + queue.Enqueue((edge.To.RoomId, depth + 1)); + } + } + + return int.MaxValue; + } + + /// 图中总节点数,供调试使用。 + public int NodeCount => _nodes.Count; + + /// + /// 单次 BFS 计算从 到所有可达节点的最短跳数。 + /// 返回字典供调用方缓存,避免每次查询重复运行 BFS。 + /// + public Dictionary GetAllHopDistances(string startRoomId) + { + var result = new Dictionary(); + if (!_nodes.ContainsKey(startRoomId)) return result; + + result[startRoomId] = 0; + var queue = new Queue<(string roomId, int depth)>(); + queue.Enqueue((startRoomId, 0)); + + while (queue.Count > 0) + { + var (current, depth) = queue.Dequeue(); + if (!_nodes.TryGetValue(current, out var node)) continue; + + foreach (var edge in node.Edges) + { + if (result.ContainsKey(edge.To.RoomId)) continue; + result[edge.To.RoomId] = depth + 1; + queue.Enqueue((edge.To.RoomId, depth + 1)); + } + } + + return result; + } + } +} diff --git a/Docs/Standards/AssetFolderSpec.md b/Docs/Standards/AssetFolderSpec.md index 580ad26..b4881ed 100644 --- a/Docs/Standards/AssetFolderSpec.md +++ b/Docs/Standards/AssetFolderSpec.md @@ -269,6 +269,7 @@ Data/ ├── World/ │ ├── Map/ 地图与房间配置 │ └── Shop/ 商店配置 +├── Streaming/ 流式加载配置(StreamingBudgetConfigSO) ├── UI/ │ ├── Panels/ UI 面板配置 │ └── InputIcons/ 按键图标集 SO(每设备一个文件,通过 Inspector 直接引用,不走 Addressables) @@ -296,6 +297,7 @@ Data/ | `SET_` | 设置 | `SET_GlobalSettings.asset` | | `ABL_` | 能力 | `ABL_DoubleJump.asset` | | `ICN_` | 按键图标集 | `ICN_KeyboardMouse.asset`、`ICN_Xbox.asset` | +| `STR_` | 流式加载配置 | `STR_BudgetConfig_Default.asset` | ### 3.3 事件频道 SO 特别规则 @@ -491,7 +493,7 @@ UI Toolkit/ | `Room_{Region}` | 该区域的关卡场景 + 区域专属资产 | Local | 进入区域时加载 | | `Boss_{Name}` | Boss 专属 Prefab + 场景 | Local | Boss 战开始前加载 | | `Audio_Music` | BGM 音频(FMOD bank 引用) | Remote(可选)| 按需流式加载 | -| `Config` | 运行时需要动态加载的配置 SO | Local | 按需加载 | +| `Config` | 运行时需要动态加载的配置 SO(含 `STR_BudgetConfig_Default`、`Config/FootstepCatalog` 等) | Local | 按需加载 | **划分原则**: 1. **生命周期相同的资源放同一组**——一起加载、一起卸载 @@ -522,6 +524,7 @@ Boss_CaoZhi # 配置数据类(带路径前缀区分) Config/FootstepCatalog Config/DifficultyEasy +Config/StreamingBudgetConfig ``` **强制要求**:所有 Address 必须在 `AddressKeys.cs` 中定义对应常量,**禁止在代码中硬编码字符串**。 diff --git a/zeling_v2.sln b/zeling_v2.sln index 90bb6df..1b10da4 100644 --- a/zeling_v2.sln +++ b/zeling_v2.sln @@ -49,10 +49,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opsive.BehaviorDesigner.Edi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Parry", "BaseGames.Parry.csproj", "{CFD59BED-321E-6F34-65CA-408816F768FA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies", "BaseGames.Enemies.csproj", "{5E00F025-ED00-233A-3B2F-BAFF76D883F0}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Quest", "BaseGames.Quest.csproj", "{4D3050DE-F729-61B6-5E21-4D4D1BAA9DD5}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Localization", "BaseGames.Localization.csproj", "{54A12CB8-FDC2-A038-90D9-B8626E1E7B7D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies.Navigation", "BaseGames.Enemies.Navigation.csproj", "{53E8D9CF-060B-99CB-BEBD-39FC626FD593}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp-firstpass", "Assembly-CSharp-firstpass.csproj", "{A4F2B84C-88C0-47A4-3127-6C338342D39C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Core", "BaseGames.Core.csproj", "{FE8FDA48-F779-850A-348D-48764F9384AF}" @@ -63,8 +67,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PathBerserker2d.Editor", "P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Camera", "BaseGames.Camera.csproj", "{BF259A17-68A8-44BC-05D5-CAF1B979D687}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies", "BaseGames.Enemies.csproj", "{5E00F025-ED00-233A-3B2F-BAFF76D883F0}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kybernetik.Animancer.FSM", "Kybernetik.Animancer.FSM.csproj", "{54A35301-41E5-2524-BFA0-B5B1B9B2BCD9}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Map", "BaseGames.World.Map.csproj", "{16BB97E7-3EA9-4707-2D93-441D9C908404}" @@ -107,8 +109,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp-Editor-firs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opsive.Shared.Runtime", "Opsive.Shared.Runtime.csproj", "{ECCD6E34-452D-CDC6-1478-F31514CE0DA0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies.Navigation", "BaseGames.Enemies.Navigation.csproj", "{53E8D9CF-060B-99CB-BEBD-39FC626FD593}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Feedback", "BaseGames.Feedback.csproj", "{43AEDD34-4132-8EAF-E2A6-A0B6BBE53858}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Animation", "BaseGames.Animation.csproj", "{344DC018-FDC3-6B01-DADE-9903E71FE8BC}" @@ -221,6 +221,10 @@ Global {CFD59BED-321E-6F34-65CA-408816F768FA}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFD59BED-321E-6F34-65CA-408816F768FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {CFD59BED-321E-6F34-65CA-408816F768FA}.Release|Any CPU.Build.0 = Release|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.Build.0 = Release|Any CPU {4D3050DE-F729-61B6-5E21-4D4D1BAA9DD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D3050DE-F729-61B6-5E21-4D4D1BAA9DD5}.Debug|Any CPU.Build.0 = Debug|Any CPU {4D3050DE-F729-61B6-5E21-4D4D1BAA9DD5}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -229,6 +233,10 @@ Global {54A12CB8-FDC2-A038-90D9-B8626E1E7B7D}.Debug|Any CPU.Build.0 = Debug|Any CPU {54A12CB8-FDC2-A038-90D9-B8626E1E7B7D}.Release|Any CPU.ActiveCfg = Release|Any CPU {54A12CB8-FDC2-A038-90D9-B8626E1E7B7D}.Release|Any CPU.Build.0 = Release|Any CPU + {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Release|Any CPU.Build.0 = Release|Any CPU {A4F2B84C-88C0-47A4-3127-6C338342D39C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4F2B84C-88C0-47A4-3127-6C338342D39C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4F2B84C-88C0-47A4-3127-6C338342D39C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -249,10 +257,6 @@ Global {BF259A17-68A8-44BC-05D5-CAF1B979D687}.Debug|Any CPU.Build.0 = Debug|Any CPU {BF259A17-68A8-44BC-05D5-CAF1B979D687}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF259A17-68A8-44BC-05D5-CAF1B979D687}.Release|Any CPU.Build.0 = Release|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.Build.0 = Release|Any CPU {54A35301-41E5-2524-BFA0-B5B1B9B2BCD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54A35301-41E5-2524-BFA0-B5B1B9B2BCD9}.Debug|Any CPU.Build.0 = Debug|Any CPU {54A35301-41E5-2524-BFA0-B5B1B9B2BCD9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -337,10 +341,6 @@ Global {ECCD6E34-452D-CDC6-1478-F31514CE0DA0}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECCD6E34-452D-CDC6-1478-F31514CE0DA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECCD6E34-452D-CDC6-1478-F31514CE0DA0}.Release|Any CPU.Build.0 = Release|Any CPU - {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Debug|Any CPU.Build.0 = Debug|Any CPU - {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Release|Any CPU.ActiveCfg = Release|Any CPU - {53E8D9CF-060B-99CB-BEBD-39FC626FD593}.Release|Any CPU.Build.0 = Release|Any CPU {43AEDD34-4132-8EAF-E2A6-A0B6BBE53858}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {43AEDD34-4132-8EAF-E2A6-A0B6BBE53858}.Debug|Any CPU.Build.0 = Debug|Any CPU {43AEDD34-4132-8EAF-E2A6-A0B6BBE53858}.Release|Any CPU.ActiveCfg = Release|Any CPU