feat: 드로그 공통 보스 BT 프레임워크 정리

- 보스 공통 전투 컨텍스트와 패턴 역할 기반 BT 액션을 추가
- 드로그 패턴 선택을 다운 추가타, 도약, 기본 및 보조 패턴 우선순위 브랜치로 이관
- BT_Drog authoring 그래프를 공통 구조에 맞게 재구성
- 드로그 전용 BT 헬퍼를 정리하고 공통 베이스 액션으로 통합
- 플레이 검증으로 도약, 기본 패턴, 내려찍기, 다운 추가타 루프를 확인
This commit is contained in:
2026-03-23 16:02:45 +09:00
parent 74ea3e57b8
commit 1fec139e81
65 changed files with 4514 additions and 2374 deletions

View File

@@ -0,0 +1,520 @@
using System.Collections;
using System.Collections.Generic;
using Colosseum.AI;
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 UnityEngine.AI.NavMeshAgent navMeshAgent;
[SerializeField] protected BehaviorGraphAgent behaviorGraphAgent;
[Header("Pattern Data")]
[Tooltip("기본 근접 압박 패턴")]
[FormerlySerializedAs("mainPattern")]
[SerializeField] protected BossPatternData primaryPattern;
[Tooltip("보조 근접 압박 패턴")]
[FormerlySerializedAs("slamPattern")]
[SerializeField] protected BossPatternData secondaryPattern;
[Tooltip("기동 또는 거리 징벌 패턴")]
[FormerlySerializedAs("leapPattern")]
[SerializeField] protected BossPatternData mobilityPattern;
[Tooltip("특정 상황에서 우선 발동하는 징벌 패턴")]
[FormerlySerializedAs("downPunishPattern")]
[SerializeField] protected BossPatternData punishPattern;
[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;
[Header("Pattern Cadence")]
[Tooltip("1페이즈에서 몇 번의 근접 패턴마다 보조 패턴을 섞을지")]
[FormerlySerializedAs("phase1SlamInterval")]
[Min(1)] [SerializeField] protected int phase1SecondaryInterval = 3;
[Tooltip("2페이즈에서 몇 번의 근접 패턴마다 보조 패턴을 섞을지")]
[FormerlySerializedAs("phase2SlamInterval")]
[Min(1)] [SerializeField] protected int phase2SecondaryInterval = 2;
[Tooltip("3페이즈에서 몇 번의 근접 패턴마다 보조 패턴을 섞을지")]
[FormerlySerializedAs("phase3SlamInterval")]
[Min(1)] [SerializeField] protected int phase3SecondaryInterval = 2;
[Header("Behavior")]
[Tooltip("전용 컨텍스트 사용 시 기존 BehaviorGraph를 비활성화할지 여부")]
[SerializeField] protected bool disableBehaviorGraph = true;
[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;
/// <summary>
/// 전용 컨텍스트 사용 시 BehaviorGraph를 비활성화할지 여부
/// </summary>
public bool DisableBehaviorGraph => disableBehaviorGraph;
/// <summary>
/// 기동 패턴을 고려하는 최소 거리
/// </summary>
public float MobilityTriggerDistance => mobilityTriggerDistance;
/// <summary>
/// 징벌 패턴을 고려하는 최대 반경
/// </summary>
public float PunishSearchRadius => punishSearchRadius;
/// <summary>
/// 디버그 로그 출력 여부
/// </summary>
public bool DebugModeEnabled => debugMode;
/// <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 (!disableBehaviorGraph)
return;
RefreshTargetIfNeeded();
UpdateMovement();
if (skillController.IsPlayingAnimation)
return;
if (TryStartMobilityPattern())
return;
TryStartPrimaryLoopPattern();
}
/// <summary>
/// 현재 역할의 패턴 데이터를 반환합니다.
/// </summary>
public BossPatternData GetPattern(BossCombatPatternRole role)
{
return role switch
{
BossCombatPatternRole.Primary => primaryPattern,
BossCombatPatternRole.Secondary => secondaryPattern,
BossCombatPatternRole.Mobility => mobilityPattern,
BossCombatPatternRole.Punish => punishPattern,
_ => null,
};
}
/// <summary>
/// 다음 근접 패턴 차례가 보조 패턴인지 여부
/// </summary>
public bool IsNextSecondaryPattern()
{
int secondaryInterval = GetSecondaryIntervalForPhase(CurrentPatternPhase);
if (secondaryInterval <= 1)
return true;
return (meleePatternCounter + 1) % secondaryInterval == 0;
}
/// <summary>
/// 현재 페이즈 기준의 보조 근접 패턴 주기를 반환합니다.
/// </summary>
public int GetSecondaryIntervalForPhase(int phase)
{
return phase switch
{
1 => Mathf.Max(1, phase1SecondaryInterval),
2 => Mathf.Max(1, phase2SecondaryInterval),
_ => Mathf.Max(1, phase3SecondaryInterval),
};
}
/// <summary>
/// 근접 패턴 사용 카운터를 갱신합니다.
/// </summary>
public void RegisterPatternUse(BossCombatPatternRole role)
{
if (!role.IsMeleeRole())
return;
meleePatternCounter++;
}
/// <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 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}");
}
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 = GetPattern(BossCombatPatternRole.Mobility);
if (!IsPatternReady(pattern))
return false;
GameObject target = FindMobilityTarget();
if (target == null)
return false;
currentTarget = target;
StartPattern(pattern, target);
return true;
}
protected virtual BossPatternData SelectPrimaryLoopPattern()
{
BossPatternData primary = GetPattern(BossCombatPatternRole.Primary);
BossPatternData secondary = GetPattern(BossCombatPatternRole.Secondary);
bool canUsePrimary = IsPatternReady(primary);
bool canUseSecondary = IsPatternReady(secondary);
if (canUseSecondary && IsNextSecondaryPattern())
{
meleePatternCounter++;
return secondary;
}
if (canUsePrimary)
{
meleePatternCounter++;
return primary;
}
if (canUseSecondary)
{
meleePatternCounter++;
return secondary;
}
return null;
}
protected virtual void StartPattern(BossPatternData pattern, GameObject target)
{
if (pattern == null || activePatternCoroutine != null)
return;
LogDebug(GetType().Name, $"패턴 시작: {pattern.PatternName} / Target={(target != null ? target.name : "None")} / Phase={CurrentPatternPhase}");
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 && target != null)
{
enemyBase?.SetJumpTarget(target.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 (navMeshAgent == null)
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
if (behaviorGraphAgent == null)
behaviorGraphAgent = GetComponent<BehaviorGraphAgent>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3568a8ab7f49c5242a8f7c4bc655b68d

View File

@@ -0,0 +1,27 @@
namespace Colosseum.Enemy
{
/// <summary>
/// 보스 전투 BT에서 사용하는 공통 패턴 역할 구분값입니다.
/// </summary>
public enum BossCombatPatternRole
{
Primary = 0,
Secondary = 1,
Mobility = 2,
Punish = 3,
}
/// <summary>
/// 공통 패턴 역할 보조 확장 메서드입니다.
/// </summary>
public static class BossCombatPatternRoleExtensions
{
/// <summary>
/// 현재 역할이 근접 순환 패턴인지 반환합니다.
/// </summary>
public static bool IsMeleeRole(this BossCombatPatternRole role)
{
return role == BossCombatPatternRole.Primary || role == BossCombatPatternRole.Secondary;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee34c0af35cdfbd45baf0a6b9dcc2dd9

View File

@@ -33,7 +33,7 @@ namespace Colosseum.Enemy
// 컴포넌트
private BehaviorGraphAgent behaviorAgent;
private DrogPatternController drogPatternController;
private BossCombatBehaviorContext combatBehaviorContext;
// 페이즈 상태
private int currentPhaseIndex = 0;
@@ -78,10 +78,10 @@ namespace Colosseum.Enemy
behaviorAgent = gameObject.AddComponent<BehaviorGraphAgent>();
}
drogPatternController = GetComponent<DrogPatternController>();
combatBehaviorContext = GetComponent<BossCombatBehaviorContext>();
// 초기 AI 설정
if (IsServer && drogPatternController != null && drogPatternController.DisableBehaviorGraph)
if (IsServer && combatBehaviorContext != null && combatBehaviorContext.DisableBehaviorGraph)
{
behaviorAgent.enabled = false;
behaviorAgent.Graph = null;

View File

@@ -1,408 +1,10 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
using Colosseum.AI;
using Colosseum.Player;
using Colosseum.Skills;
namespace Colosseum.Enemy
{
/// <summary>
/// 드로그 전용 패턴 선택 컨트롤러입니다.
/// 기본 루프, 도약, 다운 추가타 같은 고우선 패턴을 직접 선택합니다.
/// 드로그가 사용하는 보스 전투 컨텍스트 컴포넌트입니다.
/// 현재는 공통 보스 전투 BT 프레임워크를 그대로 사용합니다.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(BossEnemy))]
[RequireComponent(typeof(SkillController))]
public class DrogPatternController : NetworkBehaviour
public class DrogPatternController : BossCombatBehaviorContext
{
[Header("References")]
[SerializeField] private BossEnemy bossEnemy;
[SerializeField] private EnemyBase enemyBase;
[SerializeField] private SkillController skillController;
[SerializeField] private UnityEngine.AI.NavMeshAgent navMeshAgent;
[Header("Pattern Data")]
[Tooltip("기본 근접 압박 패턴")]
[SerializeField] private BossPatternData mainPattern;
[Tooltip("먼 대상 징벌용 도약 패턴")]
[SerializeField] private BossPatternData leapPattern;
[Tooltip("다운 대상이 있을 때 우선 발동하는 광역 추가타 패턴")]
[SerializeField] private BossPatternData downPunishPattern;
[Header("Phase Thresholds")]
[Tooltip("2페이즈 진입 체력 비율")]
[Range(0f, 1f)] [SerializeField] private float phase2HealthThreshold = 0.75f;
[Tooltip("3페이즈 진입 체력 비율")]
[Range(0f, 1f)] [SerializeField] private float phase3HealthThreshold = 0.4f;
[Header("Targeting")]
[Tooltip("타겟 재탐색 주기")]
[Min(0.05f)] [SerializeField] private float targetRefreshInterval = 0.2f;
[Tooltip("도약 패턴을 고려하기 시작하는 거리")]
[Min(0f)] [SerializeField] private float leapDistanceThreshold = 8f;
[Tooltip("다운 추가타를 고려할 최대 반경")]
[Min(0f)] [SerializeField] private float downPunishSearchRadius = 6f;
[Header("Behavior")]
[Tooltip("드로그 전용 컨트롤러 사용 시 기존 BehaviorGraph를 비활성화할지 여부")]
[SerializeField] private bool disableBehaviorGraph = true;
[Tooltip("디버그 로그 출력 여부")]
[SerializeField] private bool debugMode = false;
private readonly Dictionary<BossPatternData, float> patternCooldownTracker = new Dictionary<BossPatternData, float>();
private Coroutine activePatternCoroutine;
private GameObject currentTarget;
private float nextTargetRefreshTime;
/// <summary>
/// 드로그 컨트롤러 사용 시 BehaviorGraph를 비활성화할지 여부
/// </summary>
public bool DisableBehaviorGraph => disableBehaviorGraph;
/// <summary>
/// 현재 전용 패턴 실행 중인지 여부
/// </summary>
public bool IsExecutingPattern => activePatternCoroutine != null;
/// <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;
}
}
private void Awake()
{
ResolveReferences();
}
public override void OnNetworkSpawn()
{
ResolveReferences();
if (!IsServer)
{
enabled = false;
}
}
private void Update()
{
if (!IsServer)
return;
ResolveReferences();
if (bossEnemy == null || enemyBase == null || skillController == null)
return;
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
return;
RefreshTargetIfNeeded();
UpdateMovement();
if (activePatternCoroutine != null || skillController.IsPlayingAnimation)
return;
if (TryStartDownPunishPattern())
return;
TryStartMainPattern();
}
private bool TryStartDownPunishPattern()
{
if (!IsPatternReady(downPunishPattern))
return false;
GameObject downedTarget = FindNearestDownedTarget();
if (downedTarget == null)
return false;
StartPattern(downPunishPattern, downedTarget);
return true;
}
private bool TryStartMainPattern()
{
if (currentTarget == null)
return false;
float distanceToTarget = Vector3.Distance(transform.position, currentTarget.transform.position);
if (distanceToTarget >= leapDistanceThreshold && IsPatternReady(leapPattern))
{
StartPattern(leapPattern, currentTarget);
return true;
}
float attackRange = enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
if (distanceToTarget <= attackRange + 0.25f && IsPatternReady(mainPattern))
{
StartPattern(mainPattern, currentTarget);
return true;
}
return false;
}
private void StartPattern(BossPatternData pattern, GameObject target)
{
if (pattern == null || activePatternCoroutine != null)
return;
if (debugMode)
{
string targetName = target != null ? target.name : "None";
Debug.Log($"[DrogPattern] 패턴 시작: {pattern.PatternName} / Target={targetName} / Phase={CurrentPatternPhase}");
}
activePatternCoroutine = StartCoroutine(RunPatternCoroutine(pattern, target));
}
private IEnumerator RunPatternCoroutine(BossPatternData pattern, GameObject target)
{
StopMovement();
bool completed = true;
BossPatternData chainedPattern = null;
GameObject chainedTarget = null;
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($"[DrogPattern] 패턴 스텝 스킬이 비어 있습니다. Pattern={pattern.PatternName}, Index={i}");
break;
}
if (step.Skill.JumpToTarget && target != null)
{
enemyBase?.SetJumpTarget(target.transform.position);
}
if (!skillController.ExecuteSkill(step.Skill))
{
completed = false;
if (debugMode)
{
Debug.LogWarning($"[DrogPattern] 스킬 실행 실패: {step.Skill.SkillName}");
}
break;
}
yield return new WaitUntil(() => skillController == null || !skillController.IsPlayingAnimation || bossEnemy == null || bossEnemy.IsDead);
if (bossEnemy == null || bossEnemy.IsDead)
{
completed = false;
break;
}
if (pattern != downPunishPattern && TryPrepareDownPunishChain(out chainedTarget))
{
chainedPattern = downPunishPattern;
break;
}
}
if (completed)
{
patternCooldownTracker[pattern] = Time.time + pattern.Cooldown;
}
activePatternCoroutine = null;
if (chainedPattern != null && chainedTarget != null && bossEnemy != null && !bossEnemy.IsDead)
{
StartPattern(chainedPattern, chainedTarget);
}
}
private 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;
}
private void RefreshTargetIfNeeded()
{
if (Time.time < nextTargetRefreshTime)
return;
nextTargetRefreshTime = Time.time + targetRefreshInterval;
GameObject highestThreatTarget = enemyBase.GetHighestThreatTarget(currentTarget, null, enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity);
currentTarget = highestThreatTarget != null ? highestThreatTarget : FindNearestLivingPlayer();
}
private void UpdateMovement()
{
if (navMeshAgent == null || !navMeshAgent.enabled)
return;
if (activePatternCoroutine != null || (skillController != null && skillController.IsPlayingAnimation))
{
StopMovement();
return;
}
if (currentTarget == null)
{
StopMovement();
return;
}
float attackRange = 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.SetDestination(currentTarget.transform.position);
}
private void StopMovement()
{
if (navMeshAgent == null || !navMeshAgent.enabled)
return;
navMeshAgent.isStopped = true;
navMeshAgent.ResetPath();
}
private GameObject FindNearestDownedTarget()
{
HitReactionController[] hitReactionControllers = FindObjectsByType<HitReactionController>(FindObjectsSortMode.None);
GameObject bestTarget = null;
float bestDistance = 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 || Team.IsSameTeam(gameObject, candidate))
continue;
float distance = Vector3.Distance(transform.position, candidate.transform.position);
if (distance > downPunishSearchRadius || distance >= bestDistance)
continue;
bestDistance = distance;
bestTarget = candidate;
}
return bestTarget;
}
private bool TryPrepareDownPunishChain(out GameObject downedTarget)
{
downedTarget = null;
if (!IsPatternReady(downPunishPattern))
return false;
downedTarget = FindNearestDownedTarget();
if (downedTarget == null)
return false;
if (debugMode)
{
Debug.Log($"[DrogPattern] 다운 대상 감지, 다운 추가타 연계 준비: {downedTarget.name}");
}
return true;
}
private GameObject FindNearestLivingPlayer()
{
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
GameObject bestTarget = null;
float bestDistance = float.MaxValue;
float aggroRange = 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 (Team.IsSameTeam(gameObject, candidate))
continue;
float distance = Vector3.Distance(transform.position, candidate.transform.position);
if (distance > aggroRange || distance >= bestDistance)
continue;
bestDistance = distance;
bestTarget = candidate;
}
return bestTarget;
}
private void ResolveReferences()
{
if (bossEnemy == null)
bossEnemy = GetComponent<BossEnemy>();
if (enemyBase == null)
enemyBase = GetComponent<EnemyBase>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (navMeshAgent == null)
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
}
}
}