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

@@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("스탯 계수 (1.0 = 100%)")]
[Min(0f)] [SerializeField] private float statScaling = 1f;
[Header("Mitigation")]
[Tooltip("이 피해가 방어/회피 규칙에서 어떤 판정으로 처리되는지 설정합니다.")]
[SerializeField] private DamageMitigationTier mitigationTier = DamageMitigationTier.Normal;
public float BaseDamage => baseDamage;
public DamageType DamageKind => damageType;
public float StatScaling => statScaling;
@@ -46,7 +50,7 @@ namespace Colosseum.Skills.Effects
var damageable = target.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(totalDamage, caster);
damageable.TakeDamage(new DamageContext(totalDamage, caster, mitigationTier));
}
}

View File

@@ -27,6 +27,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("다운 상태 대상에게 적용되는 추가 피해 배율")]
[Min(1f)] [SerializeField] private float downedDamageMultiplier = 1.5f;
[Header("Mitigation")]
[Tooltip("이 피해가 방어/회피 규칙에서 어떤 판정으로 처리되는지 설정합니다.")]
[SerializeField] private DamageMitigationTier mitigationTier = DamageMitigationTier.Normal;
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (target == null)
@@ -49,7 +53,7 @@ namespace Colosseum.Skills.Effects
totalDamage *= downedDamageMultiplier;
}
damageable.TakeDamage(totalDamage, caster);
damageable.TakeDamage(new DamageContext(totalDamage, caster, mitigationTier));
}
/// <summary>

View File

