feat: 드로그 공통 보스 BT 프레임워크 정리
- 보스 공통 전투 컨텍스트와 패턴 역할 기반 BT 액션을 추가 - 드로그 패턴 선택을 다운 추가타, 도약, 기본 및 보조 패턴 우선순위 브랜치로 이관 - BT_Drog authoring 그래프를 공통 구조에 맞게 재구성 - 드로그 전용 BT 헬퍼를 정리하고 공통 베이스 액션으로 통합 - 플레이 검증으로 도약, 기본 패턴, 내려찍기, 다운 추가타 루프를 확인
This commit is contained in:
520
Assets/_Game/Scripts/Enemy/BossCombatBehaviorContext.cs
Normal file
520
Assets/_Game/Scripts/Enemy/BossCombatBehaviorContext.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user