fix: 드로그 패턴 애니메이션 재생 끊김 수정

- BT 재평가 중에도 패턴 실행 상태를 보존하도록 보스 패턴 액션과 런타임 상태를 조정했다.
- 스킬 컨트롤러에서 동일 프레임 종료 판정을 막아 패턴 내 다음 스킬이 즉시 잘리는 문제를 수정했다.
- 드로그 BT, 패턴/스킬 데이터, 애니메이션 클립과 컨트롤러를 현재 검증된 재생 구성으로 정리했다.
- 자연 발동 기준으로 콤보-기본기2 재생 시간을 재검증해 클립 길이와 실제 재생 간격이 맞는 것을 확인했다.
This commit is contained in:
2026-04-12 05:44:54 +09:00
parent 12a481b596
commit 9fd231626b
40 changed files with 598072 additions and 425361 deletions

View File

@@ -43,6 +43,12 @@ public abstract partial class BossPatternActionBase : Action
private ChargeStepData activeChargeData;
private bool chargeTelegraphApplied;
/// <summary>
/// 현재 액션 인스턴스가 진행 중인 패턴 실행 상태를 이미 보유하고 있는지 여부입니다.
/// BT 재평가 중 재진입할 때 기존 실행을 이어가기 위한 가드로 사용합니다.
/// </summary>
protected bool HasActivePatternExecutionState => activePattern != null;
/// <summary>
/// 액션 시작 시 실제로 실행할 패턴과 대상을 결정합니다.
/// </summary>
@@ -56,6 +62,10 @@ public abstract partial class BossPatternActionBase : Action
protected override Status OnStart()
{
ResolveReferences();
if (ShouldPreserveExecutionState())
return Status.Running;
ClearRuntimeState();
if (!IsReady())
@@ -153,6 +163,9 @@ public abstract partial class BossPatternActionBase : Action
protected override void OnEnd()
{
if (ShouldPreserveExecutionState())
return;
ClearRuntimeState();
}
@@ -389,6 +402,20 @@ public abstract partial class BossPatternActionBase : Action
waitEndTime = 0f;
}
/// <summary>
/// BT 관찰자 재평가로 노드가 다시 시작될 때 현재 패턴 실행 상태를 유지해야 하는지 판단합니다.
/// </summary>
private bool ShouldPreserveExecutionState()
{
if (!IsReady() || activePattern == null || runtimeState == null || !runtimeState.IsExecutingPattern)
return false;
if (runtimeState.IsBehaviorSuppressed || bossEnemy.IsDead)
return false;
return true;
}
private bool IsFirstSkillStep(int stepIndex)
{
if (activePattern == null || activePattern.Steps == null)

View File

@@ -34,6 +34,9 @@ namespace Colosseum.AI.BehaviorActions.Actions
protected override Status OnStart()
{
if (HasActivePatternExecutionState)
return base.OnStart();
if (!TrySelectPattern(out selectedPattern))
return Status.Failure;
@@ -44,7 +47,9 @@ namespace Colosseum.AI.BehaviorActions.Actions
protected override void OnEnd()
{
selectedPattern = null;
if (!HasActivePatternExecutionState)
selectedPattern = null;
base.OnEnd();
}

View File

@@ -45,6 +45,8 @@ namespace Colosseum.Enemy
[Header("Pattern Flow")]
[Tooltip("패턴 하나가 끝난 뒤 다음 패턴을 시작하기까지의 공통 텀")]
[Min(0f)] [SerializeField] protected float commonPatternInterval = 0.35f;
[Tooltip("패턴 종료 후 Idle 자세가 잠깐 안착할 수 있도록 추가로 확보하는 시간")]
[Min(0f)] [SerializeField] protected float postPatternIdleSettleDuration = 0.12f;
[Header("Phase State")]
[Tooltip("BT가 관리하는 최대 페이즈 수")]
@@ -62,6 +64,7 @@ namespace Colosseum.Enemy
protected int currentPatternPhase = 1;
protected float currentPhaseStartTime;
protected float nextPatternReadyTime;
protected float lastPatternCompletedTime = float.NegativeInfinity;
protected BossPatternExecutionResult lastPatternExecutionResult;
protected BossPatternData lastExecutedPattern;
protected BossPatternData activePattern;
@@ -100,6 +103,7 @@ namespace Colosseum.Enemy
/// 패턴 종료 후 다음 패턴 시작까지 남은 공통 텀입니다.
/// </summary>
public float RemainingPatternInterval => Mathf.Max(0f, nextPatternReadyTime - Time.time);
public float RemainingPatternIdleSettleTime => Mathf.Max(0f, (lastPatternCompletedTime + Mathf.Max(0f, postPatternIdleSettleDuration)) - Time.time);
/// <summary>
/// 마지막 패턴 실행 결과
@@ -239,6 +243,7 @@ namespace Colosseum.Enemy
activePattern = null;
currentPatternSkillStartsFromIdle = false;
currentPatternSkillReturnsToIdle = false;
lastPatternCompletedTime = Time.time;
if (pattern != null && IsTerminalPatternExecutionResult(result))
StartCommonPatternInterval();
@@ -338,6 +343,9 @@ namespace Colosseum.Enemy
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
return false;
if (Time.time < lastPatternCompletedTime + Mathf.Max(0f, postPatternIdleSettleDuration))
return false;
if (!IsCommonPatternIntervalReady())
return false;
@@ -407,6 +415,7 @@ namespace Colosseum.Enemy
currentPatternPhase = 1;
currentPhaseStartTime = Time.time;
nextPatternReadyTime = 0f;
lastPatternCompletedTime = float.NegativeInfinity;
lastPatternExecutionResult = BossPatternExecutionResult.None;
lastExecutedPattern = null;
lastReviveCaster = null;

View File

@@ -127,6 +127,10 @@ namespace Colosseum.Skills
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
private AnimatorOverrideController runtimeOverrideController;
private int cachedRecoveryStateHash;
private float currentClipStartTime = -1f;
private float currentClipExpectedDuration = 0f;
private string currentClipDebugName = string.Empty;
private int currentClipStartFrame = -1;
public bool IsExecutingSkill => currentSkill != null;
@@ -292,7 +296,13 @@ namespace Colosseum.Skills
// 애니메이션 종료 시 처리
if (stateInfo.normalizedTime >= 1f)
{
if (Time.frameCount <= currentClipStartFrame)
return;
// 같은 반복 차수 내에서 다음 클립이 있으면 재생
if (HasNextClipInSequence())
LogCurrentClipTiming("Advance");
if (TryPlayNextClipInSequence())
return;
@@ -308,6 +318,7 @@ namespace Colosseum.Skills
}
// 모든 클립과 단계가 끝나면 종료
LogCurrentClipTiming("Complete");
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseControllerIfNeeded();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
@@ -566,7 +577,8 @@ namespace Colosseum.Skills
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentPhaseAnimationClips[0], ShouldBlendIntoClip());
float enterTransitionDuration = ResolveSkillEnterTransitionDuration();
PlaySkillClip(currentPhaseAnimationClips[0], enterTransitionDuration > 0f, enterTransitionDuration);
}
TriggerImmediateSelfEffectsIfNeeded();
@@ -588,7 +600,7 @@ namespace Colosseum.Skills
return false;
currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex], blendIn: false);
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex], blendIn: false, transitionDuration: 0f);
if (debugMode)
{
@@ -598,6 +610,17 @@ namespace Colosseum.Skills
return true;
}
/// <summary>
/// 현재 시퀀스에 다음 클립이 남아 있는지 반환합니다.
/// </summary>
private bool HasNextClipInSequence()
{
if (currentSkill == null || currentPhaseAnimationClips == null)
return false;
return currentClipSequenceIndex + 1 < currentPhaseAnimationClips.Count;
}
/// <summary>
/// 반복 시전이 남아 있으면 다음 차수를 시작합니다.
/// </summary>
@@ -616,7 +639,7 @@ namespace Colosseum.Skills
/// <summary>
/// 스킬 클립으로 Override Controller 생성 후 재생
/// </summary>
private void PlaySkillClip(AnimationClip clip, bool blendIn)
private void PlaySkillClip(AnimationClip clip, bool blendIn, float transitionDuration)
{
if (baseSkillClip == null)
{
@@ -630,19 +653,21 @@ namespace Colosseum.Skills
return;
}
RecordCurrentClipTiming(clip);
if (debugMode)
{
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}");
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}, Blend={blendIn}, Transition={transitionDuration:F2}");
}
if (blendIn)
animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
animator.CrossFadeInFixedTime(GetSkillStateHash(), transitionDuration, 0, 0f);
else
animator.Play(GetSkillStateHash(), 0, 0f);
// 클라이언트에 클립 동기화
if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn);
PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn, transitionDuration);
}
/// <summary>
@@ -673,7 +698,7 @@ namespace Colosseum.Skills
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
/// </summary>
[Rpc(SendTo.NotServer)]
private void PlaySkillClipClientRpc(int clipIndex, bool blendIn)
private void PlaySkillClipClientRpc(int clipIndex, bool blendIn, float transitionDuration)
{
if (baseSkillClip == null || animator == null || baseController == null) return;
if (clipIndex < 0 || clipIndex >= registeredClips.Count || registeredClips[clipIndex] == null)
@@ -685,7 +710,7 @@ namespace Colosseum.Skills
if (!ApplyOverrideClip(registeredClips[clipIndex]))
return;
if (blendIn)
animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f);
animator.CrossFadeInFixedTime(GetSkillStateHash(), transitionDuration, 0, 0f);
else
animator.Play(GetSkillStateHash(), 0, 0f);
}
@@ -825,6 +850,7 @@ namespace Colosseum.Skills
lastCancelledSkillName = currentSkill.SkillName;
lastCancelReason = reason;
LogCurrentClipTiming("Cancelled");
Debug.Log($"[Skill] Cancelled: {currentSkill.SkillName} / reason={reason}");
RestoreBaseController();
@@ -1171,7 +1197,7 @@ namespace Colosseum.Skills
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentPhaseAnimationClips[0], blendIn: false);
PlaySkillClip(currentPhaseAnimationClips[0], blendIn: false, transitionDuration: 0f);
if (debugMode)
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
@@ -1265,6 +1291,10 @@ namespace Colosseum.Skills
currentIterationIndex = 0;
loopHoldRequested = false;
cachedRecoveryStateHash = 0;
currentClipStartTime = -1f;
currentClipExpectedDuration = 0f;
currentClipDebugName = string.Empty;
currentClipStartFrame = -1;
shouldBlendIntoCurrentSkill = true;
shouldRestoreToIdleAfterCurrentSkill = true;
}
@@ -1288,6 +1318,38 @@ namespace Colosseum.Skills
sustainController?.HandleSkillExecutionEnded();
}
/// <summary>
/// 현재 클립의 예상 재생 시간을 기록합니다.
/// </summary>
private void RecordCurrentClipTiming(AnimationClip clip)
{
if (clip == null)
return;
currentClipStartTime = Time.time;
currentClipExpectedDuration = clip.length / Mathf.Max(0.0001f, animator != null ? animator.speed : 1f);
currentClipDebugName = clip.name;
currentClipStartFrame = Time.frameCount;
if (!debugMode)
return;
Debug.Log($"[SkillTiming] Start: skill={currentSkill?.SkillName ?? "<null>"}, clip={currentClipDebugName}, t={currentClipStartTime:F3}, expected={currentClipExpectedDuration:F3}");
}
/// <summary>
/// 현재 클립의 실제 경과 시간을 로그로 남깁니다.
/// </summary>
private void LogCurrentClipTiming(string phase)
{
if (!debugMode || string.IsNullOrEmpty(currentClipDebugName) || currentClipStartTime < 0f)
return;
float elapsed = Time.time - currentClipStartTime;
float delta = elapsed - currentClipExpectedDuration;
Debug.Log($"[SkillTiming] {phase}: skill={currentSkill?.SkillName ?? "<null>"}, clip={currentClipDebugName}, t={Time.time:F3}, elapsed={elapsed:F3}, expected={currentClipExpectedDuration:F3}, delta={delta:F3}");
}
/// <summary>
/// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다.
/// </summary>
@@ -1460,29 +1522,46 @@ namespace Colosseum.Skills
/// <summary>
/// 현재 재생할 클립이 스킬 시작 블렌드 대상인지 반환합니다.
/// </summary>
private bool ShouldBlendIntoClip()
private float ResolveSkillEnterTransitionDuration()
{
if (isBossPatternBoundarySkill)
return false;
if (!shouldBlendIntoCurrentSkill)
return false;
return 0f;
if (isPlayingReleasePhase)
return false;
return 0f;
return currentClipSequenceIndex == 0 && currentIterationIndex == 1;
if (currentClipSequenceIndex != 0 || currentIterationIndex != 1)
return 0f;
if (!isBossPatternBoundarySkill)
return skillEnterTransitionDuration;
bool shouldBlendBossEntry = ShouldBlendBossPatternEntryFromCurrentState();
return shouldBlendBossEntry
? Mathf.Max(skillEnterTransitionDuration, bossPatternEnterTransitionDuration)
: 0f;
}
/// <summary>
/// 현재 스킬 진입에 사용할 전환 시간을 반환합니다.
/// 보스 패턴 첫 스킬이 이동 중 진입인지 판단합니다.
/// </summary>
private float GetSkillEnterTransitionDuration()
private bool ShouldBlendBossPatternEntryFromCurrentState()
{
if (isBossPatternBoundarySkill)
return bossPatternEnterTransitionDuration;
if (animator == null)
return false;
return skillEnterTransitionDuration;
if (animator.IsInTransition(0))
return true;
AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
int moveStateHash = Animator.StringToHash($"{BaseLayerName}.{MoveStateName}");
if (currentState.fullPathHash == moveStateHash)
return true;
UnityEngine.AI.NavMeshAgent agent = GetComponent<UnityEngine.AI.NavMeshAgent>();
return agent != null
&& agent.enabled
&& agent.velocity.sqrMagnitude > 0.0025f;
}
/// <summary>