- BossBehaviorRuntimeState에 패턴 종료 후 공통 텀과 준비 판정을 추가\n- UsePatternAction이 런타임 패턴 실행 결과와 공통 텀을 함께 사용하도록 정리\n- 드로그 PlayMode 테스트에 패턴 종료 후 공통 간격 검증 케이스를 추가
530 lines
19 KiB
C#
530 lines
19 KiB
C#
using System.Collections.Generic;
|
|
|
|
using Colosseum.AI;
|
|
using Colosseum.Abnormalities;
|
|
using Colosseum.Player;
|
|
using Colosseum.Skills;
|
|
|
|
using Unity.Behavior;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
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
|
|
{
|
|
public static event System.Action<GameObject, GameObject> PlayerRevivedBySkill;
|
|
|
|
[Header("References")]
|
|
[SerializeField] protected BossEnemy bossEnemy;
|
|
[SerializeField] protected EnemyBase enemyBase;
|
|
[SerializeField] protected SkillController skillController;
|
|
[SerializeField] protected AbnormalityManager abnormalityManager;
|
|
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
|
|
[SerializeField] protected BehaviorGraphAgent behaviorGraphAgent;
|
|
|
|
[Header("Pattern Flow")]
|
|
[Tooltip("대형 패턴(시그니처/기동/조합) 직후 기본 패턴 최소 순환 횟수")]
|
|
[Min(0)] [SerializeField] protected int basicLoopMinCountAfterBigPattern = 2;
|
|
[Tooltip("패턴 하나가 끝난 뒤 다음 패턴을 시작하기까지의 공통 텀")]
|
|
[Min(0f)] [SerializeField] protected float commonPatternInterval = 0.35f;
|
|
|
|
[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 float nextPatternReadyTime;
|
|
protected BossPatternExecutionResult lastPatternExecutionResult;
|
|
protected BossPatternData lastExecutedPattern;
|
|
protected GameObject lastReviveCaster;
|
|
protected GameObject lastRevivedTarget;
|
|
protected float lastReviveEventTime = float.NegativeInfinity;
|
|
|
|
/// <summary>
|
|
/// 현재 전투 대상
|
|
/// </summary>
|
|
public GameObject CurrentTarget => currentTarget;
|
|
|
|
/// <summary>
|
|
/// BT가 관리하는 현재 페이즈
|
|
/// </summary>
|
|
public int CurrentPatternPhase => Mathf.Clamp(currentPatternPhase, 1, Mathf.Max(1, maxPatternPhase));
|
|
|
|
/// <summary>
|
|
/// BT가 관리하는 최대 페이즈 수
|
|
/// </summary>
|
|
public int MaxPatternPhase => Mathf.Max(1, maxPatternPhase);
|
|
|
|
/// <summary>
|
|
/// 현재 페이즈의 경과 시간
|
|
/// </summary>
|
|
public float PhaseElapsedTime => Time.time - currentPhaseStartTime;
|
|
|
|
/// <summary>
|
|
/// 마지막 대형 패턴 이후 누적된 기본 루프 횟수
|
|
/// </summary>
|
|
public int BasicLoopCountSinceLastBigPattern => basicLoopCountSinceLastBigPattern;
|
|
|
|
/// <summary>
|
|
/// 패턴 종료 후 다음 패턴 시작까지 남은 공통 텀입니다.
|
|
/// </summary>
|
|
public float RemainingPatternInterval => Mathf.Max(0f, nextPatternReadyTime - Time.time);
|
|
|
|
/// <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;
|
|
return;
|
|
}
|
|
|
|
PlayerRevivedBySkill += HandlePlayerRevivedBySkill;
|
|
}
|
|
|
|
protected virtual void Update()
|
|
{
|
|
if (!IsServer)
|
|
return;
|
|
|
|
ResolveReferences();
|
|
if (bossEnemy == null || enemyBase == null || skillController == null)
|
|
return;
|
|
|
|
if (bossEnemy.IsDead)
|
|
return;
|
|
|
|
if (IsBehaviorSuppressed)
|
|
StopMovement();
|
|
}
|
|
|
|
/// <summary>
|
|
/// BT가 선택한 현재 전투 대상을 동기화합니다.
|
|
/// </summary>
|
|
public void SetCurrentTarget(GameObject target)
|
|
{
|
|
currentTarget = target;
|
|
}
|
|
|
|
/// <summary>
|
|
/// BT가 현재 페이즈 값을 갱신합니다.
|
|
/// 필요하면 경과 시간 기준도 함께 초기화합니다.
|
|
/// </summary>
|
|
public void SetCurrentPatternPhase(int phase, bool resetTimer = true)
|
|
{
|
|
currentPatternPhase = Mathf.Clamp(phase, 1, MaxPatternPhase);
|
|
|
|
if (resetTimer)
|
|
currentPhaseStartTime = Time.time;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 페이즈 타이머를 다시 시작합니다.
|
|
/// </summary>
|
|
public void RestartCurrentPhaseTimer()
|
|
{
|
|
currentPhaseStartTime = Time.time;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 패턴 실행 시작을 기록합니다.
|
|
/// </summary>
|
|
public void BeginPatternExecution(BossPatternData pattern)
|
|
{
|
|
lastExecutedPattern = pattern;
|
|
lastPatternExecutionResult = BossPatternExecutionResult.Running;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 패턴 실행 결과를 기록합니다.
|
|
/// </summary>
|
|
public void CompletePatternExecution(BossPatternData pattern, BossPatternExecutionResult result)
|
|
{
|
|
lastExecutedPattern = pattern;
|
|
lastPatternExecutionResult = result;
|
|
|
|
if (pattern != null && IsTerminalPatternExecutionResult(result))
|
|
StartCommonPatternInterval();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 부활 스킬 사용 사실을 보스 AI에 알립니다.
|
|
/// </summary>
|
|
public static void ReportPlayerRevivedBySkill(GameObject caster, GameObject revivedTarget)
|
|
{
|
|
PlayerRevivedBySkill?.Invoke(caster, revivedTarget);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 최근 부활 트리거가 아직 유효한지 확인합니다.
|
|
/// </summary>
|
|
public bool HasRecentReviveTrigger(float maxAge)
|
|
{
|
|
return ResolveRecentReviveTriggerTarget(maxAge) != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 최근 부활 트리거에서 우선 공격할 대상을 반환합니다.
|
|
/// </summary>
|
|
public GameObject ResolveRecentReviveTriggerTarget(float maxAge, bool preferCaster = true, bool fallbackToRevivedTarget = true)
|
|
{
|
|
if (Time.time - lastReviveEventTime > Mathf.Max(0f, maxAge))
|
|
return null;
|
|
|
|
GameObject preferredTarget = preferCaster ? lastReviveCaster : lastRevivedTarget;
|
|
if (IsValidReviveTriggerTarget(preferredTarget))
|
|
return preferredTarget;
|
|
|
|
if (!fallbackToRevivedTarget)
|
|
return null;
|
|
|
|
GameObject fallbackTarget = preferCaster ? lastRevivedTarget : lastReviveCaster;
|
|
return IsValidReviveTriggerTarget(fallbackTarget) ? fallbackTarget : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 페이즈 커스텀 조건을 기록합니다.
|
|
/// </summary>
|
|
public void SetPhaseCustomCondition(string conditionId, bool value)
|
|
{
|
|
if (string.IsNullOrEmpty(conditionId))
|
|
return;
|
|
|
|
customPhaseConditions[conditionId] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 페이즈 커스텀 조건 값을 읽습니다.
|
|
/// </summary>
|
|
public bool CheckPhaseCustomCondition(string conditionId)
|
|
{
|
|
return !string.IsNullOrEmpty(conditionId)
|
|
&& customPhaseConditions.TryGetValue(conditionId, out bool value)
|
|
&& value;
|
|
}
|
|
|
|
/// <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 (!IsCommonPatternIntervalReady())
|
|
return false;
|
|
|
|
if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime))
|
|
return true;
|
|
|
|
return Time.time >= readyTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 패턴 쿨다운을 설정합니다. BT 노드(BossPatternActionBase)와 코드 폴백 모두에서 호출합니다.
|
|
/// </summary>
|
|
public void SetPatternCooldown(BossPatternData pattern)
|
|
{
|
|
if (pattern != null)
|
|
patternCooldownTracker[pattern] = Time.time + pattern.Cooldown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 공통 패턴 텀이 끝났는지 반환합니다.
|
|
/// </summary>
|
|
public bool IsCommonPatternIntervalReady()
|
|
{
|
|
return Time.time >= nextPatternReadyTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 시점부터 공통 패턴 텀을 다시 시작합니다.
|
|
/// </summary>
|
|
public void StartCommonPatternInterval()
|
|
{
|
|
nextPatternReadyTime = Time.time + Mathf.Max(0f, commonPatternInterval);
|
|
}
|
|
|
|
protected void StopMovement()
|
|
{
|
|
if (navMeshAgent == null || !navMeshAgent.enabled)
|
|
return;
|
|
|
|
navMeshAgent.isStopped = true;
|
|
navMeshAgent.ResetPath();
|
|
}
|
|
|
|
protected virtual void ResolveReferences()
|
|
{
|
|
if (bossEnemy == null)
|
|
bossEnemy = GetComponent<BossEnemy>();
|
|
|
|
if (enemyBase == null)
|
|
enemyBase = GetComponent<EnemyBase>();
|
|
|
|
if (skillController == null)
|
|
skillController = GetComponent<SkillController>();
|
|
|
|
if (abnormalityManager == null)
|
|
abnormalityManager = GetComponent<AbnormalityManager>();
|
|
|
|
if (navMeshAgent == null)
|
|
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
|
|
|
if (behaviorGraphAgent == null)
|
|
behaviorGraphAgent = GetComponent<BehaviorGraphAgent>();
|
|
}
|
|
|
|
public virtual void ResetPhaseState()
|
|
{
|
|
currentPatternPhase = 1;
|
|
currentPhaseStartTime = Time.time;
|
|
nextPatternReadyTime = 0f;
|
|
lastPatternExecutionResult = BossPatternExecutionResult.None;
|
|
lastExecutedPattern = null;
|
|
lastReviveCaster = null;
|
|
lastRevivedTarget = null;
|
|
lastReviveEventTime = float.NegativeInfinity;
|
|
customPhaseConditions.Clear();
|
|
}
|
|
|
|
private static bool IsTerminalPatternExecutionResult(BossPatternExecutionResult result)
|
|
{
|
|
return result == BossPatternExecutionResult.Succeeded
|
|
|| result == BossPatternExecutionResult.Failed
|
|
|| result == BossPatternExecutionResult.Cancelled;
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
if (IsServer)
|
|
PlayerRevivedBySkill -= HandlePlayerRevivedBySkill;
|
|
|
|
base.OnNetworkDespawn();
|
|
}
|
|
|
|
protected void HandlePlayerRevivedBySkill(GameObject caster, GameObject revivedTarget)
|
|
{
|
|
if (!IsServer)
|
|
return;
|
|
|
|
lastReviveCaster = caster;
|
|
lastRevivedTarget = revivedTarget;
|
|
lastReviveEventTime = Time.time;
|
|
|
|
LogDebug(nameof(BossBehaviorRuntimeState), $"부활 트리거 기록: 시전자={caster?.name ?? "없음"} / 대상={revivedTarget?.name ?? "없음"}");
|
|
}
|
|
|
|
protected bool IsValidReviveTriggerTarget(GameObject candidate)
|
|
{
|
|
if (candidate == null || !candidate.activeInHierarchy)
|
|
return false;
|
|
|
|
PlayerNetworkController player = candidate.GetComponent<PlayerNetworkController>();
|
|
return player != null && !player.IsDead;
|
|
}
|
|
|
|
private static bool HasAnimatorParameter(Animator animator, string parameterName, AnimatorControllerParameterType parameterType)
|
|
{
|
|
if (animator == null || string.IsNullOrEmpty(parameterName))
|
|
return false;
|
|
|
|
AnimatorControllerParameter[] parameters = animator.parameters;
|
|
for (int i = 0; i < parameters.Length; i++)
|
|
{
|
|
AnimatorControllerParameter parameter = parameters[i];
|
|
if (parameter.type == parameterType && parameter.name == parameterName)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|