@@ -26,6 +26,7 @@ namespace Colosseum.Skills
Stun,
Stagger,
HitReaction,
ResourceExhausted,
Respawn,
Revive,
}
@@ -90,14 +91,18 @@ namespace Colosseum.Skills
private int currentIterationIndex = 0;
private GameObject currentTargetOverride;
private Vector3? currentGroundTargetPosition;
private IReadOnlyList<AnimationClip> currentPhaseAnimationClips = Array.Empty<AnimationClip>();
private bool isPlayingReleasePhase = false;
// 채널링 상태
private bool isChannelingActive = false;
private float channelElapsedTime = 0f;
private float channelTickAccumulator = 0f;
private GameObject channelVfxInstance;
private readonly List<SkillEffect> currentChannelTickEffects = new();
private readonly List<SkillEffect> currentChannelEndEffects = new();
// 반복 유지 단계 상태
private bool isLoopPhaseActive = false;
private float loopElapsedTime = 0f;
private float loopTickAccumulator = 0f;
private GameObject loopVfxInstance;
private readonly List<SkillEffect> currentLoopTickEffects = new();
private readonly List<SkillEffect> currentLoopExitEffects = new();
private readonly List<SkillEffect> currentReleaseStartEffects = new();
private bool loopHoldRequested = false;
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
@@ -114,7 +119,8 @@ namespace Colosseum.Skills
public string LastCancelledSkillName => lastCancelledSkillName;
public SkillExecutionResult LastExecutionResult => lastExecutionResult;
public GameObject CurrentTargetOverride => currentTargetOverride;
public bool IsChannelingActive => isChannelingActive;
public bool IsChannelingActive => isLoopPhaseActive;
public bool IsLoopPhaseActive => isLoopPhaseActive;
private void Awake()
{
@@ -251,10 +257,10 @@ namespace Colosseum.Skills
UpdateCastTargetTracking();
// 채널링 중일 때
if (isChannelingActive)
// 반복 유지 단계 중일 때
if (isLoopPhaseActive)
{
UpdateChanneling();
UpdateLoopPhase();
return;
}
@@ -267,18 +273,18 @@ namespace Colosseum.Skills
if (TryPlayNextClipInSequence())
return;
// 다음 반복 차수가 있으면 시작
if (TryStartNextIteration())
return;
// 채널링 스킬이면 채널링 시작
if (currentSkill.IsChanneling)
if (!isPlayingReleasePhase)
{
StartChanneling();
return;
// 다음 반복 차수가 있으면 시작
if (TryStartNextIteration())
return;
// 반복 유지 단계가 있으면 시작
if (TryStartLoopPhase())
return;
}
// 모든 클립과 반복이 끝나면 종료
// 모든 클립과 단계가 끝나면 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
@@ -376,6 +382,7 @@ namespace Colosseum.Skills
BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0;
loopHoldRequested = skill.RequiresLoopHold;
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
@@ -492,6 +499,9 @@ namespace Colosseum.Skills
currentTriggeredEffects.Clear();
currentCastStartAbnormalities.Clear();
currentTriggeredAbnormalities.Clear();
currentLoopTickEffects.Clear();
currentLoopExitEffects.Clear();
currentReleaseStartEffects.Clear();
if (loadoutEntry == null)
return;
@@ -500,8 +510,9 @@ namespace Colosseum.Skills
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities);
loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities);
loadoutEntry.CollectChannelTickEffects(currentChannelTickEffects);
loadoutEntry.CollectChannelEndEffects(currentChannelEndEffects);
loadoutEntry.CollectLoopTickEffects(currentLoopTickEffects);
loadoutEntry.CollectLoopExitEffects(currentLoopExitEffects);
loadoutEntry.CollectReleaseStartEffects(currentReleaseStartEffects);
}
/// <summary>
@@ -514,6 +525,8 @@ namespace Colosseum.Skills
currentIterationIndex++;
currentClipSequenceIndex = 0;
isPlayingReleasePhase = false;
currentPhaseAnimationClips = currentSkill.AnimationClips;
if (debugMode && currentRepeatCount > 1)
{
@@ -522,13 +535,13 @@ namespace Colosseum.Skills
TriggerCastStartEffects();
if (currentSkill.AnimationClips.Count > 0 && animator != null)
if (currentPhaseAnimationClips.Count > 0 && animator != null)
{
float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentSkill.AnimationClips[0]);
PlaySkillClip(currentPhaseAnimationClips[0]);
}
TriggerImmediateSelfEffectsIfNeeded();
@@ -542,16 +555,19 @@ namespace Colosseum.Skills
if (currentSkill == null)
return false;
if (currentPhaseAnimationClips == null)
return false;
int nextIndex = currentClipSequenceIndex + 1;
if (nextIndex >= currentSkill.AnimationClips.Count)
if (nextIndex >= currentPhaseAnimationClips.Count)
return false;
currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentSkill.AnimationClips[currentClipSequenceIndex]);
PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex]);
if (debugMode)
{
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentSkill.AnimationClips.Count}: {currentSkill.AnimationClips[currentClipSequenceIndex].name}");
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentPhaseAnimationClips.Count}: {currentPhaseAnimationClips[currentClipSequenceIndex].name}");
}
return true;
@@ -725,6 +741,45 @@ namespace Colosseum.Skills
if (debugMode) Debug.Log($"[Skill] End event received: {currentSkill.SkillName}");
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 상태를 시작합니다.
/// </summary>
public void OnDefenseStateEnter()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.EnterDefenseState();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 상태를 종료합니다.
/// </summary>
public void OnDefenseStateExit()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.ExitDefenseState();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 시작합니다.
/// </summary>
public void OnDefenseSustainEnter()
{
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
if (sustainController == null)
sustainController = gameObject.AddComponent<PlayerDefenseSustainController>();
sustainController.BeginSustain();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 종료합니다.
/// </summary>
public void OnDefenseSustainExit()
{
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
sustainController?.EndSustain();
}
/// <summary>
/// 현재 스킬을 강제 취소합니다.
/// </summary>
@@ -743,6 +798,33 @@ namespace Colosseum.Skills
return true;
}
/// <summary>
/// 서버에서 현재 스킬 취소를 확정하고 클라이언트에 동기화합니다.
/// </summary>
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))
@@ -776,49 +858,64 @@ namespace Colosseum.Skills
}
/// <summary>
/// 채널링을 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다.
/// 반복 유지 단계를 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다.
/// </summary>
private void StartChanneling()
private bool TryStartLoopPhase()
{
if (currentSkill == null || !currentSkill.IsChanneling)
return;
if (currentSkill == null || !currentSkill.HasLoopPhase)
return false;
isChannelingActive = true;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
if (currentSkill.RequiresLoopHold && !loopHoldRequested)
{
if (debugMode)
Debug.Log($"[Skill] 반복 유지 진입 전 버튼 해제됨: {currentSkill.SkillName}");
SpawnChannelVfx();
if (TryStartReleasePhase())
return true;
RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
return true;
}
isLoopPhaseActive = true;
loopElapsedTime = 0f;
loopTickAccumulator = 0f;
SpawnLoopVfx();
if (debugMode)
Debug.Log($"[Skill] 채널링 시작: {currentSkill.SkillName} (duration={currentSkill.ChannelDuration}s, tick={currentSkill.ChannelTickInterval}s)");
Debug.Log($"[Skill] 반복 유지 시작: {currentSkill.SkillName} (duration={currentSkill.LoopMaxDuration}s, tick={currentSkill.LoopTickInterval}s)");
return true;
}
/// <summary>
/// 채널링 VFX를 시전자 위치에 생성합니다.
/// 반복 유지 VFX를 시전자 위치에 생성합니다.
/// </summary>
private void SpawnChannelVfx()
private void SpawnLoopVfx()
{
if (currentSkill == null || currentSkill.ChannelVfxPrefab == null)
if (currentSkill == null || currentSkill.LoopVfxPrefab == null)
return;
Transform mount = ResolveChannelVfxMount();
Transform mount = ResolveLoopVfxMount();
Vector3 spawnPos = mount != null ? mount.position : transform.position;
channelVfxInstance = UnityEngine.Object.Instantiate(
currentSkill.ChannelVfxPrefab,
loopVfxInstance = UnityEngine.Object.Instantiate(
currentSkill.LoopVfxPrefab,
spawnPos,
transform.rotation);
if (mount != null)
channelVfxInstance.transform.SetParent(mount);
loopVfxInstance.transform.SetParent(mount);
channelVfxInstance.transform.localScale = new Vector3(
currentSkill.ChannelVfxWidthScale,
currentSkill.ChannelVfxWidthScale,
currentSkill.ChannelVfxLengthScale);
loopVfxInstance.transform.localScale = new Vector3(
currentSkill.LoopVfxWidthScale,
currentSkill.LoopVfxWidthScale,
currentSkill.LoopVfxLengthScale);
// 모든 파티클을 루핑 모드로 설정
ForceLoopParticleSystems(channelVfxInstance);
ForceLoopParticleSystems(loopVfxInstance);
}
/// <summary>
@@ -842,24 +939,24 @@ namespace Colosseum.Skills
}
/// <summary>
/// channelVfxMountPath에서 VFX 장착 위치를 찾습니다.
/// loopVfxMountPath에서 VFX 장착 위치를 찾습니다.
/// </summary>
private Transform ResolveChannelVfxMount()
private Transform ResolveLoopVfxMount()
{
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.ChannelVfxMountPath))
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.LoopVfxMountPath))
return null;
// Animator 하위에서 이름으로 재귀 검색
Animator animator = GetComponentInChildren<Animator>();
if (animator != null)
{
Transform found = FindTransformRecursive(animator.transform, currentSkill.ChannelVfxMountPath);
Transform found = FindTransformRecursive(animator.transform, currentSkill.LoopVfxMountPath);
if (found != null)
return found;
}
// 자식 GameObject에서 경로 검색
return transform.Find(currentSkill.ChannelVfxMountPath);
return transform.Find(currentSkill.LoopVfxMountPath);
}
private static Transform FindTransformRecursive(Transform parent, string name)
@@ -878,60 +975,60 @@ namespace Colosseum.Skills
}
/// <summary>
/// 채널링 VFX를 파괴합니다.
/// 반복 유지 VFX를 파괴합니다.
/// </summary>
private void DestroyChannelVfx()
private void DestroyLoopVfx()
{
if (channelVfxInstance != null)
if (loopVfxInstance != null)
{
UnityEngine.Object.Destroy(channelVfxInstance);
channelVfxInstance = null;
UnityEngine.Object.Destroy(loopVfxInstance);
loopVfxInstance = null;
}
}
/// <summary>
/// 채널링을 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
/// 반복 유지 단계를 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
/// </summary>
private void UpdateChanneling()
private void UpdateLoopPhase()
{
if (!isChannelingActive || currentSkill == null)
if (!isLoopPhaseActive || currentSkill == null)
return;
channelElapsedTime += Time.deltaTime;
channelTickAccumulator += Time.deltaTime;
loopElapsedTime += Time.deltaTime;
loopTickAccumulator += Time.deltaTime;
// 틱 효과 발동
float tickInterval = currentSkill.ChannelTickInterval;
while (channelTickAccumulator >= tickInterval)
float tickInterval = currentSkill.LoopTickInterval;
while (loopTickAccumulator >= tickInterval)
{
channelTickAccumulator -= tickInterval;
TriggerChannelTick();
loopTickAccumulator -= tickInterval;
TriggerLoopTick();
}
// 지속 시간 초과 → 채널링 종료
if (channelElapsedTime >= currentSkill.ChannelDuration)
// 지속 시간 초과 → 반복 유지 종료
if (currentSkill.UsesLoopMaxDuration && loopElapsedTime >= currentSkill.LoopMaxDuration)
{
if (debugMode)
Debug.Log($"[Skill] 채널링 지속 시간 만료: {currentSkill.SkillName}");
EndChanneling();
Debug.Log($"[Skill] 반복 유지 지속 시간 만료: {currentSkill.SkillName}");
EndLoopPhase();
}
}
/// <summary>
/// 채널링 틱 효과를 발동합니다.
/// 반복 유지 틱 효과를 발동합니다.
/// </summary>
private void TriggerChannelTick()
private void TriggerLoopTick()
{
if (currentChannelTickEffects.Count == 0)
if (currentLoopTickEffects.Count == 0)
return;
if (debugMode)
Debug.Log($"[Skill] 채널링 틱 발동: {currentSkill.SkillName} (elapsed={channelElapsedTime:F1}s)");
Debug.Log($"[Skill] 반복 유지 틱 발동: {currentSkill.SkillName} (elapsed={loopElapsedTime:F1}s)");
// VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelTickEffects.Count; i++)
for (int i = 0; i < currentLoopTickEffects.Count; i++)
{
SkillEffect effect = currentChannelTickEffects[i];
SkillEffect effect = currentLoopTickEffects[i];
if (effect != null && effect.IsVisualOnly)
{
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
@@ -942,14 +1039,14 @@ namespace Colosseum.Skills
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentChannelTickEffects.Count; i++)
for (int i = 0; i < currentLoopTickEffects.Count; i++)
{
SkillEffect effect = currentChannelTickEffects[i];
SkillEffect effect = currentLoopTickEffects[i];
if (effect == null || effect.IsVisualOnly)
continue;
if (debugMode)
Debug.Log($"[Skill] 채널링 틱 효과: {effect.name}");
Debug.Log($"[Skill] 반복 유지 틱 효과: {effect.name}");
if (showAreaDebug)
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
@@ -959,40 +1056,43 @@ namespace Colosseum.Skills
}
/// <summary>
/// 채널링을 종료합니다. 종료 효과를 발동하고 스킬 상태를 정리합니다.
/// 반복 유지 단계를 종료합니다. 종료 효과를 발동하고 다음 단계를 시작합니다.
/// </summary>
private void EndChanneling()
private void EndLoopPhase()
{
if (!isChannelingActive)
if (!isLoopPhaseActive)
return;
// 채널링 종료 효과 발동
TriggerChannelEndEffects();
DestroyChannelVfx();
// 반복 유지 종료 효과 발동
TriggerLoopExitEffects();
DestroyLoopVfx();
isChannelingActive = false;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
isLoopPhaseActive = false;
loopElapsedTime = 0f;
loopTickAccumulator = 0f;
if (debugMode)
Debug.Log($"[Skill] 채널링 종료: {currentSkill?.SkillName}");
Debug.Log($"[Skill] 반복 유지 종료: {currentSkill?.SkillName}");
if (TryStartReleasePhase())
return;
RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
}
/// <summary>
/// 채널링 종료 효과를 발동합니다.
/// 반복 유지 종료 효과를 발동합니다.
/// </summary>
private void TriggerChannelEndEffects()
private void TriggerLoopExitEffects()
{
if (currentChannelEndEffects.Count == 0)
if (currentLoopExitEffects.Count == 0)
return;
// VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelEndEffects.Count; i++)
for (int i = 0; i < currentLoopExitEffects.Count; i++)
{
SkillEffect effect = currentChannelEndEffects[i];
SkillEffect effect = currentLoopExitEffects[i];
if (effect != null && effect.IsVisualOnly)
{
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
@@ -1003,32 +1103,104 @@ namespace Colosseum.Skills
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentChannelEndEffects.Count; i++)
for (int i = 0; i < currentLoopExitEffects.Count; i++)
{
SkillEffect effect = currentChannelEndEffects[i];
SkillEffect effect = currentLoopExitEffects[i];
if (effect == null || effect.IsVisualOnly)
continue;
if (debugMode)
Debug.Log($"[Skill] 채널링 종료 효과: {effect.name}");
Debug.Log($"[Skill] 반복 유지 종료 효과: {effect.name}");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
/// <summary>
/// 플레이어가 버튼을 놓았을 때 채널링을 중단합니다.
/// 해제 단계를 시작합니다.
/// </summary>
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]);
if (debugMode)
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
return true;
}
/// <summary>
/// 해제 단계 시작 효과를 발동합니다.
/// </summary>
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);
}
}
/// <summary>
/// 플레이어가 버튼을 놓았을 때 반복 유지 단계를 중단합니다.
/// PlayerSkillInput에서 호출됩니다.
/// </summary>
public void NotifyChannelHoldReleased()
public void NotifyLoopHoldReleased()
{
if (!isChannelingActive)
if (currentSkill == null || !currentSkill.RequiresLoopHold)
return;
loopHoldRequested = false;
if (!isLoopPhaseActive)
return;
if (debugMode)
Debug.Log($"[Skill] 채널링 버튼 해제로 중단: {currentSkill?.SkillName}");
Debug.Log($"[Skill] 반복 유지 버튼 해제로 중단: {currentSkill?.SkillName}");
EndChanneling();
EndLoopPhase();
}
/// <summary>
/// 레거시 채널링 입력 해제 경로 호환 메서드입니다.
/// </summary>
public void NotifyChannelHoldReleased()
{
NotifyLoopHoldReleased();
}
/// <summary>
@@ -1043,17 +1215,21 @@ namespace Colosseum.Skills
currentCastStartAbnormalities.Clear();
currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear();
currentChannelTickEffects.Clear();
currentChannelEndEffects.Clear();
isChannelingActive = false;
channelElapsedTime = 0f;
channelTickAccumulator = 0f;
DestroyChannelVfx();
currentLoopTickEffects.Clear();
currentLoopExitEffects.Clear();
currentReleaseStartEffects.Clear();
isLoopPhaseActive = false;
loopElapsedTime = 0f;
loopTickAccumulator = 0f;
DestroyLoopVfx();
currentTargetOverride = null;
currentGroundTargetPosition = null;
currentPhaseAnimationClips = Array.Empty<AnimationClip>();
isPlayingReleasePhase = false;
currentClipSequenceIndex = 0;
currentRepeatCount = 1;
currentIterationIndex = 0;
loopHoldRequested = false;
}
/// <summary>
@@ -1062,9 +1238,19 @@ namespace Colosseum.Skills
private void CompleteCurrentSkillExecution(SkillExecutionResult result)
{
lastExecutionResult = result;
NotifyDefenseStateEnded();
ClearCurrentSkillState();
}
private void NotifyDefenseStateEnded()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.HandleSkillExecutionEnded();
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
sustainController?.HandleSkillExecutionEnded();
}
/// <summary>
/// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다.
/// </summary>

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>

