- SkillController를 패턴 경계 기준으로 분기해 보스 첫 스킬은 즉시 시작하고 마지막 스킬만 Idle로 부드럽게 복귀하도록 조정 - 보스 패턴 실행 중 현재 스킬이 첫/마지막 스텝인지 BossBehaviorRuntimeState와 패턴 실행 경로에서 공유하도록 확장 - 패턴 내부 연속 클립 전환은 하드 전환으로 유지해 시작 프레임 스킵과 중간 Idle 복귀 문제를 줄이고 종료 전환 시간을 별도 노출
614 lines
22 KiB
C#
614 lines
22 KiB
C#
#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
|
|
{
|
|
/// <summary>
|
|
/// 디버그 패턴 강제 발동 중 BT 개입 방식을 정의합니다.
|
|
/// </summary>
|
|
public enum DebugPatternBehaviorTreeMode
|
|
{
|
|
DisableDuringPattern = 0,
|
|
KeepRunning = 1,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 디버그 패널에서 보스 패턴을 임의 발동할 때 사용하는 런타임 실행기입니다.
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// 현재 디버그 패턴 실행 중 여부입니다.
|
|
/// </summary>
|
|
public bool IsExecutingPattern => activePatternRoutine != null;
|
|
|
|
/// <summary>
|
|
/// 현재 디버그 실행 중인 패턴입니다.
|
|
/// </summary>
|
|
public BossPatternData CurrentPattern => currentPattern;
|
|
|
|
/// <summary>
|
|
/// 디버그 토글로 BT가 중지된 상태인지 반환합니다.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정한 패턴을 디버그 용도로 즉시 실행합니다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 디버그 패턴 실행을 취소합니다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 디버그 토글에 따라 BT를 지속적으로 정지하거나 재개합니다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
runtimeState.SetCurrentPatternSkillBoundary(
|
|
startsFromIdle: IsFirstSkillStep(pattern, i),
|
|
returnsToIdle: IsLastSkillStep(pattern, i));
|
|
|
|
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<bool> 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);
|
|
|
|
runtimeState?.SetCurrentPatternSkillBoundary(false, 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 static bool IsFirstSkillStep(BossPatternData pattern, int stepIndex)
|
|
{
|
|
if (pattern == null || pattern.Steps == null)
|
|
return false;
|
|
|
|
for (int i = 0; i < pattern.Steps.Count; i++)
|
|
{
|
|
PatternStep candidate = pattern.Steps[i];
|
|
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
|
|
return i == stepIndex;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsLastSkillStep(BossPatternData pattern, int stepIndex)
|
|
{
|
|
if (pattern == null || pattern.Steps == null)
|
|
return false;
|
|
|
|
for (int i = pattern.Steps.Count - 1; i >= 0; i--)
|
|
{
|
|
PatternStep candidate = pattern.Steps[i];
|
|
if (candidate != null && candidate.Type == PatternStepType.Skill && candidate.Skill != null)
|
|
return i == stepIndex;
|
|
}
|
|
|
|
return 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<PlayerNetworkController>(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<PlayerNetworkController>(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<IDamageable>();
|
|
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<BossEnemy>();
|
|
|
|
if (enemyBase == null)
|
|
enemyBase = GetComponent<EnemyBase>();
|
|
|
|
if (skillController == null)
|
|
skillController = GetComponent<SkillController>();
|
|
|
|
if (runtimeState == null)
|
|
runtimeState = GetComponent<BossBehaviorRuntimeState>();
|
|
|
|
if (abnormalityManager == null)
|
|
abnormalityManager = GetComponent<AbnormalityManager>();
|
|
|
|
if (navMeshAgent == null)
|
|
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
|
|
|
if (behaviorGraphAgent == null)
|
|
behaviorGraphAgent = GetComponent<BehaviorGraphAgent>();
|
|
}
|
|
|
|
private void LogDebug(string message)
|
|
{
|
|
if (debugMode)
|
|
Debug.Log($"[BossPatternDebugRunner] {message}", this);
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|