- BossCombatPatternRole enum 완전 제거, BossPatternData에 직접 필드 추가 - 14개 패턴별 Check*/Use*Action → CheckPatternReadyCondition + UsePatternByRoleAction으로 통합 - BT 계단식 Branch 체인 구조 도입 (BranchingConditionComposite + FloatingPort) - 패턴별 고유 전제 조건을 BT Condition으로 분리 - Punish: IsDownedTargetInRangeCondition (다운 대상 반경) - Mobility: IsTargetBeyondDistanceCondition (원거리 대상) - Utility: IsTargetBeyondDistanceCondition (원거리 대상) - Primary: IsTargetInAttackRangeCondition (사거리 이내) - Phase 진입 조건을 BT에서 확인 가능하도록 IsMinPhaseSatisfiedCondition 추가 - IsPatternReady()에서 minPhase 체크 분리 → 전용 Condition으로 노출 - Secondary 패턴 개념 제거 (secondaryPattern, 보조 차례, 교대 카운터 로직 전부 삭제) - CanResolvePatternTargetCondition 삭제 (7개 중 5개가 노이즈) - RebuildDrogBehaviorAuthoringGraph로 BT 에셋 자동 재구성 메뉴 제공
1184 lines
43 KiB
C#
1184 lines
43 KiB
C#
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("Signature Pattern")]
|
|
[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 bool isSignaturePatternActive;
|
|
protected bool isPreviewingSignatureTelegraph;
|
|
protected float signatureAccumulatedDamage;
|
|
protected float signatureRequiredDamage;
|
|
protected float signatureElapsedTime;
|
|
protected float signatureTotalDuration;
|
|
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 IsSignaturePatternActive => isSignaturePatternActive;
|
|
public string SignaturePatternName => isSignaturePatternActive && signaturePattern != null ? signaturePattern.PatternName : string.Empty;
|
|
public float SignatureAccumulatedDamage => signatureAccumulatedDamage;
|
|
public float SignatureRequiredDamage => signatureRequiredDamage;
|
|
public float SignatureBreakProgressNormalized => signatureRequiredDamage > 0f ? Mathf.Clamp01(signatureAccumulatedDamage / signatureRequiredDamage) : 0f;
|
|
public float SignatureElapsedTime => signatureElapsedTime;
|
|
public float SignatureTotalDuration => signatureTotalDuration;
|
|
public float SignatureCastProgressNormalized => signatureTotalDuration > 0f ? Mathf.Clamp01(signatureElapsedTime / signatureTotalDuration) : 0f;
|
|
public float SignatureRemainingTime => Mathf.Max(0f, signatureTotalDuration - signatureElapsedTime);
|
|
|
|
/// <summary>
|
|
/// 디버그 로그 출력 여부
|
|
/// </summary>
|
|
public bool DebugModeEnabled => debugMode;
|
|
|
|
/// <summary>
|
|
/// 기절 등으로 인해 보스 전투 로직을 진행할 수 없는 상태인지 여부
|
|
/// </summary>
|
|
public bool IsBehaviorSuppressed => isPreviewingSignatureTelegraph || (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 (TryStartSignaturePatternInLoop())
|
|
return;
|
|
|
|
// 3. 조합 패턴 (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 bool IsSignaturePatternReady()
|
|
{
|
|
if (!IsServer || bossEnemy == null || skillController == null)
|
|
return false;
|
|
|
|
if (IsBehaviorSuppressed)
|
|
return false;
|
|
|
|
if (activePatternCoroutine != null || isSignaturePatternActive)
|
|
return false;
|
|
|
|
if (bossEnemy.IsDead || bossEnemy.IsTransitioning || skillController.IsPlayingAnimation)
|
|
return false;
|
|
|
|
if (!IsPatternGracePeriodAllowed(signaturePattern))
|
|
return false;
|
|
|
|
return IsPatternReady(signaturePattern);
|
|
}
|
|
|
|
/// <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 || isSignaturePatternActive)
|
|
return false;
|
|
|
|
if (bossEnemy.IsDead || bossEnemy.IsTransitioning || skillController.IsPlayingAnimation)
|
|
return false;
|
|
|
|
if (!IsPatternGracePeriodAllowed(comboPattern))
|
|
return false;
|
|
|
|
return IsPatternReady(comboPattern);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시그니처 패턴을 시작합니다.
|
|
/// </summary>
|
|
public bool TryStartSignaturePattern(GameObject target)
|
|
{
|
|
if (!IsSignaturePatternReady())
|
|
return false;
|
|
|
|
GameObject resolvedTarget = IsValidHostileTarget(target) ? target : FindNearestLivingTarget();
|
|
currentTarget = resolvedTarget;
|
|
activePatternCoroutine = StartCoroutine(RunSignaturePatternCoroutine(signaturePattern, resolvedTarget));
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 디버그 또는 특수 연출에서 시그니처 패턴을 강제로 시작합니다.
|
|
/// </summary>
|
|
public bool ForceStartSignaturePattern(GameObject target = null)
|
|
{
|
|
if (!IsServer || signaturePattern == null || activePatternCoroutine != null || isSignaturePatternActive)
|
|
return false;
|
|
|
|
GameObject resolvedTarget = IsValidHostileTarget(target) ? target : ResolvePrimaryTarget();
|
|
activePatternCoroutine = StartCoroutine(RunSignaturePatternCoroutine(signaturePattern, resolvedTarget));
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 네트워크 상태와 무관하게 시그니처 전조 모션만 미리보기로 재생합니다.
|
|
/// 전조 연출 확인용 디버그 경로입니다.
|
|
/// </summary>
|
|
public bool PreviewSignatureTelegraph()
|
|
{
|
|
if (signaturePattern == null || skillController == null)
|
|
return false;
|
|
|
|
if (activePatternCoroutine != null || isSignaturePatternActive || isPreviewingSignatureTelegraph)
|
|
return false;
|
|
|
|
StartCoroutine(PreviewSignatureTelegraphCoroutine());
|
|
return true;
|
|
}
|
|
|
|
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>
|
|
/// 시그니처 패턴을 context 루프에서 발동합니다.
|
|
/// grace period와 Phase 제한을 적용합니다.
|
|
/// </summary>
|
|
protected virtual bool TryStartSignaturePatternInLoop()
|
|
{
|
|
if (!IsSignaturePatternReady())
|
|
return false;
|
|
|
|
if (!IsPatternGracePeriodAllowed(signaturePattern))
|
|
return false;
|
|
|
|
GameObject target = ResolvePrimaryTarget();
|
|
return TryStartSignaturePattern(target);
|
|
}
|
|
|
|
/// <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>();
|
|
|
|
if (enemyBase != null)
|
|
{
|
|
enemyBase.OnDamageTaken -= HandleBossDamageTaken;
|
|
enemyBase.OnDamageTaken += HandleBossDamageTaken;
|
|
}
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
if (enemyBase != null)
|
|
{
|
|
enemyBase.OnDamageTaken -= HandleBossDamageTaken;
|
|
}
|
|
|
|
base.OnNetworkDespawn();
|
|
}
|
|
|
|
private IEnumerator RunSignaturePatternCoroutine(BossPatternData pattern, GameObject target)
|
|
{
|
|
StopMovement();
|
|
|
|
isSignaturePatternActive = true;
|
|
signatureAccumulatedDamage = 0f;
|
|
signatureRequiredDamage = bossEnemy.MaxHealth * signatureRequiredDamageRatio;
|
|
signatureElapsedTime = 0f;
|
|
signatureTotalDuration = CalculatePatternDuration(pattern);
|
|
|
|
bool interrupted = false;
|
|
bool completed = true;
|
|
|
|
for (int i = 0; i < pattern.Steps.Count; i++)
|
|
{
|
|
if (HasMetSignatureBreakThreshold())
|
|
{
|
|
interrupted = true;
|
|
break;
|
|
}
|
|
|
|
PatternStep step = pattern.Steps[i];
|
|
if (step.Type == PatternStepType.Wait)
|
|
{
|
|
float remaining = step.Duration;
|
|
while (remaining > 0f)
|
|
{
|
|
if (HasMetSignatureBreakThreshold())
|
|
{
|
|
interrupted = true;
|
|
break;
|
|
}
|
|
|
|
if (bossEnemy == null || bossEnemy.IsDead)
|
|
{
|
|
completed = false;
|
|
break;
|
|
}
|
|
|
|
signatureElapsedTime += Time.deltaTime;
|
|
remaining -= Time.deltaTime;
|
|
yield return null;
|
|
}
|
|
|
|
if (interrupted || !completed)
|
|
break;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (step.Skill == null)
|
|
{
|
|
completed = false;
|
|
Debug.LogWarning($"[{GetType().Name}] 시그니처 패턴 스텝 스킬이 비어 있습니다. Pattern={pattern.PatternName}, Index={i}");
|
|
break;
|
|
}
|
|
|
|
if (!skillController.ExecuteSkill(step.Skill))
|
|
{
|
|
completed = false;
|
|
LogDebug(GetType().Name, $"시그니처 스킬 실행 실패: {step.Skill.SkillName}");
|
|
break;
|
|
}
|
|
|
|
while (skillController != null && skillController.IsPlayingAnimation)
|
|
{
|
|
if (HasMetSignatureBreakThreshold())
|
|
{
|
|
interrupted = true;
|
|
break;
|
|
}
|
|
|
|
if (bossEnemy == null || bossEnemy.IsDead)
|
|
{
|
|
completed = false;
|
|
break;
|
|
}
|
|
|
|
signatureElapsedTime += Time.deltaTime;
|
|
yield return null;
|
|
}
|
|
|
|
if (interrupted || !completed)
|
|
break;
|
|
}
|
|
|
|
if (interrupted)
|
|
{
|
|
skillController?.CancelSkill(SkillCancelReason.Interrupt);
|
|
UsePatternAction.MarkPatternUsed(gameObject, pattern);
|
|
LogDebug(GetType().Name, $"시그니처 차단 성공: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
|
|
CombatBalanceTracker.RecordBossEvent("집행 개시 차단 성공");
|
|
|
|
if (signatureSuccessStaggerDuration > 0f)
|
|
{
|
|
if (enemyBase != null && enemyBase.Animator != null &&
|
|
HasAnimatorParameter(enemyBase.Animator, "Hit", AnimatorControllerParameterType.Trigger))
|
|
{
|
|
enemyBase.Animator.SetTrigger("Hit");
|
|
}
|
|
|
|
float endTime = Time.time + signatureSuccessStaggerDuration;
|
|
while (Time.time < endTime && bossEnemy != null && !bossEnemy.IsDead)
|
|
{
|
|
StopMovement();
|
|
yield return null;
|
|
}
|
|
}
|
|
}
|
|
else if (completed)
|
|
{
|
|
UsePatternAction.MarkPatternUsed(gameObject, pattern);
|
|
LogDebug(GetType().Name, $"시그니처 실패: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
|
|
CombatBalanceTracker.RecordBossEvent("집행 개시 실패");
|
|
ExecuteSignatureFailure();
|
|
}
|
|
|
|
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
|
|
{
|
|
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
|
|
}
|
|
|
|
isSignaturePatternActive = false;
|
|
signatureAccumulatedDamage = 0f;
|
|
signatureRequiredDamage = 0f;
|
|
signatureElapsedTime = 0f;
|
|
signatureTotalDuration = 0f;
|
|
activePatternCoroutine = null;
|
|
}
|
|
|
|
private IEnumerator PreviewSignatureTelegraphCoroutine()
|
|
{
|
|
bool restoreBehaviorGraph = behaviorGraphAgent != null && behaviorGraphAgent.enabled;
|
|
isPreviewingSignatureTelegraph = true;
|
|
|
|
if (restoreBehaviorGraph)
|
|
{
|
|
behaviorGraphAgent.enabled = false;
|
|
}
|
|
|
|
StopMovement();
|
|
|
|
if (skillController != null && skillController.IsPlayingAnimation)
|
|
{
|
|
skillController.CancelSkill(SkillCancelReason.Interrupt);
|
|
yield return null;
|
|
}
|
|
|
|
bool executed = false;
|
|
for (int i = 0; i < signaturePattern.Steps.Count; i++)
|
|
{
|
|
PatternStep step = signaturePattern.Steps[i];
|
|
if (step == null || step.Type != PatternStepType.Skill || step.Skill == null)
|
|
continue;
|
|
|
|
executed = skillController.ExecuteSkill(step.Skill);
|
|
break;
|
|
}
|
|
|
|
if (executed)
|
|
{
|
|
while (skillController != null && skillController.IsPlayingAnimation)
|
|
{
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
|
|
{
|
|
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
|
|
}
|
|
|
|
if (restoreBehaviorGraph && behaviorGraphAgent != null)
|
|
{
|
|
behaviorGraphAgent.enabled = true;
|
|
}
|
|
|
|
isPreviewingSignatureTelegraph = false;
|
|
}
|
|
|
|
private static float CalculatePatternDuration(BossPatternData pattern)
|
|
{
|
|
if (pattern == null || pattern.Steps == null)
|
|
return 0f;
|
|
|
|
float totalDuration = 0f;
|
|
for (int i = 0; i < pattern.Steps.Count; i++)
|
|
{
|
|
PatternStep step = pattern.Steps[i];
|
|
if (step == null)
|
|
continue;
|
|
|
|
if (step.Type == PatternStepType.Wait)
|
|
{
|
|
totalDuration += Mathf.Max(0f, step.Duration);
|
|
continue;
|
|
}
|
|
|
|
if (step.Skill == null)
|
|
continue;
|
|
|
|
AnimationClip skillClip = step.Skill.SkillClip;
|
|
if (skillClip != null)
|
|
{
|
|
float animationSpeed = Mathf.Max(0.01f, step.Skill.AnimationSpeed);
|
|
totalDuration += skillClip.length / animationSpeed;
|
|
}
|
|
|
|
if (step.Skill.EndClip != null)
|
|
{
|
|
totalDuration += step.Skill.EndClip.length;
|
|
}
|
|
}
|
|
|
|
return totalDuration;
|
|
}
|
|
|
|
private void ExecuteSignatureFailure()
|
|
{
|
|
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
|
for (int i = 0; i < players.Length; i++)
|
|
{
|
|
PlayerNetworkController player = players[i];
|
|
if (player == null || player.IsDead || !player.gameObject.activeInHierarchy)
|
|
continue;
|
|
|
|
GameObject target = player.gameObject;
|
|
if (!IsValidHostileTarget(target))
|
|
continue;
|
|
|
|
player.TakeDamage(signatureFailureDamage, gameObject);
|
|
|
|
AbnormalityManager abnormalityManager = target.GetComponent<AbnormalityManager>();
|
|
if (abnormalityManager != null && signatureFailureAbnormality != null)
|
|
{
|
|
abnormalityManager.ApplyAbnormality(signatureFailureAbnormality, gameObject);
|
|
}
|
|
|
|
HitReactionController hitReactionController = target.GetComponent<HitReactionController>();
|
|
if (hitReactionController == null)
|
|
continue;
|
|
|
|
float distance = Vector3.Distance(transform.position, target.transform.position);
|
|
if (distance <= signatureFailureDownRadius)
|
|
{
|
|
hitReactionController.ApplyDown(signatureFailureDownDuration);
|
|
continue;
|
|
}
|
|
|
|
if (distance > signatureFailureKnockbackRadius)
|
|
continue;
|
|
|
|
Vector3 knockbackDirection = target.transform.position - transform.position;
|
|
knockbackDirection.y = 0f;
|
|
if (knockbackDirection.sqrMagnitude < 0.0001f)
|
|
{
|
|
knockbackDirection = transform.forward;
|
|
}
|
|
|
|
hitReactionController.ApplyKnockback(knockbackDirection.normalized * signatureFailureKnockbackSpeed, signatureFailureKnockbackDuration);
|
|
}
|
|
}
|
|
|
|
private bool HasMetSignatureBreakThreshold()
|
|
{
|
|
if (!isSignaturePatternActive)
|
|
return false;
|
|
|
|
if (signatureRequiredDamage <= 0f)
|
|
return true;
|
|
|
|
return signatureAccumulatedDamage >= signatureRequiredDamage;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private void HandleBossDamageTaken(float damage)
|
|
{
|
|
if (!IsServer || !isSignaturePatternActive || damage <= 0f)
|
|
return;
|
|
|
|
signatureAccumulatedDamage += damage;
|
|
}
|
|
}
|
|
}
|