feat: 채널링 빔 스킬 시스템 구현 및 PolygonParticleFX VFX 에셋 추가
- SkillData: 채널링 필드 추가 (지속시간, 틱 간격, 틱/종료 효과, VFX 프리팹, 마운트 경로, 크기 배율) - SkillController: 채널링 상태 관리 (Start/Update/End), VFX 생성/파괴, 틱 효과 주기 발동, 버튼 해제로 중단 - SkillEffect: Beam(원통) 범위 판정 추가 (OverlapCapsule), 디버그 시각화 - PlayerSkillInput: 스킬 취소(canceled) 이벤트 구독 → 채널링 중단 통지 - SkillLoadoutEntry: 채널링 틱/종료 효과 수집 메서드 추가 - 스킬 데이터/이펙트/애니메이션/VFX 에셋 추가 (채널링 스킬용) - PolygonParticleFX VFX 에셋 패키지 추가 (Materials, Models, Prefabs, Textures, Scenes)
This commit is contained in:
@@ -76,6 +76,14 @@ namespace Colosseum.Skills
|
||||
private GameObject currentTargetOverride;
|
||||
private Vector3? currentGroundTargetPosition;
|
||||
|
||||
// 채널링 상태
|
||||
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 Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
|
||||
|
||||
@@ -90,6 +98,7 @@ namespace Colosseum.Skills
|
||||
public SkillCancelReason LastCancelReason => lastCancelReason;
|
||||
public string LastCancelledSkillName => lastCancelledSkillName;
|
||||
public GameObject CurrentTargetOverride => currentTargetOverride;
|
||||
public bool IsChannelingActive => isChannelingActive;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -224,6 +233,13 @@ namespace Colosseum.Skills
|
||||
{
|
||||
if (currentSkill == null || animator == null) return;
|
||||
|
||||
// 채널링 중일 때
|
||||
if (isChannelingActive)
|
||||
{
|
||||
UpdateChanneling();
|
||||
return;
|
||||
}
|
||||
|
||||
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
|
||||
|
||||
// 애니메이션 종료 시 처리
|
||||
@@ -237,6 +253,13 @@ namespace Colosseum.Skills
|
||||
if (TryStartNextIteration())
|
||||
return;
|
||||
|
||||
// 채널링 스킬이면 채널링 시작
|
||||
if (currentSkill.IsChanneling)
|
||||
{
|
||||
StartChanneling();
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 클립과 반복이 끝나면 종료
|
||||
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
|
||||
RestoreBaseController();
|
||||
@@ -444,6 +467,8 @@ namespace Colosseum.Skills
|
||||
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
|
||||
loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities);
|
||||
loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities);
|
||||
loadoutEntry.CollectChannelTickEffects(currentChannelTickEffects);
|
||||
loadoutEntry.CollectChannelEndEffects(currentChannelEndEffects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -717,6 +742,262 @@ namespace Colosseum.Skills
|
||||
cooldownTracker.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링을 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다.
|
||||
/// </summary>
|
||||
private void StartChanneling()
|
||||
{
|
||||
if (currentSkill == null || !currentSkill.IsChanneling)
|
||||
return;
|
||||
|
||||
isChannelingActive = true;
|
||||
channelElapsedTime = 0f;
|
||||
channelTickAccumulator = 0f;
|
||||
|
||||
SpawnChannelVfx();
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 시작: {currentSkill.SkillName} (duration={currentSkill.ChannelDuration}s, tick={currentSkill.ChannelTickInterval}s)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링 VFX를 시전자 위치에 생성합니다.
|
||||
/// </summary>
|
||||
private void SpawnChannelVfx()
|
||||
{
|
||||
if (currentSkill == null || currentSkill.ChannelVfxPrefab == null)
|
||||
return;
|
||||
|
||||
Transform mount = ResolveChannelVfxMount();
|
||||
Vector3 spawnPos = mount != null ? mount.position : transform.position;
|
||||
|
||||
channelVfxInstance = UnityEngine.Object.Instantiate(
|
||||
currentSkill.ChannelVfxPrefab,
|
||||
spawnPos,
|
||||
transform.rotation);
|
||||
|
||||
if (mount != null)
|
||||
channelVfxInstance.transform.SetParent(mount);
|
||||
|
||||
channelVfxInstance.transform.localScale = new Vector3(
|
||||
currentSkill.ChannelVfxWidthScale,
|
||||
currentSkill.ChannelVfxWidthScale,
|
||||
currentSkill.ChannelVfxLengthScale);
|
||||
|
||||
// 모든 파티클을 루핑 모드로 설정
|
||||
ForceLoopParticleSystems(channelVfxInstance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 하위 모든 ParticleSystem을 루핑 모드로 강제 설정하고 충돌을 비활성화합니다.
|
||||
/// 채널링 종료 시 파괴되므로 자연 종료 및 충돌 반응 방지용.
|
||||
/// </summary>
|
||||
private static void ForceLoopParticleSystems(GameObject instance)
|
||||
{
|
||||
if (instance == null) return;
|
||||
|
||||
ParticleSystem[] particles = instance.GetComponentsInChildren<ParticleSystem>(true);
|
||||
for (int i = 0; i < particles.Length; i++)
|
||||
{
|
||||
var main = particles[i].main;
|
||||
main.loop = true;
|
||||
main.stopAction = ParticleSystemStopAction.None;
|
||||
|
||||
var collision = particles[i].collision;
|
||||
collision.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// channelVfxMountPath에서 VFX 장착 위치를 찾습니다.
|
||||
/// </summary>
|
||||
private Transform ResolveChannelVfxMount()
|
||||
{
|
||||
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.ChannelVfxMountPath))
|
||||
return null;
|
||||
|
||||
// Animator 하위에서 이름으로 재귀 검색
|
||||
Animator animator = GetComponentInChildren<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
Transform found = FindTransformRecursive(animator.transform, currentSkill.ChannelVfxMountPath);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
|
||||
// 자식 GameObject에서 경로 검색
|
||||
return transform.Find(currentSkill.ChannelVfxMountPath);
|
||||
}
|
||||
|
||||
private static Transform FindTransformRecursive(Transform parent, string name)
|
||||
{
|
||||
for (int i = 0; i < parent.childCount; i++)
|
||||
{
|
||||
Transform child = parent.GetChild(i);
|
||||
if (child.name == name)
|
||||
return child;
|
||||
|
||||
Transform found = FindTransformRecursive(child, name);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링 VFX를 파괴합니다.
|
||||
/// </summary>
|
||||
private void DestroyChannelVfx()
|
||||
{
|
||||
if (channelVfxInstance != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(channelVfxInstance);
|
||||
channelVfxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링을 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
|
||||
/// </summary>
|
||||
private void UpdateChanneling()
|
||||
{
|
||||
if (!isChannelingActive || currentSkill == null)
|
||||
return;
|
||||
|
||||
channelElapsedTime += Time.deltaTime;
|
||||
channelTickAccumulator += Time.deltaTime;
|
||||
|
||||
// 틱 효과 발동
|
||||
float tickInterval = currentSkill.ChannelTickInterval;
|
||||
while (channelTickAccumulator >= tickInterval)
|
||||
{
|
||||
channelTickAccumulator -= tickInterval;
|
||||
TriggerChannelTick();
|
||||
}
|
||||
|
||||
// 지속 시간 초과 → 채널링 종료
|
||||
if (channelElapsedTime >= currentSkill.ChannelDuration)
|
||||
{
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 지속 시간 만료: {currentSkill.SkillName}");
|
||||
EndChanneling();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링 틱 효과를 발동합니다.
|
||||
/// </summary>
|
||||
private void TriggerChannelTick()
|
||||
{
|
||||
if (currentChannelTickEffects.Count == 0)
|
||||
return;
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 틱 발동: {currentSkill.SkillName} (elapsed={channelElapsedTime:F1}s)");
|
||||
|
||||
// VFX는 모든 클라이언트에서 로컬 실행
|
||||
for (int i = 0; i < currentChannelTickEffects.Count; i++)
|
||||
{
|
||||
SkillEffect effect = currentChannelTickEffects[i];
|
||||
if (effect != null && effect.IsVisualOnly)
|
||||
{
|
||||
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// 게임플레이 효과는 서버에서만 실행
|
||||
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < currentChannelTickEffects.Count; i++)
|
||||
{
|
||||
SkillEffect effect = currentChannelTickEffects[i];
|
||||
if (effect == null || effect.IsVisualOnly)
|
||||
continue;
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 틱 효과: {effect.name}");
|
||||
|
||||
if (showAreaDebug)
|
||||
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
|
||||
|
||||
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링을 종료합니다. 종료 효과를 발동하고 스킬 상태를 정리합니다.
|
||||
/// </summary>
|
||||
private void EndChanneling()
|
||||
{
|
||||
if (!isChannelingActive)
|
||||
return;
|
||||
|
||||
// 채널링 종료 효과 발동
|
||||
TriggerChannelEndEffects();
|
||||
DestroyChannelVfx();
|
||||
|
||||
isChannelingActive = false;
|
||||
channelElapsedTime = 0f;
|
||||
channelTickAccumulator = 0f;
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 종료: {currentSkill?.SkillName}");
|
||||
|
||||
RestoreBaseController();
|
||||
ClearCurrentSkillState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채널링 종료 효과를 발동합니다.
|
||||
/// </summary>
|
||||
private void TriggerChannelEndEffects()
|
||||
{
|
||||
if (currentChannelEndEffects.Count == 0)
|
||||
return;
|
||||
|
||||
// VFX는 모든 클라이언트에서 로컬 실행
|
||||
for (int i = 0; i < currentChannelEndEffects.Count; i++)
|
||||
{
|
||||
SkillEffect effect = currentChannelEndEffects[i];
|
||||
if (effect != null && effect.IsVisualOnly)
|
||||
{
|
||||
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// 게임플레이 효과는 서버에서만 실행
|
||||
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < currentChannelEndEffects.Count; i++)
|
||||
{
|
||||
SkillEffect effect = currentChannelEndEffects[i];
|
||||
if (effect == null || effect.IsVisualOnly)
|
||||
continue;
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 종료 효과: {effect.name}");
|
||||
|
||||
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어가 버튼을 놓았을 때 채널링을 중단합니다.
|
||||
/// PlayerSkillInput에서 호출됩니다.
|
||||
/// </summary>
|
||||
public void NotifyChannelHoldReleased()
|
||||
{
|
||||
if (!isChannelingActive)
|
||||
return;
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[Skill] 채널링 버튼 해제로 중단: {currentSkill?.SkillName}");
|
||||
|
||||
EndChanneling();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 실행 중인 스킬 상태를 정리합니다.
|
||||
/// </summary>
|
||||
@@ -729,6 +1010,12 @@ namespace Colosseum.Skills
|
||||
currentCastStartAbnormalities.Clear();
|
||||
currentTriggeredAbnormalities.Clear();
|
||||
currentTriggeredTargetsBuffer.Clear();
|
||||
currentChannelTickEffects.Clear();
|
||||
currentChannelEndEffects.Clear();
|
||||
isChannelingActive = false;
|
||||
channelElapsedTime = 0f;
|
||||
channelTickAccumulator = 0f;
|
||||
DestroyChannelVfx();
|
||||
currentTargetOverride = null;
|
||||
currentGroundTargetPosition = null;
|
||||
currentClipSequenceIndex = 0;
|
||||
|
||||
Reference in New Issue
Block a user