View File

@@ -397,18 +397,18 @@ namespace Colosseum.Skills
}
/// <summary>
/// 기반 스킬의 채널링 틱 효과를 수집합니다.
/// 기반 스킬의 반복 유지 틱 효과를 수집합니다.
/// </summary>
public void CollectChannelTickEffects(List<SkillEffect> destination)
public void CollectLoopTickEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ChannelTickEffects != null)
if (baseSkill != null && baseSkill.LoopTickEffects != null)
{
for (int i = 0; i < baseSkill.ChannelTickEffects.Count; i++)
for (int i = 0; i < baseSkill.LoopTickEffects.Count; i++)
{
SkillEffect effect = baseSkill.ChannelTickEffects[i];
SkillEffect effect = baseSkill.LoopTickEffects[i];
if (effect != null)
destination.Add(effect);
}
@@ -426,18 +426,18 @@ namespace Colosseum.Skills
}
/// <summary>
/// 기반 스킬의 채널링 종료 효과를 수집합니다.
/// 기반 스킬의 반복 유지 종료 효과를 수집합니다.
/// </summary>
public void CollectChannelEndEffects(List<SkillEffect> destination)
public void CollectLoopExitEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ChannelEndEffects != null)
if (baseSkill != null && baseSkill.LoopExitEffects != null)
{
for (int i = 0; i < baseSkill.ChannelEndEffects.Count; i++)
for (int i = 0; i < baseSkill.LoopExitEffects.Count; i++)
{
SkillEffect effect = baseSkill.ChannelEndEffects[i];
SkillEffect effect = baseSkill.LoopExitEffects[i];
if (effect != null)
destination.Add(effect);
}
@@ -454,6 +454,51 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 기반 스킬의 해제 단계 시작 효과를 수집합니다.
/// </summary>
public void CollectReleaseStartEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ReleaseStartEffects != null)
{
for (int i = 0; i < baseSkill.ReleaseStartEffects.Count; i++)
{
SkillEffect effect = baseSkill.ReleaseStartEffects[i];
if (effect != null)
destination.Add(effect);
}
}
if (socketedGems == null)
return;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null)
continue;
}
}
/// <summary>
/// 레거시 채널링 틱 효과 수집 호환 경로입니다.
/// </summary>
public void CollectChannelTickEffects(List<SkillEffect> destination)
{
CollectLoopTickEffects(destination);
}
/// <summary>
/// 레거시 채널링 종료 효과 수집 호환 경로입니다.
/// </summary>
public void CollectChannelEndEffects(List<SkillEffect> destination)
{
CollectLoopExitEffects(destination);
}
private static void AddTriggeredEffect(Dictionary<int, List<SkillEffect>> destination, int triggerIndex, SkillEffect effect)
{
if (!destination.TryGetValue(triggerIndex, out List<SkillEffect> effectList))