feat: 보스 공통 패턴 간격 추가

- BossBehaviorRuntimeState에 패턴 종료 후 공통 텀과 준비 판정을 추가\n- UsePatternAction이 런타임 패턴 실행 결과와 공통 텀을 함께 사용하도록 정리\n- 드로그 PlayMode 테스트에 패턴 종료 후 공통 간격 검증 케이스를 추가
This commit is contained in:
2026-04-10 07:57:15 +09:00
parent 4ba6d9d9ff
commit b019acd0a3
3 changed files with 120 additions and 7 deletions

View File

@@ -27,6 +27,7 @@ public partial class UsePatternAction : Action
new System.Collections.Generic.Dictionary<string, float>(); new System.Collections.Generic.Dictionary<string, float>();
private SkillController skillController; private SkillController skillController;
private BossBehaviorRuntimeState runtimeState;
private int currentStepIndex; private int currentStepIndex;
private float waitEndTime; private float waitEndTime;
private bool isWaiting; private bool isWaiting;
@@ -65,6 +66,9 @@ public partial class UsePatternAction : Action
return Status.Failure; return Status.Failure;
} }
runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
runtimeState?.BeginPatternExecution(Pattern.Value);
currentStepIndex = 0; currentStepIndex = 0;
isWaiting = false; isWaiting = false;
isSkillStepExecuting = false; isSkillStepExecuting = false;
@@ -74,7 +78,7 @@ public partial class UsePatternAction : Action
protected override Status OnUpdate() protected override Status OnUpdate()
{ {
if (skillController == null) if (skillController == null)
return Status.Failure; return FinalizePatternFailure(BossPatternExecutionResult.Cancelled);
if (isWaiting) if (isWaiting)
{ {
@@ -92,7 +96,7 @@ public partial class UsePatternAction : Action
isSkillStepExecuting = false; isSkillStepExecuting = false;
if (skillController.LastExecutionResult != SkillExecutionResult.Completed) if (skillController.LastExecutionResult != SkillExecutionResult.Completed)
return Status.Failure; return FinalizePatternFailure(BossPatternExecutionResult.Cancelled);
} }
else if (skillController.IsPlayingAnimation) else if (skillController.IsPlayingAnimation)
return Status.Running; return Status.Running;
@@ -102,8 +106,7 @@ public partial class UsePatternAction : Action
if (currentStepIndex >= Pattern.Value.Steps.Count) if (currentStepIndex >= Pattern.Value.Steps.Count)
{ {
MarkPatternUsed(GameObject, Pattern.Value); return FinalizePatternSuccess();
return Status.Success;
} }
return ExecuteCurrentStep(); return ExecuteCurrentStep();
@@ -112,6 +115,7 @@ public partial class UsePatternAction : Action
protected override void OnEnd() protected override void OnEnd()
{ {
skillController = null; skillController = null;
runtimeState = null;
isSkillStepExecuting = false; isSkillStepExecuting = false;
} }
@@ -130,7 +134,7 @@ public partial class UsePatternAction : Action
if (step.Skill == null) if (step.Skill == null)
{ {
Debug.LogWarning($"[UsePatternAction] 스킬이 null입니다. (index {currentStepIndex})"); Debug.LogWarning($"[UsePatternAction] 스킬이 null입니다. (index {currentStepIndex})");
return Status.Failure; return FinalizePatternFailure(BossPatternExecutionResult.Failed);
} }
GameObject jumpTarget = null; GameObject jumpTarget = null;
@@ -139,7 +143,7 @@ public partial class UsePatternAction : Action
jumpTarget = ResolveJumpTarget(); jumpTarget = ResolveJumpTarget();
if (jumpTarget == null) if (jumpTarget == null)
{ {
return Status.Failure; return FinalizePatternFailure(BossPatternExecutionResult.Failed);
} }
} }
@@ -150,7 +154,7 @@ public partial class UsePatternAction : Action
if (!success) if (!success)
{ {
Debug.LogWarning($"[UsePatternAction] 스킬 실행 실패: {step.Skill.SkillName} (index {currentStepIndex})"); Debug.LogWarning($"[UsePatternAction] 스킬 실행 실패: {step.Skill.SkillName} (index {currentStepIndex})");
return Status.Failure; return FinalizePatternFailure(BossPatternExecutionResult.Failed);
} }
isSkillStepExecuting = true; isSkillStepExecuting = true;
@@ -222,6 +226,10 @@ public partial class UsePatternAction : Action
if (owner == null || pattern == null) if (owner == null || pattern == null)
return false; return false;
BossBehaviorRuntimeState context = owner.GetComponent<BossBehaviorRuntimeState>();
if (context != null)
return context.IsPatternReady(pattern);
string cooldownKey = BuildCooldownKey(owner, pattern); string cooldownKey = BuildCooldownKey(owner, pattern);
if (!patternReadyTimes.TryGetValue(cooldownKey, out float readyTime)) if (!patternReadyTimes.TryGetValue(cooldownKey, out float readyTime))
return true; return true;
@@ -237,6 +245,14 @@ public partial class UsePatternAction : Action
if (owner == null || pattern == null) if (owner == null || pattern == null)
return; return;
BossBehaviorRuntimeState context = owner.GetComponent<BossBehaviorRuntimeState>();
if (context != null)
{
context.SetPatternCooldown(pattern);
context.CompletePatternExecution(pattern, BossPatternExecutionResult.Succeeded);
return;
}
string cooldownKey = BuildCooldownKey(owner, pattern); string cooldownKey = BuildCooldownKey(owner, pattern);
patternReadyTimes[cooldownKey] = Time.time + pattern.Cooldown; patternReadyTimes[cooldownKey] = Time.time + pattern.Cooldown;
} }
@@ -251,4 +267,25 @@ public partial class UsePatternAction : Action
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>(); BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
context?.LogDebug(nameof(UsePatternAction), message); 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;
}
} }

