From 5d58397fe0f2183416a91c58ca6d03a96282da25 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Fri, 10 Apr 2026 22:09:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8C=A8=ED=84=B4=20=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20BT=20=ED=86=A0=EA=B8=80=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보스 패턴 디버그 실행기를 추가해 강제 패턴 실행과 BT 일시정지를 분리 - 디버그 패널의 패턴 강제 발동 UI에 BT ON/OFF 토글과 상태 동기화를 반영 - Unity 리프레시 및 dotnet build로 컴파일 오류 없이 동작 확인 --- .../Scripts/Enemy/BossPatternDebugRunner.cs | 577 ++++++++++++++++++ .../Enemy/BossPatternDebugRunner.cs.meta | 2 + Assets/_Game/Scripts/UI/DebugPanelUI.cs | 266 +++++++- 3 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs create mode 100644 Assets/_Game/Scripts/Enemy/BossPatternDebugRunner.cs.meta 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