- BT 재평가 중에도 패턴 실행 상태를 보존하도록 보스 패턴 액션과 런타임 상태를 조정했다. - 스킬 컨트롤러에서 동일 프레임 종료 판정을 막아 패턴 내 다음 스킬이 즉시 잘리는 문제를 수정했다. - 드로그 BT, 패턴/스킬 데이터, 애니메이션 클립과 컨트롤러를 현재 검증된 재생 구성으로 정리했다. - 자연 발동 기준으로 콤보-기본기2 재생 시간을 재검증해 클립 길이와 실제 재생 간격이 맞는 것을 확인했다.
1660 lines
63 KiB
C#
1660 lines
63 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
|
|
using UnityEngine;
|
|
using Unity.Netcode;
|
|
|
|
using Colosseum.Abnormalities;
|
|
using Colosseum.Combat;
|
|
using Colosseum.Player;
|
|
|
|
#if UNITY_EDITOR
|
|
using UnityEditor;
|
|
#endif
|
|
|
|
namespace Colosseum.Skills
|
|
{
|
|
/// <summary>
|
|
/// 스킬 강제 취소 이유
|
|
/// </summary>
|
|
public enum SkillCancelReason
|
|
{
|
|
None,
|
|
Manual,
|
|
Interrupt,
|
|
Death,
|
|
Stun,
|
|
Stagger,
|
|
HitReaction,
|
|
ResourceExhausted,
|
|
Respawn,
|
|
Revive,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 마지막 스킬 실행 결과입니다.
|
|
/// </summary>
|
|
public enum SkillExecutionResult
|
|
{
|
|
None,
|
|
Running,
|
|
Completed,
|
|
Cancelled,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 실행을 관리하는 컴포넌트.
|
|
/// 애니메이션 이벤트 기반으로 효과가 발동됩니다.
|
|
/// </summary>
|
|
public class SkillController : NetworkBehaviour
|
|
{
|
|
private const string SKILL_STATE_NAME = "Skill";
|
|
private const string BaseLayerName = "Base Layer";
|
|
private const string MoveStateName = "Move";
|
|
private const string IdleStateName = "Idle";
|
|
private const string BossIdlePhase1StateName = "Idle_Phase1";
|
|
private const string BossIdlePhase3StateName = "Idle_Phase3";
|
|
|
|
[Header("애니메이션")]
|
|
[SerializeField] private Animator animator;
|
|
[Tooltip("기본 Animator Controller (스킬 종료 후 복원용)")]
|
|
[SerializeField] private RuntimeAnimatorController baseController;
|
|
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")]
|
|
[SerializeField] private AnimationClip baseSkillClip;
|
|
|
|
[Header("애니메이션 전환")]
|
|
[Tooltip("스킬 상태 진입 시 사용할 고정 전환 시간(초)")]
|
|
[Min(0f)] [SerializeField] private float skillEnterTransitionDuration = 0.06f;
|
|
[Tooltip("보스 패턴 첫 스킬 진입 시 사용할 고정 전환 시간(초)")]
|
|
[Min(0f)] [SerializeField] private float bossPatternEnterTransitionDuration = 0.02f;
|
|
[Tooltip("스킬 종료 후 기본 상태 복귀 시 사용할 고정 전환 시간(초)")]
|
|
[Min(0f)] [SerializeField] private float skillExitTransitionDuration = 0.12f;
|
|
[Tooltip("보스 패턴 마지막 스킬 종료 후 Idle 복귀 시 사용할 고정 전환 시간(초)")]
|
|
[Min(0f)] [SerializeField] private float bossPatternExitTransitionDuration = 0.2f;
|
|
|
|
[Header("네트워크 동기화")]
|
|
[Tooltip("이 이름이 포함된 클립이 자동 등록됩니다. 서버→클라이언트 클립 동기화에 사용됩니다.")]
|
|
[SerializeField] private string clipAutoRegisterFilter = "_Player_";
|
|
[Tooltip("자동 등록된 클립 목록 (서버→클라이언트 클립 인덱스 동기화용)")]
|
|
[SerializeField] private List<AnimationClip> registeredClips = new();
|
|
|
|
[Header("설정")]
|
|
[SerializeField] private bool debugMode = false;
|
|
[Tooltip("공격 범위 시각화 (Scene 뷰에서 확인)")]
|
|
[SerializeField] private bool showAreaDebug = true;
|
|
[Tooltip("범위 표시 지속 시간")]
|
|
[Min(0.1f)] [SerializeField] private float debugDrawDuration = 1f;
|
|
|
|
[Header("디버그")]
|
|
[Tooltip("마지막으로 강제 취소된 스킬 이름")]
|
|
[SerializeField] private string lastCancelledSkillName = string.Empty;
|
|
[Tooltip("마지막 강제 취소 이유")]
|
|
[SerializeField] private SkillCancelReason lastCancelReason = SkillCancelReason.None;
|
|
[Tooltip("마지막 스킬 실행 결과")]
|
|
[SerializeField] private SkillExecutionResult lastExecutionResult = SkillExecutionResult.None;
|
|
|
|
// 현재 실행 중인 스킬
|
|
private SkillData currentSkill;
|
|
private SkillLoadoutEntry currentLoadoutEntry;
|
|
private readonly List<SkillEffect> currentCastStartEffects = new();
|
|
private readonly Dictionary<int, List<SkillEffect>> currentTriggeredEffects = new();
|
|
private readonly List<AbnormalityData> currentCastStartAbnormalities = new();
|
|
private readonly Dictionary<int, List<AbnormalityData>> currentTriggeredAbnormalities = new();
|
|
private readonly List<GameObject> currentTriggeredTargetsBuffer = new();
|
|
private int currentClipSequenceIndex; // 현재 재생 중인 클립 순서 (animationClips 내 인덱스)
|
|
private int currentRepeatCount = 1;
|
|
private int currentIterationIndex = 0;
|
|
private GameObject currentTargetOverride;
|
|
private Vector3? currentGroundTargetPosition;
|
|
private IReadOnlyList<AnimationClip> currentPhaseAnimationClips = Array.Empty<AnimationClip>();
|
|
private bool isPlayingReleasePhase = false;
|
|
|
|
// 반복 유지 단계 상태
|
|
private bool isLoopPhaseActive = false;
|
|
private float loopElapsedTime = 0f;
|
|
private float loopTickAccumulator = 0f;
|
|
private GameObject loopVfxInstance;
|
|
private readonly List<SkillEffect> currentLoopTickEffects = new();
|
|
private readonly List<SkillEffect> currentLoopExitEffects = new();
|
|
private readonly List<SkillEffect> currentReleaseStartEffects = new();
|
|
private bool loopHoldRequested = false;
|
|
private bool shouldBlendIntoCurrentSkill = true;
|
|
private bool shouldRestoreToIdleAfterCurrentSkill = true;
|
|
private bool isBossPatternBoundarySkill = false;
|
|
|
|
// 쿨타임 추적
|
|
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;
|
|
public bool IsPlayingAnimation => currentSkill != null;
|
|
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
|
|
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
|
|
public SkillData CurrentSkill => currentSkill;
|
|
public SkillLoadoutEntry CurrentLoadoutEntry => currentLoadoutEntry;
|
|
public Animator Animator => animator;
|
|
public SkillCancelReason LastCancelReason => lastCancelReason;
|
|
public string LastCancelledSkillName => lastCancelledSkillName;
|
|
public SkillExecutionResult LastExecutionResult => lastExecutionResult;
|
|
public GameObject CurrentTargetOverride => currentTargetOverride;
|
|
public bool IsChannelingActive => isLoopPhaseActive;
|
|
public bool IsLoopPhaseActive => isLoopPhaseActive;
|
|
|
|
private void Awake()
|
|
{
|
|
lastCancelledSkillName = string.Empty;
|
|
lastCancelReason = SkillCancelReason.None;
|
|
|
|
if (animator == null)
|
|
{
|
|
animator = GetComponentInChildren<Animator>();
|
|
}
|
|
|
|
// 기본 컨트롤러 저장
|
|
if (baseController == null && animator != null)
|
|
{
|
|
baseController = animator.runtimeAnimatorController;
|
|
}
|
|
|
|
EnsureRuntimeOverrideController();
|
|
}
|
|
|
|
#if UNITY_EDITOR
|
|
private void OnValidate()
|
|
{
|
|
AutoDiscoverBaseSkillClip();
|
|
AutoRegisterClips();
|
|
}
|
|
|
|
/// <summary>
|
|
/// baseController의 Skill 상태에 연결된 클립을 baseSkillClip으로 자동 발견합니다.
|
|
/// </summary>
|
|
private void AutoDiscoverBaseSkillClip()
|
|
{
|
|
if (baseSkillClip != null) return;
|
|
if (baseController == null) return;
|
|
|
|
var ac = baseController as UnityEditor.Animations.AnimatorController;
|
|
if (ac == null) return;
|
|
|
|
AnimationClip foundClip = FindClipInState(ac, SKILL_STATE_NAME);
|
|
if (foundClip != null)
|
|
{
|
|
baseSkillClip = foundClip;
|
|
UnityEditor.EditorUtility.SetDirty(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// AnimatorController의 지정한 상태에 연결된 AnimationClip을 찾습니다.
|
|
/// </summary>
|
|
private static AnimationClip FindClipInState(UnityEditor.Animations.AnimatorController ac, string stateName)
|
|
{
|
|
for (int i = 0; i < ac.layers.Length; i++)
|
|
{
|
|
AnimationClip clip = FindClipInStateMachine(ac.layers[i].stateMachine, stateName);
|
|
if (clip != null) return clip;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에서 클립을 찾습니다.
|
|
/// </summary>
|
|
private static AnimationClip FindClipInStateMachine(UnityEditor.Animations.AnimatorStateMachine sm, string stateName)
|
|
{
|
|
for (int i = 0; i < sm.states.Length; i++)
|
|
{
|
|
if (sm.states[i].state.name == stateName && sm.states[i].state.motion is AnimationClip clip)
|
|
return clip;
|
|
}
|
|
|
|
for (int i = 0; i < sm.stateMachines.Length; i++)
|
|
{
|
|
AnimationClip clip = FindClipInStateMachine(sm.stateMachines[i].stateMachine, stateName);
|
|
if (clip != null) return clip;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// clipAutoRegisterFilter 이름이 포함된 모든 AnimationClip을 registeredClips에 자동 등록합니다.
|
|
/// 서버→클라이언트 클립 동기화 인덱스의 일관성을 위해 이름순으로 정렬합니다.
|
|
/// </summary>
|
|
private void AutoRegisterClips()
|
|
{
|
|
string trimmedFilter = clipAutoRegisterFilter.Trim('_');
|
|
if (string.IsNullOrEmpty(trimmedFilter))
|
|
return;
|
|
|
|
string[] guids = AssetDatabase.FindAssets("t:AnimationClip", new[] { "Assets/_Game/Animations" });
|
|
var clips = new List<AnimationClip>();
|
|
|
|
foreach (string guid in guids)
|
|
{
|
|
string path = AssetDatabase.GUIDToAssetPath(guid);
|
|
string clipName = Path.GetFileNameWithoutExtension(path);
|
|
|
|
if (clipName.IndexOf(trimmedFilter, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
|
if (clip != null)
|
|
clips.Add(clip);
|
|
}
|
|
}
|
|
|
|
clips.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.Ordinal));
|
|
|
|
// 변경이 있는 경우만 갱신 (무한 루프 방지)
|
|
bool changed = registeredClips.Count != clips.Count;
|
|
if (!changed)
|
|
{
|
|
for (int i = 0; i < clips.Count; i++)
|
|
{
|
|
if (registeredClips[i] != clips[i])
|
|
{
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
registeredClips.Clear();
|
|
registeredClips.AddRange(clips);
|
|
Debug.Log($"[SkillController] 자동 등록: {clips.Count}개 클립 (필터: {clipAutoRegisterFilter})", this);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
|
|
private void Update()
|
|
{
|
|
if (currentSkill == null || animator == null) return;
|
|
|
|
UpdateCastTargetTracking();
|
|
|
|
// 반복 유지 단계 중일 때
|
|
if (isLoopPhaseActive)
|
|
{
|
|
UpdateLoopPhase();
|
|
return;
|
|
}
|
|
|
|
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
|
|
|
|
// 애니메이션 종료 시 처리
|
|
if (stateInfo.normalizedTime >= 1f)
|
|
{
|
|
if (Time.frameCount <= currentClipStartFrame)
|
|
return;
|
|
|
|
// 같은 반복 차수 내에서 다음 클립이 있으면 재생
|
|
if (HasNextClipInSequence())
|
|
LogCurrentClipTiming("Advance");
|
|
|
|
if (TryPlayNextClipInSequence())
|
|
return;
|
|
|
|
if (!isPlayingReleasePhase)
|
|
{
|
|
// 다음 반복 차수가 있으면 시작
|
|
if (TryStartNextIteration())
|
|
return;
|
|
|
|
// 반복 유지 단계가 있으면 시작
|
|
if (TryStartLoopPhase())
|
|
return;
|
|
}
|
|
|
|
// 모든 클립과 단계가 끝나면 종료
|
|
LogCurrentClipTiming("Complete");
|
|
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
|
|
RestoreBaseControllerIfNeeded();
|
|
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 시전
|
|
/// </summary>
|
|
public bool ExecuteSkill(SkillData skill)
|
|
{
|
|
return ExecuteSkill(SkillLoadoutEntry.CreateTemporary(skill));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 타겟 오버라이드와 함께 스킬 시전
|
|
/// </summary>
|
|
public bool ExecuteSkill(SkillData skill, GameObject targetOverride)
|
|
{
|
|
return ExecuteSkill(SkillLoadoutEntry.CreateTemporary(skill), targetOverride);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 슬롯 엔트리 기준으로 스킬 시전
|
|
/// </summary>
|
|
public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry)
|
|
{
|
|
currentTargetOverride = null;
|
|
return ExecuteSkillInternal(loadoutEntry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 타겟 오버라이드와 함께 스킬 시전.
|
|
/// SingleAlly 타입 효과에서 외부 타겟을 사용할 때 호출합니다.
|
|
/// </summary>
|
|
public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry, GameObject targetOverride)
|
|
{
|
|
currentTargetOverride = targetOverride;
|
|
return ExecuteSkillInternal(loadoutEntry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지면 타겟 위치와 함께 스킬 시전.
|
|
/// Ground Target 스킬에서 사용합니다.
|
|
/// </summary>
|
|
public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry, GameObject targetOverride, Vector3 groundTargetPosition)
|
|
{
|
|
currentTargetOverride = targetOverride;
|
|
currentGroundTargetPosition = groundTargetPosition;
|
|
return ExecuteSkillInternal(loadoutEntry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 시전 공통 로직
|
|
/// </summary>
|
|
private bool ExecuteSkillInternal(SkillLoadoutEntry loadoutEntry)
|
|
{
|
|
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null;
|
|
if (skill == null)
|
|
{
|
|
Debug.LogWarning("Skill is null!");
|
|
return false;
|
|
}
|
|
|
|
// 사망 상태면 스킬 사용 불가
|
|
var damageable = GetComponent<Colosseum.Combat.IDamageable>();
|
|
if (damageable != null && damageable.IsDead)
|
|
{
|
|
if (debugMode) Debug.Log($"[Skill] Cannot execute skill - owner is dead");
|
|
return false;
|
|
}
|
|
|
|
if (IsExecutingSkill)
|
|
{
|
|
if (debugMode) Debug.Log($"Already executing skill: {currentSkill.SkillName}");
|
|
return false;
|
|
}
|
|
|
|
if (IsOnCooldown(skill))
|
|
{
|
|
if (debugMode) Debug.Log($"Skill {skill.SkillName} is on cooldown");
|
|
return false;
|
|
}
|
|
|
|
if (skill.IsEvadeSkill)
|
|
{
|
|
HitReactionController hitReactionController = GetComponent<HitReactionController>();
|
|
hitReactionController?.TryConsumeDownRecoverableEvade();
|
|
}
|
|
|
|
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
|
|
currentSkill = skill;
|
|
lastCancelReason = SkillCancelReason.None;
|
|
lastExecutionResult = SkillExecutionResult.Running;
|
|
CacheRecoveryState();
|
|
ResolveSkillBoundaryTransitions();
|
|
BuildResolvedEffects(currentLoadoutEntry);
|
|
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
|
|
currentIterationIndex = 0;
|
|
loopHoldRequested = skill.RequiresLoopHold;
|
|
|
|
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
|
|
|
|
// 쿨타임 시작
|
|
StartCooldown(skill, currentLoadoutEntry.GetResolvedCooldown());
|
|
|
|
StartCurrentIteration();
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시전 시작 즉시 발동하는 효과를 실행합니다.
|
|
/// 서버 권한으로만 처리해 실제 게임플레이 효과가 한 번만 적용되게 합니다.
|
|
/// </summary>
|
|
private void TriggerCastStartEffects()
|
|
{
|
|
if (currentSkill == null)
|
|
return;
|
|
|
|
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
|
|
for (int i = 0; i < currentCastStartEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentCastStartEffects[i];
|
|
if (effect != null && effect.IsVisualOnly)
|
|
{
|
|
if (debugMode) Debug.Log($"[Skill] Cast start VFX: {effect.name} (index {i})");
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
// 게임플레이 효과는 서버에서만 실행
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
|
return;
|
|
|
|
for (int i = 0; i < currentCastStartEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentCastStartEffects[i];
|
|
if (effect == null || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
if (debugMode) Debug.Log($"[Skill] Cast start effect: {effect.name} (index {i})");
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
|
|
if (currentCastStartAbnormalities.Count <= 0)
|
|
return;
|
|
|
|
AbnormalityManager abnormalityManager = GetComponent<AbnormalityManager>();
|
|
if (abnormalityManager == null)
|
|
{
|
|
if (debugMode) Debug.LogWarning("[Skill] Cast start abnormality skipped - no AbnormalityManager");
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < currentCastStartAbnormalities.Count; i++)
|
|
{
|
|
AbnormalityData abnormality = currentCastStartAbnormalities[i];
|
|
if (abnormality == null)
|
|
continue;
|
|
|
|
if (debugMode) Debug.Log($"[Skill] Cast start abnormality: {abnormality.abnormalityName} (index {i})");
|
|
abnormalityManager.ApplyAbnormality(abnormality, gameObject);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트가 없는 자기 자신 대상 효과는 시전 즉시 발동합니다.
|
|
/// 버프/무적 같은 자기 강화 스킬이 이벤트 누락으로 동작하지 않는 상황을 막기 위한 보정입니다.
|
|
/// </summary>
|
|
private void TriggerImmediateSelfEffectsIfNeeded()
|
|
{
|
|
if (currentSkill == null || currentTriggeredEffects.Count == 0)
|
|
return;
|
|
|
|
if (currentSkill.SkillClip != null && currentSkill.SkillClip.events != null && currentSkill.SkillClip.events.Length > 0)
|
|
return;
|
|
|
|
if (!currentTriggeredEffects.TryGetValue(0, out List<SkillEffect> effectsAtZero))
|
|
return;
|
|
|
|
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
|
|
for (int i = 0; i < effectsAtZero.Count; i++)
|
|
{
|
|
SkillEffect effect = effectsAtZero[i];
|
|
if (effect != null && effect.IsVisualOnly && effect.TargetType == TargetType.Self)
|
|
{
|
|
if (debugMode) Debug.Log($"[Skill] Immediate self VFX: {effect.name} (index {i})");
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
// 게임플레이 효과는 서버에서만 실행
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
|
return;
|
|
|
|
for (int i = 0; i < effectsAtZero.Count; i++)
|
|
{
|
|
SkillEffect effect = effectsAtZero[i];
|
|
if (effect == null || effect.TargetType != TargetType.Self || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
if (debugMode) Debug.Log($"[Skill] Immediate self effect: {effect.name} (index {i})");
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 슬롯 엔트리 기준으로 시전 시작/트리거 효과를 합성합니다.
|
|
/// </summary>
|
|
private void BuildResolvedEffects(SkillLoadoutEntry loadoutEntry)
|
|
{
|
|
currentCastStartEffects.Clear();
|
|
currentTriggeredEffects.Clear();
|
|
currentCastStartAbnormalities.Clear();
|
|
currentTriggeredAbnormalities.Clear();
|
|
currentLoopTickEffects.Clear();
|
|
currentLoopExitEffects.Clear();
|
|
currentReleaseStartEffects.Clear();
|
|
|
|
if (loadoutEntry == null)
|
|
return;
|
|
|
|
loadoutEntry.CollectCastStartEffects(currentCastStartEffects);
|
|
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
|
|
loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities);
|
|
loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities);
|
|
loadoutEntry.CollectLoopTickEffects(currentLoopTickEffects);
|
|
loadoutEntry.CollectLoopExitEffects(currentLoopExitEffects);
|
|
loadoutEntry.CollectReleaseStartEffects(currentReleaseStartEffects);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 스킬의 반복 차수 하나를 시작합니다.
|
|
/// </summary>
|
|
private void StartCurrentIteration()
|
|
{
|
|
if (currentSkill == null)
|
|
return;
|
|
|
|
currentIterationIndex++;
|
|
currentClipSequenceIndex = 0;
|
|
isPlayingReleasePhase = false;
|
|
currentPhaseAnimationClips = currentSkill.AnimationClips;
|
|
|
|
if (debugMode && currentRepeatCount > 1)
|
|
{
|
|
Debug.Log($"[Skill] Iteration {currentIterationIndex}/{currentRepeatCount}: {currentSkill.SkillName}");
|
|
}
|
|
|
|
TriggerCastStartEffects();
|
|
|
|
if (currentPhaseAnimationClips.Count > 0 && animator != null)
|
|
{
|
|
float resolvedAnimationSpeed = currentLoadoutEntry != null
|
|
? currentLoadoutEntry.GetResolvedAnimationSpeed()
|
|
: currentSkill.AnimationSpeed;
|
|
animator.speed = resolvedAnimationSpeed;
|
|
float enterTransitionDuration = ResolveSkillEnterTransitionDuration();
|
|
PlaySkillClip(currentPhaseAnimationClips[0], enterTransitionDuration > 0f, enterTransitionDuration);
|
|
}
|
|
|
|
TriggerImmediateSelfEffectsIfNeeded();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
|
|
/// </summary>
|
|
private bool TryPlayNextClipInSequence()
|
|
{
|
|
if (currentSkill == null)
|
|
return false;
|
|
|
|
if (currentPhaseAnimationClips == null)
|
|
return false;
|
|
|
|
int nextIndex = currentClipSequenceIndex + 1;
|
|
if (nextIndex >= currentPhaseAnimationClips.Count)
|
|
return false;
|
|
|
|
currentClipSequenceIndex = nextIndex;
|
|
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex], blendIn: false, transitionDuration: 0f);
|
|
|
|
if (debugMode)
|
|
{
|
|
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentPhaseAnimationClips.Count}: {currentPhaseAnimationClips[currentClipSequenceIndex].name}");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 시퀀스에 다음 클립이 남아 있는지 반환합니다.
|
|
/// </summary>
|
|
private bool HasNextClipInSequence()
|
|
{
|
|
if (currentSkill == null || currentPhaseAnimationClips == null)
|
|
return false;
|
|
|
|
return currentClipSequenceIndex + 1 < currentPhaseAnimationClips.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 시전이 남아 있으면 다음 차수를 시작합니다.
|
|
/// </summary>
|
|
private bool TryStartNextIteration()
|
|
{
|
|
if (currentSkill == null)
|
|
return false;
|
|
|
|
if (currentIterationIndex >= currentRepeatCount)
|
|
return false;
|
|
|
|
StartCurrentIteration();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 클립으로 Override Controller 생성 후 재생
|
|
/// </summary>
|
|
private void PlaySkillClip(AnimationClip clip, bool blendIn, float transitionDuration)
|
|
{
|
|
if (baseSkillClip == null)
|
|
{
|
|
Debug.LogError("[SkillController] Base Skill Clip is not assigned!");
|
|
return;
|
|
}
|
|
|
|
if (!ApplyOverrideClip(clip))
|
|
{
|
|
Debug.LogError("[SkillController] Skill override clip 적용에 실패했습니다.");
|
|
return;
|
|
}
|
|
|
|
RecordCurrentClipTiming(clip);
|
|
|
|
if (debugMode)
|
|
{
|
|
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}, Blend={blendIn}, Transition={transitionDuration:F2}");
|
|
}
|
|
|
|
if (blendIn)
|
|
animator.CrossFadeInFixedTime(GetSkillStateHash(), transitionDuration, 0, 0f);
|
|
else
|
|
animator.Play(GetSkillStateHash(), 0, 0f);
|
|
|
|
// 클라이언트에 클립 동기화
|
|
if (IsServer && IsSpawned)
|
|
PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn, transitionDuration);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기본 컨트롤러로 복원
|
|
/// </summary>
|
|
private void RestoreBaseController()
|
|
{
|
|
int recoveryStateHash = ResolveRecoveryStateHash();
|
|
RestoreBaseAnimationState(recoveryStateHash);
|
|
|
|
// 클라이언트에 복원 동기화
|
|
if (IsServer && IsSpawned)
|
|
RestoreBaseControllerClientRpc(recoveryStateHash);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 스킬이 Idle 복귀가 필요한 경계 스킬일 때만 기본 상태 복귀를 수행합니다.
|
|
/// </summary>
|
|
private void RestoreBaseControllerIfNeeded()
|
|
{
|
|
if (!shouldRestoreToIdleAfterCurrentSkill)
|
|
return;
|
|
|
|
RestoreBaseController();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
|
|
/// </summary>
|
|
[Rpc(SendTo.NotServer)]
|
|
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)
|
|
{
|
|
if (debugMode) Debug.LogWarning($"[SkillController] Clip index {clipIndex} not found in registeredClips. Add it to sync to clients.");
|
|
return;
|
|
}
|
|
|
|
if (!ApplyOverrideClip(registeredClips[clipIndex]))
|
|
return;
|
|
if (blendIn)
|
|
animator.CrossFadeInFixedTime(GetSkillStateHash(), transitionDuration, 0, 0f);
|
|
else
|
|
animator.Play(GetSkillStateHash(), 0, 0f);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 클라이언트: 기본 컨트롤러 복원
|
|
/// </summary>
|
|
[Rpc(SendTo.NotServer)]
|
|
private void RestoreBaseControllerClientRpc(int recoveryStateHash)
|
|
{
|
|
RestoreBaseAnimationState(recoveryStateHash);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. Effect 리스트의 index번째 효과를 발동합니다.
|
|
/// Animation Event: Function = OnEffect, Int Parameter = effect index (0-based)
|
|
/// </summary>
|
|
public void OnEffect(int index)
|
|
{
|
|
if (currentSkill == null)
|
|
{
|
|
if (debugMode) Debug.LogWarning("[Effect] No skill executing");
|
|
return;
|
|
}
|
|
|
|
// 사망 상태면 효과 발동 중단
|
|
var damageable = GetComponent<Colosseum.Combat.IDamageable>();
|
|
if (damageable != null && damageable.IsDead)
|
|
{
|
|
if (debugMode) Debug.Log($"[Effect] Cancelled - owner is dead");
|
|
return;
|
|
}
|
|
|
|
if (!currentTriggeredEffects.TryGetValue(index, out List<SkillEffect> effects) || effects == null || effects.Count == 0)
|
|
{
|
|
if (debugMode) Debug.LogWarning($"[Effect] Invalid index: {index}");
|
|
return;
|
|
}
|
|
|
|
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
|
|
for (int i = 0; i < effects.Count; i++)
|
|
{
|
|
SkillEffect effect = effects[i];
|
|
if (effect != null && effect.IsVisualOnly)
|
|
{
|
|
if (debugMode) Debug.Log($"[Effect] VFX: {effect.name} (index {index})");
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
// 게임플레이 효과는 서버에서만 실행
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
|
|
|
|
for (int i = 0; i < effects.Count; i++)
|
|
{
|
|
SkillEffect effect = effects[i];
|
|
if (effect == null || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
if (debugMode) Debug.Log($"[Effect] {effect.name} (index {index})");
|
|
|
|
// 공격 범위 시각화
|
|
if (showAreaDebug)
|
|
{
|
|
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
|
|
}
|
|
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. 스킬 종료를 요청합니다.
|
|
/// 애니메이션은 끝까지 재생된 후 종료됩니다.
|
|
/// Animation Event: Function = OnSkillEnd
|
|
/// </summary>
|
|
public void OnSkillEnd()
|
|
{
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
|
|
|
|
if (currentSkill == null)
|
|
{
|
|
if (debugMode) Debug.LogWarning("[SkillEnd] No skill executing");
|
|
return;
|
|
}
|
|
|
|
if (debugMode) Debug.Log($"[Skill] End event received: {currentSkill.SkillName}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. 방어 상태를 시작합니다.
|
|
/// </summary>
|
|
public void OnDefenseStateEnter()
|
|
{
|
|
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
|
|
defenseController?.EnterDefenseState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. 방어 상태를 종료합니다.
|
|
/// </summary>
|
|
public void OnDefenseStateExit()
|
|
{
|
|
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
|
|
defenseController?.ExitDefenseState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 시작합니다.
|
|
/// </summary>
|
|
public void OnDefenseSustainEnter()
|
|
{
|
|
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
|
|
if (sustainController == null)
|
|
sustainController = gameObject.AddComponent<PlayerDefenseSustainController>();
|
|
|
|
sustainController.BeginSustain();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 종료합니다.
|
|
/// </summary>
|
|
public void OnDefenseSustainExit()
|
|
{
|
|
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
|
|
sustainController?.EndSustain();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 스킬을 강제 취소합니다.
|
|
/// </summary>
|
|
public bool CancelSkill(SkillCancelReason reason = SkillCancelReason.Manual)
|
|
{
|
|
if (currentSkill == null)
|
|
return false;
|
|
|
|
lastCancelledSkillName = currentSkill.SkillName;
|
|
lastCancelReason = reason;
|
|
|
|
LogCurrentClipTiming("Cancelled");
|
|
Debug.Log($"[Skill] Cancelled: {currentSkill.SkillName} / reason={reason}");
|
|
|
|
RestoreBaseController();
|
|
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 서버에서 현재 스킬 취소를 확정하고 클라이언트에 동기화합니다.
|
|
/// </summary>
|
|
public bool CancelSkillFromServer(SkillCancelReason reason)
|
|
{
|
|
bool cancelled = CancelSkill(reason);
|
|
if (!cancelled)
|
|
return false;
|
|
|
|
if (NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer)
|
|
{
|
|
SyncCancelledSkillClientRpc((int)reason);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
[Rpc(SendTo.NotServer)]
|
|
private void SyncCancelledSkillClientRpc(int reasonValue)
|
|
{
|
|
SkillCancelReason reason = System.Enum.IsDefined(typeof(SkillCancelReason), reasonValue)
|
|
? (SkillCancelReason)reasonValue
|
|
: SkillCancelReason.Manual;
|
|
|
|
CancelSkill(reason);
|
|
}
|
|
|
|
public bool IsOnCooldown(SkillData skill)
|
|
{
|
|
if (!cooldownTracker.ContainsKey(skill))
|
|
return false;
|
|
|
|
return Time.time < cooldownTracker[skill];
|
|
}
|
|
|
|
public float GetRemainingCooldown(SkillData skill)
|
|
{
|
|
if (!cooldownTracker.ContainsKey(skill))
|
|
return 0f;
|
|
|
|
float remaining = cooldownTracker[skill] - Time.time;
|
|
return Mathf.Max(0f, remaining);
|
|
}
|
|
|
|
private void StartCooldown(SkillData skill, float cooldownDuration)
|
|
{
|
|
cooldownTracker[skill] = Time.time + cooldownDuration;
|
|
}
|
|
|
|
public void ResetCooldown(SkillData skill)
|
|
{
|
|
cooldownTracker.Remove(skill);
|
|
}
|
|
|
|
public void ResetAllCooldowns()
|
|
{
|
|
cooldownTracker.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 단계를 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다.
|
|
/// </summary>
|
|
private bool TryStartLoopPhase()
|
|
{
|
|
if (currentSkill == null || !currentSkill.HasLoopPhase)
|
|
return false;
|
|
|
|
if (currentSkill.RequiresLoopHold && !loopHoldRequested)
|
|
{
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 진입 전 버튼 해제됨: {currentSkill.SkillName}");
|
|
|
|
if (TryStartReleasePhase())
|
|
return true;
|
|
|
|
RestoreBaseControllerIfNeeded();
|
|
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
|
|
return true;
|
|
}
|
|
|
|
isLoopPhaseActive = true;
|
|
loopElapsedTime = 0f;
|
|
loopTickAccumulator = 0f;
|
|
|
|
SpawnLoopVfx();
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 시작: {currentSkill.SkillName} (duration={currentSkill.LoopMaxDuration}s, tick={currentSkill.LoopTickInterval}s)");
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 VFX를 시전자 위치에 생성합니다.
|
|
/// </summary>
|
|
private void SpawnLoopVfx()
|
|
{
|
|
if (currentSkill == null || currentSkill.LoopVfxPrefab == null)
|
|
return;
|
|
|
|
Transform mount = ResolveLoopVfxMount();
|
|
Vector3 spawnPos = mount != null ? mount.position : transform.position;
|
|
|
|
loopVfxInstance = UnityEngine.Object.Instantiate(
|
|
currentSkill.LoopVfxPrefab,
|
|
spawnPos,
|
|
transform.rotation);
|
|
|
|
if (mount != null)
|
|
loopVfxInstance.transform.SetParent(mount);
|
|
|
|
loopVfxInstance.transform.localScale = new Vector3(
|
|
currentSkill.LoopVfxWidthScale,
|
|
currentSkill.LoopVfxWidthScale,
|
|
currentSkill.LoopVfxLengthScale);
|
|
|
|
// 모든 파티클을 루핑 모드로 설정
|
|
ForceLoopParticleSystems(loopVfxInstance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 하위 모든 ParticleSystem을 루핑 모드로 강제 설정하고 충돌을 비활성화합니다.
|
|
/// 채널링 종료 시 파괴되므로 자연 종료 및 충돌 반응 방지용.
|
|
/// </summary>
|
|
private static void ForceLoopParticleSystems(GameObject instance)
|
|
{
|
|
if (instance == null) return;
|
|
|
|
ParticleSystem[] particles = instance.GetComponentsInChildren<ParticleSystem>(true);
|
|
for (int i = 0; i < particles.Length; i++)
|
|
{
|
|
var main = particles[i].main;
|
|
main.loop = true;
|
|
main.stopAction = ParticleSystemStopAction.None;
|
|
|
|
var collision = particles[i].collision;
|
|
collision.enabled = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// loopVfxMountPath에서 VFX 장착 위치를 찾습니다.
|
|
/// </summary>
|
|
private Transform ResolveLoopVfxMount()
|
|
{
|
|
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.LoopVfxMountPath))
|
|
return null;
|
|
|
|
// Animator 하위에서 이름으로 재귀 검색
|
|
Animator animator = GetComponentInChildren<Animator>();
|
|
if (animator != null)
|
|
{
|
|
Transform found = FindTransformRecursive(animator.transform, currentSkill.LoopVfxMountPath);
|
|
if (found != null)
|
|
return found;
|
|
}
|
|
|
|
// 자식 GameObject에서 경로 검색
|
|
return transform.Find(currentSkill.LoopVfxMountPath);
|
|
}
|
|
|
|
private static Transform FindTransformRecursive(Transform parent, string name)
|
|
{
|
|
for (int i = 0; i < parent.childCount; i++)
|
|
{
|
|
Transform child = parent.GetChild(i);
|
|
if (child.name == name)
|
|
return child;
|
|
|
|
Transform found = FindTransformRecursive(child, name);
|
|
if (found != null)
|
|
return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 VFX를 파괴합니다.
|
|
/// </summary>
|
|
private void DestroyLoopVfx()
|
|
{
|
|
if (loopVfxInstance != null)
|
|
{
|
|
UnityEngine.Object.Destroy(loopVfxInstance);
|
|
loopVfxInstance = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 단계를 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
|
|
/// </summary>
|
|
private void UpdateLoopPhase()
|
|
{
|
|
if (!isLoopPhaseActive || currentSkill == null)
|
|
return;
|
|
|
|
loopElapsedTime += Time.deltaTime;
|
|
loopTickAccumulator += Time.deltaTime;
|
|
|
|
// 틱 효과 발동
|
|
float tickInterval = currentSkill.LoopTickInterval;
|
|
while (loopTickAccumulator >= tickInterval)
|
|
{
|
|
loopTickAccumulator -= tickInterval;
|
|
TriggerLoopTick();
|
|
}
|
|
|
|
// 지속 시간 초과 → 반복 유지 종료
|
|
if (currentSkill.UsesLoopMaxDuration && loopElapsedTime >= currentSkill.LoopMaxDuration)
|
|
{
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 지속 시간 만료: {currentSkill.SkillName}");
|
|
EndLoopPhase();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 틱 효과를 발동합니다.
|
|
/// </summary>
|
|
private void TriggerLoopTick()
|
|
{
|
|
if (currentLoopTickEffects.Count == 0)
|
|
return;
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 틱 발동: {currentSkill.SkillName} (elapsed={loopElapsedTime:F1}s)");
|
|
|
|
// VFX는 모든 클라이언트에서 로컬 실행
|
|
for (int i = 0; i < currentLoopTickEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentLoopTickEffects[i];
|
|
if (effect != null && effect.IsVisualOnly)
|
|
{
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
// 게임플레이 효과는 서버에서만 실행
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
|
return;
|
|
|
|
for (int i = 0; i < currentLoopTickEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentLoopTickEffects[i];
|
|
if (effect == null || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 틱 효과: {effect.name}");
|
|
|
|
if (showAreaDebug)
|
|
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
|
|
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 단계를 종료합니다. 종료 효과를 발동하고 다음 단계를 시작합니다.
|
|
/// </summary>
|
|
private void EndLoopPhase()
|
|
{
|
|
if (!isLoopPhaseActive)
|
|
return;
|
|
|
|
// 반복 유지 종료 효과 발동
|
|
TriggerLoopExitEffects();
|
|
DestroyLoopVfx();
|
|
|
|
isLoopPhaseActive = false;
|
|
loopElapsedTime = 0f;
|
|
loopTickAccumulator = 0f;
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 종료: {currentSkill?.SkillName}");
|
|
|
|
if (TryStartReleasePhase())
|
|
return;
|
|
|
|
RestoreBaseControllerIfNeeded();
|
|
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 반복 유지 종료 효과를 발동합니다.
|
|
/// </summary>
|
|
private void TriggerLoopExitEffects()
|
|
{
|
|
if (currentLoopExitEffects.Count == 0)
|
|
return;
|
|
|
|
// VFX는 모든 클라이언트에서 로컬 실행
|
|
for (int i = 0; i < currentLoopExitEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentLoopExitEffects[i];
|
|
if (effect != null && effect.IsVisualOnly)
|
|
{
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
// 게임플레이 효과는 서버에서만 실행
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
|
return;
|
|
|
|
for (int i = 0; i < currentLoopExitEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentLoopExitEffects[i];
|
|
if (effect == null || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 종료 효과: {effect.name}");
|
|
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 해제 단계를 시작합니다.
|
|
/// </summary>
|
|
private bool TryStartReleasePhase()
|
|
{
|
|
if (currentSkill == null || !currentSkill.HasReleasePhase)
|
|
return false;
|
|
|
|
currentClipSequenceIndex = 0;
|
|
isPlayingReleasePhase = true;
|
|
currentPhaseAnimationClips = currentSkill.ReleaseAnimationClips;
|
|
|
|
TriggerReleaseStartEffects();
|
|
|
|
if (currentPhaseAnimationClips.Count <= 0 || animator == null)
|
|
return false;
|
|
|
|
float resolvedAnimationSpeed = currentLoadoutEntry != null
|
|
? currentLoadoutEntry.GetResolvedAnimationSpeed()
|
|
: currentSkill.AnimationSpeed;
|
|
animator.speed = resolvedAnimationSpeed;
|
|
PlaySkillClip(currentPhaseAnimationClips[0], blendIn: false, transitionDuration: 0f);
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 해제 단계 시작 효과를 발동합니다.
|
|
/// </summary>
|
|
private void TriggerReleaseStartEffects()
|
|
{
|
|
if (currentReleaseStartEffects.Count == 0)
|
|
return;
|
|
|
|
for (int i = 0; i < currentReleaseStartEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentReleaseStartEffects[i];
|
|
if (effect != null && effect.IsVisualOnly)
|
|
{
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
|
return;
|
|
|
|
for (int i = 0; i < currentReleaseStartEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = currentReleaseStartEffects[i];
|
|
if (effect == null || effect.IsVisualOnly)
|
|
continue;
|
|
|
|
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 플레이어가 버튼을 놓았을 때 반복 유지 단계를 중단합니다.
|
|
/// PlayerSkillInput에서 호출됩니다.
|
|
/// </summary>
|
|
public void NotifyLoopHoldReleased()
|
|
{
|
|
if (currentSkill == null || !currentSkill.RequiresLoopHold)
|
|
return;
|
|
|
|
loopHoldRequested = false;
|
|
|
|
if (!isLoopPhaseActive)
|
|
return;
|
|
|
|
if (debugMode)
|
|
Debug.Log($"[Skill] 반복 유지 버튼 해제로 중단: {currentSkill?.SkillName}");
|
|
|
|
EndLoopPhase();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 레거시 채널링 입력 해제 경로 호환 메서드입니다.
|
|
/// </summary>
|
|
public void NotifyChannelHoldReleased()
|
|
{
|
|
NotifyLoopHoldReleased();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 실행 중인 스킬 상태를 정리합니다.
|
|
/// </summary>
|
|
private void ClearCurrentSkillState()
|
|
{
|
|
currentSkill = null;
|
|
currentLoadoutEntry = null;
|
|
currentCastStartEffects.Clear();
|
|
currentTriggeredEffects.Clear();
|
|
currentCastStartAbnormalities.Clear();
|
|
currentTriggeredAbnormalities.Clear();
|
|
currentTriggeredTargetsBuffer.Clear();
|
|
currentLoopTickEffects.Clear();
|
|
currentLoopExitEffects.Clear();
|
|
currentReleaseStartEffects.Clear();
|
|
isLoopPhaseActive = false;
|
|
loopElapsedTime = 0f;
|
|
loopTickAccumulator = 0f;
|
|
DestroyLoopVfx();
|
|
currentTargetOverride = null;
|
|
currentGroundTargetPosition = null;
|
|
currentPhaseAnimationClips = Array.Empty<AnimationClip>();
|
|
isPlayingReleasePhase = false;
|
|
currentClipSequenceIndex = 0;
|
|
currentRepeatCount = 1;
|
|
currentIterationIndex = 0;
|
|
loopHoldRequested = false;
|
|
cachedRecoveryStateHash = 0;
|
|
currentClipStartTime = -1f;
|
|
currentClipExpectedDuration = 0f;
|
|
currentClipDebugName = string.Empty;
|
|
currentClipStartFrame = -1;
|
|
shouldBlendIntoCurrentSkill = true;
|
|
shouldRestoreToIdleAfterCurrentSkill = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 시전 중인 스킬을 지정 결과로 종료합니다.
|
|
/// </summary>
|
|
private void CompleteCurrentSkillExecution(SkillExecutionResult result)
|
|
{
|
|
lastExecutionResult = result;
|
|
NotifyDefenseStateEnded();
|
|
ClearCurrentSkillState();
|
|
}
|
|
|
|
private void NotifyDefenseStateEnded()
|
|
{
|
|
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
|
|
defenseController?.HandleSkillExecutionEnded();
|
|
|
|
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
|
|
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>
|
|
private void UpdateCastTargetTracking()
|
|
{
|
|
if (currentSkill == null || currentTargetOverride == null || !currentTargetOverride.activeInHierarchy)
|
|
return;
|
|
|
|
var enemyBase = GetComponent<Colosseum.Enemy.EnemyBase>();
|
|
if (enemyBase == null)
|
|
return;
|
|
|
|
if (IsSpawned && !IsServer)
|
|
return;
|
|
|
|
Vector3 direction = TargetSurfaceUtility.GetHorizontalDirectionToSurface(transform.position, currentTargetOverride);
|
|
if (direction.sqrMagnitude < 0.0001f)
|
|
return;
|
|
|
|
bool suppressRotationWhileContactingPlayer = currentSkill.UseRootMotion
|
|
&& currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
|
|
&& enemyBase.IsTouchingPlayerContact;
|
|
|
|
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
|
|
currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget)
|
|
{
|
|
if (!suppressRotationWhileContactingPlayer)
|
|
{
|
|
Quaternion targetRotation = Quaternion.LookRotation(direction.normalized);
|
|
float rotationSpeed = Mathf.Max(0f, currentSkill.CastTargetRotationSpeed);
|
|
if (rotationSpeed <= 0f)
|
|
transform.rotation = targetRotation;
|
|
else
|
|
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * 360f * Time.deltaTime);
|
|
}
|
|
}
|
|
|
|
if (currentSkill.CastTargetTrackingMode != SkillCastTargetTrackingMode.MoveTowardTarget || currentSkill.UseRootMotion)
|
|
return;
|
|
|
|
UnityEngine.AI.NavMeshAgent navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
|
if (navMeshAgent == null || !navMeshAgent.enabled)
|
|
return;
|
|
|
|
float stopDistance = Mathf.Max(0f, currentSkill.CastTargetStopDistance);
|
|
float surfaceDistance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, currentTargetOverride);
|
|
if (surfaceDistance <= stopDistance)
|
|
{
|
|
navMeshAgent.isStopped = true;
|
|
navMeshAgent.ResetPath();
|
|
return;
|
|
}
|
|
|
|
navMeshAgent.isStopped = false;
|
|
navMeshAgent.SetDestination(TargetSurfaceUtility.GetClosestSurfacePoint(transform.position, currentTargetOverride));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 트리거 인덱스에 연결된 젬 이상상태를 적중 대상에게 적용합니다.
|
|
/// </summary>
|
|
private void ApplyTriggeredAbnormalities(int index, List<SkillEffect> referenceEffects)
|
|
{
|
|
if (!currentTriggeredAbnormalities.TryGetValue(index, out List<AbnormalityData> abnormalities) ||
|
|
abnormalities == null ||
|
|
abnormalities.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
currentTriggeredTargetsBuffer.Clear();
|
|
|
|
for (int i = 0; i < referenceEffects.Count; i++)
|
|
{
|
|
SkillEffect effect = referenceEffects[i];
|
|
if (effect == null || effect.TargetType == TargetType.Self)
|
|
continue;
|
|
|
|
effect.CollectTargets(gameObject, currentTriggeredTargetsBuffer, currentTargetOverride, currentGroundTargetPosition);
|
|
}
|
|
|
|
if (currentTriggeredTargetsBuffer.Count == 0)
|
|
{
|
|
if (debugMode)
|
|
{
|
|
Debug.LogWarning($"[Skill] Trigger abnormality skipped - no hit target resolved for index {index}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < currentTriggeredTargetsBuffer.Count; i++)
|
|
{
|
|
GameObject target = currentTriggeredTargetsBuffer[i];
|
|
if (target == null)
|
|
continue;
|
|
|
|
AbnormalityManager abnormalityManager = target.GetComponent<AbnormalityManager>();
|
|
if (abnormalityManager == null)
|
|
continue;
|
|
|
|
for (int j = 0; j < abnormalities.Count; j++)
|
|
{
|
|
AbnormalityData abnormality = abnormalities[j];
|
|
if (abnormality == null)
|
|
continue;
|
|
|
|
if (debugMode)
|
|
{
|
|
Debug.Log($"[Skill] Trigger abnormality: {abnormality.abnormalityName} -> {target.name} (index {index})");
|
|
}
|
|
|
|
abnormalityManager.ApplyAbnormality(abnormality, gameObject);
|
|
}
|
|
}
|
|
|
|
currentTriggeredTargetsBuffer.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기본 컨트롤러를 기반으로 런타임 OverrideController를 준비합니다.
|
|
/// </summary>
|
|
private bool EnsureRuntimeOverrideController()
|
|
{
|
|
if (animator == null || baseController == null || baseSkillClip == null)
|
|
return false;
|
|
|
|
if (runtimeOverrideController == null || runtimeOverrideController.runtimeAnimatorController != baseController)
|
|
runtimeOverrideController = new AnimatorOverrideController(baseController);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정한 클립을 Skill 상태 override로 적용합니다.
|
|
/// </summary>
|
|
private bool ApplyOverrideClip(AnimationClip clip)
|
|
{
|
|
if (clip == null)
|
|
return false;
|
|
|
|
if (!EnsureRuntimeOverrideController())
|
|
return false;
|
|
|
|
// 같은 상태에 다른 스킬 클립을 연속 적용할 때는 새 override 인스턴스를 다시 붙여야
|
|
// Unity가 바뀐 Motion을 안정적으로 반영합니다.
|
|
runtimeOverrideController = new AnimatorOverrideController(baseController);
|
|
runtimeOverrideController[baseSkillClip] = clip;
|
|
animator.runtimeAnimatorController = runtimeOverrideController;
|
|
animator.Update(0f);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 스킬이 패턴 경계에서 Idle과 블렌드해야 하는지 결정합니다.
|
|
/// </summary>
|
|
private void ResolveSkillBoundaryTransitions()
|
|
{
|
|
shouldBlendIntoCurrentSkill = true;
|
|
shouldRestoreToIdleAfterCurrentSkill = true;
|
|
isBossPatternBoundarySkill = false;
|
|
|
|
Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent<Colosseum.Enemy.BossBehaviorRuntimeState>();
|
|
if (runtimeState == null || !runtimeState.IsExecutingPattern)
|
|
return;
|
|
|
|
isBossPatternBoundarySkill = true;
|
|
shouldBlendIntoCurrentSkill = runtimeState.CurrentPatternSkillStartsFromIdle;
|
|
shouldRestoreToIdleAfterCurrentSkill = runtimeState.CurrentPatternSkillReturnsToIdle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 재생할 클립이 스킬 시작 블렌드 대상인지 반환합니다.
|
|
/// </summary>
|
|
private float ResolveSkillEnterTransitionDuration()
|
|
{
|
|
if (!shouldBlendIntoCurrentSkill)
|
|
return 0f;
|
|
|
|
if (isPlayingReleasePhase)
|
|
return 0f;
|
|
|
|
if (currentClipSequenceIndex != 0 || currentIterationIndex != 1)
|
|
return 0f;
|
|
|
|
if (!isBossPatternBoundarySkill)
|
|
return skillEnterTransitionDuration;
|
|
|
|
bool shouldBlendBossEntry = ShouldBlendBossPatternEntryFromCurrentState();
|
|
return shouldBlendBossEntry
|
|
? Mathf.Max(skillEnterTransitionDuration, bossPatternEnterTransitionDuration)
|
|
: 0f;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보스 패턴 첫 스킬이 이동 중 진입인지 판단합니다.
|
|
/// </summary>
|
|
private bool ShouldBlendBossPatternEntryFromCurrentState()
|
|
{
|
|
if (animator == null)
|
|
return false;
|
|
|
|
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>
|
|
/// 현재 스킬 종료에 사용할 전환 시간을 반환합니다.
|
|
/// </summary>
|
|
private float GetSkillExitTransitionDuration()
|
|
{
|
|
if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill)
|
|
return bossPatternExitTransitionDuration;
|
|
|
|
return skillExitTransitionDuration;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 시작 전 기본 상태를 저장합니다.
|
|
/// </summary>
|
|
private void CacheRecoveryState()
|
|
{
|
|
if (animator == null)
|
|
{
|
|
cachedRecoveryStateHash = 0;
|
|
return;
|
|
}
|
|
|
|
AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
|
|
cachedRecoveryStateHash = currentState.fullPathHash;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 상태 해시를 반환합니다.
|
|
/// </summary>
|
|
private static int GetSkillStateHash()
|
|
{
|
|
return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 종료 후 복귀할 상태 해시를 결정합니다.
|
|
/// </summary>
|
|
private int ResolveRecoveryStateHash()
|
|
{
|
|
if (animator == null)
|
|
return 0;
|
|
|
|
if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill)
|
|
{
|
|
int bossIdleStateHash = ResolveBossIdleStateHash();
|
|
if (bossIdleStateHash != 0)
|
|
return bossIdleStateHash;
|
|
}
|
|
|
|
if (cachedRecoveryStateHash != 0 && animator.HasState(0, cachedRecoveryStateHash))
|
|
return cachedRecoveryStateHash;
|
|
|
|
int moveStateHash = Animator.StringToHash($"{BaseLayerName}.{MoveStateName}");
|
|
if (animator.HasState(0, moveStateHash))
|
|
return moveStateHash;
|
|
|
|
int idleStateHash = Animator.StringToHash($"{BaseLayerName}.{IdleStateName}");
|
|
if (animator.HasState(0, idleStateHash))
|
|
return idleStateHash;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보스 패턴 종료 시 현재 페이즈에 맞는 Idle 상태 해시를 반환합니다.
|
|
/// </summary>
|
|
private int ResolveBossIdleStateHash()
|
|
{
|
|
Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent<Colosseum.Enemy.BossBehaviorRuntimeState>();
|
|
if (runtimeState == null)
|
|
return 0;
|
|
|
|
int phase = runtimeState.CurrentPatternPhase;
|
|
string stateName = phase >= 3 ? BossIdlePhase3StateName : BossIdlePhase1StateName;
|
|
int stateHash = Animator.StringToHash($"{BaseLayerName}.{stateName}");
|
|
return animator.HasState(0, stateHash) ? stateHash : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기본 스킬 클립 Override를 복원하고 지정한 상태로 부드럽게 복귀합니다.
|
|
/// </summary>
|
|
private void RestoreBaseAnimationState(int recoveryStateHash)
|
|
{
|
|
if (animator == null || baseController == null || baseSkillClip == null)
|
|
return;
|
|
|
|
animator.speed = 1f;
|
|
|
|
if (recoveryStateHash != 0 && animator.HasState(0, recoveryStateHash))
|
|
animator.CrossFadeInFixedTime(recoveryStateHash, GetSkillExitTransitionDuration(), 0, 0f);
|
|
}
|
|
}
|
|
}
|