Files
Colosseum/Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
dal4segno 9fd231626b fix: 드로그 패턴 애니메이션 재생 끊김 수정
- BT 재평가 중에도 패턴 실행 상태를 보존하도록 보스 패턴 액션과 런타임 상태를 조정했다.
- 스킬 컨트롤러에서 동일 프레임 종료 판정을 막아 패턴 내 다음 스킬이 즉시 잘리는 문제를 수정했다.
- 드로그 BT, 패턴/스킬 데이터, 애니메이션 클립과 컨트롤러를 현재 검증된 재생 구성으로 정리했다.
- 자연 발동 기준으로 콤보-기본기2 재생 시간을 재검증해 클립 길이와 실제 재생 간격이 맞는 것을 확인했다.
2026-04-12 05:44:54 +09:00

480 lines
17 KiB
C#

using System.Collections.Generic;
using Colosseum.AI;
using Colosseum.Abnormalities;
using Colosseum.Player;
using Colosseum.Skills;
using Unity.Behavior;
using Unity.Netcode;
using UnityEngine;
namespace Colosseum.Enemy
{
/// <summary>
/// 마지막 패턴 실행 결과입니다.
/// </summary>
public enum BossPatternExecutionResult
{
None,
Running,
Succeeded,
Failed,
Cancelled,
}
/// <summary>
/// 보스 BT가 읽고 쓰는 런타임 전투 상태를 보관합니다.
/// 타겟, 페이즈 진행 상태, 패턴 쿨다운 같은 전투 런타임 결과를 외부 시스템과 공유합니다.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(BossEnemy))]
[RequireComponent(typeof(SkillController))]
public class BossBehaviorRuntimeState : NetworkBehaviour
{
public static event System.Action<GameObject, GameObject> PlayerRevivedBySkill;
[Header("References")]
[SerializeField] protected BossEnemy bossEnemy;
[SerializeField] protected EnemyBase enemyBase;
[SerializeField] protected SkillController skillController;
[SerializeField] protected AbnormalityManager abnormalityManager;
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
[SerializeField] protected BehaviorGraphAgent behaviorGraphAgent;
[Header("Pattern Flow")]
[Tooltip("패턴 하나가 끝난 뒤 다음 패턴을 시작하기까지의 공통 텀")]
[Min(0f)] [SerializeField] protected float commonPatternInterval = 0.35f;
[Tooltip("패턴 종료 후 Idle 자세가 잠깐 안착할 수 있도록 추가로 확보하는 시간")]
[Min(0f)] [SerializeField] protected float postPatternIdleSettleDuration = 0.12f;
[Header("Phase State")]
[Tooltip("BT가 관리하는 최대 페이즈 수")]
[Min(1)] [SerializeField] protected int maxPatternPhase = 3;
[Tooltip("디버그 로그 출력 여부")]
[SerializeField] protected bool debugMode = false;
protected readonly Dictionary<BossPatternData, float> patternCooldownTracker = new Dictionary<BossPatternData, float>();
protected readonly Dictionary<string, bool> customPhaseConditions = new Dictionary<string, bool>();
protected GameObject currentTarget;
protected int meleePatternCounter;
protected int basicLoopCountSinceLastBigPattern;
protected int currentPatternPhase = 1;
protected float currentPhaseStartTime;
protected float nextPatternReadyTime;
protected float lastPatternCompletedTime = float.NegativeInfinity;
protected BossPatternExecutionResult lastPatternExecutionResult;
protected BossPatternData lastExecutedPattern;
protected BossPatternData activePattern;
protected bool currentPatternSkillStartsFromIdle;
protected bool currentPatternSkillReturnsToIdle;
protected GameObject lastReviveCaster;
protected GameObject lastRevivedTarget;
protected float lastReviveEventTime = float.NegativeInfinity;
/// <summary>
/// 현재 전투 대상
/// </summary>
public GameObject CurrentTarget => currentTarget;
/// <summary>
/// BT가 관리하는 현재 페이즈
/// </summary>
public int CurrentPatternPhase => Mathf.Clamp(currentPatternPhase, 1, Mathf.Max(1, maxPatternPhase));
/// <summary>
/// BT가 관리하는 최대 페이즈 수
/// </summary>
public int MaxPatternPhase => Mathf.Max(1, maxPatternPhase);
/// <summary>
/// 현재 페이즈의 경과 시간
/// </summary>
public float PhaseElapsedTime => Time.time - currentPhaseStartTime;
/// <summary>
/// 마지막 대형/징벌 패턴 이후 누적된 기본 루프 횟수
/// </summary>
public int BasicLoopCountSinceLastBigPattern => basicLoopCountSinceLastBigPattern;
/// <summary>
/// 패턴 종료 후 다음 패턴 시작까지 남은 공통 텀입니다.
/// </summary>
public float RemainingPatternInterval => Mathf.Max(0f, nextPatternReadyTime - Time.time);
public float RemainingPatternIdleSettleTime => Mathf.Max(0f, (lastPatternCompletedTime + Mathf.Max(0f, postPatternIdleSettleDuration)) - Time.time);
/// <summary>
/// 마지막 패턴 실행 결과
/// </summary>
public BossPatternExecutionResult LastPatternExecutionResult => lastPatternExecutionResult;
/// <summary>
/// 마지막으로 실행한 패턴
/// </summary>
public BossPatternData LastExecutedPattern => lastExecutedPattern;
/// <summary>
/// 현재 패턴 실행 중인지 여부
/// </summary>
public bool IsExecutingPattern => activePattern != null && lastPatternExecutionResult == BossPatternExecutionResult.Running;
/// <summary>
/// 현재 스킬 스텝이 패턴 시작에서 Idle과 이어져야 하는지 여부
/// </summary>
public bool CurrentPatternSkillStartsFromIdle => currentPatternSkillStartsFromIdle;
/// <summary>
/// 현재 스킬 스텝이 패턴 종료에서 Idle로 돌아가야 하는지 여부
/// </summary>
public bool CurrentPatternSkillReturnsToIdle => currentPatternSkillReturnsToIdle;
/// <summary>
/// EnemyBase 접근자
/// </summary>
public EnemyBase EnemyBase => enemyBase;
/// <summary>
/// 디버그 로그 출력 여부
/// </summary>
public bool DebugModeEnabled => debugMode;
/// <summary>
/// 마지막 충전 차단 시 설정된 경직 시간 (BossPatternActionBase가 설정)
/// </summary>
public float LastChargeStaggerDuration { get; set; }
/// <summary>
/// 마지막 패턴 실행에서 충전이 차단되었는지 여부.
/// BT 노드(IsChargeBrokenCondition)에서 판독합니다.
/// </summary>
public bool WasChargeBroken { get; set; }
/// <summary>
/// 기절 등으로 인해 보스 전투 로직을 진행할 수 없는 상태인지 여부
/// </summary>
public bool IsBehaviorSuppressed => abnormalityManager != null && abnormalityManager.IsStunned;
/// <summary>
protected virtual void Awake()
{
ResolveReferences();
ResetPhaseState();
}
public override void OnNetworkSpawn()
{
ResolveReferences();
ResetPhaseState();
if (!IsServer)
{
enabled = false;
return;
}
PlayerRevivedBySkill += HandlePlayerRevivedBySkill;
}
protected virtual void Update()
{
if (!IsServer)
return;
ResolveReferences();
if (bossEnemy == null || enemyBase == null || skillController == null)
return;
if (bossEnemy.IsDead)
return;
if (IsBehaviorSuppressed)
StopMovement();
}
/// <summary>
/// BT가 선택한 현재 전투 대상을 동기화합니다.
/// </summary>
public void SetCurrentTarget(GameObject target)
{
currentTarget = target;
}
/// <summary>
/// BT가 현재 페이즈 값을 갱신합니다.
/// 필요하면 경과 시간 기준도 함께 초기화합니다.
/// </summary>
public void SetCurrentPatternPhase(int phase, bool resetTimer = true)
{
currentPatternPhase = Mathf.Clamp(phase, 1, MaxPatternPhase);
if (resetTimer)
currentPhaseStartTime = Time.time;
}
/// <summary>
/// 현재 페이즈 타이머를 다시 시작합니다.
/// </summary>
public void RestartCurrentPhaseTimer()
{
currentPhaseStartTime = Time.time;
}
/// <summary>
/// 패턴 실행 시작을 기록합니다.
/// </summary>
public void BeginPatternExecution(BossPatternData pattern)
{
activePattern = pattern;
lastExecutedPattern = pattern;
lastPatternExecutionResult = BossPatternExecutionResult.Running;
currentPatternSkillStartsFromIdle = false;
currentPatternSkillReturnsToIdle = false;
}
/// <summary>
/// 패턴 실행 결과를 기록합니다.
/// </summary>
public void CompletePatternExecution(BossPatternData pattern, BossPatternExecutionResult result)
{
lastExecutedPattern = pattern;
lastPatternExecutionResult = result;
activePattern = null;
currentPatternSkillStartsFromIdle = false;
currentPatternSkillReturnsToIdle = false;
lastPatternCompletedTime = Time.time;
if (pattern != null && IsTerminalPatternExecutionResult(result))
StartCommonPatternInterval();
}
/// <summary>
/// 현재 실행할 패턴 스킬이 패턴 시작/종료 경계인지 기록합니다.
/// </summary>
public void SetCurrentPatternSkillBoundary(bool startsFromIdle, bool returnsToIdle)
{
currentPatternSkillStartsFromIdle = startsFromIdle;
currentPatternSkillReturnsToIdle = returnsToIdle;
}
/// <summary>
/// 부활 스킬 사용 사실을 보스 AI에 알립니다.
/// </summary>
public static void ReportPlayerRevivedBySkill(GameObject caster, GameObject revivedTarget)
{
PlayerRevivedBySkill?.Invoke(caster, revivedTarget);
}
/// <summary>
/// 최근 부활 트리거가 아직 유효한지 확인합니다.
/// </summary>
public bool HasRecentReviveTrigger(float maxAge)
{
return ResolveRecentReviveTriggerTarget(maxAge) != null;
}
/// <summary>
/// 최근 부활 트리거에서 우선 공격할 대상을 반환합니다.
/// </summary>
public GameObject ResolveRecentReviveTriggerTarget(float maxAge, bool preferCaster = true, bool fallbackToRevivedTarget = true)
{
if (Time.time - lastReviveEventTime > Mathf.Max(0f, maxAge))
return null;
GameObject preferredTarget = preferCaster ? lastReviveCaster : lastRevivedTarget;
if (IsValidReviveTriggerTarget(preferredTarget))
return preferredTarget;
if (!fallbackToRevivedTarget)
return null;
GameObject fallbackTarget = preferCaster ? lastRevivedTarget : lastReviveCaster;
return IsValidReviveTriggerTarget(fallbackTarget) ? fallbackTarget : null;
}
/// <summary>
/// 페이즈 커스텀 조건을 기록합니다.
/// </summary>
public void SetPhaseCustomCondition(string conditionId, bool value)
{
if (string.IsNullOrEmpty(conditionId))
return;
customPhaseConditions[conditionId] = value;
}
/// <summary>
/// 페이즈 커스텀 조건 값을 읽습니다.
/// </summary>
public bool CheckPhaseCustomCondition(string conditionId)
{
return !string.IsNullOrEmpty(conditionId)
&& customPhaseConditions.TryGetValue(conditionId, out bool value)
&& value;
}
public void IncrementBasicLoopCount(int count = 1)
{
int appliedCount = Mathf.Max(0, count);
if (appliedCount <= 0)
return;
meleePatternCounter += appliedCount;
basicLoopCountSinceLastBigPattern += appliedCount;
}
public void ResetBasicLoopCount()
{
basicLoopCountSinceLastBigPattern = 0;
}
/// <summary>
/// 로그를 출력합니다.
/// </summary>
public void LogDebug(string source, string message)
{
if (debugMode)
Debug.Log($"[{source}] {message}");
}
public bool IsPatternReady(BossPatternData pattern)
{
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
return false;
if (Time.time < lastPatternCompletedTime + Mathf.Max(0f, postPatternIdleSettleDuration))
return false;
if (!IsCommonPatternIntervalReady())
return false;
if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime))
return true;
return Time.time >= readyTime;
}
/// <summary>
/// 패턴 쿨다운을 설정합니다. BT 노드(BossPatternActionBase)와 코드 폴백 모두에서 호출합니다.
/// </summary>
public void SetPatternCooldown(BossPatternData pattern)
{
if (pattern != null)
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()
{
if (navMeshAgent == null || !navMeshAgent.enabled)
return;
navMeshAgent.isStopped = true;
navMeshAgent.ResetPath();
}
protected virtual void ResolveReferences()
{
if (bossEnemy == null)
bossEnemy = GetComponent<BossEnemy>();
if (enemyBase == null)
enemyBase = GetComponent<EnemyBase>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (navMeshAgent == null)
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
if (behaviorGraphAgent == null)
behaviorGraphAgent = GetComponent<BehaviorGraphAgent>();
}
public virtual void ResetPhaseState()
{
currentPatternPhase = 1;
currentPhaseStartTime = Time.time;
nextPatternReadyTime = 0f;
lastPatternCompletedTime = float.NegativeInfinity;
lastPatternExecutionResult = BossPatternExecutionResult.None;
lastExecutedPattern = null;
lastReviveCaster = null;
lastRevivedTarget = null;
lastReviveEventTime = float.NegativeInfinity;
customPhaseConditions.Clear();
}
private static bool IsTerminalPatternExecutionResult(BossPatternExecutionResult result)
{
return result == BossPatternExecutionResult.Succeeded
|| result == BossPatternExecutionResult.Failed
|| result == BossPatternExecutionResult.Cancelled;
}
public override void OnNetworkDespawn()
{
if (IsServer)
PlayerRevivedBySkill -= HandlePlayerRevivedBySkill;
base.OnNetworkDespawn();
}
protected void HandlePlayerRevivedBySkill(GameObject caster, GameObject revivedTarget)
{
if (!IsServer)
return;
lastReviveCaster = caster;
lastRevivedTarget = revivedTarget;
lastReviveEventTime = Time.time;
LogDebug(nameof(BossBehaviorRuntimeState), $"부활 트리거 기록: 시전자={caster?.name ?? ""} / 대상={revivedTarget?.name ?? ""}");
}
protected bool IsValidReviveTriggerTarget(GameObject candidate)
{
if (candidate == null || !candidate.activeInHierarchy)
return false;
PlayerNetworkController player = candidate.GetComponent<PlayerNetworkController>();
return player != null && !player.IsDead;
}
private static bool HasAnimatorParameter(Animator animator, string parameterName, AnimatorControllerParameterType parameterType)
{
if (animator == null || string.IsNullOrEmpty(parameterName))
return false;
AnimatorControllerParameter[] parameters = animator.parameters;
for (int i = 0; i < parameters.Length; i++)
{
AnimatorControllerParameter parameter = parameters[i];
if (parameter.type == parameterType && parameter.name == parameterName)
return true;
}
return false;
}
}
}