feat: 방어 시스템과 드로그 검증 경로 정리

- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다.
- 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다.
- 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
This commit is contained in:
2026-04-07 21:28:52 +09:00
parent 147e9baa25
commit 0c9967d131
72 changed files with 231096 additions and 698 deletions

View File

@@ -60,6 +60,96 @@ namespace Colosseum.Skills
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>
@@ -77,7 +167,11 @@ namespace Colosseum.Skills
/// </summary>
private void OnValidate()
{
bool changed = MigrateLegacyExecutionPhases();
RefreshAnimationClips();
if (changed)
UnityEditor.EditorUtility.SetDirty(this);
}
/// <summary>
@@ -219,24 +313,24 @@ namespace Colosseum.Skills
[SerializeField] private List<SkillTriggeredEffectEntry> triggeredEffects = new();
[Header("채널링")]
[Tooltip("이 스킬이 채널링 스킬인지 여부. 캐스트 애니메이션 종료 후 채널링이 시작됩니다.")]
[Tooltip("이 스킬이 채널링 스킬인지 여부. 켜져 있을 때만 반복 유지/해제 단계를 사용합니다.")]
[SerializeField] private bool isChanneling = false;
[Tooltip("채널링 최대 지속 시간 (초)")]
[Min(0.1f)] [SerializeField] private float channelDuration = 3f;
[Tooltip("채널링 틱 간격 (초). 이 간격마다 channelTickEffects가 발동합니다.")]
[Min(0.05f)] [SerializeField] private float channelTickInterval = 0.5f;
[Tooltip("채널링 중 주기적으로 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> channelTickEffects = new();
[Tooltip("채널링 종료 시 발동하는 효과 목록 (지속 시간 만료 시)")]
[SerializeField] private List<SkillEffect> channelEndEffects = new();
[Tooltip("채널링 중 지속되는 VFX 프리팹. 채널링 시작에 시전자 위치에 생성되고 종료에 파괴됩니다.")]
[SerializeField] private GameObject channelVfxPrefab;
[Tooltip("VFX 생성 기준 위치의 Transform 경로. Animator 본 이름 (예: RightHand, Head) 또는 자식 GameObject 경로. 비어있으면 루트 위치.")]
[SerializeField] private string channelVfxMountPath;
[Tooltip("채널링 VFX 길이 배율. 빔의 진행 방향 (z축) 크기를 조절합니다.")]
[Min(0.01f)] [SerializeField] private float channelVfxLengthScale = 1f;
[Tooltip("채널링 VFX 폭 배율. 빔의 너비 (x/y축) 크기를 조절합니다.")]
[Min(0.01f)] [SerializeField] private float channelVfxWidthScale = 1f;
[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;
@@ -273,14 +367,32 @@ namespace Colosseum.Skills
public IReadOnlyList<SkillTriggeredEffectEntry> TriggeredEffects => triggeredEffects;
public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits;
public bool IsChanneling => isChanneling;
public float ChannelDuration => channelDuration;
public float ChannelTickInterval => channelTickInterval;
public IReadOnlyList<SkillEffect> ChannelTickEffects => channelTickEffects;
public IReadOnlyList<SkillEffect> ChannelEndEffects => channelEndEffects;
public GameObject ChannelVfxPrefab => channelVfxPrefab;
public string ChannelVfxMountPath => channelVfxMountPath;
public float ChannelVfxLengthScale => channelVfxLengthScale;
public float ChannelVfxWidthScale => channelVfxWidthScale;
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>
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.
@@ -318,6 +430,62 @@ namespace Colosseum.Skills
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>