Files
Colosseum/Assets/_Game/Scripts/Skills/SkillData.cs
dal4segno 0c9967d131 feat: 방어 시스템과 드로그 검증 경로 정리
- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다.
- 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다.
- 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
2026-04-07 21:28:52 +09:00

574 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Weapons;
namespace Colosseum.Skills
{
/// <summary>
/// 젬 장착 조건에서 사용하는 기반 스킬 분류입니다.
/// 하나의 스킬이 여러 분류를 동시에 가질 수 있습니다.
/// </summary>
[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,
}
/// <summary>
/// 스킬의 역할 분류입니다.
/// 젬 장착 조건에는 비트 마스크 형태로도 사용합니다.
/// </summary>
[Flags]
public enum SkillRoleType
{
None = 0,
Attack = 1 << 0,
Defense = 1 << 1,
Support = 1 << 2,
All = Attack | Defense | Support,
}
/// <summary>
/// 스킬의 발동 타입 분류입니다.
/// </summary>
[Flags]
public enum SkillActivationType
{
None = 0,
Instant = 1 << 0,
Buff = 1 << 1,
All = Instant | Buff,
}
/// <summary>
/// 시전 중 대상 추적 방식입니다.
/// </summary>
public enum SkillCastTargetTrackingMode
{
None,
FaceTarget,
MoveTowardTarget,
}
/// <summary>
/// 반복 유지 단계의 입력/종료 조건입니다.
/// </summary>
public enum SkillLoopMode
{
None,
Timed,
HoldWhilePressed,
HoldWhilePressedWithMaxDuration,
}
/// <summary>
/// 채널링 스킬의 반복 유지 단계 데이터입니다.
/// </summary>
[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<SkillEffect> tickEffects = new();
[Tooltip("반복 유지 종료 시 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> 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<SkillEffect> TickEffects => tickEffects;
public IReadOnlyList<SkillEffect> 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<SkillEffect> legacyTickEffects, List<SkillEffect> 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<SkillEffect>(legacyTickEffects) : new List<SkillEffect>();
exitEffects = legacyExitEffects != null ? new List<SkillEffect>(legacyExitEffects) : new List<SkillEffect>();
loopVfxPrefab = legacyVfxPrefab;
loopVfxMountPath = legacyVfxMountPath;
loopVfxLengthScale = Mathf.Max(0.01f, legacyVfxLengthScale);
loopVfxWidthScale = Mathf.Max(0.01f, legacyVfxWidthScale);
}
}
/// <summary>
/// 채널링 스킬의 해제 단계 데이터입니다.
/// </summary>
[Serializable]
public class SkillReleasePhaseData
{
[Tooltip("이 채널링 스킬이 해제 단계를 사용하는지 여부")]
[SerializeField] private bool enabled = false;
[Tooltip("반복 유지 종료 뒤 순차 재생할 해제 클립 목록")]
[SerializeField] private List<AnimationClip> animationClips = new();
[Tooltip("해제 단계 시작 즉시 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> startEffects = new();
public bool Enabled => enabled && ((animationClips != null && animationClips.Count > 0) || (startEffects != null && startEffects.Count > 0));
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
public IReadOnlyList<SkillEffect> StartEffects => startEffects;
}
/// <summary>
/// 스킬 데이터. 스킬의 기본 정보와 효과 목록을 관리합니다.
/// </summary>
[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
/// <summary>
/// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭.
/// 에디터에서 애셋 로드/수정 시 자동 실행됩니다.
/// </summary>
private void OnValidate()
{
bool changed = MigrateLegacyExecutionPhases();
RefreshAnimationClips();
if (changed)
UnityEditor.EditorUtility.SetDirty(this);
}
/// <summary>
/// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다.
/// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다.
/// </summary>
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<AnimationClip>(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<AnimationClip> 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<SkillEffect> castStartEffects = new List<SkillEffect>();
[Header("트리거 효과 목록")]
[Tooltip("애니메이션 이벤트 OnEffect(index)로 발동. 각 엔트리의 Trigger Index가 이벤트 인덱스와 매칭됩니다.")]
[SerializeField] private List<SkillTriggeredEffectEntry> 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<SkillEffect> channelTickEffects = new();
[HideInInspector] [SerializeField] private List<SkillEffect> 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));
/// <summary>
/// 순차 재생할 클립 목록입니다.
/// </summary>
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
/// <summary>
/// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다.
/// </summary>
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<SkillEffect> CastStartEffects => castStartEffects;
public IReadOnlyList<SkillTriggeredEffectEntry> 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<SkillEffect> LoopTickEffects => HasLoopPhase ? GetResolvedLoopPhase().TickEffects : Array.Empty<SkillEffect>();
public IReadOnlyList<SkillEffect> LoopExitEffects => HasLoopPhase ? GetResolvedLoopPhase().ExitEffects : Array.Empty<SkillEffect>();
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<AnimationClip> ReleaseAnimationClips => HasReleasePhase ? GetResolvedReleasePhase().AnimationClips : Array.Empty<AnimationClip>();
public IReadOnlyList<SkillEffect> ReleaseStartEffects => HasReleasePhase ? GetResolvedReleasePhase().StartEffects : Array.Empty<SkillEffect>();
public float ChannelDuration => LoopMaxDuration;
public bool IsInfiniteChannel => IsInfiniteLoop;
public float ChannelTickInterval => LoopTickInterval;
public IReadOnlyList<SkillEffect> ChannelTickEffects => LoopTickEffects;
public IReadOnlyList<SkillEffect> ChannelEndEffects => LoopExitEffects;
public GameObject ChannelVfxPrefab => LoopVfxPrefab;
public string ChannelVfxMountPath => LoopVfxMountPath;
public float ChannelVfxLengthScale => LoopVfxLengthScale;
public float ChannelVfxWidthScale => LoopVfxWidthScale;
/// <summary>
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.
/// </summary>
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;
}
/// <summary>
/// 지정한 무기 특성 조합이 이 스킬의 무기 조건을 충족하는지 확인합니다.
/// allowedWeaponTraits가 None이면 항상 true입니다.
/// </summary>
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
}
/// <summary>
/// 스킬/젬 분류를 UI 친화적인 문자열로 변환하는 유틸리티입니다.
/// </summary>
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<string> labels = new List<string>();
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<string> labels = new List<string>();
if ((activationTypes & SkillActivationType.Instant) != 0)
labels.Add("즉발");
if ((activationTypes & SkillActivationType.Buff) != 0)
labels.Add("버프");
return labels.Count > 0 ? string.Join(" + ", labels) : "미분류";
}
}
}