From b019acd0a372f4fecf22a4c4014a11bc34638c3e Mon Sep 17 00:00:00 2001 From: dal4segno Date: Fri, 10 Apr 2026 07:57:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B3=B4=EC=8A=A4=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EA=B0=84=EA=B2=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BossBehaviorRuntimeState에 패턴 종료 후 공통 텀과 준비 판정을 추가\n- UsePatternAction이 런타임 패턴 실행 결과와 공통 텀을 함께 사용하도록 정리\n- 드로그 PlayMode 테스트에 패턴 종료 후 공통 간격 검증 케이스를 추가 --- .../Actions/UsePatternAction.cs | 51 ++++++++++++++++--- .../Scripts/Enemy/BossBehaviorRuntimeState.cs | 38 ++++++++++++++ .../Tests/PlayMode/DrogBehaviorTreeTests.cs | 38 ++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/UsePatternAction.cs b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/UsePatternAction.cs index a2287901..c0bcaf53 100644 --- a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/UsePatternAction.cs +++ b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/UsePatternAction.cs @@ -27,6 +27,7 @@ public partial class UsePatternAction : Action new System.Collections.Generic.Dictionary(); private SkillController skillController; + private BossBehaviorRuntimeState runtimeState; private int currentStepIndex; private float waitEndTime; private bool isWaiting; @@ -65,6 +66,9 @@ public partial class UsePatternAction : Action return Status.Failure; } + runtimeState = GameObject.GetComponent(); + runtimeState?.BeginPatternExecution(Pattern.Value); + currentStepIndex = 0; isWaiting = false; isSkillStepExecuting = false; @@ -74,7 +78,7 @@ public partial class UsePatternAction : Action protected override Status OnUpdate() { if (skillController == null) - return Status.Failure; + return FinalizePatternFailure(BossPatternExecutionResult.Cancelled); if (isWaiting) { @@ -92,7 +96,7 @@ public partial class UsePatternAction : Action isSkillStepExecuting = false; if (skillController.LastExecutionResult != SkillExecutionResult.Completed) - return Status.Failure; + return FinalizePatternFailure(BossPatternExecutionResult.Cancelled); } else if (skillController.IsPlayingAnimation) return Status.Running; @@ -102,8 +106,7 @@ public partial class UsePatternAction : Action if (currentStepIndex >= Pattern.Value.Steps.Count) { - MarkPatternUsed(GameObject, Pattern.Value); - return Status.Success; + return FinalizePatternSuccess(); } return ExecuteCurrentStep(); @@ -112,6 +115,7 @@ public partial class UsePatternAction : Action protected override void OnEnd() { skillController = null; + runtimeState = null; isSkillStepExecuting = false; } @@ -130,7 +134,7 @@ public partial class UsePatternAction : Action if (step.Skill == null) { Debug.LogWarning($"[UsePatternAction] 스킬이 null입니다. (index {currentStepIndex})"); - return Status.Failure; + return FinalizePatternFailure(BossPatternExecutionResult.Failed); } GameObject jumpTarget = null; @@ -139,7 +143,7 @@ public partial class UsePatternAction : Action jumpTarget = ResolveJumpTarget(); if (jumpTarget == null) { - return Status.Failure; + return FinalizePatternFailure(BossPatternExecutionResult.Failed); } } @@ -150,7 +154,7 @@ public partial class UsePatternAction : Action if (!success) { Debug.LogWarning($"[UsePatternAction] 스킬 실행 실패: {step.Skill.SkillName} (index {currentStepIndex})"); - return Status.Failure; + return FinalizePatternFailure(BossPatternExecutionResult.Failed); } isSkillStepExecuting = true; @@ -222,6 +226,10 @@ public partial class UsePatternAction : Action if (owner == null || pattern == null) return false; + BossBehaviorRuntimeState context = owner.GetComponent(); + if (context != null) + return context.IsPatternReady(pattern); + string cooldownKey = BuildCooldownKey(owner, pattern); if (!patternReadyTimes.TryGetValue(cooldownKey, out float readyTime)) return true; @@ -237,6 +245,14 @@ public partial class UsePatternAction : Action if (owner == null || pattern == null) return; + BossBehaviorRuntimeState context = owner.GetComponent(); + if (context != null) + { + context.SetPatternCooldown(pattern); + context.CompletePatternExecution(pattern, BossPatternExecutionResult.Succeeded); + return; + } + string cooldownKey = BuildCooldownKey(owner, pattern); patternReadyTimes[cooldownKey] = Time.time + pattern.Cooldown; } @@ -251,4 +267,25 @@ public partial class UsePatternAction : Action BossBehaviorRuntimeState context = GameObject.GetComponent(); context?.LogDebug(nameof(UsePatternAction), message); } + + private Status FinalizePatternSuccess() + { + if (runtimeState != null) + { + runtimeState.SetPatternCooldown(Pattern.Value); + runtimeState.CompletePatternExecution(Pattern.Value, BossPatternExecutionResult.Succeeded); + } + else + { + MarkPatternUsed(GameObject, Pattern.Value); + } + + return Status.Success; + } + + private Status FinalizePatternFailure(BossPatternExecutionResult result) + { + runtimeState?.CompletePatternExecution(Pattern.Value, result); + return Status.Failure; + } } diff --git a/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs b/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs index 81feaa46..439651be 100644 --- a/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs +++ b/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs @@ -46,6 +46,8 @@ namespace Colosseum.Enemy [Header("Pattern Flow")] [Tooltip("대형 패턴(시그니처/기동/조합) 직후 기본 패턴 최소 순환 횟수")] [Min(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2; + [Tooltip("패턴 하나가 끝난 뒤 다음 패턴을 시작하기까지의 공통 텀")] + [Min(0f)] [SerializeField] protected float commonPatternInterval = 0.35f; [Header("시그니처 효과 설정")] [Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")] @@ -93,6 +95,7 @@ namespace Colosseum.Enemy protected int basicLoopCountSinceLastBigPattern; protected int currentPatternPhase = 1; protected float currentPhaseStartTime; + protected float nextPatternReadyTime; protected BossPatternExecutionResult lastPatternExecutionResult; protected BossPatternData lastExecutedPattern; protected GameObject lastReviveCaster; @@ -124,6 +127,11 @@ namespace Colosseum.Enemy /// public int BasicLoopCountSinceLastBigPattern => basicLoopCountSinceLastBigPattern; + /// + /// 패턴 종료 후 다음 패턴 시작까지 남은 공통 텀입니다. + /// + public float RemainingPatternInterval => Mathf.Max(0f, nextPatternReadyTime - Time.time); + /// /// 마지막 패턴 실행 결과 /// @@ -276,6 +284,9 @@ namespace Colosseum.Enemy { lastExecutedPattern = pattern; lastPatternExecutionResult = result; + + if (pattern != null && IsTerminalPatternExecutionResult(result)) + StartCommonPatternInterval(); } /// @@ -386,6 +397,9 @@ namespace Colosseum.Enemy if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0) return false; + if (!IsCommonPatternIntervalReady()) + return false; + if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime)) return true; @@ -401,6 +415,22 @@ namespace Colosseum.Enemy patternCooldownTracker[pattern] = Time.time + pattern.Cooldown; } + /// + /// 공통 패턴 텀이 끝났는지 반환합니다. + /// + public bool IsCommonPatternIntervalReady() + { + return Time.time >= nextPatternReadyTime; + } + + /// + /// 현재 시점부터 공통 패턴 텀을 다시 시작합니다. + /// + public void StartCommonPatternInterval() + { + nextPatternReadyTime = Time.time + Mathf.Max(0f, commonPatternInterval); + } + protected void StopMovement() { if (navMeshAgent == null || !navMeshAgent.enabled) @@ -435,6 +465,7 @@ namespace Colosseum.Enemy { currentPatternPhase = 1; currentPhaseStartTime = Time.time; + nextPatternReadyTime = 0f; lastPatternExecutionResult = BossPatternExecutionResult.None; lastExecutedPattern = null; lastReviveCaster = null; @@ -443,6 +474,13 @@ namespace Colosseum.Enemy customPhaseConditions.Clear(); } + private static bool IsTerminalPatternExecutionResult(BossPatternExecutionResult result) + { + return result == BossPatternExecutionResult.Succeeded + || result == BossPatternExecutionResult.Failed + || result == BossPatternExecutionResult.Cancelled; + } + public override void OnNetworkDespawn() { if (IsServer) diff --git a/Assets/_Game/Tests/PlayMode/DrogBehaviorTreeTests.cs b/Assets/_Game/Tests/PlayMode/DrogBehaviorTreeTests.cs index 0c1aab73..13885cb1 100644 --- a/Assets/_Game/Tests/PlayMode/DrogBehaviorTreeTests.cs +++ b/Assets/_Game/Tests/PlayMode/DrogBehaviorTreeTests.cs @@ -283,6 +283,44 @@ namespace Colosseum.Tests "상위 기본기들이 쿨다운이어도 콤보-기본기1로 폴백하지 않았습니다."); } + [UnityTest] + public IEnumerator Phase1_AfterPatternEnds_WaitsForCommonPatternInterval() + { + const float commonPatternInterval = 1.2f; + + yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); + SetRuntimeField("commonPatternInterval", commonPatternInterval); + SetPlayerOffset(2f); + ResumeBehavior(); + + yield return WaitForCondition( + () => runtimeState.LastExecutedPattern != null + && runtimeState.LastPatternExecutionResult == BossPatternExecutionResult.Running, + DefaultTimeout, + "첫 패턴 시작을 확인하지 못했습니다."); + + BossPatternData firstPattern = runtimeState.LastExecutedPattern; + + yield return WaitForCondition( + () => runtimeState.LastPatternExecutionResult != BossPatternExecutionResult.Running, + LongPatternTimeout, + "첫 패턴 종료를 확인하지 못했습니다."); + + Assert.AreSame(firstPattern, runtimeState.LastExecutedPattern, "첫 패턴 종료 전에 다른 패턴이 시작되었습니다."); + + yield return new WaitForSeconds(commonPatternInterval * 0.5f); + + Assert.AreNotEqual( + BossPatternExecutionResult.Running, + runtimeState.LastPatternExecutionResult, + "공통 텀 전에 다음 패턴이 시작되었습니다."); + + yield return WaitForCondition( + () => runtimeState.LastPatternExecutionResult == BossPatternExecutionResult.Running, + commonPatternInterval, + "공통 텀 이후 다음 패턴이 다시 시작되지 않았습니다."); + } + [UnityTest] public IEnumerator Phase3_After90Seconds_ReusesSignature() {