feat: 방어 시스템과 드로그 검증 경로 정리
- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다. - 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다. - 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
This commit is contained in:
78
Assets/_Game/Scripts/Combat/DamageContext.cs
Normal file
78
Assets/_Game/Scripts/Combat/DamageContext.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Colosseum.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 피해가 방어/회피 규칙과 함께 전달될 때 사용하는 판정 등급입니다.
|
||||
/// </summary>
|
||||
public enum DamageMitigationTier
|
||||
{
|
||||
Normal,
|
||||
Unblockable,
|
||||
Undodgeable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 피해량과 출처, 방어 가능 여부를 함께 전달하는 런타임 컨텍스트입니다.
|
||||
/// </summary>
|
||||
public readonly struct DamageContext
|
||||
{
|
||||
public DamageContext(float amount, object source = null, DamageMitigationTier mitigationTier = DamageMitigationTier.Normal)
|
||||
{
|
||||
Amount = Mathf.Max(0f, amount);
|
||||
Source = source;
|
||||
MitigationTier = mitigationTier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 원본 피해량입니다.
|
||||
/// </summary>
|
||||
public float Amount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 피해 출처입니다. GameObject 또는 Component가 일반적입니다.
|
||||
/// </summary>
|
||||
public object Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 방어/회피 허용 규칙입니다.
|
||||
/// </summary>
|
||||
public DamageMitigationTier MitigationTier { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 현재 피해가 방어 가능한지 여부입니다.
|
||||
/// </summary>
|
||||
public bool CanBeGuarded => MitigationTier != DamageMitigationTier.Unblockable;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 피해가 회피 가능한지 여부입니다.
|
||||
/// </summary>
|
||||
public bool CanBeDodged => MitigationTier != DamageMitigationTier.Undodgeable;
|
||||
|
||||
/// <summary>
|
||||
/// 피해 출처를 GameObject로 해석합니다.
|
||||
/// </summary>
|
||||
public GameObject SourceGameObject => ResolveSourceGameObject(Source);
|
||||
|
||||
/// <summary>
|
||||
/// 같은 메타데이터를 유지한 채 피해량만 교체합니다.
|
||||
/// </summary>
|
||||
public DamageContext WithAmount(float amount)
|
||||
{
|
||||
return new DamageContext(amount, Source, MitigationTier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// object 기반 출처를 GameObject로 변환합니다.
|
||||
/// </summary>
|
||||
public static GameObject ResolveSourceGameObject(object source)
|
||||
{
|
||||
return source switch
|
||||
{
|
||||
GameObject sourceObject => sourceObject,
|
||||
Component component => component.gameObject,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Combat/DamageContext.cs.meta
Normal file
2
Assets/_Game/Scripts/Combat/DamageContext.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2407c0c8ab0fa0a9be210edea29e451
|
||||
@@ -103,10 +103,19 @@ namespace Colosseum.Combat
|
||||
/// </summary>
|
||||
public float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
return TakeDamage(new DamageContext(damage, source));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 컨텍스트를 사용해 피해를 적용합니다.
|
||||
/// </summary>
|
||||
public float TakeDamage(DamageContext damageContext)
|
||||
{
|
||||
float damage = damageContext.Amount;
|
||||
if (!IsServer || damage <= 0f)
|
||||
return 0f;
|
||||
|
||||
GameObject sourceObject = ResolveSource(source);
|
||||
GameObject sourceObject = damageContext.SourceGameObject;
|
||||
float actualDamage = Mathf.Min(damage, currentHealth);
|
||||
currentHealth = Mathf.Max(0f, currentHealth - actualDamage);
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ namespace Colosseum.Combat
|
||||
/// <returns>실제로 적용된 대미지량</returns>
|
||||
float TakeDamage(float damage, object source = null);
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
|
||||
/// </summary>
|
||||
/// <param name="damageContext">피해량과 방어 규칙이 담긴 컨텍스트</param>
|
||||
/// <returns>실제로 적용된 대미지량</returns>
|
||||
float TakeDamage(DamageContext damageContext);
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복
|
||||
/// </summary>
|
||||
|
||||
@@ -54,6 +54,9 @@ namespace Colosseum.Editor
|
||||
AnimationClip comboBasic1Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_0.anim");
|
||||
AnimationClip comboBasic1Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_1.anim");
|
||||
AnimationClip comboBasic1Hit2Clip = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_2_0.anim", LightCombo01BSourcePath, "A_MOD_SWD_Attack_LightCombo01B_RM_Neut");
|
||||
SetSingleOnEffectEvent(comboBasic1Hit1Clip0, -1f);
|
||||
SetSingleOnEffectEvent(comboBasic1Hit1Clip1, 0.30f);
|
||||
SetSingleOnEffectEvent(comboBasic1Hit2Clip, 0.28f);
|
||||
|
||||
AnimationClip comboBasic2Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_0.anim");
|
||||
AnimationClip comboBasic2Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_1.anim");
|
||||
@@ -971,6 +974,35 @@ namespace Colosseum.Editor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 클립에 단일 OnEffect(0) 이벤트를 설정합니다. 음수 시간이면 이벤트를 비웁니다.
|
||||
/// </summary>
|
||||
private static void SetSingleOnEffectEvent(AnimationClip clip, float time)
|
||||
{
|
||||
if (clip == null)
|
||||
return;
|
||||
|
||||
if (time < 0f)
|
||||
{
|
||||
AnimationUtility.SetAnimationEvents(clip, Array.Empty<AnimationEvent>());
|
||||
EditorUtility.SetDirty(clip);
|
||||
return;
|
||||
}
|
||||
|
||||
float clampedTime = Mathf.Clamp(time, 0f, Mathf.Max(0f, clip.length - 0.01f));
|
||||
AnimationUtility.SetAnimationEvents(clip, new[]
|
||||
{
|
||||
new AnimationEvent
|
||||
{
|
||||
time = clampedTime,
|
||||
functionName = "OnEffect",
|
||||
intParameter = 0,
|
||||
},
|
||||
});
|
||||
|
||||
EditorUtility.SetDirty(clip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 범위형 효과의 공통 판정 설정을 적용합니다.
|
||||
/// </summary>
|
||||
@@ -987,6 +1019,7 @@ namespace Colosseum.Editor
|
||||
serializedObject.FindProperty("targetTeam").enumValueIndex = (int)TargetTeam.Enemy;
|
||||
serializedObject.FindProperty("areaCenter").enumValueIndex = (int)areaCenter;
|
||||
serializedObject.FindProperty("areaShape").enumValueIndex = (int)areaShape;
|
||||
serializedObject.FindProperty("targetLayers").intValue = Physics.AllLayers;
|
||||
serializedObject.FindProperty("includeCasterInArea").boolValue = false;
|
||||
serializedObject.FindProperty("areaRadius").floatValue = areaRadius;
|
||||
serializedObject.FindProperty("fanOriginDistance").floatValue = fanOriginDistance;
|
||||
@@ -1173,7 +1206,8 @@ namespace Colosseum.Editor
|
||||
serializedObject.FindProperty("cooldown").floatValue = 0f;
|
||||
serializedObject.FindProperty("manaCost").floatValue = 0f;
|
||||
serializedObject.FindProperty("maxGemSlotCount").intValue = 0;
|
||||
serializedObject.FindProperty("triggeredEffects").arraySize = 0;
|
||||
serializedObject.FindProperty("castStartEffects").arraySize = 0;
|
||||
ConfigureTriggeredEffects(serializedObject, castStartEffects);
|
||||
|
||||
var clipObjects = new List<UnityEngine.Object>();
|
||||
if (clips != null)
|
||||
@@ -1187,17 +1221,6 @@ namespace Colosseum.Editor
|
||||
|
||||
SetObjectList(serializedObject, "animationClips", clipObjects);
|
||||
|
||||
var effectObjects = new List<UnityEngine.Object>();
|
||||
if (castStartEffects != null)
|
||||
{
|
||||
for (int i = 0; i < castStartEffects.Length; i++)
|
||||
{
|
||||
if (castStartEffects[i] != null)
|
||||
effectObjects.Add(castStartEffects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
SetObjectList(serializedObject, "castStartEffects", effectObjects);
|
||||
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||
skill.RefreshAnimationClips();
|
||||
|
||||
@@ -1205,6 +1228,40 @@ namespace Colosseum.Editor
|
||||
return skill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 전달된 효과를 모두 Trigger Index 0의 애니메이션 이벤트 효과로 기록합니다.
|
||||
/// </summary>
|
||||
private static void ConfigureTriggeredEffects(SerializedObject serializedObject, IReadOnlyList<SkillEffect> effects)
|
||||
{
|
||||
SerializedProperty triggeredEffectsProperty = serializedObject.FindProperty("triggeredEffects");
|
||||
if (triggeredEffectsProperty == null)
|
||||
return;
|
||||
|
||||
var validEffects = new List<SkillEffect>();
|
||||
if (effects != null)
|
||||
{
|
||||
for (int i = 0; i < effects.Count; i++)
|
||||
{
|
||||
if (effects[i] != null)
|
||||
validEffects.Add(effects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
triggeredEffectsProperty.arraySize = validEffects.Count > 0 ? 1 : 0;
|
||||
if (triggeredEffectsProperty.arraySize == 0)
|
||||
return;
|
||||
|
||||
SerializedProperty entryProperty = triggeredEffectsProperty.GetArrayElementAtIndex(0);
|
||||
entryProperty.FindPropertyRelative("triggerIndex").intValue = 0;
|
||||
SerializedProperty effectsProperty = entryProperty.FindPropertyRelative("effects");
|
||||
effectsProperty.arraySize = validEffects.Count;
|
||||
|
||||
for (int i = 0; i < validEffects.Count; i++)
|
||||
{
|
||||
effectsProperty.GetArrayElementAtIndex(i).objectReferenceValue = validEffects[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 드로그 보스 패턴 자산을 생성하거나 갱신합니다.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,6 +9,7 @@ using Colosseum.Abnormalities;
|
||||
using Colosseum.Passives;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
@@ -28,6 +29,8 @@ namespace Colosseum.Enemy
|
||||
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
[Tooltip("이상상태 관리자")]
|
||||
[SerializeField] protected AbnormalityManager abnormalityManager;
|
||||
[Tooltip("플레이어와의 물리 겹침을 계산할 본체 콜라이더")]
|
||||
[SerializeField] private Collider bodyCollider;
|
||||
|
||||
[Header("Data")]
|
||||
[SerializeField] protected EnemyData enemyData;
|
||||
@@ -46,6 +49,12 @@ namespace Colosseum.Enemy
|
||||
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
|
||||
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
||||
|
||||
[Header("Player Separation")]
|
||||
[Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")]
|
||||
[Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f;
|
||||
[Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")]
|
||||
[SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true;
|
||||
|
||||
|
||||
// 네트워크 동기화 변수
|
||||
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
@@ -95,6 +104,8 @@ namespace Colosseum.Enemy
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
if (abnormalityManager == null)
|
||||
abnormalityManager = GetComponent<AbnormalityManager>();
|
||||
if (bodyCollider == null)
|
||||
bodyCollider = GetComponent<Collider>();
|
||||
|
||||
// 서버에서 초기화
|
||||
if (IsServer)
|
||||
@@ -126,34 +137,27 @@ namespace Colosseum.Enemy
|
||||
protected virtual void OnServerUpdate() { }
|
||||
|
||||
/// <summary>
|
||||
/// 보스와 플레이어가 겹치면 플레이어를 밀어냅니다.
|
||||
/// 보스와 플레이어가 겹치면 적 자신을 살짝 밀어내 겹침을 해소합니다.
|
||||
/// 점프 착지 포함, 항상 실행됩니다.
|
||||
/// </summary>
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (!IsServer || IsDead) return;
|
||||
|
||||
float separationDist = navMeshAgent != null
|
||||
? Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f)
|
||||
: 1f;
|
||||
Vector3 separationOffset = ComputePlayerSeparationOffset();
|
||||
if (separationOffset.sqrMagnitude <= 0.000001f)
|
||||
return;
|
||||
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, separationDist, overlapBuffer);
|
||||
for (int i = 0; i < count; i++)
|
||||
if (navMeshAgent != null && !isAirborne && navMeshAgent.enabled)
|
||||
{
|
||||
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out var cc)) continue;
|
||||
|
||||
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
|
||||
toPlayer.y = 0f;
|
||||
float dist = toPlayer.magnitude;
|
||||
if (dist >= separationDist) continue;
|
||||
|
||||
// 플레이어를 보스 바깥으로 밀어냄
|
||||
Vector3 pushDir = dist > 0.001f ? toPlayer.normalized : transform.forward;
|
||||
cc.Move(pushDir * (separationDist - dist));
|
||||
|
||||
// 보스가 이동 중이었으면 정지 (플레이어 안으로 더 진입하지 않도록)
|
||||
if (navMeshAgent != null && !isAirborne && navMeshAgent.velocity.sqrMagnitude > 0.01f)
|
||||
if (navMeshAgent.velocity.sqrMagnitude > 0.01f)
|
||||
navMeshAgent.isStopped = true;
|
||||
|
||||
navMeshAgent.Move(separationOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.position += separationOffset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,23 +226,10 @@ namespace Colosseum.Enemy
|
||||
navMeshAgent.Warp(transform.position);
|
||||
}
|
||||
|
||||
// XZ 차단: 플레이어 방향으로의 이동 방지 (일반 이동 중에만)
|
||||
float blockRadius = Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f);
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, blockRadius, overlapBuffer);
|
||||
for (int i = 0; i < count; i++)
|
||||
if (freezeHorizontalMotionOnPlayerContact && IsTouchingPlayer())
|
||||
{
|
||||
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out _)) continue;
|
||||
|
||||
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
|
||||
toPlayer.y = 0f;
|
||||
if (toPlayer.sqrMagnitude < 0.0001f) continue;
|
||||
|
||||
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
|
||||
if (Vector3.Dot(deltaXZ, toPlayer.normalized) > 0f)
|
||||
{
|
||||
deltaPosition.x = 0f;
|
||||
deltaPosition.z = 0f;
|
||||
}
|
||||
deltaPosition.x = 0f;
|
||||
deltaPosition.z = 0f;
|
||||
}
|
||||
|
||||
navMeshAgent.Move(deltaPosition);
|
||||
@@ -282,23 +273,36 @@ namespace Colosseum.Enemy
|
||||
/// 대미지 적용 (서버에서 실행)
|
||||
/// </summary>
|
||||
public virtual float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
return TakeDamage(new DamageContext(damage, source));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
|
||||
/// </summary>
|
||||
public virtual float TakeDamage(DamageContext damageContext)
|
||||
{
|
||||
if (!IsServer || isDead.Value)
|
||||
return 0f;
|
||||
|
||||
if (ShouldIgnoreIncomingDamage(damage, source))
|
||||
float damage = damageContext.Amount;
|
||||
if (damage <= 0f)
|
||||
return 0f;
|
||||
|
||||
if (ShouldIgnoreIncomingDamage(damage, damageContext.Source))
|
||||
return 0f;
|
||||
|
||||
float mitigatedDamage = ConsumeShield(damage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
|
||||
RegisterThreatFromDamage(actualDamage, source);
|
||||
GameObject sourceObject = damageContext.SourceGameObject;
|
||||
CombatBalanceTracker.RecordDamage(sourceObject, gameObject, actualDamage);
|
||||
RegisterThreatFromDamage(actualDamage, sourceObject);
|
||||
OnDamageTaken?.Invoke(actualDamage);
|
||||
|
||||
// 대미지 피드백 (애니메이션, 이펙트 등)
|
||||
OnTakeDamageFeedback(actualDamage, source);
|
||||
OnTakeDamageFeedback(actualDamage, damageContext.Source);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
@@ -327,6 +331,93 @@ namespace Colosseum.Enemy
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 ComputePlayerSeparationOffset()
|
||||
{
|
||||
if (bodyCollider == null)
|
||||
return Vector3.zero;
|
||||
|
||||
float scanRadius = GetPlayerDetectionRadius();
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
|
||||
Vector3 separationOffset = Vector3.zero;
|
||||
int overlapCount = 0;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
|
||||
continue;
|
||||
if (!Physics.ComputePenetration(
|
||||
bodyCollider, transform.position, transform.rotation,
|
||||
playerController, playerController.transform.position, playerController.transform.rotation,
|
||||
out Vector3 separationDirection, out float separationDistance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
separationDirection.y = 0f;
|
||||
if (separationDirection.sqrMagnitude <= 0.0001f)
|
||||
separationDirection = -transform.forward;
|
||||
|
||||
separationOffset += separationDirection.normalized * (separationDistance + playerSeparationPadding);
|
||||
overlapCount++;
|
||||
}
|
||||
|
||||
if (overlapCount <= 0)
|
||||
return Vector3.zero;
|
||||
|
||||
separationOffset /= overlapCount;
|
||||
separationOffset.y = 0f;
|
||||
return separationOffset;
|
||||
}
|
||||
|
||||
private bool IsTouchingPlayer()
|
||||
{
|
||||
float scanRadius = GetPlayerDetectionRadius();
|
||||
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
|
||||
continue;
|
||||
|
||||
Vector3 toPlayer = playerController.transform.position - transform.position;
|
||||
toPlayer.y = 0f;
|
||||
if (toPlayer.magnitude < GetRequiredSeparationDistance(playerController))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private float GetPlayerDetectionRadius()
|
||||
{
|
||||
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
|
||||
return enemyRadius + 1f + playerSeparationPadding;
|
||||
}
|
||||
|
||||
private float GetRequiredSeparationDistance(CharacterController playerController)
|
||||
{
|
||||
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
|
||||
float playerRadius = playerController != null ? playerController.radius : 0.5f;
|
||||
return enemyRadius + playerRadius + playerSeparationPadding;
|
||||
}
|
||||
|
||||
private static bool TryGetPlayerCharacterController(Collider overlapCollider, out CharacterController playerController)
|
||||
{
|
||||
playerController = null;
|
||||
if (overlapCollider == null)
|
||||
return false;
|
||||
|
||||
playerController = overlapCollider.GetComponent<CharacterController>();
|
||||
if (playerController == null)
|
||||
playerController = overlapCollider.GetComponentInParent<CharacterController>();
|
||||
|
||||
if (playerController == null)
|
||||
return false;
|
||||
|
||||
return playerController.GetComponent<PlayerNetworkController>() != null
|
||||
|| playerController.GetComponentInParent<PlayerNetworkController>() != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복 (서버에서 실행)
|
||||
/// </summary>
|
||||
|
||||
@@ -25,6 +25,9 @@ namespace Colosseum.Player
|
||||
[Tooltip("피격 제어 관리자")]
|
||||
[SerializeField] private HitReactionController hitReactionController;
|
||||
|
||||
[Tooltip("방어 상태 관리자")]
|
||||
[SerializeField] private PlayerDefenseController defenseController;
|
||||
|
||||
[Tooltip("관전 관리자")]
|
||||
[SerializeField] private PlayerSpectator spectator;
|
||||
|
||||
@@ -145,7 +148,9 @@ namespace Colosseum.Player
|
||||
if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsDowned)
|
||||
return 0f;
|
||||
|
||||
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
|
||||
float abnormalityMultiplier = abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
|
||||
float defenseMultiplier = defenseController != null ? defenseController.MoveSpeedMultiplier : 1f;
|
||||
return abnormalityMultiplier * defenseMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +164,8 @@ namespace Colosseum.Player
|
||||
skillController = GetComponent<SkillController>();
|
||||
if (hitReactionController == null)
|
||||
hitReactionController = GetOrCreateHitReactionController();
|
||||
if (defenseController == null)
|
||||
defenseController = GetOrCreateDefenseController();
|
||||
if (spectator == null)
|
||||
spectator = GetComponentInChildren<PlayerSpectator>();
|
||||
}
|
||||
@@ -195,5 +202,14 @@ namespace Colosseum.Player
|
||||
|
||||
return gameObject.AddComponent<HitReactionController>();
|
||||
}
|
||||
|
||||
private PlayerDefenseController GetOrCreateDefenseController()
|
||||
{
|
||||
PlayerDefenseController foundController = GetComponent<PlayerDefenseController>();
|
||||
if (foundController != null)
|
||||
return foundController;
|
||||
|
||||
return gameObject.AddComponent<PlayerDefenseController>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
Assets/_Game/Scripts/Player/PlayerDefenseController.cs
Normal file
225
Assets/_Game/Scripts/Player/PlayerDefenseController.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Combat;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어의 방어 판정 상태를 관리합니다.
|
||||
/// 마나 유지나 이동 감속 없이 순수하게 방어 가능 여부와 피해 감쇠만 처리합니다.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerDefenseController : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("완벽한 방어 보상과 이동 감속을 처리하는 유지 컨트롤러")]
|
||||
[SerializeField] private PlayerDefenseSustainController sustainController;
|
||||
|
||||
[Header("Defense Settings")]
|
||||
[Tooltip("정면 판정을 사용할지 여부")]
|
||||
[SerializeField] private bool useFrontGuardArc = true;
|
||||
|
||||
[Tooltip("방어가 유효한 정면 반각입니다.")]
|
||||
[Range(1f, 89f)] [SerializeField] private float guardHalfAngle = 75f;
|
||||
|
||||
[Tooltip("일반 방어 성공 시 남는 피해 배율입니다.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float guardDamageMultiplier = 0.65f;
|
||||
|
||||
[Tooltip("방어 시작 후 완벽한 방어로 인정하는 시간 창입니다.")]
|
||||
[Min(0f)] [SerializeField] private float perfectGuardWindow = 0.5f;
|
||||
|
||||
[Tooltip("방어 판정 상세 로그 출력 여부")]
|
||||
[SerializeField] private bool enableDefenseDebugLogs = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("현재 방어 판정 활성 여부")]
|
||||
[SerializeField] private bool isDefenseStateActive;
|
||||
|
||||
[Tooltip("현재 방어 판정 유지 시간")]
|
||||
[Min(0f)] [SerializeField] private float defenseStateElapsed;
|
||||
|
||||
[Tooltip("마지막 방어 판정으로 막은 피해량")]
|
||||
[Min(0f)] [SerializeField] private float lastPreventedDamage;
|
||||
|
||||
[Tooltip("마지막 방어 판정이 완벽한 방어였는지 여부")]
|
||||
[SerializeField] private bool lastWasPerfectGuard;
|
||||
|
||||
private bool perfectGuardAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// 방어 시작 시 완벽한 방어 유효 시간을 전달합니다.
|
||||
/// </summary>
|
||||
public event Action<float> OnDefenseStateEntered;
|
||||
|
||||
/// <summary>
|
||||
/// 방어 성공 시 일반/완벽 여부와 막은 피해량을 전달합니다.
|
||||
/// </summary>
|
||||
public event Action<bool, float> OnDefenseResolved;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 방어 판정 활성 여부입니다.
|
||||
/// </summary>
|
||||
public bool IsDefenseStateActive => isDefenseStateActive;
|
||||
|
||||
/// <summary>
|
||||
/// 방어 유지 시스템이 제공하는 이동 속도 배율입니다.
|
||||
/// </summary>
|
||||
public float MoveSpeedMultiplier
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureReferences();
|
||||
return sustainController != null ? sustainController.MoveSpeedMultiplier : 1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureReferences();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
ClearDefenseState();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isDefenseStateActive)
|
||||
return;
|
||||
|
||||
defenseStateElapsed += Time.deltaTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 이벤트로 방어 판정을 시작합니다.
|
||||
/// </summary>
|
||||
public void EnterDefenseState()
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
isDefenseStateActive = true;
|
||||
defenseStateElapsed = 0f;
|
||||
lastPreventedDamage = 0f;
|
||||
lastWasPerfectGuard = false;
|
||||
perfectGuardAvailable = true;
|
||||
OnDefenseStateEntered?.Invoke(perfectGuardWindow);
|
||||
|
||||
if (enableDefenseDebugLogs)
|
||||
{
|
||||
Debug.Log($"[Defense] 상태 시작 | owner={gameObject.name} | perfectWindow={perfectGuardWindow:F2}s");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 이벤트로 방어 판정을 종료합니다.
|
||||
/// </summary>
|
||||
public void ExitDefenseState()
|
||||
{
|
||||
ClearDefenseState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 종료/취소 시 방어 판정을 정리합니다.
|
||||
/// </summary>
|
||||
public void HandleSkillExecutionEnded()
|
||||
{
|
||||
ClearDefenseState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 방어 판정이 유효하다면 피해를 감쇠합니다.
|
||||
/// </summary>
|
||||
public float ResolveIncomingDamage(DamageContext damageContext)
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
if (!isDefenseStateActive || !damageContext.CanBeGuarded)
|
||||
return damageContext.Amount;
|
||||
|
||||
if (useFrontGuardArc && !IsWithinGuardArc(damageContext.SourceGameObject))
|
||||
return damageContext.Amount;
|
||||
|
||||
bool isPerfectGuard = perfectGuardAvailable && defenseStateElapsed <= perfectGuardWindow;
|
||||
perfectGuardAvailable = false;
|
||||
lastWasPerfectGuard = isPerfectGuard;
|
||||
|
||||
if (isPerfectGuard)
|
||||
{
|
||||
sustainController?.RefundStartManaOnPerfectGuard();
|
||||
lastPreventedDamage = damageContext.Amount;
|
||||
LogDefenseResolution(damageContext, true, 0f);
|
||||
OnDefenseResolved?.Invoke(true, lastPreventedDamage);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float resolvedDamage = damageContext.Amount * guardDamageMultiplier;
|
||||
lastPreventedDamage = Mathf.Max(0f, damageContext.Amount - resolvedDamage);
|
||||
LogDefenseResolution(damageContext, false, resolvedDamage);
|
||||
OnDefenseResolved?.Invoke(false, lastPreventedDamage);
|
||||
return resolvedDamage;
|
||||
}
|
||||
|
||||
private void EnsureReferences()
|
||||
{
|
||||
if (sustainController == null)
|
||||
sustainController = GetComponent<PlayerDefenseSustainController>();
|
||||
}
|
||||
|
||||
private bool IsWithinGuardArc(GameObject sourceObject)
|
||||
{
|
||||
if (sourceObject == null)
|
||||
return true;
|
||||
|
||||
Vector3 toSource = sourceObject.transform.position - transform.position;
|
||||
toSource.y = 0f;
|
||||
if (toSource.sqrMagnitude <= 0.0001f)
|
||||
return true;
|
||||
|
||||
float dot = Vector3.Dot(transform.forward.normalized, toSource.normalized);
|
||||
float threshold = Mathf.Cos(guardHalfAngle * Mathf.Deg2Rad);
|
||||
return dot >= threshold;
|
||||
}
|
||||
|
||||
private void ClearDefenseState()
|
||||
{
|
||||
isDefenseStateActive = false;
|
||||
defenseStateElapsed = 0f;
|
||||
lastPreventedDamage = 0f;
|
||||
lastWasPerfectGuard = false;
|
||||
perfectGuardAvailable = false;
|
||||
}
|
||||
|
||||
private void LogDefenseResolution(DamageContext damageContext, bool isPerfectGuard, float resolvedDamage)
|
||||
{
|
||||
if (!enableDefenseDebugLogs)
|
||||
return;
|
||||
|
||||
GameObject sourceObject = damageContext.SourceGameObject;
|
||||
string sourceName = sourceObject != null ? sourceObject.name : "Unknown";
|
||||
string sourceSkillName = ResolveSourceSkillName(sourceObject);
|
||||
string guardType = isPerfectGuard ? "완벽 방어" : "방어";
|
||||
|
||||
Debug.Log(
|
||||
$"[Defense] {guardType} 성공 | owner={gameObject.name} | source={sourceName} | skill={sourceSkillName} | incoming={damageContext.Amount:F2} | prevented={lastPreventedDamage:F2} | resolved={resolvedDamage:F2} | elapsed={defenseStateElapsed:F3}s | mitigation={damageContext.MitigationTier}");
|
||||
}
|
||||
|
||||
private static string ResolveSourceSkillName(GameObject sourceObject)
|
||||
{
|
||||
if (sourceObject == null)
|
||||
return "None";
|
||||
|
||||
SkillController skillController = sourceObject.GetComponent<SkillController>();
|
||||
if (skillController == null)
|
||||
skillController = sourceObject.GetComponentInParent<SkillController>();
|
||||
|
||||
if (skillController?.CurrentSkill == null)
|
||||
return "None";
|
||||
|
||||
return skillController.CurrentSkill.SkillName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64a105def0eba753fba29d2e8ef03638
|
||||
171
Assets/_Game/Scripts/Player/PlayerDefenseSustainController.cs
Normal file
171
Assets/_Game/Scripts/Player/PlayerDefenseSustainController.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Passives;
|
||||
using Colosseum.Skills;
|
||||
using Colosseum.Weapons;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어의 방어 유지 자원 소모를 관리합니다.
|
||||
/// 방어 판정과 분리되어 있으며, 애니메이션 이벤트로 시작/종료됩니다.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerDefenseSustainController : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("플레이어 자원 관리자")]
|
||||
[SerializeField] private PlayerNetworkController networkController;
|
||||
|
||||
[Tooltip("현재 실행 중인 스킬 정보 참조용")]
|
||||
[SerializeField] private SkillController skillController;
|
||||
|
||||
[Tooltip("무기 마나 배율 참조용")]
|
||||
[SerializeField] private WeaponEquipment weaponEquipment;
|
||||
|
||||
[Header("Sustain Settings")]
|
||||
[Tooltip("방어 유지의 초당 기본 마나 소모량입니다.")]
|
||||
[Min(0f)] [SerializeField] private float sustainManaPerSecond = 4f;
|
||||
|
||||
[Tooltip("방어 유지 시간이 길어질수록 추가되는 초당 마나 소모량입니다.")]
|
||||
[Min(0f)] [SerializeField] private float sustainManaRampPerSecond = 4f;
|
||||
|
||||
[Tooltip("완벽한 방어 성공 시 환급할 시작 마나 비율입니다.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float perfectGuardStartManaRefundRatio = 1f;
|
||||
|
||||
[Tooltip("방어 유지 중 이동 속도 배율입니다.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float sustainMoveSpeedMultiplier = 0.45f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("현재 방어 유지 활성 여부")]
|
||||
[SerializeField] private bool isSustainActive;
|
||||
|
||||
[Tooltip("현재 방어 유지 시간")]
|
||||
[Min(0f)] [SerializeField] private float sustainElapsed;
|
||||
|
||||
[Tooltip("현재 프레임에 계산된 초당 유지 마나")]
|
||||
[Min(0f)] [SerializeField] private float currentSustainManaPerSecond;
|
||||
|
||||
[Tooltip("마지막으로 캡처한 시작 마나 소모량")]
|
||||
[Min(0f)] [SerializeField] private float capturedStartManaCost;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 방어 유지 활성 여부입니다.
|
||||
/// </summary>
|
||||
public bool IsSustainActive => isSustainActive;
|
||||
|
||||
/// <summary>
|
||||
/// 방어 유지 중 이동 속도 배율입니다.
|
||||
/// </summary>
|
||||
public float MoveSpeedMultiplier => isSustainActive ? sustainMoveSpeedMultiplier : 1f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureReferences();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
ClearSustainState();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isSustainActive)
|
||||
return;
|
||||
|
||||
sustainElapsed += Time.deltaTime;
|
||||
currentSustainManaPerSecond = sustainManaPerSecond + (sustainManaRampPerSecond * sustainElapsed);
|
||||
|
||||
if (networkController == null || !networkController.IsServer)
|
||||
return;
|
||||
|
||||
float requiredMana = currentSustainManaPerSecond * Time.deltaTime;
|
||||
float actualSpentMana = networkController.SpendMana(requiredMana);
|
||||
if (actualSpentMana + 0.001f < requiredMana)
|
||||
{
|
||||
skillController?.CancelSkillFromServer(SkillCancelReason.ResourceExhausted);
|
||||
ClearSustainState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 이벤트로 방어 유지 자원 소모를 시작합니다.
|
||||
/// </summary>
|
||||
public void BeginSustain()
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
isSustainActive = true;
|
||||
sustainElapsed = 0f;
|
||||
currentSustainManaPerSecond = sustainManaPerSecond;
|
||||
capturedStartManaCost = ResolveCurrentSkillManaCost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 이벤트로 방어 유지 자원 소모를 종료합니다.
|
||||
/// </summary>
|
||||
public void EndSustain()
|
||||
{
|
||||
ClearSustainState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 종료/취소 시 방어 유지 상태를 정리합니다.
|
||||
/// </summary>
|
||||
public void HandleSkillExecutionEnded()
|
||||
{
|
||||
ClearSustainState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 완벽한 방어 성공 시 시작 마나를 환급합니다.
|
||||
/// </summary>
|
||||
public void RefundStartManaOnPerfectGuard()
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
if (!isSustainActive || networkController == null || !networkController.IsServer)
|
||||
return;
|
||||
|
||||
float refundAmount = capturedStartManaCost * perfectGuardStartManaRefundRatio;
|
||||
if (refundAmount <= 0f)
|
||||
return;
|
||||
|
||||
networkController.RestoreMana(refundAmount);
|
||||
}
|
||||
|
||||
private void EnsureReferences()
|
||||
{
|
||||
if (networkController == null)
|
||||
networkController = GetComponent<PlayerNetworkController>();
|
||||
|
||||
if (skillController == null)
|
||||
skillController = GetComponent<SkillController>();
|
||||
|
||||
if (weaponEquipment == null)
|
||||
weaponEquipment = GetComponent<WeaponEquipment>();
|
||||
}
|
||||
|
||||
private float ResolveCurrentSkillManaCost()
|
||||
{
|
||||
SkillLoadoutEntry loadoutEntry = skillController != null ? skillController.CurrentLoadoutEntry : null;
|
||||
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : skillController != null ? skillController.CurrentSkill : null;
|
||||
if (skill == null)
|
||||
return 0f;
|
||||
|
||||
float baseManaCost = loadoutEntry != null ? loadoutEntry.GetResolvedManaCost() : skill.ManaCost;
|
||||
float weaponMultiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
|
||||
float passiveMultiplier = PassiveRuntimeModifierUtility.GetManaCostMultiplier(gameObject, skill);
|
||||
return baseManaCost * weaponMultiplier * passiveMultiplier;
|
||||
}
|
||||
|
||||
private void ClearSustainState()
|
||||
{
|
||||
isSustainActive = false;
|
||||
sustainElapsed = 0f;
|
||||
currentSustainManaPerSecond = 0f;
|
||||
capturedStartManaCost = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d2ca368f37f0c044848bd88536e3b55
|
||||
@@ -26,6 +26,9 @@ namespace Colosseum.Player
|
||||
[Tooltip("이상상태 관리자 (없으면 자동 검색)")]
|
||||
[SerializeField] private AbnormalityManager abnormalityManager;
|
||||
|
||||
[Tooltip("방어 상태 관리자 (없으면 자동 검색)")]
|
||||
[SerializeField] private PlayerDefenseController defenseController;
|
||||
|
||||
[Header("Shield")]
|
||||
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
|
||||
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
||||
@@ -101,6 +104,13 @@ namespace Colosseum.Player
|
||||
abnormalityManager = GetComponent<AbnormalityManager>();
|
||||
}
|
||||
|
||||
if (defenseController == null)
|
||||
{
|
||||
defenseController = GetComponent<PlayerDefenseController>();
|
||||
if (defenseController == null)
|
||||
defenseController = gameObject.AddComponent<PlayerDefenseController>();
|
||||
}
|
||||
|
||||
EnsurePassiveRuntimeReferences();
|
||||
|
||||
currentHealth.OnValueChanged += HandleHealthChanged;
|
||||
@@ -197,7 +207,7 @@ namespace Colosseum.Player
|
||||
[Rpc(SendTo.Server)]
|
||||
public void TakeDamageRpc(float damage)
|
||||
{
|
||||
ApplyDamageInternal(damage, null);
|
||||
ApplyDamageInternal(new DamageContext(damage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -206,10 +216,7 @@ namespace Colosseum.Player
|
||||
[Rpc(SendTo.Server)]
|
||||
public void UseManaRpc(float amount)
|
||||
{
|
||||
if (isDead.Value)
|
||||
return;
|
||||
|
||||
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
|
||||
SpendMana(amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -230,10 +237,7 @@ namespace Colosseum.Player
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RestoreManaRpc(float amount)
|
||||
{
|
||||
if (isDead.Value)
|
||||
return;
|
||||
|
||||
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
|
||||
RestoreMana(amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -665,7 +669,15 @@ namespace Colosseum.Player
|
||||
/// </summary>
|
||||
public float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
return ApplyDamageInternal(damage, source);
|
||||
return ApplyDamageInternal(new DamageContext(damage, source));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
|
||||
/// </summary>
|
||||
public float TakeDamage(DamageContext damageContext)
|
||||
{
|
||||
return ApplyDamageInternal(damageContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -682,6 +694,32 @@ namespace Colosseum.Player
|
||||
return actualHeal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마나를 소모하고 실제 소모량을 반환합니다.
|
||||
/// </summary>
|
||||
public float SpendMana(float amount)
|
||||
{
|
||||
if (!IsServer || isDead.Value || amount <= 0f)
|
||||
return 0f;
|
||||
|
||||
float actualSpent = Mathf.Min(amount, currentMana.Value);
|
||||
currentMana.Value = Mathf.Max(0f, currentMana.Value - actualSpent);
|
||||
return actualSpent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마나를 회복하고 실제 회복량을 반환합니다.
|
||||
/// </summary>
|
||||
public float RestoreMana(float amount)
|
||||
{
|
||||
if (!IsServer || isDead.Value || amount <= 0f)
|
||||
return 0f;
|
||||
|
||||
float actualRestore = Mathf.Min(amount, MaxMana - currentMana.Value);
|
||||
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + actualRestore);
|
||||
return actualRestore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보호막을 적용합니다.
|
||||
/// </summary>
|
||||
@@ -724,17 +762,29 @@ namespace Colosseum.Player
|
||||
return remainingDamage;
|
||||
}
|
||||
|
||||
private float ApplyDamageInternal(float damage, object source)
|
||||
private float ApplyDamageInternal(DamageContext damageContext)
|
||||
{
|
||||
if (!IsServer || isDead.Value || IsDamageImmune())
|
||||
return 0f;
|
||||
|
||||
float finalDamage = damage * GetIncomingDamageMultiplier();
|
||||
if (defenseController == null)
|
||||
defenseController = GetComponent<PlayerDefenseController>();
|
||||
|
||||
float rawDamage = damageContext.Amount;
|
||||
if (rawDamage <= 0f)
|
||||
return 0f;
|
||||
|
||||
if (defenseController != null)
|
||||
{
|
||||
rawDamage = defenseController.ResolveIncomingDamage(damageContext.WithAmount(rawDamage));
|
||||
}
|
||||
|
||||
float finalDamage = rawDamage * GetIncomingDamageMultiplier();
|
||||
float mitigatedDamage = ConsumeShield(finalDamage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
|
||||
CombatBalanceTracker.RecordDamage(damageContext.SourceGameObject, gameObject, actualDamage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
|
||||
@@ -872,16 +872,43 @@ namespace Colosseum.Player
|
||||
private void OnSkill6Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 버튼 해제 시 채널링 중단을 알립니다.
|
||||
/// 스킬 버튼 해제 시 반복 유지 단계 중단을 알립니다.
|
||||
/// </summary>
|
||||
private void OnSkillCanceled()
|
||||
{
|
||||
if (skillController != null && skillController.IsChannelingActive)
|
||||
if (skillController != null && skillController.CurrentSkill != null && skillController.CurrentSkill.RequiresLoopHold)
|
||||
{
|
||||
skillController.NotifyChannelHoldReleased();
|
||||
skillController.NotifyLoopHoldReleased();
|
||||
if (IsOwner)
|
||||
RequestChannelHoldReleaseRpc();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 반복 유지 입력 해제를 서버에 알리고, 다른 클라이언트에도 종료를 동기화합니다.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestChannelHoldReleaseRpc()
|
||||
{
|
||||
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
|
||||
return;
|
||||
|
||||
skillController.NotifyLoopHoldReleased();
|
||||
SyncChannelHoldReleaseRpc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에서 확정된 반복 유지 종료를 클라이언트에 전파합니다.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.NotServer)]
|
||||
private void SyncChannelHoldReleaseRpc()
|
||||
{
|
||||
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
|
||||
return;
|
||||
|
||||
skillController.NotifyLoopHoldReleased();
|
||||
}
|
||||
|
||||
private PlayerActionState GetOrCreateActionState()
|
||||
{
|
||||
var foundState = GetComponent<PlayerActionState>();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -26,6 +26,34 @@ namespace Colosseum.UI
|
||||
[Tooltip("이상상태 요약 텍스트를 자동 생성할지 여부")]
|
||||
[SerializeField] private bool autoCreateAbnormalitySummary = true;
|
||||
|
||||
[Header("Defense Feedback")]
|
||||
[Tooltip("방어 성공 피드백 텍스트 (비어 있으면 런타임에 자동 생성)")]
|
||||
[SerializeField] private TMP_Text defenseFeedbackText;
|
||||
|
||||
[Tooltip("방어 성공 피드백 텍스트를 자동 생성할지 여부")]
|
||||
[SerializeField] private bool autoCreateDefenseFeedback = true;
|
||||
|
||||
[Tooltip("일반 방어 성공 시 표시할 텍스트")]
|
||||
[SerializeField] private string guardFeedbackLabel = "방어";
|
||||
|
||||
[Tooltip("완벽한 방어 성공 시 표시할 텍스트")]
|
||||
[SerializeField] private string perfectGuardFeedbackLabel = "완벽 방어";
|
||||
|
||||
[Tooltip("완벽한 방어 유효 시간 동안 표시할 텍스트")]
|
||||
[SerializeField] private string perfectGuardWindowLabel = "완벽 창";
|
||||
|
||||
[Tooltip("일반 방어 성공 텍스트 색상")]
|
||||
[SerializeField] private Color guardFeedbackColor = new Color(0.65f, 0.86f, 1f, 1f);
|
||||
|
||||
[Tooltip("완벽한 방어 성공 텍스트 색상")]
|
||||
[SerializeField] private Color perfectGuardFeedbackColor = new Color(1f, 0.92f, 0.45f, 1f);
|
||||
|
||||
[Tooltip("완벽한 방어 유효 시간 표시 색상")]
|
||||
[SerializeField] private Color perfectGuardWindowColor = new Color(0.62f, 1f, 0.88f, 1f);
|
||||
|
||||
[Tooltip("방어 성공 텍스트 표시 시간")]
|
||||
[Min(0.1f)] [SerializeField] private float defenseFeedbackDuration = 0.75f;
|
||||
|
||||
[Header("Passive UI")]
|
||||
[Tooltip("런타임 패시브 UI 컴포넌트를 자동으로 보정할지 여부")]
|
||||
[SerializeField] private bool autoCreatePassiveTreeUi = true;
|
||||
@@ -36,7 +64,9 @@ namespace Colosseum.UI
|
||||
|
||||
private PlayerNetworkController targetPlayer;
|
||||
private AbnormalityManager targetAbnormalityManager;
|
||||
private PlayerDefenseController targetDefenseController;
|
||||
private float abnormalityRefreshTimer;
|
||||
private float defenseFeedbackRemaining;
|
||||
|
||||
private const float AbnormalityRefreshInterval = 0.1f;
|
||||
|
||||
@@ -77,6 +107,8 @@ namespace Colosseum.UI
|
||||
UpdateAbnormalitySummary();
|
||||
}
|
||||
}
|
||||
|
||||
UpdateDefenseFeedback();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -107,6 +139,7 @@ namespace Colosseum.UI
|
||||
|
||||
targetPlayer = player;
|
||||
targetAbnormalityManager = targetPlayer != null ? targetPlayer.GetComponent<AbnormalityManager>() : null;
|
||||
targetDefenseController = targetPlayer != null ? targetPlayer.GetComponent<PlayerDefenseController>() : null;
|
||||
|
||||
// 새 타겟 구독
|
||||
SubscribeToEvents();
|
||||
@@ -114,6 +147,7 @@ namespace Colosseum.UI
|
||||
// 초기 값 설정
|
||||
UpdateStatBars();
|
||||
UpdateAbnormalitySummary();
|
||||
ClearDefenseFeedback();
|
||||
}
|
||||
|
||||
private void SubscribeToEvents()
|
||||
@@ -123,6 +157,11 @@ namespace Colosseum.UI
|
||||
targetPlayer.OnHealthChanged += HandleHealthChanged;
|
||||
targetPlayer.OnManaChanged += HandleManaChanged;
|
||||
targetPlayer.OnShieldChanged += HandleShieldChanged;
|
||||
if (targetDefenseController != null)
|
||||
{
|
||||
targetDefenseController.OnDefenseStateEntered += HandleDefenseStateEntered;
|
||||
targetDefenseController.OnDefenseResolved += HandleDefenseResolved;
|
||||
}
|
||||
|
||||
if (targetAbnormalityManager != null)
|
||||
{
|
||||
@@ -141,6 +180,12 @@ namespace Colosseum.UI
|
||||
targetPlayer.OnShieldChanged -= HandleShieldChanged;
|
||||
}
|
||||
|
||||
if (targetDefenseController != null)
|
||||
{
|
||||
targetDefenseController.OnDefenseStateEntered -= HandleDefenseStateEntered;
|
||||
targetDefenseController.OnDefenseResolved -= HandleDefenseResolved;
|
||||
}
|
||||
|
||||
if (targetAbnormalityManager != null)
|
||||
{
|
||||
targetAbnormalityManager.OnAbnormalityAdded -= HandleAbnormalityAdded;
|
||||
@@ -244,6 +289,49 @@ namespace Colosseum.UI
|
||||
abnormalitySummaryText = summaryText;
|
||||
}
|
||||
|
||||
private void EnsureDefenseFeedbackText()
|
||||
{
|
||||
if (defenseFeedbackText != null || !autoCreateDefenseFeedback)
|
||||
return;
|
||||
|
||||
if (transform is not RectTransform parentRect)
|
||||
return;
|
||||
|
||||
GameObject feedbackObject = new GameObject("DefenseFeedbackText", typeof(RectTransform));
|
||||
feedbackObject.transform.SetParent(parentRect, false);
|
||||
|
||||
RectTransform rectTransform = feedbackObject.GetComponent<RectTransform>();
|
||||
rectTransform.anchorMin = new Vector2(0.5f, 0f);
|
||||
rectTransform.anchorMax = new Vector2(0.5f, 0f);
|
||||
rectTransform.pivot = new Vector2(0.5f, 0f);
|
||||
rectTransform.anchoredPosition = new Vector2(0f, 116f);
|
||||
rectTransform.sizeDelta = new Vector2(360f, 48f);
|
||||
|
||||
TextMeshProUGUI feedback = feedbackObject.AddComponent<TextMeshProUGUI>();
|
||||
feedback.fontSize = 28f;
|
||||
feedback.fontStyle = FontStyles.Bold;
|
||||
feedback.alignment = TextAlignmentOptions.Center;
|
||||
feedback.textWrappingMode = TextWrappingModes.NoWrap;
|
||||
feedback.richText = true;
|
||||
feedback.alpha = 0f;
|
||||
feedback.text = string.Empty;
|
||||
|
||||
TMP_FontAsset feedbackFont = healthBar != null && healthBar.FontAsset != null
|
||||
? healthBar.FontAsset
|
||||
: manaBar != null ? manaBar.FontAsset : null;
|
||||
|
||||
if (feedbackFont != null)
|
||||
{
|
||||
feedback.font = feedbackFont;
|
||||
}
|
||||
else if (TMP_Settings.defaultFontAsset != null)
|
||||
{
|
||||
feedback.font = TMP_Settings.defaultFontAsset;
|
||||
}
|
||||
|
||||
defenseFeedbackText = feedback;
|
||||
}
|
||||
|
||||
private void EnsurePassiveTreeUi()
|
||||
{
|
||||
if (!autoCreatePassiveTreeUi || GetComponent<PassiveTreeUI>() != null)
|
||||
@@ -298,5 +386,79 @@ namespace Colosseum.UI
|
||||
|
||||
abnormalitySummaryText.text = builder.ToString();
|
||||
}
|
||||
|
||||
private void HandleDefenseStateEntered(float perfectWindowDuration)
|
||||
{
|
||||
ShowDefenseFeedback(
|
||||
perfectGuardWindowLabel,
|
||||
perfectGuardWindowColor,
|
||||
Mathf.Max(0.05f, perfectWindowDuration));
|
||||
}
|
||||
|
||||
private void HandleDefenseResolved(bool isPerfectGuard, float preventedDamage)
|
||||
{
|
||||
if (preventedDamage <= 0f)
|
||||
return;
|
||||
|
||||
ShowDefenseFeedback(
|
||||
isPerfectGuard ? perfectGuardFeedbackLabel : guardFeedbackLabel,
|
||||
isPerfectGuard ? perfectGuardFeedbackColor : guardFeedbackColor,
|
||||
defenseFeedbackDuration);
|
||||
}
|
||||
|
||||
private void UpdateDefenseFeedback()
|
||||
{
|
||||
if (defenseFeedbackRemaining <= 0f)
|
||||
return;
|
||||
|
||||
if (defenseFeedbackText == null)
|
||||
{
|
||||
EnsureDefenseFeedbackText();
|
||||
}
|
||||
|
||||
if (defenseFeedbackText == null)
|
||||
return;
|
||||
|
||||
defenseFeedbackRemaining = Mathf.Max(0f, defenseFeedbackRemaining - Time.deltaTime);
|
||||
defenseFeedbackText.alpha = defenseFeedbackDuration > 0f
|
||||
? defenseFeedbackRemaining / defenseFeedbackDuration
|
||||
: 0f;
|
||||
|
||||
if (defenseFeedbackRemaining <= 0f)
|
||||
{
|
||||
defenseFeedbackText.text = string.Empty;
|
||||
defenseFeedbackText.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearDefenseFeedback()
|
||||
{
|
||||
defenseFeedbackRemaining = 0f;
|
||||
|
||||
if (defenseFeedbackText == null)
|
||||
return;
|
||||
|
||||
defenseFeedbackText.text = string.Empty;
|
||||
defenseFeedbackText.alpha = 0f;
|
||||
}
|
||||
|
||||
private void ShowDefenseFeedback(string message, Color color, float duration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message))
|
||||
return;
|
||||
|
||||
if (defenseFeedbackText == null)
|
||||
{
|
||||
EnsureDefenseFeedbackText();
|
||||
}
|
||||
|
||||
if (defenseFeedbackText == null)
|
||||
return;
|
||||
|
||||
defenseFeedbackRemaining = Mathf.Max(0.05f, duration);
|
||||
defenseFeedbackText.text = message;
|
||||
defenseFeedbackText.color = color;
|
||||
defenseFeedbackText.alpha = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user