using System; using System.Collections.Generic; using UnityEngine; using Colosseum.Weapons; namespace Colosseum.Skills { /// /// 젬 장착 조건에서 사용하는 기반 스킬 분류입니다. /// 하나의 스킬이 여러 분류를 동시에 가질 수 있습니다. /// [Flags] public enum SkillBaseType { None = 0, Attack = 1 << 0, Defense = 1 << 1, Support = 1 << 2, Control = 1 << 3, Mobility = 1 << 4, Utility = 1 << 5, All = Attack | Defense | Support | Control | Mobility | Utility, } /// /// 스킬의 역할 분류입니다. /// 젬 장착 조건에는 비트 마스크 형태로도 사용합니다. /// [Flags] public enum SkillRoleType { None = 0, Attack = 1 << 0, Defense = 1 << 1, Support = 1 << 2, All = Attack | Defense | Support, } /// /// 스킬의 발동 타입 분류입니다. /// [Flags] public enum SkillActivationType { None = 0, Instant = 1 << 0, Buff = 1 << 1, All = Instant | Buff, } /// /// 시전 중 대상 추적 방식입니다. /// public enum SkillCastTargetTrackingMode { None, FaceTarget, MoveTowardTarget, } /// /// 반복 유지 단계의 입력/종료 조건입니다. /// public enum SkillLoopMode { None, Timed, HoldWhilePressed, HoldWhilePressedWithMaxDuration, } /// /// 채널링 스킬의 반복 유지 단계 데이터입니다. /// [Serializable] public class SkillLoopPhaseData { [Tooltip("이 채널링 스킬이 반복 유지 단계를 사용하는지 여부")] [SerializeField] private bool enabled = false; [Tooltip("반복 유지 단계의 종료 규칙입니다.")] [SerializeField] private SkillLoopMode loopMode = SkillLoopMode.Timed; [Tooltip("반복 유지 최대 지속 시간 (초). 모드가 시간 제한을 사용할 때만 의미가 있습니다.")] [Min(0f)] [SerializeField] private float maxDuration = 3f; [Tooltip("반복 유지 틱 간격 (초). 이 간격마다 tickEffects가 발동합니다.")] [Min(0.05f)] [SerializeField] private float tickInterval = 0.5f; [Tooltip("반복 유지 중 주기적으로 발동하는 효과 목록")] [SerializeField] private List tickEffects = new(); [Tooltip("반복 유지 종료 시 발동하는 효과 목록")] [SerializeField] private List exitEffects = new(); [Tooltip("반복 유지 중 지속되는 VFX 프리팹")] [SerializeField] private GameObject loopVfxPrefab; [Tooltip("VFX 생성 기준 위치의 Transform 경로. 비어있으면 루트 위치.")] [SerializeField] private string loopVfxMountPath; [Tooltip("반복 유지 VFX 길이 배율")] [Min(0.01f)] [SerializeField] private float loopVfxLengthScale = 1f; [Tooltip("반복 유지 VFX 폭 배율")] [Min(0.01f)] [SerializeField] private float loopVfxWidthScale = 1f; public bool Enabled => enabled; public SkillLoopMode LoopMode => enabled ? loopMode : SkillLoopMode.None; public float MaxDuration => maxDuration; public float TickInterval => tickInterval; public IReadOnlyList TickEffects => tickEffects; public IReadOnlyList ExitEffects => exitEffects; public GameObject LoopVfxPrefab => loopVfxPrefab; public string LoopVfxMountPath => loopVfxMountPath; public float LoopVfxLengthScale => loopVfxLengthScale; public float LoopVfxWidthScale => loopVfxWidthScale; public bool RequiresHoldInput => enabled && (loopMode == SkillLoopMode.HoldWhilePressed || loopMode == SkillLoopMode.HoldWhilePressedWithMaxDuration); public bool UsesMaxDuration => enabled && (loopMode == SkillLoopMode.Timed || loopMode == SkillLoopMode.HoldWhilePressedWithMaxDuration); public bool HasAuthoringData => enabled || (tickEffects != null && tickEffects.Count > 0) || (exitEffects != null && exitEffects.Count > 0) || loopVfxPrefab != null || !string.IsNullOrWhiteSpace(loopVfxMountPath); public void ApplyLegacyChanneling(float legacyDuration, float legacyTickInterval, List legacyTickEffects, List legacyExitEffects, GameObject legacyVfxPrefab, string legacyVfxMountPath, float legacyVfxLengthScale, float legacyVfxWidthScale) { enabled = true; loopMode = legacyDuration > 0f ? SkillLoopMode.Timed : SkillLoopMode.HoldWhilePressed; maxDuration = Mathf.Max(0f, legacyDuration); tickInterval = Mathf.Max(0.05f, legacyTickInterval); tickEffects = legacyTickEffects != null ? new List(legacyTickEffects) : new List(); exitEffects = legacyExitEffects != null ? new List(legacyExitEffects) : new List(); loopVfxPrefab = legacyVfxPrefab; loopVfxMountPath = legacyVfxMountPath; loopVfxLengthScale = Mathf.Max(0.01f, legacyVfxLengthScale); loopVfxWidthScale = Mathf.Max(0.01f, legacyVfxWidthScale); } } /// /// 채널링 스킬의 해제 단계 데이터입니다. /// [Serializable] public class SkillReleasePhaseData { [Tooltip("이 채널링 스킬이 해제 단계를 사용하는지 여부")] [SerializeField] private bool enabled = false; [Tooltip("반복 유지 종료 뒤 순차 재생할 해제 클립 목록")] [SerializeField] private List animationClips = new(); [Tooltip("해제 단계 시작 즉시 발동하는 효과 목록")] [SerializeField] private List startEffects = new(); public bool Enabled => enabled && ((animationClips != null && animationClips.Count > 0) || (startEffects != null && startEffects.Count > 0)); public IReadOnlyList AnimationClips => animationClips; public IReadOnlyList StartEffects => startEffects; } /// /// 스킬 데이터. 스킬의 기본 정보와 효과 목록을 관리합니다. /// [CreateAssetMenu(fileName = "NewSkill", menuName = "Colosseum/Skill")] public class SkillData : ScriptableObject { private const string SkillAssetPrefix = "Data_Skill_"; private const string ClipAssetPrefix = "Anim_"; private const string AnimationsSearchPath = "Assets/_Game/Animations"; #if UNITY_EDITOR /// /// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭. /// 에디터에서 애셋 로드/수정 시 자동 실행됩니다. /// private void OnValidate() { bool changed = MigrateLegacyExecutionPhases(); RefreshAnimationClips(); if (changed) UnityEditor.EditorUtility.SetDirty(this); } /// /// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다. /// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다. /// public void RefreshAnimationClips() { if (!name.StartsWith(SkillAssetPrefix)) return; string key = name.Substring(SkillAssetPrefix.Length); if (string.IsNullOrEmpty(key)) return; string[] guids = UnityEditor.AssetDatabase.FindAssets("t:AnimationClip", new[] { AnimationsSearchPath }); var matchedClips = new List<(AnimationClip clip, int order)>(); for (int i = 0; i < guids.Length; i++) { string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); AnimationClip clip = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (clip == null) continue; string clipName = clip.name; if (!clipName.StartsWith(ClipAssetPrefix)) continue; string remaining = clipName.Substring(ClipAssetPrefix.Length); if (remaining == key) { // 정확 매칭 (순서 번호 없음) → 최우선 matchedClips.Add((clip, -1)); } else if (remaining.StartsWith(key + "_")) { string suffix = remaining.Substring(key.Length + 1); if (int.TryParse(suffix, out int order)) { matchedClips.Add((clip, order)); } } } if (matchedClips.Count == 0) return; // 정렬: 순서 번호 없음(-1) → 순서 번호 오름차순 matchedClips.Sort((a, b) => a.order.CompareTo(b.order)); // 변경이 있는 경우만 갱신 (무한 루프 방지) bool changed = matchedClips.Count != animationClips.Count; if (!changed) { for (int i = 0; i < matchedClips.Count; i++) { if (matchedClips[i].clip != animationClips[i]) { changed = true; break; } } } if (!changed) return; animationClips.Clear(); for (int i = 0; i < matchedClips.Count; i++) animationClips.Add(matchedClips[i].clip); UnityEditor.EditorUtility.SetDirty(this); } #endif [Header("기본 정보")] [SerializeField] private string skillName; [TextArea(2, 4)] [SerializeField] private string description; [SerializeField] private Sprite icon; [Header("스킬 분류")] [Tooltip("이 스킬의 주 역할입니다.")] [SerializeField] private SkillRoleType skillRole = SkillRoleType.Attack; [Tooltip("이 스킬의 발동 타입입니다.")] [SerializeField] private SkillActivationType activationType = SkillActivationType.Instant; [Header("레거시 기반 스킬 분류")] [Tooltip("기존 테스트 데이터와의 호환을 위한 기반 분류입니다.")] [SerializeField] private SkillBaseType baseTypes = SkillBaseType.None; [Header("애니메이션")] [Tooltip("순차 재생할 클립 목록. 애셋 이름이 Data_Skill_ 접두사면 Anim_{이름}_{순서} 클립을 자동 수집합니다.")] [SerializeField] private List animationClips = new(); [Tooltip("애니메이션 재생 속도 (1 = 기본, 2 = 2배속)")] [Min(0.1f)] [SerializeField] private float animationSpeed = 1f; [Header("루트 모션")] [Tooltip("애니메이션의 이동/회전 데이터를 캐릭터에 적용")] [SerializeField] private bool useRootMotion = false; [Tooltip("루트 모션 적용 시 Y축 이동 무시 (중력과 충돌)")] [SerializeField] private bool ignoreRootMotionY = true; [Tooltip("스킬 시전 시 대상 위치로 점프 이동 (UseRootMotion + IgnoreRootMotionY=false 필요)")] [SerializeField] private bool jumpToTarget = false; [Header("행동 제한")] [Tooltip("시전 중 이동 입력 차단 여부")] [SerializeField] private bool blockMovementWhileCasting = true; [Tooltip("시전 중 점프 입력 차단 여부")] [SerializeField] private bool blockJumpWhileCasting = true; [Tooltip("시전 중 다른 스킬 입력 차단 여부")] [SerializeField] private bool blockOtherSkillsWhileCasting = true; [Header("시전 중 대상 추적")] [Tooltip("시전 중 대상에게 얼마나 추종할지 결정합니다.")] [SerializeField] private SkillCastTargetTrackingMode castTargetTrackingMode = SkillCastTargetTrackingMode.None; [Tooltip("대상을 바라볼 때 사용하는 회전 속도입니다.")] [Min(0f)] [SerializeField] private float castTargetRotationSpeed = 12f; [Tooltip("대상을 추격할 때 멈추는 거리입니다.")] [Min(0f)] [SerializeField] private float castTargetStopDistance = 0f; [Header("무기 조건")] [Tooltip("이 스킬 사용에 필요한 무기 특성. None이면 제약 없음.")] [SerializeField] private WeaponTrait allowedWeaponTraits = WeaponTrait.None; [Header("쿨타임 & 비용")] [Min(0f)] [SerializeField] private float cooldown = 1f; [Min(0f)] [SerializeField] private float manaCost = 0f; [Header("젬 슬롯")] [Tooltip("이 스킬에 장착 가능한 젬 슬롯 수")] [Min(0)] [SerializeField] private int maxGemSlotCount = 2; [Header("시전 시작 효과")] [Tooltip("시전 시작 즉시 발동하는 효과 목록. 시전 보호 버프 등에 사용됩니다.")] [SerializeField] private List castStartEffects = new List(); [Header("트리거 효과 목록")] [Tooltip("애니메이션 이벤트 OnEffect(index)로 발동. 각 엔트리의 Trigger Index가 이벤트 인덱스와 매칭됩니다.")] [SerializeField] private List triggeredEffects = new(); [Header("채널링")] [Tooltip("이 스킬이 채널링 스킬인지 여부. 켜져 있을 때만 반복 유지/해제 단계를 사용합니다.")] [SerializeField] private bool isChanneling = false; [Header("반복 유지 단계")] [SerializeField] private SkillLoopPhaseData loopPhase = new(); [Header("해제 단계")] [SerializeField] private SkillReleasePhaseData releasePhase = new(); [Header("레거시 채널링 데이터")] [HideInInspector] [Min(0f)] [SerializeField] private float channelDuration = 3f; [HideInInspector] [Min(0.05f)] [SerializeField] private float channelTickInterval = 0.5f; [HideInInspector] [SerializeField] private List channelTickEffects = new(); [HideInInspector] [SerializeField] private List channelEndEffects = new(); [HideInInspector] [SerializeField] private GameObject channelVfxPrefab; [HideInInspector] [SerializeField] private string channelVfxMountPath; [HideInInspector] [Min(0.01f)] [SerializeField] private float channelVfxLengthScale = 1f; [HideInInspector] [Min(0.01f)] [SerializeField] private float channelVfxWidthScale = 1f; // Properties public string SkillName => skillName; public string Description => description; public Sprite Icon => icon; public SkillRoleType SkillRole => skillRole; public SkillActivationType ActivationType => activationType; public SkillBaseType BaseTypes => baseTypes; public bool IsEvadeSkill => ((baseTypes & SkillBaseType.Mobility) != 0) && (ContainsEvadeKeyword(skillName) || ContainsEvadeKeyword(name)); /// /// 순차 재생할 클립 목록입니다. /// public IReadOnlyList AnimationClips => animationClips; /// /// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다. /// public AnimationClip SkillClip => animationClips.Count > 0 ? animationClips[0] : null; public float AnimationSpeed => animationSpeed; public float Cooldown => cooldown; public float ManaCost => manaCost; public int MaxGemSlotCount => maxGemSlotCount; public bool UseRootMotion => useRootMotion; public bool IgnoreRootMotionY => ignoreRootMotionY; public bool JumpToTarget => jumpToTarget; public bool BlockMovementWhileCasting => blockMovementWhileCasting; public bool BlockJumpWhileCasting => blockJumpWhileCasting; public bool BlockOtherSkillsWhileCasting => blockOtherSkillsWhileCasting; public SkillCastTargetTrackingMode CastTargetTrackingMode => castTargetTrackingMode; public float CastTargetRotationSpeed => castTargetRotationSpeed; public float CastTargetStopDistance => castTargetStopDistance; public IReadOnlyList CastStartEffects => castStartEffects; public IReadOnlyList TriggeredEffects => triggeredEffects; public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits; public bool IsChanneling => isChanneling; public SkillLoopPhaseData LoopPhase => GetResolvedLoopPhase(); public SkillReleasePhaseData ReleasePhase => GetResolvedReleasePhase(); public bool HasLoopPhase => isChanneling && GetResolvedLoopPhase().Enabled; public bool RequiresLoopHold => HasLoopPhase && GetResolvedLoopPhase().RequiresHoldInput; public bool UsesLoopMaxDuration => HasLoopPhase && GetResolvedLoopPhase().UsesMaxDuration; public float LoopMaxDuration => HasLoopPhase ? GetResolvedLoopPhase().MaxDuration : 0f; public bool IsInfiniteLoop => HasLoopPhase && !UsesLoopMaxDuration; public float LoopTickInterval => HasLoopPhase ? GetResolvedLoopPhase().TickInterval : 0.05f; public IReadOnlyList LoopTickEffects => HasLoopPhase ? GetResolvedLoopPhase().TickEffects : Array.Empty(); public IReadOnlyList LoopExitEffects => HasLoopPhase ? GetResolvedLoopPhase().ExitEffects : Array.Empty(); public GameObject LoopVfxPrefab => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxPrefab : null; public string LoopVfxMountPath => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxMountPath : string.Empty; public float LoopVfxLengthScale => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxLengthScale : 1f; public float LoopVfxWidthScale => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxWidthScale : 1f; public bool HasReleasePhase => isChanneling && GetResolvedReleasePhase().Enabled; public IReadOnlyList ReleaseAnimationClips => HasReleasePhase ? GetResolvedReleasePhase().AnimationClips : Array.Empty(); public IReadOnlyList ReleaseStartEffects => HasReleasePhase ? GetResolvedReleasePhase().StartEffects : Array.Empty(); public float ChannelDuration => LoopMaxDuration; public bool IsInfiniteChannel => IsInfiniteLoop; public float ChannelTickInterval => LoopTickInterval; public IReadOnlyList ChannelTickEffects => LoopTickEffects; public IReadOnlyList ChannelEndEffects => LoopExitEffects; public GameObject ChannelVfxPrefab => LoopVfxPrefab; public string ChannelVfxMountPath => LoopVfxMountPath; public float ChannelVfxLengthScale => LoopVfxLengthScale; public float ChannelVfxWidthScale => LoopVfxWidthScale; /// /// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다. /// public bool MatchesClassification(SkillRoleType allowedRoles, SkillActivationType allowedActivationTypes) { bool matchesRole = allowedRoles == SkillRoleType.None || allowedRoles == SkillRoleType.All || (allowedRoles & skillRole) != 0; bool matchesActivationType = allowedActivationTypes == SkillActivationType.None || allowedActivationTypes == SkillActivationType.All || (allowedActivationTypes & activationType) != 0; return matchesRole && matchesActivationType; } /// /// 지정한 무기 특성 조합이 이 스킬의 무기 조건을 충족하는지 확인합니다. /// allowedWeaponTraits가 None이면 항상 true입니다. /// public bool MatchesWeaponTrait(WeaponTrait equippedTraits) { if (allowedWeaponTraits == WeaponTrait.None) return true; return (equippedTraits & allowedWeaponTraits) == allowedWeaponTraits; } private static bool ContainsEvadeKeyword(string value) { if (string.IsNullOrWhiteSpace(value)) return false; return value.IndexOf("구르기", StringComparison.OrdinalIgnoreCase) >= 0 || value.IndexOf("회피", StringComparison.OrdinalIgnoreCase) >= 0; } private SkillLoopPhaseData GetResolvedLoopPhase() { if (loopPhase == null) loopPhase = new SkillLoopPhaseData(); if (loopPhase.HasAuthoringData || !isChanneling) return loopPhase; loopPhase.ApplyLegacyChanneling( channelDuration, channelTickInterval, channelTickEffects, channelEndEffects, channelVfxPrefab, channelVfxMountPath, channelVfxLengthScale, channelVfxWidthScale); return loopPhase; } private SkillReleasePhaseData GetResolvedReleasePhase() { if (releasePhase == null) releasePhase = new SkillReleasePhaseData(); return releasePhase; } #if UNITY_EDITOR private bool MigrateLegacyExecutionPhases() { if (!isChanneling) return false; if (loopPhase == null) { loopPhase = new SkillLoopPhaseData(); } if (loopPhase.HasAuthoringData) return false; loopPhase.ApplyLegacyChanneling( channelDuration, channelTickInterval, channelTickEffects, channelEndEffects, channelVfxPrefab, channelVfxMountPath, channelVfxLengthScale, channelVfxWidthScale); return true; } #endif } /// /// 스킬/젬 분류를 UI 친화적인 문자열로 변환하는 유틸리티입니다. /// public static class SkillClassificationUtility { public static string GetRoleLabel(SkillRoleType role) { return role switch { SkillRoleType.Attack => "공격", SkillRoleType.Defense => "방어", SkillRoleType.Support => "지원", SkillRoleType.All => "전체", _ => "미분류", }; } public static string GetActivationTypeLabel(SkillActivationType activationType) { return activationType switch { SkillActivationType.Instant => "즉발", SkillActivationType.Buff => "버프", SkillActivationType.All => "전체", _ => "미분류", }; } public static string GetSkillClassificationLabel(SkillData skill) { if (skill == null) return "미분류"; return $"{GetRoleLabel(skill.SkillRole)}/{GetActivationTypeLabel(skill.ActivationType)}"; } public static string GetGemCategoryLabel(SkillGemCategory category) { return category switch { SkillGemCategory.Damage => "데미지", SkillGemCategory.Survival => "생존", SkillGemCategory.Mana => "마나", SkillGemCategory.Special => "특수", SkillGemCategory.BuffPower => "효과 강화", SkillGemCategory.Duration => "지속시간", SkillGemCategory.Area => "범위", SkillGemCategory.Cost => "비용", _ => "공용", }; } public static string GetAllowedRoleSummary(SkillRoleType roles) { if (roles == SkillRoleType.None || roles == SkillRoleType.All) return "전체"; List labels = new List(); if ((roles & SkillRoleType.Attack) != 0) labels.Add("공격"); if ((roles & SkillRoleType.Defense) != 0) labels.Add("방어"); if ((roles & SkillRoleType.Support) != 0) labels.Add("지원"); return labels.Count > 0 ? string.Join(" + ", labels) : "미분류"; } public static string GetAllowedActivationSummary(SkillActivationType activationTypes) { if (activationTypes == SkillActivationType.None || activationTypes == SkillActivationType.All) return "전체"; List labels = new List(); if ((activationTypes & SkillActivationType.Instant) != 0) labels.Add("즉발"); if ((activationTypes & SkillActivationType.Buff) != 0) labels.Add("버프"); return labels.Count > 0 ? string.Join(" + ", labels) : "미분류"; } } }