- SkillData: 채널링 필드 추가 (지속시간, 틱 간격, 틱/종료 효과, VFX 프리팹, 마운트 경로, 크기 배율) - SkillController: 채널링 상태 관리 (Start/Update/End), VFX 생성/파괴, 틱 효과 주기 발동, 버튼 해제로 중단 - SkillEffect: Beam(원통) 범위 판정 추가 (OverlapCapsule), 디버그 시각화 - PlayerSkillInput: 스킬 취소(canceled) 이벤트 구독 → 채널링 중단 통지 - SkillLoadoutEntry: 채널링 틱/종료 효과 수집 메서드 추가 - 스킬 데이터/이펙트/애니메이션/VFX 에셋 추가 (채널링 스킬용) - PolygonParticleFX VFX 에셋 패키지 추가 (Materials, Models, Prefabs, Textures, Scenes)
374 lines
16 KiB
C#
374 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
using UnityEngine;
|
|
|
|
using Colosseum.Weapons;
|
|
|
|
namespace Colosseum.Skills
|
|
{
|
|
/// <summary>
|
|
/// 젬 장착 조건에서 사용하는 기반 스킬 분류입니다.
|
|
/// 하나의 스킬이 여러 분류를 동시에 가질 수 있습니다.
|
|
/// </summary>
|
|
[Flags]
|
|
public enum SkillBaseType
|
|
{
|
|
None = 0,
|
|
Attack = 1 << 0,
|
|
Defense = 1 << 1,
|
|
Support = 1 << 2,
|
|
Control = 1 << 3,
|
|
Mobility = 1 << 4,
|
|
Utility = 1 << 5,
|
|
All = Attack | Defense | Support | Control | Mobility | Utility,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬의 역할 분류입니다.
|
|
/// 젬 장착 조건에는 비트 마스크 형태로도 사용합니다.
|
|
/// </summary>
|
|
[Flags]
|
|
public enum SkillRoleType
|
|
{
|
|
None = 0,
|
|
Attack = 1 << 0,
|
|
Defense = 1 << 1,
|
|
Support = 1 << 2,
|
|
All = Attack | Defense | Support,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬의 발동 타입 분류입니다.
|
|
/// </summary>
|
|
[Flags]
|
|
public enum SkillActivationType
|
|
{
|
|
None = 0,
|
|
Instant = 1 << 0,
|
|
Buff = 1 << 1,
|
|
All = Instant | Buff,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬 데이터. 스킬의 기본 정보와 효과 목록을 관리합니다.
|
|
/// </summary>
|
|
[CreateAssetMenu(fileName = "NewSkill", menuName = "Colosseum/Skill")]
|
|
public class SkillData : ScriptableObject
|
|
{
|
|
private const string SkillAssetPrefix = "Data_Skill_";
|
|
private const string ClipAssetPrefix = "Anim_";
|
|
private const string AnimationsSearchPath = "Assets/_Game/Animations";
|
|
|
|
#if UNITY_EDITOR
|
|
/// <summary>
|
|
/// 레거시 마이그레이션 및 애니메이션 클립 자동 매칭.
|
|
/// 에디터에서 애셋 로드/수정 시 자동 실행됩니다.
|
|
/// </summary>
|
|
private void OnValidate()
|
|
{
|
|
RefreshAnimationClips();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애셋 이름 기반으로 매칭되는 애니메이션 클립을 자동 수집합니다.
|
|
/// SkillData 이름이 'Data_Skill_'으로 시작하면 'Anim_{key}_{순서}' 클립을 찾아 animationClips에 채웁니다.
|
|
/// </summary>
|
|
public void RefreshAnimationClips()
|
|
{
|
|
if (!name.StartsWith(SkillAssetPrefix))
|
|
return;
|
|
|
|
string key = name.Substring(SkillAssetPrefix.Length);
|
|
if (string.IsNullOrEmpty(key))
|
|
return;
|
|
|
|
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:AnimationClip", new[] { AnimationsSearchPath });
|
|
var matchedClips = new List<(AnimationClip clip, int order)>();
|
|
|
|
for (int i = 0; i < guids.Length; i++)
|
|
{
|
|
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]);
|
|
AnimationClip clip = UnityEditor.AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
|
if (clip == null) continue;
|
|
|
|
string clipName = clip.name;
|
|
if (!clipName.StartsWith(ClipAssetPrefix))
|
|
continue;
|
|
|
|
string remaining = clipName.Substring(ClipAssetPrefix.Length);
|
|
|
|
if (remaining == key)
|
|
{
|
|
// 정확 매칭 (순서 번호 없음) → 최우선
|
|
matchedClips.Add((clip, -1));
|
|
}
|
|
else if (remaining.StartsWith(key + "_"))
|
|
{
|
|
string suffix = remaining.Substring(key.Length + 1);
|
|
if (int.TryParse(suffix, out int order))
|
|
{
|
|
matchedClips.Add((clip, order));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchedClips.Count == 0)
|
|
return;
|
|
|
|
// 정렬: 순서 번호 없음(-1) → 순서 번호 오름차순
|
|
matchedClips.Sort((a, b) => a.order.CompareTo(b.order));
|
|
|
|
// 변경이 있는 경우만 갱신 (무한 루프 방지)
|
|
bool changed = matchedClips.Count != animationClips.Count;
|
|
if (!changed)
|
|
{
|
|
for (int i = 0; i < matchedClips.Count; i++)
|
|
{
|
|
if (matchedClips[i].clip != animationClips[i])
|
|
{
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!changed) return;
|
|
|
|
animationClips.Clear();
|
|
for (int i = 0; i < matchedClips.Count; i++)
|
|
animationClips.Add(matchedClips[i].clip);
|
|
|
|
UnityEditor.EditorUtility.SetDirty(this);
|
|
}
|
|
#endif
|
|
[Header("기본 정보")]
|
|
[SerializeField] private string skillName;
|
|
[TextArea(2, 4)]
|
|
[SerializeField] private string description;
|
|
[SerializeField] private Sprite icon;
|
|
|
|
[Header("스킬 분류")]
|
|
[Tooltip("이 스킬의 주 역할입니다.")]
|
|
[SerializeField] private SkillRoleType skillRole = SkillRoleType.Attack;
|
|
[Tooltip("이 스킬의 발동 타입입니다.")]
|
|
[SerializeField] private SkillActivationType activationType = SkillActivationType.Instant;
|
|
|
|
[Header("레거시 기반 스킬 분류")]
|
|
[Tooltip("기존 테스트 데이터와의 호환을 위한 기반 분류입니다.")]
|
|
[SerializeField] private SkillBaseType baseTypes = SkillBaseType.None;
|
|
|
|
[Header("애니메이션")]
|
|
[Tooltip("순차 재생할 클립 목록. 애셋 이름이 Data_Skill_ 접두사면 Anim_{이름}_{순서} 클립을 자동 수집합니다.")]
|
|
[SerializeField] private List<AnimationClip> animationClips = new();
|
|
[Tooltip("애니메이션 재생 속도 (1 = 기본, 2 = 2배속)")]
|
|
[Min(0.1f)] [SerializeField] private float animationSpeed = 1f;
|
|
|
|
[Header("루트 모션")]
|
|
[Tooltip("애니메이션의 이동/회전 데이터를 캐릭터에 적용")]
|
|
[SerializeField] private bool useRootMotion = false;
|
|
[Tooltip("루트 모션 적용 시 Y축 이동 무시 (중력과 충돌)")]
|
|
[SerializeField] private bool ignoreRootMotionY = true;
|
|
[Tooltip("스킬 시전 시 대상 위치로 점프 이동 (UseRootMotion + IgnoreRootMotionY=false 필요)")]
|
|
[SerializeField] private bool jumpToTarget = false;
|
|
|
|
[Header("행동 제한")]
|
|
[Tooltip("시전 중 이동 입력 차단 여부")]
|
|
[SerializeField] private bool blockMovementWhileCasting = true;
|
|
[Tooltip("시전 중 점프 입력 차단 여부")]
|
|
[SerializeField] private bool blockJumpWhileCasting = true;
|
|
[Tooltip("시전 중 다른 스킬 입력 차단 여부")]
|
|
[SerializeField] private bool blockOtherSkillsWhileCasting = true;
|
|
|
|
[Header("무기 조건")]
|
|
[Tooltip("이 스킬 사용에 필요한 무기 특성. None이면 제약 없음.")]
|
|
[SerializeField] private WeaponTrait allowedWeaponTraits = WeaponTrait.None;
|
|
|
|
[Header("쿨타임 & 비용")]
|
|
[Min(0f)] [SerializeField] private float cooldown = 1f;
|
|
[Min(0f)] [SerializeField] private float manaCost = 0f;
|
|
|
|
[Header("젬 슬롯")]
|
|
[Tooltip("이 스킬에 장착 가능한 젬 슬롯 수")]
|
|
[Min(0)] [SerializeField] private int maxGemSlotCount = 2;
|
|
|
|
[Header("시전 시작 효과")]
|
|
[Tooltip("시전 시작 즉시 발동하는 효과 목록. 시전 보호 버프 등에 사용됩니다.")]
|
|
[SerializeField] private List<SkillEffect> castStartEffects = new List<SkillEffect>();
|
|
|
|
[Header("트리거 효과 목록")]
|
|
[Tooltip("애니메이션 이벤트 OnEffect(index)로 발동. 각 엔트리의 Trigger Index가 이벤트 인덱스와 매칭됩니다.")]
|
|
[SerializeField] private List<SkillTriggeredEffectEntry> triggeredEffects = new();
|
|
|
|
[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;
|
|
public string Description => description;
|
|
public Sprite Icon => icon;
|
|
public SkillRoleType SkillRole => skillRole;
|
|
public SkillActivationType ActivationType => activationType;
|
|
public SkillBaseType BaseTypes => baseTypes;
|
|
/// <summary>
|
|
/// 순차 재생할 클립 목록입니다.
|
|
/// </summary>
|
|
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
|
|
|
|
/// <summary>
|
|
/// 첫 번째 클립 (레거시 호환성). 기존 코드에서 SkillClip을 참조하는 곳에서 사용됩니다.
|
|
/// </summary>
|
|
public AnimationClip SkillClip => animationClips.Count > 0 ? animationClips[0] : null;
|
|
public float AnimationSpeed => animationSpeed;
|
|
public float Cooldown => cooldown;
|
|
public float ManaCost => manaCost;
|
|
public int MaxGemSlotCount => maxGemSlotCount;
|
|
public bool UseRootMotion => useRootMotion;
|
|
public bool IgnoreRootMotionY => ignoreRootMotionY;
|
|
public bool JumpToTarget => jumpToTarget;
|
|
public bool BlockMovementWhileCasting => blockMovementWhileCasting;
|
|
public bool BlockJumpWhileCasting => blockJumpWhileCasting;
|
|
public bool BlockOtherSkillsWhileCasting => blockOtherSkillsWhileCasting;
|
|
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>
|
|
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.
|
|
/// </summary>
|
|
public bool MatchesClassification(SkillRoleType allowedRoles, SkillActivationType allowedActivationTypes)
|
|
{
|
|
bool matchesRole = allowedRoles == SkillRoleType.None ||
|
|
allowedRoles == SkillRoleType.All ||
|
|
(allowedRoles & skillRole) != 0;
|
|
|
|
bool matchesActivationType = allowedActivationTypes == SkillActivationType.None ||
|
|
allowedActivationTypes == SkillActivationType.All ||
|
|
(allowedActivationTypes & activationType) != 0;
|
|
|
|
return matchesRole && matchesActivationType;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정한 무기 특성 조합이 이 스킬의 무기 조건을 충족하는지 확인합니다.
|
|
/// allowedWeaponTraits가 None이면 항상 true입니다.
|
|
/// </summary>
|
|
public bool MatchesWeaponTrait(WeaponTrait equippedTraits)
|
|
{
|
|
if (allowedWeaponTraits == WeaponTrait.None)
|
|
return true;
|
|
|
|
return (equippedTraits & allowedWeaponTraits) == allowedWeaponTraits;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스킬/젬 분류를 UI 친화적인 문자열로 변환하는 유틸리티입니다.
|
|
/// </summary>
|
|
public static class SkillClassificationUtility
|
|
{
|
|
public static string GetRoleLabel(SkillRoleType role)
|
|
{
|
|
return role switch
|
|
{
|
|
SkillRoleType.Attack => "공격",
|
|
SkillRoleType.Defense => "방어",
|
|
SkillRoleType.Support => "지원",
|
|
SkillRoleType.All => "전체",
|
|
_ => "미분류",
|
|
};
|
|
}
|
|
|
|
public static string GetActivationTypeLabel(SkillActivationType activationType)
|
|
{
|
|
return activationType switch
|
|
{
|
|
SkillActivationType.Instant => "즉발",
|
|
SkillActivationType.Buff => "버프",
|
|
SkillActivationType.All => "전체",
|
|
_ => "미분류",
|
|
};
|
|
}
|
|
|
|
public static string GetSkillClassificationLabel(SkillData skill)
|
|
{
|
|
if (skill == null)
|
|
return "미분류";
|
|
|
|
return $"{GetRoleLabel(skill.SkillRole)}/{GetActivationTypeLabel(skill.ActivationType)}";
|
|
}
|
|
|
|
public static string GetGemCategoryLabel(SkillGemCategory category)
|
|
{
|
|
return category switch
|
|
{
|
|
SkillGemCategory.Damage => "데미지",
|
|
SkillGemCategory.Survival => "생존",
|
|
SkillGemCategory.Mana => "마나",
|
|
SkillGemCategory.Special => "특수",
|
|
SkillGemCategory.BuffPower => "효과 강화",
|
|
SkillGemCategory.Duration => "지속시간",
|
|
SkillGemCategory.Area => "범위",
|
|
SkillGemCategory.Cost => "비용",
|
|
_ => "공용",
|
|
};
|
|
}
|
|
|
|
public static string GetAllowedRoleSummary(SkillRoleType roles)
|
|
{
|
|
if (roles == SkillRoleType.None || roles == SkillRoleType.All)
|
|
return "전체";
|
|
|
|
List<string> labels = new List<string>();
|
|
if ((roles & SkillRoleType.Attack) != 0)
|
|
labels.Add("공격");
|
|
if ((roles & SkillRoleType.Defense) != 0)
|
|
labels.Add("방어");
|
|
if ((roles & SkillRoleType.Support) != 0)
|
|
labels.Add("지원");
|
|
|
|
return labels.Count > 0 ? string.Join(" + ", labels) : "미분류";
|
|
}
|
|
|
|
public static string GetAllowedActivationSummary(SkillActivationType activationTypes)
|
|
{
|
|
if (activationTypes == SkillActivationType.None || activationTypes == SkillActivationType.All)
|
|
return "전체";
|
|
|
|
List<string> labels = new List<string>();
|
|
if ((activationTypes & SkillActivationType.Instant) != 0)
|
|
labels.Add("즉발");
|
|
if ((activationTypes & SkillActivationType.Buff) != 0)
|
|
labels.Add("버프");
|
|
|
|
return labels.Count > 0 ? string.Join(" + ", labels) : "미분류";
|
|
}
|
|
}
|
|
}
|