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

@@ -150,6 +150,12 @@ namespace Colosseum.Player
inputActions.Player.Skill5.performed += OnSkill5Performed;
inputActions.Player.Skill6.performed += OnSkill6Performed;
inputActions.Player.Evade.performed += OnEvadePerformed;
inputActions.Player.Skill1.canceled += OnSkill1Canceled;
inputActions.Player.Skill2.canceled += OnSkill2Canceled;
inputActions.Player.Skill3.canceled += OnSkill3Canceled;
inputActions.Player.Skill4.canceled += OnSkill4Canceled;
inputActions.Player.Skill5.canceled += OnSkill5Canceled;
inputActions.Player.Skill6.canceled += OnSkill6Canceled;
}
SetGameplayInputEnabled(true);
@@ -377,6 +383,12 @@ namespace Colosseum.Player
{
if (inputActions != null)
{
inputActions.Player.Skill1.canceled -= OnSkill1Canceled;
inputActions.Player.Skill2.canceled -= OnSkill2Canceled;
inputActions.Player.Skill3.canceled -= OnSkill3Canceled;
inputActions.Player.Skill4.canceled -= OnSkill4Canceled;
inputActions.Player.Skill5.canceled -= OnSkill5Canceled;
inputActions.Player.Skill6.canceled -= OnSkill6Canceled;
inputActions.Player.Disable();
}
}
@@ -852,6 +864,24 @@ namespace Colosseum.Player
private void OnEvadePerformed(InputAction.CallbackContext context) => OnSkillInput(6);
private void OnSkill1Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
private void OnSkill2Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
private void OnSkill3Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
private void OnSkill4Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
private void OnSkill5Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
private void OnSkill6Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
/// <summary>
/// 스킬 버튼 해제 시 채널링 중단을 알립니다.
/// </summary>
private void OnSkillCanceled()
{
if (skillController != null && skillController.IsChannelingActive)
{
skillController.NotifyChannelHoldReleased();
}
}
private PlayerActionState GetOrCreateActionState()
{
var foundState = GetComponent<PlayerActionState>();

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;

View File

@@ -67,35 +67,9 @@ namespace Colosseum.Skills
/// </summary>
private void OnValidate()
{
MigrateLegacyEffects();
RefreshAnimationClips();
}
/// <summary>
/// 레거시 flat effects 리스트를 grouped triggeredEffects 구조로 마이그레이션합니다.
/// </summary>
private void MigrateLegacyEffects()
{
if (effects == null || effects.Count == 0)
return;
if (triggeredEffects != null && triggeredEffects.Count > 0)
return;
triggeredEffects = new List<SkillTriggeredEffectEntry>();
for (int i = 0; i < effects.Count; i++)
{
SkillEffect effect = effects[i];
if (effect == null)
continue;
triggeredEffects.Add(new SkillTriggeredEffectEntry(i, new List<SkillEffect> { effect }));
}
effects.Clear();
UnityEditor.EditorUtility.SetDirty(this);
Debug.Log($"[SkillData] '{name}' effects 마이그레이션 완료: {triggeredEffects.Count}개 엔트리", this);
}
/// <summary>
/// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다.
/// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다.
@@ -226,10 +200,25 @@ namespace Colosseum.Skills
[Tooltip("애니메이션 이벤트 OnEffect(index)로 발동. 각 엔트리의 Trigger Index가 이벤트 인덱스와 매칭됩니다.")]
[SerializeField] private List<SkillTriggeredEffectEntry> triggeredEffects = new();
/// <summary>
/// 레거시 flat effects 리스트. OnValidate에서 triggeredEffects로 자동 마이그레이션됩니다.
/// </summary>
[HideInInspector] [SerializeField] private List<SkillEffect> effects = new List<SkillEffect>();
[Header("채널링")]
[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;
// Properties
public string SkillName => skillName;
@@ -260,6 +249,15 @@ namespace Colosseum.Skills
public IReadOnlyList<SkillEffect> CastStartEffects => castStartEffects;
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;
/// <summary>
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.

View File

@@ -144,6 +144,15 @@ namespace Colosseum.Skills
private void CollectAreaTargets(GameObject caster, List<GameObject> destination, Vector3? groundPosition = null)
{
switch (areaShape)
{
case AreaShapeType.Beam:
CollectBeamTargets(caster, destination);
return;
default:
break;
}
Vector3 center = GetAreaCenter(caster, groundPosition);
Collider[] hits = Physics.OverlapSphere(center, Mathf.Max(areaRadius, fanRadius), targetLayers);
foreach (var hit in hits)
@@ -161,6 +170,31 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 빔(원통) 범위 내 타겟을 수집합니다.
/// 시전자 정면 fanOriginDistance 위치에서 areaRadius 길이의 원통을 판정합니다.
/// </summary>
private void CollectBeamTargets(GameObject caster, List<GameObject> destination)
{
Vector3 casterPos = caster.transform.position;
Vector3 forward = caster.transform.forward;
float beamLength = Mathf.Max(areaRadius, 0.1f);
float beamRadius = Mathf.Max(fanRadius, 0.1f);
float startOffset = Mathf.Max(fanOriginDistance, 0f);
Vector3 origin = casterPos + forward * startOffset;
Vector3 point1 = origin + forward * beamRadius;
Vector3 point2 = origin + forward * (beamLength - beamRadius);
Collider[] hits = Physics.OverlapCapsule(point1, point2, beamRadius, targetLayers);
foreach (var hit in hits)
{
if (!includeCasterInArea && hit.gameObject == caster) continue;
if (!IsCorrectTeam(caster, hit.gameObject)) continue;
AddUniqueTarget(destination, hit.gameObject);
}
}
private static void AddUniqueTarget(List<GameObject> destination, GameObject target)
{
if (target == null || destination.Contains(target))
@@ -232,6 +266,10 @@ namespace Colosseum.Skills
? (center - casterPos).normalized
: forward, fanOriginDistance, fanRadius, fanHalfAngle, Color.red, duration);
}
else if (areaShape == AreaShapeType.Beam)
{
DebugDrawBeam(casterPos, forward, areaRadius, fanRadius, fanOriginDistance, Color.red, duration);
}
}
private void DebugDrawSphere(Vector3 center, float radius, Color color, float duration)
@@ -287,6 +325,51 @@ namespace Colosseum.Skills
// 원점 표시
Debug.DrawLine(fanOrigin + Vector3.up * 0.1f, fanOrigin + Vector3.up * 1.5f, color, duration);
}
/// <summary>
/// 빔(원통) 범위를 디버그 시각화합니다.
/// 시전자 정면 startOffset 위치에서 사거리만큼의 원통과 단면 원을 그립니다.
/// </summary>
private void DebugDrawBeam(Vector3 casterPos, Vector3 forward, float beamLength, float beamRadius, float startOffset, Color color, float duration)
{
Vector3 origin = casterPos + forward * startOffset;
Vector3 end = origin + forward * beamLength;
int segments = 16;
float step = 360f / segments;
// 시작 단면 원
Vector3 prevStart = origin + new Vector3(beamRadius, 0, 0);
for (int i = 1; i <= segments; i++)
{
float angle = i * step * Mathf.Deg2Rad;
Vector3 next = origin + new Vector3(Mathf.Cos(angle) * beamRadius, 0, Mathf.Sin(angle) * beamRadius);
Debug.DrawLine(prevStart, next, color, duration);
prevStart = next;
}
// 끝 단면 원
Vector3 prevEnd = end + new Vector3(beamRadius, 0, 0);
for (int i = 1; i <= segments; i++)
{
float angle = i * step * Mathf.Deg2Rad;
Vector3 next = end + new Vector3(Mathf.Cos(angle) * beamRadius, 0, Mathf.Sin(angle) * beamRadius);
Debug.DrawLine(prevEnd, next, color, duration);
prevEnd = next;
}
// 네 모서리 연결선
Debug.DrawLine(origin + forward * 0 + new Vector3(beamRadius, 0, 0), end + new Vector3(beamRadius, 0, 0), color, duration);
Debug.DrawLine(origin + new Vector3(-beamRadius, 0, 0), end + new Vector3(-beamRadius, 0, 0), color, duration);
Debug.DrawLine(origin + new Vector3(0, 0, beamRadius), end + new Vector3(0, 0, beamRadius), color, duration);
Debug.DrawLine(origin + new Vector3(0, 0, -beamRadius), end + new Vector3(0, 0, -beamRadius), color, duration);
// 중심선
Debug.DrawLine(origin, end, color, duration);
// 높이 표시
Debug.DrawLine(origin + Vector3.up * 0.1f, origin + Vector3.up * 1.5f, color, duration);
Debug.DrawLine(end + Vector3.up * 0.1f, end + Vector3.up * 1.5f, color, duration);
}
#endregion
}
@@ -314,6 +397,7 @@ namespace Colosseum.Skills
public enum AreaShapeType
{
Sphere, // 원형 범위
Fan // 부채꼴 범위
Fan, // 부채꼴 범위
Beam // 원통 범위 (areaRadius=사거리, fanRadius=빔 폭)
}
}

View File

@@ -396,6 +396,64 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 기반 스킬의 채널링 틱 효과를 수집합니다.
/// </summary>
public void CollectChannelTickEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ChannelTickEffects != null)
{
for (int i = 0; i < baseSkill.ChannelTickEffects.Count; i++)
{
SkillEffect effect = baseSkill.ChannelTickEffects[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 CollectChannelEndEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ChannelEndEffects != null)
{
for (int i = 0; i < baseSkill.ChannelEndEffects.Count; i++)
{
SkillEffect effect = baseSkill.ChannelEndEffects[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;
}
}
private static void AddTriggeredEffect(Dictionary<int, List<SkillEffect>> destination, int triggerIndex, SkillEffect effect)
{
if (!destination.TryGetValue(triggerIndex, out List<SkillEffect> effectList))