using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Colosseum.Skills
{
///
/// 스킬 강제 취소 이유
///
public enum SkillCancelReason
{
None,
Manual,
Interrupt,
Death,
Stun,
HitReaction,
Respawn,
Revive,
}
///
/// 마지막 스킬 실행 결과입니다.
///
public enum SkillExecutionResult
{
None,
Running,
Completed,
Cancelled,
}
///
/// 스킬 실행을 관리하는 컴포넌트.
/// 애니메이션 이벤트 기반으로 효과가 발동됩니다.
///
public class SkillController : NetworkBehaviour
{
private const string SKILL_STATE_NAME = "Skill";
[Header("애니메이션")]
[SerializeField] private Animator animator;
[Tooltip("기본 Animator Controller (스킬 종료 후 복원용)")]
[SerializeField] private RuntimeAnimatorController baseController;
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용). baseController의 Skill state에서 자동 발견됩니다.")]
[SerializeField] private AnimationClip baseSkillClip;
[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 bool isChannelingActive = false;
private float channelElapsedTime = 0f;
private float channelTickAccumulator = 0f;
private GameObject channelVfxInstance;
private readonly List currentChannelTickEffects = new();
private readonly List currentChannelEndEffects = new();
// 쿨타임 추적
private Dictionary cooldownTracker = new Dictionary();
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 => isChannelingActive;
private void Awake()
{
lastCancelledSkillName = string.Empty;
lastCancelReason = SkillCancelReason.None;
if (animator == null)
{
animator = GetComponentInChildren();
}
// 기본 컨트롤러 저장
if (baseController == null && animator != null)
{
baseController = animator.runtimeAnimatorController;
}
}
#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 (isChannelingActive)
{
UpdateChanneling();
return;
}
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
// 애니메이션 종료 시 처리
if (stateInfo.normalizedTime >= 1f)
{
// 같은 반복 차수 내에서 다음 클립이 있으면 재생
if (TryPlayNextClipInSequence())
return;
// 다음 반복 차수가 있으면 시작
if (TryStartNextIteration())
return;
// 채널링 스킬이면 채널링 시작
if (currentSkill.IsChanneling)
{
StartChanneling();
return;
}
// 모든 클립과 반복이 끝나면 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
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;
}
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
currentSkill = skill;
lastCancelReason = SkillCancelReason.None;
lastExecutionResult = SkillExecutionResult.Running;
BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0;
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();
if (loadoutEntry == null)
return;
loadoutEntry.CollectCastStartEffects(currentCastStartEffects);
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities);
loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities);
loadoutEntry.CollectChannelTickEffects(currentChannelTickEffects);
loadoutEntry.CollectChannelEndEffects(currentChannelEndEffects);
}
///
/// 현재 스킬의 반복 차수 하나를 시작합니다.
///
private void StartCurrentIteration()
{
if (currentSkill == null)
return;
currentIterationIndex++;
currentClipSequenceIndex = 0;
if (debugMode && currentRepeatCount > 1)
{
Debug.Log($"[Skill] Iteration {currentIterationIndex}/{currentRepeatCount}: {currentSkill.SkillName}");
}
TriggerCastStartEffects();
if (currentSkill.AnimationClips.Count > 0 && animator != null)
{
float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentSkill.AnimationClips[0]);
}
TriggerImmediateSelfEffectsIfNeeded();
}
///
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
///
private bool TryPlayNextClipInSequence()
{
if (currentSkill == null)
return false;
int nextIndex = currentClipSequenceIndex + 1;
if (nextIndex >= currentSkill.AnimationClips.Count)
return false;
currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentSkill.AnimationClips[currentClipSequenceIndex]);
if (debugMode)
{
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentSkill.AnimationClips.Count}: {currentSkill.AnimationClips[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)
{
if (baseSkillClip == null)
{
Debug.LogError("[SkillController] Base Skill Clip is not assigned!");
return;
}
if (debugMode)
{
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}");
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = clip;
animator.runtimeAnimatorController = overrideController;
// 애니메이터 완전 리셋 후 재생
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
// 클라이언트에 클립 동기화
if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
///
/// 기본 컨트롤러로 복원
///
private void RestoreBaseController()
{
if (animator != null && baseController != null)
{
animator.runtimeAnimatorController = baseController;
animator.speed = 1f;
}
// 클라이언트에 복원 동기화
if (IsServer && IsSpawned)
RestoreBaseControllerClientRpc();
}
///
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
///
[Rpc(SendTo.NotServer)]
private void PlaySkillClipClientRpc(int clipIndex)
{
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;
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = registeredClips[clipIndex];
animator.runtimeAnimatorController = overrideController;
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
}
///
/// 클라이언트: 기본 컨트롤러 복원
///
[Rpc(SendTo.NotServer)]
private void RestoreBaseControllerClientRpc()
{
if (animator != null && baseController != null)
animator.runtimeAnimatorController = baseController;
}
///
/// 애니메이션 이벤트에서 호출. 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 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 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 void StartChanneling()
{
if (currentSkill == null || !currentSkill.IsChanneling)
return;
isChannelingActive = true;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
SpawnChannelVfx();
if (debugMode)
Debug.Log($"[Skill] 채널링 시작: {currentSkill.SkillName} (duration={currentSkill.ChannelDuration}s, tick={currentSkill.ChannelTickInterval}s)");
}
///
/// 채널링 VFX를 시전자 위치에 생성합니다.
///
private void SpawnChannelVfx()
{
if (currentSkill == null || currentSkill.ChannelVfxPrefab == null)
return;
Transform mount = ResolveChannelVfxMount();
Vector3 spawnPos = mount != null ? mount.position : transform.position;
channelVfxInstance = UnityEngine.Object.Instantiate(
currentSkill.ChannelVfxPrefab,
spawnPos,
transform.rotation);
if (mount != null)
channelVfxInstance.transform.SetParent(mount);
channelVfxInstance.transform.localScale = new Vector3(
currentSkill.ChannelVfxWidthScale,
currentSkill.ChannelVfxWidthScale,
currentSkill.ChannelVfxLengthScale);
// 모든 파티클을 루핑 모드로 설정
ForceLoopParticleSystems(channelVfxInstance);
}
///
/// 하위 모든 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;
}
}
///
/// channelVfxMountPath에서 VFX 장착 위치를 찾습니다.
///
private Transform ResolveChannelVfxMount()
{
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.ChannelVfxMountPath))
return null;
// Animator 하위에서 이름으로 재귀 검색
Animator animator = GetComponentInChildren();
if (animator != null)
{
Transform found = FindTransformRecursive(animator.transform, currentSkill.ChannelVfxMountPath);
if (found != null)
return found;
}
// 자식 GameObject에서 경로 검색
return transform.Find(currentSkill.ChannelVfxMountPath);
}
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 DestroyChannelVfx()
{
if (channelVfxInstance != null)
{
UnityEngine.Object.Destroy(channelVfxInstance);
channelVfxInstance = null;
}
}
///
/// 채널링을 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
///
private void UpdateChanneling()
{
if (!isChannelingActive || currentSkill == null)
return;
channelElapsedTime += Time.deltaTime;
channelTickAccumulator += Time.deltaTime;
// 틱 효과 발동
float tickInterval = currentSkill.ChannelTickInterval;
while (channelTickAccumulator >= tickInterval)
{
channelTickAccumulator -= tickInterval;
TriggerChannelTick();
}
// 지속 시간 초과 → 채널링 종료
if (channelElapsedTime >= currentSkill.ChannelDuration)
{
if (debugMode)
Debug.Log($"[Skill] 채널링 지속 시간 만료: {currentSkill.SkillName}");
EndChanneling();
}
}
///
/// 채널링 틱 효과를 발동합니다.
///
private void TriggerChannelTick()
{
if (currentChannelTickEffects.Count == 0)
return;
if (debugMode)
Debug.Log($"[Skill] 채널링 틱 발동: {currentSkill.SkillName} (elapsed={channelElapsedTime:F1}s)");
// VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelTickEffects.Count; i++)
{
SkillEffect effect = currentChannelTickEffects[i];
if (effect != null && effect.IsVisualOnly)
{
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
// 게임플레이 효과는 서버에서만 실행
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentChannelTickEffects.Count; i++)
{
SkillEffect effect = currentChannelTickEffects[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 EndChanneling()
{
if (!isChannelingActive)
return;
// 채널링 종료 효과 발동
TriggerChannelEndEffects();
DestroyChannelVfx();
isChannelingActive = false;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
if (debugMode)
Debug.Log($"[Skill] 채널링 종료: {currentSkill?.SkillName}");
RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
}
///
/// 채널링 종료 효과를 발동합니다.
///
private void TriggerChannelEndEffects()
{
if (currentChannelEndEffects.Count == 0)
return;
// VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelEndEffects.Count; i++)
{
SkillEffect effect = currentChannelEndEffects[i];
if (effect != null && effect.IsVisualOnly)
{
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
// 게임플레이 효과는 서버에서만 실행
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentChannelEndEffects.Count; i++)
{
SkillEffect effect = currentChannelEndEffects[i];
if (effect == null || effect.IsVisualOnly)
continue;
if (debugMode)
Debug.Log($"[Skill] 채널링 종료 효과: {effect.name}");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
///
/// 플레이어가 버튼을 놓았을 때 채널링을 중단합니다.
/// PlayerSkillInput에서 호출됩니다.
///
public void NotifyChannelHoldReleased()
{
if (!isChannelingActive)
return;
if (debugMode)
Debug.Log($"[Skill] 채널링 버튼 해제로 중단: {currentSkill?.SkillName}");
EndChanneling();
}
///
/// 현재 실행 중인 스킬 상태를 정리합니다.
///
private void ClearCurrentSkillState()
{
currentSkill = null;
currentLoadoutEntry = null;
currentCastStartEffects.Clear();
currentTriggeredEffects.Clear();
currentCastStartAbnormalities.Clear();
currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear();
currentChannelTickEffects.Clear();
currentChannelEndEffects.Clear();
isChannelingActive = false;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
DestroyChannelVfx();
currentTargetOverride = null;
currentGroundTargetPosition = null;
currentClipSequenceIndex = 0;
currentRepeatCount = 1;
currentIterationIndex = 0;
}
///
/// 현재 시전 중인 스킬을 지정 결과로 종료합니다.
///
private void CompleteCurrentSkillExecution(SkillExecutionResult result)
{
lastExecutionResult = result;
ClearCurrentSkillState();
}
///
/// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다.
///
private void UpdateCastTargetTracking()
{
if (currentSkill == null || currentTargetOverride == null || !currentTargetOverride.activeInHierarchy)
return;
var enemyBase = GetComponent();
if (enemyBase == null)
return;
if (IsSpawned && !IsServer)
return;
Vector3 direction = currentTargetOverride.transform.position - transform.position;
direction.y = 0f;
if (direction.sqrMagnitude < 0.0001f)
return;
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget)
{
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);
if (direction.magnitude <= stopDistance)
{
navMeshAgent.isStopped = true;
navMeshAgent.ResetPath();
return;
}
navMeshAgent.isStopped = false;
navMeshAgent.SetDestination(currentTargetOverride.transform.position);
}
///
/// 현재 트리거 인덱스에 연결된 젬 이상상태를 적중 대상에게 적용합니다.
///
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();
}
}
}