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:
2026-04-03 13:50:26 +09:00
parent 40e3252901
commit bbb2903ee1
721 changed files with 2135642 additions and 1422 deletions

View File

@@ -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;