View File

@@ -46,6 +46,8 @@ namespace Colosseum.Enemy
[Header("Pattern Flow")] [Header("Pattern Flow")]
[Tooltip("대형 패턴(시그니처/기동/조합) 직후 기본 패턴 최소 순환 횟수")] [Tooltip("대형 패턴(시그니처/기동/조합) 직후 기본 패턴 최소 순환 횟수")]
[Min(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2; [Min(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2;
[Tooltip("패턴 하나가 끝난 뒤 다음 패턴을 시작하기까지의 공통 텀")]
[Min(0f)] [SerializeField] protected float commonPatternInterval = 0.35f;
[Header("시그니처 효과 설정")] [Header("시그니처 효과 설정")]
[Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")] [Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")]
@@ -93,6 +95,7 @@ namespace Colosseum.Enemy
protected int basicLoopCountSinceLastBigPattern; protected int basicLoopCountSinceLastBigPattern;
protected int currentPatternPhase = 1; protected int currentPatternPhase = 1;
protected float currentPhaseStartTime; protected float currentPhaseStartTime;
protected float nextPatternReadyTime;
protected BossPatternExecutionResult lastPatternExecutionResult; protected BossPatternExecutionResult lastPatternExecutionResult;
protected BossPatternData lastExecutedPattern; protected BossPatternData lastExecutedPattern;
protected GameObject lastReviveCaster; protected GameObject lastReviveCaster;
@@ -124,6 +127,11 @@ namespace Colosseum.Enemy
/// </summary> /// </summary>
public int BasicLoopCountSinceLastBigPattern => basicLoopCountSinceLastBigPattern; public int BasicLoopCountSinceLastBigPattern => basicLoopCountSinceLastBigPattern;
/// <summary>
/// 패턴 종료 후 다음 패턴 시작까지 남은 공통 텀입니다.
/// </summary>
public float RemainingPatternInterval => Mathf.Max(0f, nextPatternReadyTime - Time.time);
/// <summary> /// <summary>
/// 마지막 패턴 실행 결과 /// 마지막 패턴 실행 결과
/// </summary> /// </summary>
@@ -276,6 +284,9 @@ namespace Colosseum.Enemy
{ {
lastExecutedPattern = pattern; lastExecutedPattern = pattern;
lastPatternExecutionResult = result; lastPatternExecutionResult = result;
if (pattern != null && IsTerminalPatternExecutionResult(result))
StartCommonPatternInterval();
} }
/// <summary> /// <summary>
@@ -386,6 +397,9 @@ namespace Colosseum.Enemy
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0) if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
return false; return false;
if (!IsCommonPatternIntervalReady())
return false;
if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime)) if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime))
return true; return true;
@@ -401,6 +415,22 @@ namespace Colosseum.Enemy
patternCooldownTracker[pattern] = Time.time + pattern.Cooldown; patternCooldownTracker[pattern] = Time.time + pattern.Cooldown;
} }
/// <summary>
/// 공통 패턴 텀이 끝났는지 반환합니다.
/// </summary>
public bool IsCommonPatternIntervalReady()
{
return Time.time >= nextPatternReadyTime;
}
/// <summary>
/// 현재 시점부터 공통 패턴 텀을 다시 시작합니다.
/// </summary>
public void StartCommonPatternInterval()
{
nextPatternReadyTime = Time.time + Mathf.Max(0f, commonPatternInterval);
}
protected void StopMovement() protected void StopMovement()
{ {
if (navMeshAgent == null || !navMeshAgent.enabled) if (navMeshAgent == null || !navMeshAgent.enabled)
@@ -435,6 +465,7 @@ namespace Colosseum.Enemy
{ {
currentPatternPhase = 1; currentPatternPhase = 1;
currentPhaseStartTime = Time.time; currentPhaseStartTime = Time.time;
nextPatternReadyTime = 0f;
lastPatternExecutionResult = BossPatternExecutionResult.None; lastPatternExecutionResult = BossPatternExecutionResult.None;
lastExecutedPattern = null; lastExecutedPattern = null;
lastReviveCaster = null; lastReviveCaster = null;
@@ -443,6 +474,13 @@ namespace Colosseum.Enemy
customPhaseConditions.Clear(); customPhaseConditions.Clear();
} }
private static bool IsTerminalPatternExecutionResult(BossPatternExecutionResult result)
{
return result == BossPatternExecutionResult.Succeeded
|| result == BossPatternExecutionResult.Failed
|| result == BossPatternExecutionResult.Cancelled;
}
public override void OnNetworkDespawn() public override void OnNetworkDespawn()
{ {
if (IsServer) if (IsServer)

View File

@@ -283,6 +283,44 @@ namespace Colosseum.Tests
"상위 기본기들이 쿨다운이어도 콤보-기본기1로 폴백하지 않았습니다."); "상위 기본기들이 쿨다운이어도 콤보-기본기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] [UnityTest]
public IEnumerator Phase3_After90Seconds_ReusesSignature() public IEnumerator Phase3_After90Seconds_ReusesSignature()
{ {