feat: 드로그 보스 AI 및 런타임 상태 구조 재구성
- 드로그 전투 컨텍스트를 BossBehaviorRuntimeState 중심 구조로 정리하고 BossEnemy, 패턴 액션, 조건 노드가 마지막 실행 결과와 phase 상태를 직접 사용하도록 갱신 - BT_Drog와 재빌드 에디터 스크립트를 확장해 phase 전환, 집행 결과 분기, 거리/쿨타임 기반 패턴 선택을 드로그 전용 자산과 노드 파라미터로 재구성 - 드로그 패턴/스킬/이펙트/애니메이션 플레이스홀더 자산을 재생성하고 보스 프리팹이 새 런타임 상태 및 등록 클립 구성을 참조하도록 정리
This commit is contained in:
418
Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
Normal file
418
Assets/_Game/Scripts/Enemy/BossBehaviorRuntimeState.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Colosseum.AI;
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Skills;
|
||||
|
||||
using Unity.Behavior;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
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
|
||||
{
|
||||
[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(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2;
|
||||
|
||||
[Header("시그니처 효과 설정")]
|
||||
[Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] protected float signatureRequiredDamageRatio = 0.1f;
|
||||
|
||||
[Tooltip("시그니처 준비 상태를 나타내는 이상상태")]
|
||||
[SerializeField] protected AbnormalityData signatureTelegraphAbnormality;
|
||||
|
||||
[Tooltip("시그니처 차단 성공 시 보스가 멈추는 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureSuccessStaggerDuration = 2f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 모든 플레이어에게 적용할 디버프")]
|
||||
[SerializeField] protected AbnormalityData signatureFailureAbnormality;
|
||||
|
||||
[Tooltip("시그니처 실패 시 모든 플레이어에게 주는 기본 피해")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDamage = 40f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백이 적용되는 반경")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackRadius = 8f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 다운이 적용되는 반경")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDownRadius = 3f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백 속도")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackSpeed = 12f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백 지속 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackDuration = 0.35f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 다운 지속 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDownDuration = 2f;
|
||||
|
||||
[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 BossPatternExecutionResult lastPatternExecutionResult;
|
||||
protected BossPatternData lastExecutedPattern;
|
||||
|
||||
/// <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 BossPatternExecutionResult LastPatternExecutionResult => lastPatternExecutionResult;
|
||||
|
||||
/// <summary>
|
||||
/// 마지막으로 실행한 패턴
|
||||
/// </summary>
|
||||
public BossPatternData LastExecutedPattern => lastExecutedPattern;
|
||||
|
||||
/// <summary>
|
||||
/// EnemyBase 접근자
|
||||
/// </summary>
|
||||
public EnemyBase EnemyBase => enemyBase;
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 로그 출력 여부
|
||||
/// </summary>
|
||||
public bool DebugModeEnabled => debugMode;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 모든 플레이어에게 주는 기본 피해
|
||||
/// </summary>
|
||||
public float SignatureFailureDamage => signatureFailureDamage;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 모든 플레이어에게 적용할 디버프
|
||||
/// </summary>
|
||||
public AbnormalityData SignatureFailureAbnormality => signatureFailureAbnormality;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백이 적용되는 반경
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackRadius => signatureFailureKnockbackRadius;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 다운이 적용되는 반경
|
||||
/// </summary>
|
||||
public float SignatureFailureDownRadius => signatureFailureDownRadius;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백 속도
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackSpeed => signatureFailureKnockbackSpeed;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백 지속 시간
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackDuration => signatureFailureKnockbackDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 다운 지속 시간
|
||||
/// </summary>
|
||||
public float SignatureFailureDownDuration => signatureFailureDownDuration;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
lastExecutedPattern = pattern;
|
||||
lastPatternExecutionResult = BossPatternExecutionResult.Running;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 패턴 실행 결과를 기록합니다.
|
||||
/// </summary>
|
||||
public void CompletePatternExecution(BossPatternData pattern, BossPatternExecutionResult result)
|
||||
{
|
||||
lastExecutedPattern = pattern;
|
||||
lastPatternExecutionResult = result;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 근접 패턴 사용 카운터를 갱신합니다.
|
||||
/// </summary>
|
||||
public void RegisterPatternUse(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null)
|
||||
return;
|
||||
|
||||
if (pattern.IsMelee)
|
||||
{
|
||||
meleePatternCounter++;
|
||||
basicLoopCountSinceLastBigPattern++;
|
||||
}
|
||||
|
||||
if (pattern.Category == PatternCategory.Punish || pattern.IsBigPattern)
|
||||
{
|
||||
basicLoopCountSinceLastBigPattern = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로그를 출력합니다.
|
||||
/// </summary>
|
||||
public void LogDebug(string source, string message)
|
||||
{
|
||||
if (debugMode)
|
||||
Debug.Log($"[{source}] {message}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정 패턴이 grace period를 통과했는지 반환합니다.
|
||||
/// Punish/Melee/Utility는 항상 허용됩니다.
|
||||
/// </summary>
|
||||
public bool IsPatternGracePeriodAllowed(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null)
|
||||
return false;
|
||||
|
||||
if (pattern.Category == PatternCategory.Punish)
|
||||
return true;
|
||||
|
||||
if (pattern.IsMelee || pattern.TargetMode == TargetResolveMode.Utility)
|
||||
return true;
|
||||
|
||||
return basicLoopCountSinceLastBigPattern >= basicLoopMinCountAfterBigPattern;
|
||||
}
|
||||
|
||||
public bool IsPatternReady(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
lastPatternExecutionResult = BossPatternExecutionResult.None;
|
||||
lastExecutedPattern = null;
|
||||
customPhaseConditions.Clear();
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
base.OnNetworkDespawn();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,848 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Colosseum.AI;
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Combat;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Skills;
|
||||
|
||||
using Unity.Behavior;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 공통 전투 BT가 참조하는 전투 컨텍스트입니다.
|
||||
/// 패턴 슬롯, 거리 기준, 페이즈별 주기, 공통 타겟 판정 정보를 제공합니다.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(BossEnemy))]
|
||||
[RequireComponent(typeof(SkillController))]
|
||||
public abstract class BossCombatBehaviorContext : NetworkBehaviour
|
||||
{
|
||||
[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 Data")]
|
||||
[Tooltip("기본 근접 압박 패턴")]
|
||||
[FormerlySerializedAs("mainPattern")]
|
||||
[SerializeField] protected BossPatternData primaryPattern;
|
||||
|
||||
[Tooltip("기동 또는 거리 징벌 패턴")]
|
||||
[FormerlySerializedAs("leapPattern")]
|
||||
[SerializeField] protected BossPatternData mobilityPattern;
|
||||
|
||||
[Tooltip("비주 대상 원거리 견제 패턴")]
|
||||
[SerializeField] protected BossPatternData utilityPattern;
|
||||
|
||||
[Tooltip("Phase 3 조합 패턴")]
|
||||
[SerializeField] protected BossPatternData comboPattern;
|
||||
|
||||
[Tooltip("특정 상황에서 우선 발동하는 징벌 패턴")]
|
||||
[FormerlySerializedAs("downPunishPattern")]
|
||||
[SerializeField] protected BossPatternData punishPattern;
|
||||
|
||||
[Tooltip("파티 누킹을 시험하는 시그니처 패턴")]
|
||||
[SerializeField] protected BossPatternData signaturePattern;
|
||||
|
||||
[Header("Phase Thresholds")]
|
||||
[Tooltip("2페이즈 진입 체력 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] protected float phase2HealthThreshold = 0.75f;
|
||||
|
||||
[Tooltip("3페이즈 진입 체력 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] protected float phase3HealthThreshold = 0.4f;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("타겟 재탐색 주기")]
|
||||
[FormerlySerializedAs("targetRefreshInterval")]
|
||||
[Min(0.05f)] [SerializeField] protected float primaryTargetRefreshInterval = 0.2f;
|
||||
|
||||
[Tooltip("기동 패턴을 고려하기 시작하는 거리")]
|
||||
[FormerlySerializedAs("leapDistanceThreshold")]
|
||||
[Min(0f)] [SerializeField] protected float mobilityTriggerDistance = 8f;
|
||||
|
||||
[Tooltip("징벌 패턴을 고려할 최대 반경")]
|
||||
[FormerlySerializedAs("downPunishSearchRadius")]
|
||||
[Min(0f)] [SerializeField] protected float punishSearchRadius = 6f;
|
||||
|
||||
[Tooltip("원거리 견제 패턴을 고려하기 시작하는 최소 거리")]
|
||||
[Min(0f)] [SerializeField] protected float utilityTriggerDistance = 5f;
|
||||
|
||||
[Header("Pattern Flow")]
|
||||
[Tooltip("대형 패턴(시그니처/기동/조합) 직후 기본 패턴 최소 순환 횟수")]
|
||||
[Min(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2;
|
||||
|
||||
[Header("시그니처 효과 설정")]
|
||||
[Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] protected float signatureRequiredDamageRatio = 0.1f;
|
||||
|
||||
[Tooltip("시그니처 준비 상태를 나타내는 이상상태")]
|
||||
[SerializeField] protected AbnormalityData signatureTelegraphAbnormality;
|
||||
|
||||
[Tooltip("시그니처 차단 성공 시 보스가 멈추는 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureSuccessStaggerDuration = 2f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 모든 플레이어에게 적용할 디버프")]
|
||||
[SerializeField] protected AbnormalityData signatureFailureAbnormality;
|
||||
|
||||
[Tooltip("시그니처 실패 시 모든 플레이어에게 주는 기본 피해")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDamage = 40f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백이 적용되는 반경")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackRadius = 8f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 다운이 적용되는 반경")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDownRadius = 3f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백 속도")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackSpeed = 12f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 넉백 지속 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureKnockbackDuration = 0.35f;
|
||||
|
||||
[Tooltip("시그니처 실패 시 다운 지속 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureFailureDownDuration = 2f;
|
||||
|
||||
[Header("Behavior")]
|
||||
[Tooltip("true면 컨텍스트 코드가 AI를 직접 구동합니다. false면 BehaviorGraph가 모든 의사결정을 담당합니다.")]
|
||||
[SerializeField] protected bool disableBehaviorGraph = false;
|
||||
|
||||
[Tooltip("디버그 로그 출력 여부")]
|
||||
[SerializeField] protected bool debugMode = false;
|
||||
|
||||
protected readonly Dictionary<BossPatternData, float> patternCooldownTracker = new Dictionary<BossPatternData, float>();
|
||||
|
||||
protected Coroutine activePatternCoroutine;
|
||||
protected GameObject currentTarget;
|
||||
protected float nextTargetRefreshTime;
|
||||
protected int meleePatternCounter;
|
||||
protected int basicLoopCountSinceLastBigPattern;
|
||||
|
||||
/// <summary>
|
||||
/// 전용 컨텍스트 사용 시 BehaviorGraph를 비활성화할지 여부
|
||||
/// </summary>
|
||||
public bool DisableBehaviorGraph => disableBehaviorGraph;
|
||||
|
||||
/// <summary>
|
||||
/// 기동 패턴을 고려하는 최소 거리
|
||||
/// </summary>
|
||||
public float MobilityTriggerDistance => mobilityTriggerDistance;
|
||||
|
||||
/// <summary>
|
||||
/// 원거리 견제 패턴을 고려하는 최소 거리
|
||||
/// </summary>
|
||||
public float UtilityTriggerDistance => utilityTriggerDistance;
|
||||
|
||||
/// <summary>
|
||||
/// 징벌 패턴을 고려하는 최대 반경
|
||||
/// </summary>
|
||||
public float PunishSearchRadius => punishSearchRadius;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 전투 대상
|
||||
/// </summary>
|
||||
public GameObject CurrentTarget => currentTarget;
|
||||
|
||||
/// <summary>
|
||||
/// EnemyBase 접근자
|
||||
/// </summary>
|
||||
public EnemyBase EnemyBase => enemyBase;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 전투 기준이 되는 주 대상을 반환합니다.
|
||||
/// </summary>
|
||||
public GameObject ResolvePrimaryTarget()
|
||||
{
|
||||
if (IsValidHostileTarget(currentTarget))
|
||||
return currentTarget;
|
||||
|
||||
GameObject highestThreatTarget = enemyBase != null
|
||||
? enemyBase.GetHighestThreatTarget(currentTarget, null, enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity)
|
||||
: null;
|
||||
|
||||
return highestThreatTarget != null ? highestThreatTarget : FindNearestLivingTarget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 로그 출력 여부
|
||||
/// </summary>
|
||||
public bool DebugModeEnabled => debugMode;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 모든 플레이어에게 주는 기본 피해
|
||||
/// </summary>
|
||||
public float SignatureFailureDamage => signatureFailureDamage;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 모든 플레이어에게 적용할 디버프
|
||||
/// </summary>
|
||||
public AbnormalityData SignatureFailureAbnormality => signatureFailureAbnormality;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백이 적용되는 반경
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackRadius => signatureFailureKnockbackRadius;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 다운이 적용되는 반경
|
||||
/// </summary>
|
||||
public float SignatureFailureDownRadius => signatureFailureDownRadius;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백 속도
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackSpeed => signatureFailureKnockbackSpeed;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 넉백 지속 시간
|
||||
/// </summary>
|
||||
public float SignatureFailureKnockbackDuration => signatureFailureKnockbackDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 실패 시 다운 지속 시간
|
||||
/// </summary>
|
||||
public float SignatureFailureDownDuration => signatureFailureDownDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 마지막 충전 차단 시 설정된 경직 시간 (BossPatternActionBase가 설정)
|
||||
/// </summary>
|
||||
public float LastChargeStaggerDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 기절 등으로 인해 보스 전투 로직을 진행할 수 없는 상태인지 여부
|
||||
/// </summary>
|
||||
public bool IsBehaviorSuppressed => abnormalityManager != null && abnormalityManager.IsStunned;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 보스 패턴 페이즈
|
||||
/// </summary>
|
||||
public int CurrentPatternPhase
|
||||
{
|
||||
get
|
||||
{
|
||||
float healthRatio = bossEnemy != null && bossEnemy.MaxHealth > 0f
|
||||
? bossEnemy.CurrentHealth / bossEnemy.MaxHealth
|
||||
: 1f;
|
||||
|
||||
if (healthRatio <= phase3HealthThreshold)
|
||||
return 3;
|
||||
|
||||
if (healthRatio <= phase2HealthThreshold)
|
||||
return 2;
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
ResolveReferences();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
ResolveReferences();
|
||||
|
||||
if (!IsServer)
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
ResolveReferences();
|
||||
if (bossEnemy == null || enemyBase == null || skillController == null)
|
||||
return;
|
||||
|
||||
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
|
||||
return;
|
||||
|
||||
if (IsBehaviorSuppressed)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!disableBehaviorGraph)
|
||||
return;
|
||||
|
||||
RefreshTargetIfNeeded();
|
||||
UpdateMovement();
|
||||
|
||||
if (skillController.IsPlayingAnimation)
|
||||
return;
|
||||
|
||||
// 1. 다운 추가타 (최우선 인터럽트, grace period 면제)
|
||||
if (TryStartPunishPattern())
|
||||
return;
|
||||
|
||||
// 2. 조합 패턴 (Phase 3, 드물게)
|
||||
if (TryStartComboPattern())
|
||||
return;
|
||||
|
||||
// 4. 기동 패턴 (거리 기반 조건부)
|
||||
if (TryStartMobilityPattern())
|
||||
return;
|
||||
|
||||
// 5. 원거리 견제 (보조)
|
||||
if (TryStartUtilityPattern())
|
||||
return;
|
||||
|
||||
// 6. 기본 루프
|
||||
TryStartPrimaryLoopPattern();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 근접 패턴 사용 카운터를 갱신합니다.
|
||||
/// </summary>
|
||||
public void RegisterPatternUse(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null)
|
||||
return;
|
||||
|
||||
if (pattern.IsMelee)
|
||||
{
|
||||
meleePatternCounter++;
|
||||
basicLoopCountSinceLastBigPattern++;
|
||||
}
|
||||
|
||||
if (pattern.Category == PatternCategory.Punish || pattern.IsBigPattern)
|
||||
{
|
||||
basicLoopCountSinceLastBigPattern = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 살아 있는 적대 대상인지 확인합니다.
|
||||
/// </summary>
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기동 패턴 대상으로 유효한지 확인합니다.
|
||||
/// </summary>
|
||||
public bool IsValidMobilityTarget(GameObject candidate)
|
||||
{
|
||||
if (!IsValidHostileTarget(candidate))
|
||||
return false;
|
||||
|
||||
float maxDistance = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
|
||||
float distance = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
return distance >= mobilityTriggerDistance && distance <= maxDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기동 패턴 대상으로 사용할 수 있는 가장 먼 유효 타겟을 찾습니다.
|
||||
/// </summary>
|
||||
public GameObject FindMobilityTarget()
|
||||
{
|
||||
GameObject[] candidates = GameObject.FindGameObjectsWithTag("Player");
|
||||
GameObject farthestTarget = null;
|
||||
float bestDistance = mobilityTriggerDistance;
|
||||
float maxDistance = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
|
||||
|
||||
for (int i = 0; i < candidates.Length; i++)
|
||||
{
|
||||
GameObject candidate = candidates[i];
|
||||
if (!IsValidMobilityTarget(candidate))
|
||||
continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
if (distance > maxDistance || distance <= bestDistance)
|
||||
continue;
|
||||
|
||||
bestDistance = distance;
|
||||
farthestTarget = candidate;
|
||||
}
|
||||
|
||||
return farthestTarget;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 원거리 견제 패턴 대상으로 유효한지 확인합니다.
|
||||
/// </summary>
|
||||
public bool IsValidUtilityTarget(GameObject candidate)
|
||||
{
|
||||
if (!IsValidHostileTarget(candidate))
|
||||
return false;
|
||||
|
||||
if (candidate == ResolvePrimaryTarget())
|
||||
return false;
|
||||
|
||||
float maxDistance = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
|
||||
float distance = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
return distance >= utilityTriggerDistance && distance <= maxDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 주 대상이 아닌 원거리 견제 대상을 찾습니다.
|
||||
/// </summary>
|
||||
public GameObject FindUtilityTarget()
|
||||
{
|
||||
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
List<GameObject> validTargets = new List<GameObject>();
|
||||
GameObject primaryTarget = ResolvePrimaryTarget();
|
||||
|
||||
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 (!IsValidUtilityTarget(candidate))
|
||||
continue;
|
||||
|
||||
validTargets.Add(candidate);
|
||||
}
|
||||
|
||||
if (validTargets.Count == 0)
|
||||
{
|
||||
if (IsValidHostileTarget(primaryTarget))
|
||||
{
|
||||
float maxDistance = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
|
||||
float distance = Vector3.Distance(transform.position, primaryTarget.transform.position);
|
||||
if (distance >= utilityTriggerDistance && distance <= maxDistance)
|
||||
return primaryTarget;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int randomIndex = UnityEngine.Random.Range(0, validTargets.Count);
|
||||
return validTargets[randomIndex];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 가장 가까운 생존 플레이어를 찾습니다.
|
||||
/// </summary>
|
||||
public GameObject FindNearestLivingTarget()
|
||||
{
|
||||
PlayerNetworkController[] players = 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 = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
if (distance > aggroRange || distance >= nearestDistance)
|
||||
continue;
|
||||
|
||||
nearestDistance = distance;
|
||||
nearestTarget = candidate;
|
||||
}
|
||||
|
||||
return nearestTarget;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로그를 출력합니다.
|
||||
/// </summary>
|
||||
public void LogDebug(string source, string message)
|
||||
{
|
||||
if (debugMode)
|
||||
Debug.Log($"[{source}] {message}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 패턴 슬롯에 포함된 고유 스킬 목록을 반환합니다. 디버그 용도로 사용됩니다.
|
||||
/// </summary>
|
||||
public List<SkillData> GetAllPatternSkills()
|
||||
{
|
||||
HashSet<SkillData> skillSet = new HashSet<SkillData>();
|
||||
BossPatternData[] allPatterns = { primaryPattern, mobilityPattern, utilityPattern, comboPattern, punishPattern, signaturePattern };
|
||||
for (int i = 0; i < allPatterns.Length; i++)
|
||||
{
|
||||
BossPatternData pattern = allPatterns[i];
|
||||
if (pattern?.Steps == null)
|
||||
continue;
|
||||
for (int j = 0; j < pattern.Steps.Count; j++)
|
||||
{
|
||||
PatternStep step = pattern.Steps[j];
|
||||
if (step.Skill != null)
|
||||
skillSet.Add(step.Skill);
|
||||
}
|
||||
}
|
||||
return new List<SkillData>(skillSet);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 지정 패턴이 grace period를 통과했는지 반환합니다.
|
||||
/// Punish/Melee/Utility는 항상 허용됩니다.
|
||||
/// </summary>
|
||||
public bool IsPatternGracePeriodAllowed(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null)
|
||||
return false;
|
||||
|
||||
if (pattern.Category == PatternCategory.Punish)
|
||||
return true;
|
||||
|
||||
if (pattern.IsMelee || pattern.TargetMode == TargetResolveMode.Utility)
|
||||
return true;
|
||||
|
||||
return basicLoopCountSinceLastBigPattern >= basicLoopMinCountAfterBigPattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 조합 패턴 사용 가능 여부를 반환합니다.
|
||||
/// </summary>
|
||||
public bool IsComboPatternReady()
|
||||
{
|
||||
if (!IsServer || bossEnemy == null || skillController == null)
|
||||
return false;
|
||||
|
||||
if (IsBehaviorSuppressed)
|
||||
return false;
|
||||
|
||||
if (activePatternCoroutine != null)
|
||||
return false;
|
||||
|
||||
if (bossEnemy.IsDead || bossEnemy.IsTransitioning || skillController.IsPlayingAnimation)
|
||||
return false;
|
||||
|
||||
if (!IsPatternGracePeriodAllowed(comboPattern))
|
||||
return false;
|
||||
|
||||
return IsPatternReady(comboPattern);
|
||||
}
|
||||
|
||||
protected virtual bool TryStartPrimaryLoopPattern()
|
||||
{
|
||||
if (currentTarget == null)
|
||||
return false;
|
||||
|
||||
float distanceToTarget = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
float attackRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
|
||||
if (distanceToTarget > attackRange + 0.25f)
|
||||
return false;
|
||||
|
||||
BossPatternData selectedPattern = SelectPrimaryLoopPattern();
|
||||
if (selectedPattern == null)
|
||||
return false;
|
||||
|
||||
StartPattern(selectedPattern, currentTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected virtual bool TryStartMobilityPattern()
|
||||
{
|
||||
BossPatternData pattern = mobilityPattern;
|
||||
if (!IsPatternReady(pattern))
|
||||
return false;
|
||||
|
||||
GameObject target = FindMobilityTarget();
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
currentTarget = target;
|
||||
StartPattern(pattern, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected virtual bool TryStartUtilityPattern()
|
||||
{
|
||||
BossPatternData pattern = utilityPattern;
|
||||
if (!IsPatternReady(pattern))
|
||||
return false;
|
||||
|
||||
GameObject target = FindUtilityTarget();
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
currentTarget = target;
|
||||
StartPattern(pattern, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다운 대상이 존재하면 징벌 패턴을 발동합니다.
|
||||
/// </summary>
|
||||
protected virtual bool TryStartPunishPattern()
|
||||
{
|
||||
BossPatternData pattern = punishPattern;
|
||||
if (!IsPatternReady(pattern))
|
||||
return false;
|
||||
|
||||
HitReactionController[] hitReactionControllers = FindObjectsByType<HitReactionController>(FindObjectsSortMode.None);
|
||||
GameObject nearestDownedTarget = null;
|
||||
float nearestDistance = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < hitReactionControllers.Length; i++)
|
||||
{
|
||||
HitReactionController controller = hitReactionControllers[i];
|
||||
if (controller == null || !controller.IsDowned)
|
||||
continue;
|
||||
|
||||
GameObject candidate = controller.gameObject;
|
||||
if (candidate == null || !candidate.activeInHierarchy)
|
||||
continue;
|
||||
|
||||
if (Team.IsSameTeam(gameObject, candidate))
|
||||
continue;
|
||||
|
||||
IDamageable damageable = candidate.GetComponent<IDamageable>();
|
||||
if (damageable != null && damageable.IsDead)
|
||||
continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
if (distance > punishSearchRadius || distance >= nearestDistance)
|
||||
continue;
|
||||
|
||||
nearestDistance = distance;
|
||||
nearestDownedTarget = candidate;
|
||||
}
|
||||
|
||||
if (nearestDownedTarget == null)
|
||||
return false;
|
||||
|
||||
currentTarget = nearestDownedTarget;
|
||||
StartPattern(pattern, nearestDownedTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 3 조합 패턴을 발동합니다.
|
||||
/// </summary>
|
||||
protected virtual bool TryStartComboPattern()
|
||||
{
|
||||
if (!IsComboPatternReady())
|
||||
return false;
|
||||
|
||||
currentTarget = ResolvePrimaryTarget();
|
||||
StartPattern(comboPattern, currentTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected virtual BossPatternData SelectPrimaryLoopPattern()
|
||||
{
|
||||
if (!IsPatternReady(primaryPattern))
|
||||
return null;
|
||||
|
||||
meleePatternCounter++;
|
||||
return primaryPattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기본 패턴을 선택하고 카운터를 갱신합니다.
|
||||
/// </summary>
|
||||
public BossPatternData SelectAndRegisterBasicLoopPattern()
|
||||
{
|
||||
if (!IsPatternReady(primaryPattern))
|
||||
return null;
|
||||
|
||||
RegisterPatternUse(primaryPattern);
|
||||
return primaryPattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기본 패턴이 사용 가능한지 확인합니다.
|
||||
/// 상태 변경 없이 순수 검사만 수행합니다.
|
||||
/// </summary>
|
||||
public bool IsBasicLoopReady()
|
||||
{
|
||||
return IsPatternReady(primaryPattern);
|
||||
}
|
||||
|
||||
protected virtual void StartPattern(BossPatternData pattern, GameObject target)
|
||||
{
|
||||
if (pattern == null || activePatternCoroutine != null)
|
||||
return;
|
||||
|
||||
currentTarget = target;
|
||||
LogDebug(GetType().Name, $"패턴 시작: {pattern.PatternName} / Target={(target != null ? target.name : "None")} / Phase={CurrentPatternPhase}");
|
||||
CombatBalanceTracker.RecordBossPattern(pattern.PatternName);
|
||||
activePatternCoroutine = StartCoroutine(RunPatternCoroutine(pattern, target));
|
||||
}
|
||||
|
||||
protected virtual IEnumerator RunPatternCoroutine(BossPatternData pattern, GameObject target)
|
||||
{
|
||||
StopMovement();
|
||||
bool completed = true;
|
||||
|
||||
for (int i = 0; i < pattern.Steps.Count; i++)
|
||||
{
|
||||
PatternStep step = pattern.Steps[i];
|
||||
if (step.Type == PatternStepType.Wait)
|
||||
{
|
||||
yield return new WaitForSeconds(step.Duration);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.Skill == null)
|
||||
{
|
||||
completed = false;
|
||||
Debug.LogWarning($"[{GetType().Name}] 패턴 스텝 스킬이 비어 있습니다. Pattern={pattern.PatternName}, Index={i}");
|
||||
break;
|
||||
}
|
||||
|
||||
if (step.Skill.JumpToTarget)
|
||||
{
|
||||
GameObject jumpTarget = FindMobilityTarget();
|
||||
if (jumpTarget == null)
|
||||
{
|
||||
LogDebug(GetType().Name, $"점프 대상 없음, 패턴 조기 종료: {pattern.PatternName}");
|
||||
break;
|
||||
}
|
||||
|
||||
target = jumpTarget;
|
||||
currentTarget = jumpTarget;
|
||||
enemyBase?.SetJumpTarget(jumpTarget.transform.position);
|
||||
}
|
||||
|
||||
if (!skillController.ExecuteSkill(step.Skill))
|
||||
{
|
||||
completed = false;
|
||||
LogDebug(GetType().Name, $"스킬 실행 실패: {step.Skill.SkillName}");
|
||||
break;
|
||||
}
|
||||
|
||||
yield return new WaitUntil(() => skillController == null || !skillController.IsPlayingAnimation || bossEnemy == null || bossEnemy.IsDead);
|
||||
if (bossEnemy == null || bossEnemy.IsDead)
|
||||
break;
|
||||
}
|
||||
|
||||
if (completed)
|
||||
{
|
||||
patternCooldownTracker[pattern] = Time.time + pattern.Cooldown;
|
||||
}
|
||||
|
||||
activePatternCoroutine = null;
|
||||
currentTarget = target;
|
||||
}
|
||||
|
||||
protected bool IsPatternReady(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
|
||||
return false;
|
||||
|
||||
if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime))
|
||||
return true;
|
||||
|
||||
return Time.time >= readyTime;
|
||||
}
|
||||
|
||||
protected virtual void RefreshTargetIfNeeded()
|
||||
{
|
||||
if (Time.time < nextTargetRefreshTime)
|
||||
return;
|
||||
|
||||
nextTargetRefreshTime = Time.time + primaryTargetRefreshInterval;
|
||||
GameObject highestThreatTarget = enemyBase.GetHighestThreatTarget(currentTarget, null, enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity);
|
||||
currentTarget = highestThreatTarget != null ? highestThreatTarget : FindNearestLivingTarget();
|
||||
}
|
||||
|
||||
protected virtual void UpdateMovement()
|
||||
{
|
||||
if (navMeshAgent == null || !navMeshAgent.enabled)
|
||||
return;
|
||||
|
||||
if (skillController != null && skillController.IsPlayingAnimation)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTarget == null)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
float attackRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
|
||||
float distanceToTarget = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
if (distanceToTarget <= attackRange)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
navMeshAgent.isStopped = false;
|
||||
navMeshAgent.stoppingDistance = attackRange;
|
||||
navMeshAgent.SetDestination(currentTarget.transform.position);
|
||||
}
|
||||
|
||||
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 override void OnNetworkDespawn()
|
||||
{
|
||||
base.OnNetworkDespawn();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Unity.Behavior;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 캐릭터. 페이즈 시스템과 동적 AI 전환을 지원합니다.
|
||||
/// Unity Behavior 패키지를 사용하여 Behavior Tree 기반 AI를 구현합니다.
|
||||
/// 보스 캐릭터입니다.
|
||||
/// </summary>
|
||||
|
||||
public class BossEnemy : EnemyBase
|
||||
{
|
||||
[Header("Boss Settings")]
|
||||
[Tooltip("보스 페이즈 데이터 목록 (순서대로 전환)")]
|
||||
[SerializeField] private List<BossPhaseData> phases = new();
|
||||
|
||||
[Tooltip("초기 Behavior Graph")]
|
||||
[SerializeField] private BehaviorGraph initialBehaviorGraph;
|
||||
|
||||
[Header("Phase Settings")]
|
||||
[Tooltip("페이즈 전환 시 무적 시간")]
|
||||
[Min(0f)] [SerializeField] private float phaseTransitionInvincibilityTime = 2f;
|
||||
|
||||
[Tooltip("페이즈 전환 연출 시간")]
|
||||
[Min(0f)] [SerializeField] private float phaseTransitionDuration = 3f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = true;
|
||||
|
||||
// 컴포넌트
|
||||
private BehaviorGraphAgent behaviorAgent;
|
||||
private BossCombatBehaviorContext combatBehaviorContext;
|
||||
|
||||
// 페이즈 상태
|
||||
private int currentPhaseIndex = 0;
|
||||
private bool isTransitioning = false;
|
||||
private float phaseStartTime;
|
||||
private float phaseElapsedTime;
|
||||
private bool isInvincible = false;
|
||||
|
||||
// 커스텀 조건 딕셔너리
|
||||
private Dictionary<string, bool> customConditions = new Dictionary<string, bool>();
|
||||
|
||||
// 이벤트
|
||||
public event System.Action<int> OnPhaseChanged; // phaseIndex
|
||||
public event System.Action<float> OnPhaseTransitionStart; // transitionDuration
|
||||
public event System.Action OnPhaseTransitionEnd;
|
||||
// 정적 이벤트 (UI 자동 연결용)
|
||||
/// <summary>
|
||||
/// 보스 스폰 시 발생하는 정적 이벤트
|
||||
@@ -60,13 +32,6 @@ namespace Colosseum.Enemy
|
||||
/// </summary>
|
||||
public static BossEnemy ActiveBoss { get; private set; }
|
||||
|
||||
// Properties
|
||||
public int CurrentPhaseIndex => currentPhaseIndex;
|
||||
public BossPhaseData CurrentPhase => phases.Count > currentPhaseIndex ? phases[currentPhaseIndex] : null;
|
||||
public int TotalPhases => phases.Count;
|
||||
public bool IsTransitioning => isTransitioning;
|
||||
public float PhaseElapsedTime => phaseElapsedTime;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
base.OnNetworkSpawn();
|
||||
@@ -78,15 +43,8 @@ namespace Colosseum.Enemy
|
||||
behaviorAgent = gameObject.AddComponent<BehaviorGraphAgent>();
|
||||
}
|
||||
|
||||
combatBehaviorContext = GetComponent<BossCombatBehaviorContext>();
|
||||
|
||||
// 초기 AI 설정
|
||||
if (IsServer && combatBehaviorContext != null && combatBehaviorContext.DisableBehaviorGraph)
|
||||
{
|
||||
behaviorAgent.enabled = false;
|
||||
behaviorAgent.Graph = null;
|
||||
}
|
||||
else if (IsServer && initialBehaviorGraph != null)
|
||||
if (IsServer && initialBehaviorGraph != null)
|
||||
{
|
||||
behaviorAgent.Graph = initialBehaviorGraph;
|
||||
}
|
||||
@@ -101,185 +59,12 @@ namespace Colosseum.Enemy
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected override void InitializeStats()
|
||||
{
|
||||
base.InitializeStats();
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
currentPhaseIndex = 0;
|
||||
isTransitioning = false;
|
||||
isInvincible = false;
|
||||
customConditions.Clear();
|
||||
}
|
||||
|
||||
protected override void OnServerUpdate()
|
||||
{
|
||||
if (isTransitioning) return;
|
||||
|
||||
phaseElapsedTime = Time.time - phaseStartTime;
|
||||
CheckPhaseTransition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 조건 확인
|
||||
/// </summary>
|
||||
private void CheckPhaseTransition()
|
||||
{
|
||||
int nextPhaseIndex = currentPhaseIndex + 1;
|
||||
if (nextPhaseIndex >= phases.Count)
|
||||
return;
|
||||
|
||||
BossPhaseData nextPhase = phases[nextPhaseIndex];
|
||||
if (nextPhase == null)
|
||||
return;
|
||||
|
||||
if (nextPhase.CheckTransitionCondition(this, phaseElapsedTime))
|
||||
{
|
||||
StartPhaseTransition(nextPhaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 시작
|
||||
/// </summary>
|
||||
private void StartPhaseTransition(int newPhaseIndex)
|
||||
{
|
||||
if (newPhaseIndex >= phases.Count || isTransitioning)
|
||||
return;
|
||||
|
||||
isTransitioning = true;
|
||||
isInvincible = true;
|
||||
|
||||
StartCoroutine(PhaseTransitionCoroutine(newPhaseIndex));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator PhaseTransitionCoroutine(int newPhaseIndex)
|
||||
{
|
||||
BossPhaseData newPhase = phases[newPhaseIndex];
|
||||
|
||||
// 전환 이벤트
|
||||
OnPhaseTransitionStart?.Invoke(phaseTransitionDuration);
|
||||
|
||||
// 전환 연출
|
||||
yield return PlayPhaseTransitionEffect(newPhase);
|
||||
|
||||
// AI 그래프 교체
|
||||
if (newPhase.BehaviorGraph != null && behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.End();
|
||||
behaviorAgent.Graph = newPhase.BehaviorGraph;
|
||||
}
|
||||
|
||||
// 페이즈 전환 완료
|
||||
currentPhaseIndex = newPhaseIndex;
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
|
||||
// 무적 해제
|
||||
yield return new WaitForSeconds(phaseTransitionInvincibilityTime);
|
||||
isInvincible = false;
|
||||
isTransitioning = false;
|
||||
|
||||
OnPhaseTransitionEnd?.Invoke();
|
||||
OnPhaseChanged?.Invoke(currentPhaseIndex);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[Boss] Phase transition: {currentPhaseIndex} ({newPhase.PhaseName})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 연출 재생
|
||||
/// </summary>
|
||||
private System.Collections.IEnumerator PlayPhaseTransitionEffect(BossPhaseData newPhase)
|
||||
{
|
||||
// 애니메이션 재생
|
||||
if (animator != null && newPhase.PhaseStartAnimation != null)
|
||||
{
|
||||
animator.Play(newPhase.PhaseStartAnimation.name);
|
||||
}
|
||||
|
||||
// 이펙트 생성
|
||||
if (newPhase.PhaseTransitionEffect != null)
|
||||
{
|
||||
var effect = Instantiate(newPhase.PhaseTransitionEffect, transform.position, transform.rotation);
|
||||
Destroy(effect, phaseTransitionDuration);
|
||||
}
|
||||
|
||||
// 전환 시간 대기
|
||||
yield return new WaitForSeconds(phaseTransitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (무적 상태 고려)
|
||||
/// </summary>
|
||||
public override float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (isInvincible)
|
||||
return 0f;
|
||||
|
||||
return base.TakeDamage(damage, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 설정
|
||||
/// </summary>
|
||||
public void SetCustomCondition(string conditionId, bool value)
|
||||
{
|
||||
customConditions[conditionId] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 확인
|
||||
/// </summary>
|
||||
public bool CheckCustomCondition(string conditionId)
|
||||
{
|
||||
return customConditions.TryGetValue(conditionId, out bool value) && value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 수동으로 페이즈 전환
|
||||
/// </summary>
|
||||
public void ForcePhaseTransition(int phaseIndex)
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
if (phaseIndex >= 0 && phaseIndex < phases.Count && phaseIndex != currentPhaseIndex)
|
||||
{
|
||||
StartPhaseTransition(phaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 페이즈 재시작
|
||||
/// </summary>
|
||||
public void RestartCurrentPhase()
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
|
||||
if (behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HandleDeath()
|
||||
{
|
||||
// 마지막 페이즈에서만 사망 처리
|
||||
if (currentPhaseIndex < phases.Count - 1 && !isTransitioning)
|
||||
{
|
||||
// 아직 페이즈가 남아있으면 강제로 다음 페이즈로
|
||||
StartPhaseTransition(currentPhaseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// AI 완전 중단 (순서 중요: enabled=false를 먼저 호출하여 Update() 차단)
|
||||
if (behaviorAgent != null)
|
||||
{
|
||||
@@ -291,30 +76,5 @@ namespace Colosseum.Enemy
|
||||
|
||||
base.HandleDeath();
|
||||
}
|
||||
|
||||
#region Debug
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!debugMode)
|
||||
return;
|
||||
|
||||
// 현재 페이즈 정보 표시
|
||||
#if UNITY_EDITOR
|
||||
if (phases != null && currentPhaseIndex < phases.Count)
|
||||
{
|
||||
var phase = phases[currentPhaseIndex];
|
||||
if (phase != null)
|
||||
{
|
||||
UnityEditor.Handles.Label(
|
||||
transform.position + Vector3.up * 3f,
|
||||
$"Phase {currentPhaseIndex + 1}/{phases.Count}\n{phase.PhaseName}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,13 @@ namespace Colosseum.Enemy
|
||||
/// </summary>
|
||||
public bool CheckTransitionCondition(BossEnemy boss, float elapsedTime)
|
||||
{
|
||||
BossBehaviorRuntimeState context = boss != null ? boss.GetComponent<BossBehaviorRuntimeState>() : null;
|
||||
|
||||
return transitionType switch
|
||||
{
|
||||
PhaseTransitionType.HealthPercent => boss.CurrentHealth / boss.MaxHealth <= healthPercentThreshold,
|
||||
PhaseTransitionType.TimeElapsed => elapsedTime >= timeThreshold,
|
||||
PhaseTransitionType.CustomCondition => boss.CheckCustomCondition(customConditionId),
|
||||
PhaseTransitionType.CustomCondition => context != null && context.CheckPhaseCustomCondition(customConditionId),
|
||||
PhaseTransitionType.Manual => false,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 드로그가 사용하는 보스 전투 컨텍스트 컴포넌트입니다.
|
||||
/// 현재는 공통 보스 전투 BT 프레임워크를 그대로 사용합니다.
|
||||
/// </summary>
|
||||
public class DrogPatternController : BossCombatBehaviorContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5b2d4ef2f1b4ee49b5f7f2c7175fd10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -286,6 +286,9 @@ namespace Colosseum.Enemy
|
||||
if (!IsServer || isDead.Value)
|
||||
return 0f;
|
||||
|
||||
if (ShouldIgnoreIncomingDamage(damage, source))
|
||||
return 0f;
|
||||
|
||||
float mitigatedDamage = ConsumeShield(damage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
@@ -305,6 +308,14 @@ namespace Colosseum.Enemy
|
||||
return actualDamage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 하위 클래스가 특정 상태에서 피해를 무시해야 할 때 사용합니다.
|
||||
/// </summary>
|
||||
protected virtual bool ShouldIgnoreIncomingDamage(float damage, object source)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 피드백 (애니메이션, 이펙트)
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user