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 { /// /// 스킬 강제 취소 이유 /// public enum SkillCancelReason { None, Manual, Interrupt, Death, Stun, Stagger, HitReaction, ResourceExhausted, Respawn, Revive, } /// /// 마지막 스킬 실행 결과입니다. /// public enum SkillExecutionResult { None, Running, Completed, Cancelled, } /// /// 스킬 실행을 관리하는 컴포넌트. /// 애니메이션 이벤트 기반으로 효과가 발동됩니다. /// 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 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 currentCastStartEffects = new(); private readonly Dictionary> currentTriggeredEffects = new(); private readonly List currentCastStartAbnormalities = new(); private readonly Dictionary> currentTriggeredAbnormalities = new(); private readonly List currentTriggeredTargetsBuffer = new(); private int currentClipSequenceIndex; // 현재 재생 중인 클립 순서 (animationClips 내 인덱스) private int currentRepeatCount = 1; private int currentIterationIndex = 0; private GameObject currentTargetOverride; private Vector3? currentGroundTargetPosition; private IReadOnlyList currentPhaseAnimationClips = Array.Empty(); private bool isPlayingReleasePhase = false; // 반복 유지 단계 상태 private bool isLoopPhaseActive = false; private float loopElapsedTime = 0f; private float loopTickAccumulator = 0f; private GameObject loopVfxInstance; private readonly List currentLoopTickEffects = new(); private readonly List currentLoopExitEffects = new(); private readonly List currentReleaseStartEffects = new(); private bool loopHoldRequested = false; private bool shouldBlendIntoCurrentSkill = true; private bool shouldRestoreToIdleAfterCurrentSkill = true; private bool isBossPatternBoundarySkill = false; // 쿨타임 추적 private Dictionary cooldownTracker = new Dictionary(); private AnimatorOverrideController runtimeOverrideController; private int cachedRecoveryStateHash; 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(); } // 기본 컨트롤러 저장 if (baseController == null && animator != null) { baseController = animator.runtimeAnimatorController; } EnsureRuntimeOverrideController(); } #if UNITY_EDITOR private void OnValidate() { AutoDiscoverBaseSkillClip(); AutoRegisterClips(); } /// /// baseController의 Skill 상태에 연결된 클립을 baseSkillClip으로 자동 발견합니다. /// 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); } } /// /// AnimatorController의 지정한 상태에 연결된 AnimationClip을 찾습니다. /// 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; } /// /// StateMachine을 재귀적으로 탐색하여 지정한 이름의 상태에서 클립을 찾습니다. /// 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; } /// /// clipAutoRegisterFilter 이름이 포함된 모든 AnimationClip을 registeredClips에 자동 등록합니다. /// 서버→클라이언트 클립 동기화 인덱스의 일관성을 위해 이름순으로 정렬합니다. /// 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(); 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(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 (TryPlayNextClipInSequence()) return; if (!isPlayingReleasePhase) { // 다음 반복 차수가 있으면 시작 if (TryStartNextIteration()) return; // 반복 유지 단계가 있으면 시작 if (TryStartLoopPhase()) return; } // 모든 클립과 단계가 끝나면 종료 if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}"); RestoreBaseControllerIfNeeded(); CompleteCurrentSkillExecution(SkillExecutionResult.Completed); } } /// /// 스킬 시전 /// public bool ExecuteSkill(SkillData skill) { return ExecuteSkill(SkillLoadoutEntry.CreateTemporary(skill)); } /// /// 타겟 오버라이드와 함께 스킬 시전 /// public bool ExecuteSkill(SkillData skill, GameObject targetOverride) { return ExecuteSkill(SkillLoadoutEntry.CreateTemporary(skill), targetOverride); } /// /// 슬롯 엔트리 기준으로 스킬 시전 /// public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry) { currentTargetOverride = null; return ExecuteSkillInternal(loadoutEntry); } /// /// 타겟 오버라이드와 함께 스킬 시전. /// SingleAlly 타입 효과에서 외부 타겟을 사용할 때 호출합니다. /// public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry, GameObject targetOverride) { currentTargetOverride = targetOverride; return ExecuteSkillInternal(loadoutEntry); } /// /// 지면 타겟 위치와 함께 스킬 시전. /// Ground Target 스킬에서 사용합니다. /// public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry, GameObject targetOverride, Vector3 groundTargetPosition) { currentTargetOverride = targetOverride; currentGroundTargetPosition = groundTargetPosition; return ExecuteSkillInternal(loadoutEntry); } /// /// 스킬 시전 공통 로직 /// 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(); 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?.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; } /// /// 시전 시작 즉시 발동하는 효과를 실행합니다. /// 서버 권한으로만 처리해 실제 게임플레이 효과가 한 번만 적용되게 합니다. /// 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(); 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); } } /// /// 애니메이션 이벤트가 없는 자기 자신 대상 효과는 시전 즉시 발동합니다. /// 버프/무적 같은 자기 강화 스킬이 이벤트 누락으로 동작하지 않는 상황을 막기 위한 보정입니다. /// 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 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); } } /// /// 현재 슬롯 엔트리 기준으로 시전 시작/트리거 효과를 합성합니다. /// 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); } /// /// 현재 스킬의 반복 차수 하나를 시작합니다. /// 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; PlaySkillClip(currentPhaseAnimationClips[0], ShouldBlendIntoClip()); } TriggerImmediateSelfEffectsIfNeeded(); } /// /// 시퀀스 내 다음 클립이 있으면 재생합니다. /// 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); if (debugMode) { Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentPhaseAnimationClips.Count}: {currentPhaseAnimationClips[currentClipSequenceIndex].name}"); } return true; } /// /// 반복 시전이 남아 있으면 다음 차수를 시작합니다. /// private bool TryStartNextIteration() { if (currentSkill == null) return false; if (currentIterationIndex >= currentRepeatCount) return false; StartCurrentIteration(); return true; } /// /// 스킬 클립으로 Override Controller 생성 후 재생 /// private void PlaySkillClip(AnimationClip clip, bool blendIn) { if (baseSkillClip == null) { Debug.LogError("[SkillController] Base Skill Clip is not assigned!"); return; } if (!ApplyOverrideClip(clip)) { Debug.LogError("[SkillController] Skill override clip 적용에 실패했습니다."); return; } if (debugMode) { Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}"); } if (blendIn) animator.CrossFadeInFixedTime(GetSkillStateHash(), GetSkillEnterTransitionDuration(), 0, 0f); else animator.Play(GetSkillStateHash(), 0, 0f); // 클라이언트에 클립 동기화 if (IsServer && IsSpawned) PlaySkillClipClientRpc(registeredClips.IndexOf(clip), blendIn); } /// /// 기본 컨트롤러로 복원 /// private void RestoreBaseController() { int recoveryStateHash = ResolveRecoveryStateHash(); RestoreBaseAnimationState(recoveryStateHash); // 클라이언트에 복원 동기화 if (IsServer && IsSpawned) RestoreBaseControllerClientRpc(recoveryStateHash); } /// /// 현재 스킬이 Idle 복귀가 필요한 경계 스킬일 때만 기본 상태 복귀를 수행합니다. /// private void RestoreBaseControllerIfNeeded() { if (!shouldRestoreToIdleAfterCurrentSkill) return; RestoreBaseController(); } /// /// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결) /// [Rpc(SendTo.NotServer)] private void PlaySkillClipClientRpc(int clipIndex, bool blendIn) { 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(), GetSkillEnterTransitionDuration(), 0, 0f); else animator.Play(GetSkillStateHash(), 0, 0f); } /// /// 클라이언트: 기본 컨트롤러 복원 /// [Rpc(SendTo.NotServer)] private void RestoreBaseControllerClientRpc(int recoveryStateHash) { RestoreBaseAnimationState(recoveryStateHash); } /// /// 애니메이션 이벤트에서 호출. Effect 리스트의 index번째 효과를 발동합니다. /// Animation Event: Function = OnEffect, Int Parameter = effect index (0-based) /// public void OnEffect(int index) { if (currentSkill == null) { if (debugMode) Debug.LogWarning("[Effect] No skill executing"); return; } // 사망 상태면 효과 발동 중단 var damageable = GetComponent(); if (damageable != null && damageable.IsDead) { if (debugMode) Debug.Log($"[Effect] Cancelled - owner is dead"); return; } if (!currentTriggeredEffects.TryGetValue(index, out List 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); } } /// /// 애니메이션 이벤트에서 호출. 스킬 종료를 요청합니다. /// 애니메이션은 끝까지 재생된 후 종료됩니다. /// Animation Event: Function = OnSkillEnd /// 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}"); } /// /// 애니메이션 이벤트에서 호출. 방어 상태를 시작합니다. /// public void OnDefenseStateEnter() { PlayerDefenseController defenseController = GetComponent(); defenseController?.EnterDefenseState(); } /// /// 애니메이션 이벤트에서 호출. 방어 상태를 종료합니다. /// public void OnDefenseStateExit() { PlayerDefenseController defenseController = GetComponent(); defenseController?.ExitDefenseState(); } /// /// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 시작합니다. /// public void OnDefenseSustainEnter() { PlayerDefenseSustainController sustainController = GetComponent(); if (sustainController == null) sustainController = gameObject.AddComponent(); sustainController.BeginSustain(); } /// /// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 종료합니다. /// public void OnDefenseSustainExit() { PlayerDefenseSustainController sustainController = GetComponent(); sustainController?.EndSustain(); } /// /// 현재 스킬을 강제 취소합니다. /// public bool CancelSkill(SkillCancelReason reason = SkillCancelReason.Manual) { if (currentSkill == null) return false; lastCancelledSkillName = currentSkill.SkillName; lastCancelReason = reason; Debug.Log($"[Skill] Cancelled: {currentSkill.SkillName} / reason={reason}"); RestoreBaseController(); CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled); return true; } /// /// 서버에서 현재 스킬 취소를 확정하고 클라이언트에 동기화합니다. /// 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(); } /// /// 반복 유지 단계를 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다. /// 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; } /// /// 반복 유지 VFX를 시전자 위치에 생성합니다. /// 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); } /// /// 하위 모든 ParticleSystem을 루핑 모드로 강제 설정하고 충돌을 비활성화합니다. /// 채널링 종료 시 파괴되므로 자연 종료 및 충돌 반응 방지용. /// private static void ForceLoopParticleSystems(GameObject instance) { if (instance == null) return; ParticleSystem[] particles = instance.GetComponentsInChildren(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; } } /// /// loopVfxMountPath에서 VFX 장착 위치를 찾습니다. /// private Transform ResolveLoopVfxMount() { if (currentSkill == null || string.IsNullOrEmpty(currentSkill.LoopVfxMountPath)) return null; // Animator 하위에서 이름으로 재귀 검색 Animator animator = GetComponentInChildren(); 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; } /// /// 반복 유지 VFX를 파괴합니다. /// private void DestroyLoopVfx() { if (loopVfxInstance != null) { UnityEngine.Object.Destroy(loopVfxInstance); loopVfxInstance = null; } } /// /// 반복 유지 단계를 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다. /// 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(); } } /// /// 반복 유지 틱 효과를 발동합니다. /// 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); } } /// /// 반복 유지 단계를 종료합니다. 종료 효과를 발동하고 다음 단계를 시작합니다. /// 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); } /// /// 반복 유지 종료 효과를 발동합니다. /// 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); } } /// /// 해제 단계를 시작합니다. /// 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); if (debugMode) Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}"); return true; } /// /// 해제 단계 시작 효과를 발동합니다. /// 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); } } /// /// 플레이어가 버튼을 놓았을 때 반복 유지 단계를 중단합니다. /// PlayerSkillInput에서 호출됩니다. /// public void NotifyLoopHoldReleased() { if (currentSkill == null || !currentSkill.RequiresLoopHold) return; loopHoldRequested = false; if (!isLoopPhaseActive) return; if (debugMode) Debug.Log($"[Skill] 반복 유지 버튼 해제로 중단: {currentSkill?.SkillName}"); EndLoopPhase(); } /// /// 레거시 채널링 입력 해제 경로 호환 메서드입니다. /// public void NotifyChannelHoldReleased() { NotifyLoopHoldReleased(); } /// /// 현재 실행 중인 스킬 상태를 정리합니다. /// 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(); isPlayingReleasePhase = false; currentClipSequenceIndex = 0; currentRepeatCount = 1; currentIterationIndex = 0; loopHoldRequested = false; cachedRecoveryStateHash = 0; shouldBlendIntoCurrentSkill = true; shouldRestoreToIdleAfterCurrentSkill = true; } /// /// 현재 시전 중인 스킬을 지정 결과로 종료합니다. /// private void CompleteCurrentSkillExecution(SkillExecutionResult result) { lastExecutionResult = result; NotifyDefenseStateEnded(); ClearCurrentSkillState(); } private void NotifyDefenseStateEnded() { PlayerDefenseController defenseController = GetComponent(); defenseController?.HandleSkillExecutionEnded(); PlayerDefenseSustainController sustainController = GetComponent(); sustainController?.HandleSkillExecutionEnded(); } /// /// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다. /// private void UpdateCastTargetTracking() { if (currentSkill == null || currentTargetOverride == null || !currentTargetOverride.activeInHierarchy) return; var enemyBase = GetComponent(); 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(); 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)); } /// /// 현재 트리거 인덱스에 연결된 젬 이상상태를 적중 대상에게 적용합니다. /// private void ApplyTriggeredAbnormalities(int index, List referenceEffects) { if (!currentTriggeredAbnormalities.TryGetValue(index, out List 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(); 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(); } /// /// 기본 컨트롤러를 기반으로 런타임 OverrideController를 준비합니다. /// private bool EnsureRuntimeOverrideController() { if (animator == null || baseController == null || baseSkillClip == null) return false; if (runtimeOverrideController == null || runtimeOverrideController.runtimeAnimatorController != baseController) runtimeOverrideController = new AnimatorOverrideController(baseController); return true; } /// /// 지정한 클립을 Skill 상태 override로 적용합니다. /// 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; } /// /// 현재 스킬이 패턴 경계에서 Idle과 블렌드해야 하는지 결정합니다. /// private void ResolveSkillBoundaryTransitions() { shouldBlendIntoCurrentSkill = true; shouldRestoreToIdleAfterCurrentSkill = true; isBossPatternBoundarySkill = false; Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent(); if (runtimeState == null || !runtimeState.IsExecutingPattern) return; isBossPatternBoundarySkill = true; shouldBlendIntoCurrentSkill = runtimeState.CurrentPatternSkillStartsFromIdle; shouldRestoreToIdleAfterCurrentSkill = runtimeState.CurrentPatternSkillReturnsToIdle; } /// /// 현재 재생할 클립이 스킬 시작 블렌드 대상인지 반환합니다. /// private bool ShouldBlendIntoClip() { if (isBossPatternBoundarySkill) return false; if (!shouldBlendIntoCurrentSkill) return false; if (isPlayingReleasePhase) return false; return currentClipSequenceIndex == 0 && currentIterationIndex == 1; } /// /// 현재 스킬 진입에 사용할 전환 시간을 반환합니다. /// private float GetSkillEnterTransitionDuration() { if (isBossPatternBoundarySkill) return bossPatternEnterTransitionDuration; return skillEnterTransitionDuration; } /// /// 현재 스킬 종료에 사용할 전환 시간을 반환합니다. /// private float GetSkillExitTransitionDuration() { if (isBossPatternBoundarySkill && shouldRestoreToIdleAfterCurrentSkill) return bossPatternExitTransitionDuration; return skillExitTransitionDuration; } /// /// 스킬 시작 전 기본 상태를 저장합니다. /// private void CacheRecoveryState() { if (animator == null) { cachedRecoveryStateHash = 0; return; } AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0); cachedRecoveryStateHash = currentState.fullPathHash; } /// /// 스킬 상태 해시를 반환합니다. /// private static int GetSkillStateHash() { return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}"); } /// /// 스킬 종료 후 복귀할 상태 해시를 결정합니다. /// 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; } /// /// 보스 패턴 종료 시 현재 페이즈에 맞는 Idle 상태 해시를 반환합니다. /// private int ResolveBossIdleStateHash() { Colosseum.Enemy.BossBehaviorRuntimeState runtimeState = GetComponent(); 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; } /// /// 기본 스킬 클립 Override를 복원하고 지정한 상태로 부드럽게 복귀합니다. /// 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); } } }