diff --git a/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs
new file mode 100644
index 00000000..f24000a1
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs
@@ -0,0 +1,577 @@
+#if UNITY_EDITOR || DEVELOPMENT_BUILD
+
+using System.Collections;
+
+using Unity.Behavior;
+using UnityEngine;
+
+using Colosseum.Abnormalities;
+using Colosseum.AI;
+using Colosseum.Combat;
+using Colosseum.Player;
+using Colosseum.Skills;
+
+namespace Colosseum.Enemy
+{
+ ///
+ /// 디버그 패턴 강제 발동 중 BT 개입 방식을 정의합니다.
+ ///
+ public enum DebugPatternBehaviorTreeMode
+ {
+ DisableDuringPattern = 0,
+ KeepRunning = 1,
+ }
+
+ ///
+ /// 디버그 패널에서 보스 패턴을 임의 발동할 때 사용하는 런타임 실행기입니다.
+ ///
+ [DisallowMultipleComponent]
+ [RequireComponent(typeof(BossEnemy))]
+ [RequireComponent(typeof(BossBehaviorRuntimeState))]
+ [RequireComponent(typeof(SkillController))]
+ public class BossPatternDebugRunner : MonoBehaviour
+ {
+ [Header("Debug")]
+ [Tooltip("패턴 디버그 실행기 로그 출력 여부")]
+ [SerializeField] private bool debugMode;
+
+ private BossEnemy bossEnemy;
+ private EnemyBase enemyBase;
+ private SkillController skillController;
+ private BossBehaviorRuntimeState runtimeState;
+ private AbnormalityManager abnormalityManager;
+ private UnityEngine.AI.NavMeshAgent navMeshAgent;
+ private BehaviorGraphAgent behaviorGraphAgent;
+
+ private Coroutine activePatternRoutine;
+ private BossPatternData currentPattern;
+ private GameObject currentTarget;
+ private ChargeStepData activeChargeData;
+ private float chargeEndTime;
+ private float chargeAccumulatedDamage;
+ private float chargeRequiredDamage;
+ private bool isChargeWaiting;
+ private bool chargeTelegraphApplied;
+ private bool restoreBehaviorGraphWhenDebugPauseEnds;
+ private bool debugBehaviorGraphPaused;
+
+ ///
+ /// 현재 디버그 패턴 실행 중 여부입니다.
+ ///
+ public bool IsExecutingPattern => activePatternRoutine != null;
+
+ ///
+ /// 현재 디버그 실행 중인 패턴입니다.
+ ///
+ public BossPatternData CurrentPattern => currentPattern;
+
+ ///
+ /// 디버그 토글로 BT가 중지된 상태인지 반환합니다.
+ ///
+ public bool IsBehaviorTreePaused => debugBehaviorGraphPaused;
+
+ private bool IsServer => Unity.Netcode.NetworkManager.Singleton != null
+ && Unity.Netcode.NetworkManager.Singleton.IsServer;
+
+ private void Awake()
+ {
+ ResolveReferences();
+ }
+
+ private void Update()
+ {
+ if (!IsExecutingPattern)
+ return;
+
+ StopMovement();
+ }
+
+ private void OnDisable()
+ {
+ CancelPattern();
+ }
+
+ ///
+ /// 지정한 패턴을 디버그 용도로 즉시 실행합니다.
+ ///
+ public bool TryExecutePattern(BossPatternData pattern, DebugPatternBehaviorTreeMode behaviorTreeMode = DebugPatternBehaviorTreeMode.DisableDuringPattern)
+ {
+ if (!IsServer)
+ {
+ Debug.LogWarning("[BossPatternDebugRunner] 서버가 아니어서 패턴을 강제 발동할 수 없습니다.", this);
+ return false;
+ }
+
+ ResolveReferences();
+
+ if (pattern == null)
+ {
+ Debug.LogWarning("[BossPatternDebugRunner] 발동할 패턴이 비어 있습니다.", this);
+ return false;
+ }
+
+ if (bossEnemy == null || enemyBase == null || skillController == null || runtimeState == null)
+ {
+ Debug.LogWarning("[BossPatternDebugRunner] 필수 컴포넌트를 찾지 못해 패턴을 실행할 수 없습니다.", this);
+ return false;
+ }
+
+ if (bossEnemy.IsDead)
+ {
+ Debug.LogWarning($"[BossPatternDebugRunner] 사망 상태에서는 패턴을 실행할 수 없습니다: {pattern.PatternName}", this);
+ return false;
+ }
+
+ CancelPattern();
+
+ if (skillController.IsExecutingSkill)
+ skillController.CancelSkill(SkillCancelReason.Interrupt);
+
+ currentTarget = ResolvePatternTarget(pattern);
+
+ currentPattern = pattern;
+ runtimeState.WasChargeBroken = false;
+ runtimeState.LastChargeStaggerDuration = 0f;
+ ApplyPatternFlowState(pattern);
+ runtimeState.SetCurrentTarget(currentTarget);
+ runtimeState.BeginPatternExecution(pattern);
+
+ StopMovement();
+ activePatternRoutine = StartCoroutine(RunPatternRoutine(pattern));
+ LogDebug($"디버그 패턴 발동: {pattern.PatternName} / Target={currentTarget?.name ?? "없음"} / BTMode={behaviorTreeMode}");
+ return true;
+ }
+
+ ///
+ /// 현재 디버그 패턴 실행을 취소합니다.
+ ///
+ public void CancelPattern()
+ {
+ if (activePatternRoutine != null)
+ {
+ StopCoroutine(activePatternRoutine);
+ activePatternRoutine = null;
+ }
+
+ if (currentPattern == null)
+ return;
+
+ if (skillController != null && skillController.IsExecutingSkill)
+ skillController.CancelSkill(SkillCancelReason.Interrupt);
+
+ FinalizePattern(BossPatternExecutionResult.Cancelled, applyCooldown: false);
+ }
+
+ ///
+ /// 디버그 토글에 따라 BT를 지속적으로 정지하거나 재개합니다.
+ ///
+ public void SetBehaviorTreePaused(bool paused)
+ {
+ ResolveReferences();
+ SetBehaviorGraphSuspended(paused);
+ }
+
+ private IEnumerator RunPatternRoutine(BossPatternData pattern)
+ {
+ BossPatternExecutionResult result = BossPatternExecutionResult.Cancelled;
+ bool applyCooldown = false;
+
+ try
+ {
+ for (int i = 0; i < pattern.Steps.Count; i++)
+ {
+ PatternStep step = pattern.Steps[i];
+
+ if (step.Type == PatternStepType.Wait)
+ {
+ yield return WaitForSeconds(step.Duration);
+ continue;
+ }
+
+ if (step.Type == PatternStepType.ChargeWait)
+ {
+ bool chargeBroken = false;
+ yield return RunChargeWait(step, value => chargeBroken = value);
+ if (chargeBroken)
+ {
+ runtimeState.WasChargeBroken = true;
+ applyCooldown = true;
+ result = BossPatternExecutionResult.Failed;
+ yield break;
+ }
+
+ continue;
+ }
+
+ if (step.Skill == null)
+ {
+ Debug.LogWarning($"[BossPatternDebugRunner] 스킬이 비어 있는 패턴 스텝입니다: {pattern.PatternName} / Step={i}", this);
+ result = BossPatternExecutionResult.Failed;
+ yield break;
+ }
+
+ GameObject stepTarget = currentTarget;
+ if (step.Skill.JumpToTarget)
+ {
+ stepTarget = ResolveJumpTarget();
+ if (stepTarget == null)
+ {
+ if (pattern.SkipJumpStepOnNoTarget)
+ {
+ applyCooldown = true;
+ result = BossPatternExecutionResult.Succeeded;
+ LogDebug($"점프 대상 없음, 디버그 패턴 조기 종료: {pattern.PatternName}");
+ yield break;
+ }
+
+ Debug.LogWarning($"[BossPatternDebugRunner] 점프 타겟을 찾지 못했습니다: {pattern.PatternName}", this);
+ result = BossPatternExecutionResult.Failed;
+ yield break;
+ }
+
+ enemyBase.SetJumpTarget(stepTarget.transform.position);
+ }
+
+ bool executed = stepTarget != null
+ ? skillController.ExecuteSkill(step.Skill, stepTarget)
+ : skillController.ExecuteSkill(step.Skill);
+
+ if (!executed)
+ {
+ Debug.LogWarning($"[BossPatternDebugRunner] 스킬 실행 실패: {step.Skill.SkillName}", this);
+ result = BossPatternExecutionResult.Failed;
+ yield break;
+ }
+
+ LogDebug($"디버그 패턴 실행: {pattern.PatternName} / Step={i} / Skill={step.Skill.SkillName}");
+
+ while (skillController.IsPlayingAnimation)
+ yield return null;
+
+ if (skillController.LastExecutionResult != SkillExecutionResult.Completed)
+ {
+ result = BossPatternExecutionResult.Cancelled;
+ yield break;
+ }
+ }
+
+ applyCooldown = true;
+ result = BossPatternExecutionResult.Succeeded;
+ }
+ finally
+ {
+ activePatternRoutine = null;
+ FinalizePattern(result, applyCooldown);
+ }
+ }
+
+ private IEnumerator RunChargeWait(PatternStep step, System.Action onFinished)
+ {
+ bool broken = false;
+ StartChargeWait(step);
+
+ while (Time.time < chargeEndTime)
+ {
+ if (chargeAccumulatedDamage >= chargeRequiredDamage)
+ {
+ broken = true;
+ break;
+ }
+
+ yield return null;
+ }
+
+ EndChargeWait(broken);
+
+ if (broken && skillController != null && skillController.IsExecutingSkill)
+ skillController.CancelSkill(SkillCancelReason.Interrupt);
+
+ onFinished?.Invoke(broken);
+ }
+
+ private IEnumerator WaitForSeconds(float duration)
+ {
+ float endTime = Time.time + Mathf.Max(0f, duration);
+ while (Time.time < endTime)
+ yield return null;
+ }
+
+ private void StartChargeWait(PatternStep step)
+ {
+ isChargeWaiting = true;
+ activeChargeData = step.ChargeData;
+ chargeAccumulatedDamage = 0f;
+ chargeTelegraphApplied = false;
+ chargeRequiredDamage = bossEnemy.MaxHealth * (activeChargeData != null ? activeChargeData.RequiredDamageRatio : 0.1f);
+ chargeEndTime = Time.time + Mathf.Max(0f, step.Duration);
+
+ enemyBase.OnDamageTaken += OnChargeDamageTaken;
+
+ if (activeChargeData != null && activeChargeData.TelegraphAbnormality != null && abnormalityManager != null)
+ {
+ abnormalityManager.ApplyAbnormality(activeChargeData.TelegraphAbnormality, gameObject);
+ chargeTelegraphApplied = true;
+ }
+
+ LogDebug($"디버그 충전 대기 시작: 필요 피해={chargeRequiredDamage:F1} / 대기={step.Duration:F1}s");
+ }
+
+ private void EndChargeWait(bool broken)
+ {
+ isChargeWaiting = false;
+ enemyBase.OnDamageTaken -= OnChargeDamageTaken;
+
+ if (chargeTelegraphApplied && abnormalityManager != null && activeChargeData != null
+ && activeChargeData.TelegraphAbnormality != null)
+ {
+ abnormalityManager.RemoveAbnormality(activeChargeData.TelegraphAbnormality);
+ }
+
+ if (broken && activeChargeData != null)
+ runtimeState.LastChargeStaggerDuration = activeChargeData.StaggerDuration;
+
+ activeChargeData = null;
+ chargeTelegraphApplied = false;
+ }
+
+ private void FinalizePattern(BossPatternExecutionResult result, bool applyCooldown)
+ {
+ if (isChargeWaiting)
+ EndChargeWait(broken: false);
+
+ if (currentPattern != null && runtimeState != null)
+ {
+ if (applyCooldown)
+ runtimeState.SetPatternCooldown(currentPattern);
+
+ runtimeState.CompletePatternExecution(currentPattern, result);
+ }
+
+ currentPattern = null;
+ currentTarget = null;
+ chargeAccumulatedDamage = 0f;
+ chargeRequiredDamage = 0f;
+ activeChargeData = null;
+ chargeTelegraphApplied = false;
+ }
+
+ private void ApplyPatternFlowState(BossPatternData pattern)
+ {
+ if (runtimeState == null || pattern == null)
+ return;
+
+ if (pattern.Category == PatternCategory.Punish || pattern.IsBigPattern)
+ {
+ runtimeState.ResetBasicLoopCount();
+ return;
+ }
+
+ if (pattern.IsMelee)
+ runtimeState.IncrementBasicLoopCount();
+ }
+
+ private GameObject ResolvePatternTarget(BossPatternData pattern)
+ {
+ if (pattern == null)
+ return null;
+
+ return pattern.TargetMode switch
+ {
+ TargetResolveMode.None => null,
+ TargetResolveMode.Mobility => ResolveMobilityTarget(),
+ _ => ResolvePrimaryTarget(),
+ };
+ }
+
+ private GameObject ResolvePrimaryTarget()
+ {
+ GameObject currentRuntimeTarget = runtimeState != null ? runtimeState.CurrentTarget : null;
+ if (IsValidHostileTarget(currentRuntimeTarget))
+ return currentRuntimeTarget;
+
+ float aggroRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
+ GameObject highestThreatTarget = enemyBase != null
+ ? enemyBase.GetHighestThreatTarget(currentRuntimeTarget, null, aggroRange)
+ : null;
+
+ if (IsValidHostileTarget(highestThreatTarget))
+ return highestThreatTarget;
+
+ return FindNearestLivingPlayer();
+ }
+
+ private GameObject ResolveMobilityTarget()
+ {
+ PlayerNetworkController[] players = Object.FindObjectsByType(FindObjectsSortMode.None);
+ if (players == null || players.Length == 0)
+ return ResolvePrimaryTarget();
+
+ GameObject primaryTarget = ResolvePrimaryTarget();
+ GameObject farthestTarget = null;
+ float farthestDistance = float.MinValue;
+ float aggroRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
+
+ for (int i = 0; i < players.Length; i++)
+ {
+ PlayerNetworkController player = players[i];
+ if (player == null || player.IsDead || !player.gameObject.activeInHierarchy)
+ continue;
+
+ GameObject candidate = player.gameObject;
+ if (candidate == primaryTarget || !IsValidHostileTarget(candidate))
+ continue;
+
+ float distance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, candidate);
+ if (distance > aggroRange || distance <= farthestDistance)
+ continue;
+
+ farthestDistance = distance;
+ farthestTarget = candidate;
+ }
+
+ return farthestTarget != null ? farthestTarget : primaryTarget;
+ }
+
+ private GameObject ResolveJumpTarget()
+ {
+ GameObject resolvedTarget = IsValidJumpTarget(currentTarget) ? currentTarget : ResolvePatternTarget(currentPattern);
+ currentTarget = resolvedTarget;
+ runtimeState?.SetCurrentTarget(resolvedTarget);
+ return resolvedTarget;
+ }
+
+ private GameObject FindNearestLivingPlayer()
+ {
+ PlayerNetworkController[] players = Object.FindObjectsByType(FindObjectsSortMode.None);
+ GameObject nearestTarget = null;
+ float nearestDistance = float.MaxValue;
+ float aggroRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
+
+ for (int i = 0; i < players.Length; i++)
+ {
+ PlayerNetworkController player = players[i];
+ if (player == null || player.IsDead || !player.gameObject.activeInHierarchy)
+ continue;
+
+ GameObject candidate = player.gameObject;
+ if (!IsValidHostileTarget(candidate))
+ continue;
+
+ float distance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, candidate);
+ if (distance > aggroRange || distance >= nearestDistance)
+ continue;
+
+ nearestDistance = distance;
+ nearestTarget = candidate;
+ }
+
+ return nearestTarget;
+ }
+
+ private bool IsValidHostileTarget(GameObject candidate)
+ {
+ if (candidate == null || !candidate.activeInHierarchy)
+ return false;
+
+ if (Team.IsSameTeam(gameObject, candidate))
+ return false;
+
+ IDamageable damageable = candidate.GetComponent();
+ return damageable == null || !damageable.IsDead;
+ }
+
+ private bool IsValidJumpTarget(GameObject candidate)
+ {
+ if (!IsValidHostileTarget(candidate))
+ return false;
+
+ float aggroRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
+ float distance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, candidate);
+ return distance <= aggroRange;
+ }
+
+ private void OnChargeDamageTaken(float damage)
+ {
+ if (damage <= 0f)
+ return;
+
+ chargeAccumulatedDamage += damage;
+ }
+
+ private void StopMovement()
+ {
+ if (navMeshAgent == null || !navMeshAgent.enabled)
+ return;
+
+ navMeshAgent.isStopped = true;
+ navMeshAgent.ResetPath();
+ }
+
+ private void SetBehaviorGraphSuspended(bool suspended)
+ {
+ if (behaviorGraphAgent == null)
+ return;
+
+ if (suspended)
+ {
+ if (debugBehaviorGraphPaused)
+ return;
+
+ debugBehaviorGraphPaused = true;
+ restoreBehaviorGraphWhenDebugPauseEnds = behaviorGraphAgent.enabled;
+ behaviorGraphAgent.End();
+
+ if (restoreBehaviorGraphWhenDebugPauseEnds)
+ behaviorGraphAgent.enabled = false;
+
+ return;
+ }
+
+ if (!debugBehaviorGraphPaused)
+ return;
+
+ debugBehaviorGraphPaused = false;
+ if (!restoreBehaviorGraphWhenDebugPauseEnds)
+ return;
+
+ restoreBehaviorGraphWhenDebugPauseEnds = false;
+ if (bossEnemy != null && !bossEnemy.IsDead && behaviorGraphAgent.Graph != null)
+ {
+ behaviorGraphAgent.enabled = true;
+ behaviorGraphAgent.Restart();
+ }
+ }
+
+ private void ResolveReferences()
+ {
+ if (bossEnemy == null)
+ bossEnemy = GetComponent();
+
+ if (enemyBase == null)
+ enemyBase = GetComponent();
+
+ if (skillController == null)
+ skillController = GetComponent();
+
+ if (runtimeState == null)
+ runtimeState = GetComponent();
+
+ if (abnormalityManager == null)
+ abnormalityManager = GetComponent();
+
+ if (navMeshAgent == null)
+ navMeshAgent = GetComponent();
+
+ if (behaviorGraphAgent == null)
+ behaviorGraphAgent = GetComponent();
+ }
+
+ private void LogDebug(string message)
+ {
+ if (debugMode)
+ Debug.Log($"[BossPatternDebugRunner] {message}", this);
+ }
+ }
+}
+
+#endif
diff --git a/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs.meta b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs.meta
new file mode 100644
index 00000000..8562df6d
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6b222507042045f3bd644ba58b589070
diff --git a/Assets/_Game/Scripts/UI/DebugPanelUI.cs b/Assets/_Game/Scripts/UI/DebugPanelUI.cs
index e05e1acc..76c28f38 100644
--- a/Assets/_Game/Scripts/UI/DebugPanelUI.cs
+++ b/Assets/_Game/Scripts/UI/DebugPanelUI.cs
@@ -49,6 +49,15 @@ namespace Colosseum.UI
private SkillController debugSkillController;
private BossEnemy cachedBossForSkillDropdown;
+ // 패턴 강제 발동
+ private Button patternBehaviorModeToggleButton;
+ private TMP_Text patternBehaviorModeToggleLabel;
+ private bool isBehaviorTreeEnabledForDebugPattern = true;
+ private TMP_Dropdown patternDropdown;
+ private List debugPatternList;
+ private BossPatternDebugRunner debugPatternRunner;
+ private BossEnemy cachedBossForPatternDropdown;
+
// UI 참조
private GameObject toggleButtonObject;
private GameObject panelRoot;
@@ -94,6 +103,7 @@ namespace Colosseum.UI
UpdateHPDisplay();
RefreshSkillDropdownIfNeeded();
+ RefreshPatternDropdownIfNeeded();
}
///
@@ -223,6 +233,7 @@ namespace Colosseum.UI
BuildShieldSection(content.transform);
BuildAbnormalitySection(content.transform);
BuildSkillForceSection(content.transform);
+ BuildPatternForceSection(content.transform);
}
///
@@ -306,6 +317,26 @@ namespace Colosseum.UI
MakeButton("취소", row.transform, OnCancelSkill, 80f);
}
+ ///
+ /// 패턴 강제 발동 섹션
+ ///
+ private void BuildPatternForceSection(Transform parent)
+ {
+ MakeSectionHeader("패턴 강제 발동", parent);
+
+ GameObject modeRow = MakeRow(parent);
+ MakeLabel("BT:", modeRow.transform, 14f, 56f);
+ patternBehaviorModeToggleButton = MakeButton("ON", modeRow.transform, TogglePatternBehaviorMode, 80f);
+ patternBehaviorModeToggleLabel = GetButtonLabel(patternBehaviorModeToggleButton);
+ RefreshPatternBehaviorModeToggleUI();
+
+ patternDropdown = MakeDropdown("PatternDropdown", parent);
+
+ GameObject row = MakeRow(parent);
+ MakeButton("발동", row.transform, OnForcePattern, 80f);
+ MakeButton("취소", row.transform, OnCancelPattern, 80f);
+ }
+
// ──────────────────────────────────────────────────
// UI 업데이트
// ──────────────────────────────────────────────────
@@ -457,6 +488,21 @@ namespace Colosseum.UI
}
}
+ ///
+ /// 보스가 변경되었으면 패턴 드롭다운을 갱신합니다.
+ ///
+ private void RefreshPatternDropdownIfNeeded()
+ {
+ if (patternDropdown == null)
+ return;
+
+ if (cachedBoss != cachedBossForPatternDropdown)
+ {
+ cachedBossForPatternDropdown = cachedBoss;
+ RebuildPatternDropdown();
+ }
+ }
+
///
/// 드롭다운을 갱신합니다.
/// 에디터에서는 Data/Skills에서 보스 이름이 포함된 스킬을 모두 검색하고,
@@ -508,7 +554,10 @@ namespace Colosseum.UI
///
private List LoadSkillsFromAssetFolder()
{
- string bossName = cachedBoss.gameObject.name;
+ string bossName = GetBossAssetFilterName();
+ if (string.IsNullOrEmpty(bossName))
+ return new List();
+
string[] guids = AssetDatabase.FindAssets($"t:SkillData", new[] { "Assets/_Game/Data/Skills" });
List result = new List();
@@ -528,6 +577,83 @@ namespace Colosseum.UI
}
#endif
+ ///
+ /// 보스가 변경되었으면 패턴 드롭다운을 갱신합니다.
+ /// 에디터에서는 Data/Patterns에서 보스 이름이 포함된 패턴을 모두 검색하고,
+ /// 빌드에서는 보스 강제 시전 드롭다운을 비웁니다.
+ ///
+ private void RebuildPatternDropdown()
+ {
+ debugPatternRunner = cachedBoss != null
+ ? cachedBoss.GetComponent()
+ : null;
+ SyncPatternBehaviorTreeToggleState();
+
+ if (cachedBoss == null)
+ {
+ patternDropdown.ClearOptions();
+ patternDropdown.options.Add(new TMP_Dropdown.OptionData("보스 없음"));
+ patternDropdown.value = 0;
+ debugPatternList = null;
+ return;
+ }
+
+#if UNITY_EDITOR
+ debugPatternList = LoadPatternsFromAssetFolder();
+#else
+ debugPatternList = null;
+#endif
+
+ if (debugPatternList == null || debugPatternList.Count == 0)
+ {
+ patternDropdown.ClearOptions();
+ patternDropdown.options.Add(new TMP_Dropdown.OptionData("패턴 없음"));
+ patternDropdown.value = 0;
+ return;
+ }
+
+ List options = new List();
+ for (int i = 0; i < debugPatternList.Count; i++)
+ {
+ BossPatternData pattern = debugPatternList[i];
+ string name = pattern != null ? pattern.PatternName : string.Empty;
+ options.Add(new TMP_Dropdown.OptionData(string.IsNullOrEmpty(name) ? $"Pattern {i}" : name));
+ }
+
+ patternDropdown.ClearOptions();
+ patternDropdown.AddOptions(options);
+ }
+
+#if UNITY_EDITOR
+ ///
+ /// 에디터 전용: Data/Patterns에서 보스 이름이 포함된 BossPatternData를 모두 검색합니다.
+ ///
+ private List LoadPatternsFromAssetFolder()
+ {
+ string bossName = GetBossAssetFilterName();
+ if (string.IsNullOrEmpty(bossName))
+ return new List();
+
+ string[] guids = AssetDatabase.FindAssets("t:BossPatternData", new[] { "Assets/_Game/Data/Patterns" });
+
+ List result = new List();
+ for (int i = 0; i < guids.Length; i++)
+ {
+ string path = AssetDatabase.GUIDToAssetPath(guids[i]);
+ if (!path.Contains(bossName))
+ continue;
+
+ BossPatternData pattern = AssetDatabase.LoadAssetAtPath(path);
+ if (pattern != null)
+ result.Add(pattern);
+ }
+
+ return result
+ .OrderBy(pattern => pattern.PatternName)
+ .ToList();
+ }
+#endif
+
///
/// 드롭다운에서 선택한 스킬을 강제 발동합니다.
///
@@ -559,6 +685,77 @@ namespace Colosseum.UI
debugSkillController.CancelSkill();
}
+ ///
+ /// 드롭다운에서 선택한 패턴을 강제 발동합니다.
+ ///
+ private void OnForcePattern()
+ {
+ if (!IsHost || NoBoss || debugPatternList == null)
+ return;
+
+ int index = patternDropdown.value;
+ if (index < 0 || index >= debugPatternList.Count)
+ return;
+
+ if (debugPatternRunner == null)
+ debugPatternRunner = cachedBoss.GetComponent() ?? cachedBoss.gameObject.AddComponent();
+
+ ApplyPatternBehaviorTreeToggleState();
+ debugPatternRunner.TryExecutePattern(debugPatternList[index], GetSelectedPatternBehaviorTreeMode());
+ }
+
+ ///
+ /// 현재 실행 중인 디버그 패턴을 취소합니다.
+ ///
+ private void OnCancelPattern()
+ {
+ if (!IsHost || NoBoss || debugPatternRunner == null)
+ return;
+
+ debugPatternRunner.CancelPattern();
+ }
+
+ ///
+ /// 현재 토글 상태를 디버그 패턴 실행기의 BT 정지 상태에 반영합니다.
+ ///
+ private void ApplyPatternBehaviorTreeToggleState()
+ {
+ if (!IsHost || NoBoss)
+ return;
+
+ if (debugPatternRunner == null)
+ debugPatternRunner = cachedBoss.GetComponent() ?? cachedBoss.gameObject.AddComponent();
+
+ debugPatternRunner.SetBehaviorTreePaused(!isBehaviorTreeEnabledForDebugPattern);
+ }
+
+ ///
+ /// 현재 보스의 BT 활성 상태를 토글 UI에 동기화합니다.
+ ///
+ private void SyncPatternBehaviorTreeToggleState()
+ {
+ bool isBehaviorTreeEnabled = true;
+
+ if (cachedBoss != null)
+ {
+ debugPatternRunner ??= cachedBoss.GetComponent();
+
+ if (debugPatternRunner != null)
+ {
+ isBehaviorTreeEnabled = !debugPatternRunner.IsBehaviorTreePaused;
+ }
+ else
+ {
+ Unity.Behavior.BehaviorGraphAgent behaviorGraphAgent = cachedBoss.GetComponent();
+ if (behaviorGraphAgent != null)
+ isBehaviorTreeEnabled = behaviorGraphAgent.enabled;
+ }
+ }
+
+ isBehaviorTreeEnabledForDebugPattern = isBehaviorTreeEnabled;
+ RefreshPatternBehaviorModeToggleUI();
+ }
+
// ──────────────────────────────────────────────────
// 토글
// ──────────────────────────────────────────────────
@@ -582,6 +779,61 @@ namespace Colosseum.UI
private static TMP_FontAsset DefaultFont => TMP_Settings.defaultFontAsset;
+ ///
+ /// 디버그 에셋 검색에 사용할 보스 이름을 반환합니다.
+ ///
+ private string GetBossAssetFilterName()
+ {
+ if (cachedBoss == null)
+ return string.Empty;
+
+ const string cloneSuffix = "(Clone)";
+ string bossName = cachedBoss.gameObject.name;
+ if (bossName.EndsWith(cloneSuffix, StringComparison.Ordinal))
+ bossName = bossName[..^cloneSuffix.Length];
+
+ return bossName.Trim();
+ }
+
+ ///
+ /// 패턴 강제 발동 시 사용할 BT 활성 토글 UI를 갱신합니다.
+ ///
+ private void RefreshPatternBehaviorModeToggleUI()
+ {
+ if (patternBehaviorModeToggleButton == null || patternBehaviorModeToggleLabel == null)
+ return;
+
+ patternBehaviorModeToggleLabel.text = isBehaviorTreeEnabledForDebugPattern ? "ON" : "OFF";
+
+ Image buttonImage = patternBehaviorModeToggleButton.GetComponent();
+ if (buttonImage != null)
+ {
+ buttonImage.color = isBehaviorTreeEnabledForDebugPattern
+ ? new Color(0.18f, 0.45f, 0.22f, 1f)
+ : new Color(0.38f, 0.18f, 0.18f, 1f);
+ }
+ }
+
+ ///
+ /// 패턴 강제 발동 시 BT 활성 토글 상태를 반전합니다.
+ ///
+ private void TogglePatternBehaviorMode()
+ {
+ isBehaviorTreeEnabledForDebugPattern = !isBehaviorTreeEnabledForDebugPattern;
+ RefreshPatternBehaviorModeToggleUI();
+ ApplyPatternBehaviorTreeToggleState();
+ }
+
+ ///
+ /// 현재 선택된 패턴 강제 발동 BT 모드를 반환합니다.
+ ///
+ private DebugPatternBehaviorTreeMode GetSelectedPatternBehaviorTreeMode()
+ {
+ return isBehaviorTreeEnabledForDebugPattern
+ ? DebugPatternBehaviorTreeMode.KeepRunning
+ : DebugPatternBehaviorTreeMode.DisableDuringPattern;
+ }
+
private static void MakeSectionHeader(string text, Transform parent)
{
TMP_Text h = MakeLabel(text, parent, 16f);
@@ -643,6 +895,18 @@ namespace Colosseum.UI
return go.GetComponent