241 lines
15 KiB
C#
241 lines
15 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// WLSkillSpectacleSettings.cs — 스킬 스펙터클(4클래스 × 4슬롯 차지·본체·착탄 VFX 매핑 + 동시 상한) 값의 단일 출처(SOT · C45)
|
||
//
|
||
// PD 지시 #813(1순위 축 "화려한 전투 액션") · 발주서 WL-811i §1 · 설계안 WL-811a §B-3 `vfxSlots` · §B-5-2 · §C 811i 행 ·
|
||
// 기준서 v1 §B 요소 3(대형 동시 2 · 화면 밖 스킵 · fps 저하 ≤3) · 기획안 v2 슬라이드 11(F-0B 클래스 원소 표) · 813n 입력 열 (2026-09-09)
|
||
//
|
||
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset
|
||
// → Resources.Load<WLSkillSpectacleSettings>("WL/WLSkillSpectacleSettings") 로 1회 로드 후 캐시(기존 WL 설정 SO 와 같은 관례).
|
||
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 SkillSpectacle 이 아무 것도 하지 않는다 = 원본 스킬 이펙트만(C8).
|
||
//
|
||
// ■ 행(rows) = 813n 프리셋 16행(클래스 4 × 슬롯 4). 프리팹명 = Assets/Res_Addr/Effect/<이름>.prefab 의 이름(원본 문자열 로딩 규약 그대로 · 새 아트 0).
|
||
// charge = SkillCast(시전) · f_CastingTime>0 이면 그 시간 동안(슬롯 4 광역) · 0 이면 chargeFlashSeconds 동안의 발동 플래시
|
||
// body = SkillFired(실체 발사 직전) · 원본 스킬 프리팹 이펙트는 그대로 두고 **추가 레이어**로 얹는다
|
||
// impact = HitboxHit(스킬 히트박스 명중 · 피격자 위치) · 발동 1회당 impactMaxPerFire 까지 · HitConfirmed(Beater_pd = 스킬)은 같은 프레임 중복이 아닐 때만 폴백
|
||
// ■ 클래스 원소(classElements) = 기획안 F-0B 표(원핸드 10101 Nature · 스펠블레이드 10102 Dark · 블런트실드 10501 / 팔라딘 10502 Holy)
|
||
// → 공용 프리팹(차지 · 슬롯 4 본체/착탄)에 MaterialPropertyBlock 곱셈 틴트(원본 머티리얼 색 × 클래스 색 · 머티리얼 인스턴스 0 · 새 머티리얼 0).
|
||
// 클래스 전용 프리팹(Effect_Slash_<ID>_N · Effect_Hit_<ID> · Effect_Hit_Skill_*)은 tint = false.
|
||
// ■ 예산: budgetClass Heavy 행은 EffectBudget.Heavy(코어 SO heavyEffectMax = 2) 슬롯을 차지/본체가 잡고 둘 다 끝나면 돌려준다 ·
|
||
// Medium 은 이 에셋의 mediumMax 로 로컬 상한 · 화면 밖(EffectBudget.IsInView)이면 스킵 · 초과분은 큐잉 없이 스킵(설계안 §B-5-2).
|
||
//
|
||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using System;
|
||
using UnityEngine;
|
||
|
||
namespace WL.Combat.Reaction
|
||
{
|
||
/// <summary>스펙터클 3단(설계안 §B-3 VfxSlot.phase).</summary>
|
||
public enum SpectaclePhase { Charge = 0, Body = 1, Impact = 2 }
|
||
|
||
/// <summary>동시 상한 계수(설계안 §B-3 budgetClass). Heavy = EffectBudget.Heavy · Medium = 로컬 mediumMax · Light = 상한 없음.</summary>
|
||
public enum SpectacleBudgetClass { Light = 0, Medium = 1, Heavy = 2 }
|
||
|
||
/// <summary>클래스 원소(기획안 F-0B 표 · 설계안 elementBurst 와 같은 이름 집합).</summary>
|
||
public enum SpectacleElement { None = 0, Fire = 1, Dark = 2, Blood = 3, Holy = 4, Ice = 5, Nature = 6 }
|
||
|
||
[Serializable]
|
||
public struct SpectacleClassElement
|
||
{
|
||
[Tooltip("ClassConfig.n_ClassID (근접 4클래스 10101/10102/10501/10502).")]
|
||
public int classId;
|
||
[Tooltip("표시용 라벨(데이터 · 코드가 읽지 않는다).")]
|
||
public string label;
|
||
[Tooltip("클래스 원소(기획안 F-0B 표).")]
|
||
public SpectacleElement element;
|
||
[Tooltip("틴트 색. 원본 머티리얼 색에 곱한다(각 채널 1 = 원본 밝기 그대로 · tintStrength 로 흰색과 보간).")]
|
||
public Color tint;
|
||
}
|
||
|
||
[Serializable]
|
||
public struct SpectacleVfx
|
||
{
|
||
[Tooltip("Assets/Res_Addr/Effect/<이름>.prefab 의 이름. 비우면 이 단은 스킵.")]
|
||
public string prefab;
|
||
[Tooltip("스케일 배율(프리팹 localScale × 이 값). 0 이하 = 1.")]
|
||
public float scale;
|
||
[Tooltip("앵커: Bottom = 발밑(Get_position) · Mid = 몸 중심 · Top = 머리 위. 차지/본체는 시전자 · 착탄은 피격자 기준.")]
|
||
public eEffectLocation anchor;
|
||
[Tooltip("시전자 전방(transform.forward)으로 미는 거리(m). 착탄에는 적용되지 않는다.")]
|
||
public float forwardOffset;
|
||
[Tooltip("시전자의 회전(바라보는 방향)을 이펙트에 준다(방향이 있는 본체용). 착탄에는 적용되지 않는다.")]
|
||
public bool useCasterRotation;
|
||
[Tooltip("클래스 원소 틴트를 얹는다(공용 프리팹용 · 클래스 전용 프리팹은 false).")]
|
||
public bool tint;
|
||
[Tooltip("수명(초). 0 = 자동(파티클 duration+startLifetime · autoLifetimeMax 로 캡 · 루프 프리팹은 loopingLifetime · 차지는 f_CastingTime 또는 chargeFlashSeconds).")]
|
||
public float lifetime;
|
||
}
|
||
|
||
[Serializable]
|
||
public struct SpectacleRow
|
||
{
|
||
[Tooltip("SkillList.n_SkillID (813n 프리셋 16행).")]
|
||
public int skillId;
|
||
[Tooltip("이 행이 속한 클래스(ClassConfig.n_ClassID). 슬롯 4 광역 스킬은 n_SkillClassID = 0 이라 여기서 클래스를 붙인다.")]
|
||
public int classId;
|
||
[Tooltip("슬롯 번호 1~4(표시용 · PCActor.arr_magicData index + 1).")]
|
||
public int slot;
|
||
[Tooltip("스킬 이름(표시용).")]
|
||
public string label;
|
||
public SpectacleVfx charge;
|
||
public SpectacleVfx body;
|
||
public SpectacleVfx impact;
|
||
[Tooltip("차지+본체의 동시 상한 계수. Heavy = EffectBudget.Heavy(동시 2 · 행당 슬롯 1개) · Medium = mediumMax · Light = 없음.")]
|
||
public SpectacleBudgetClass budgetClass;
|
||
}
|
||
|
||
[CreateAssetMenu(fileName = "WLSkillSpectacleSettings", menuName = "WL/Skill Spectacle Settings", order = 324)]
|
||
public sealed class WLSkillSpectacleSettings : ScriptableObject
|
||
{
|
||
public const string ResourcesPath = "WL/WLSkillSpectacleSettings";
|
||
/// <summary>원본 문자열 로딩 경로 규약(InGameInfo.Load_Effect 와 동일 · 프리팹당 1회만 결합해 캐시한다).</summary>
|
||
public const string EffectPathPrefix = "Assets/Res_Addr/Effect/";
|
||
public const string EffectPathSuffix = ".prefab";
|
||
|
||
static WLSkillSpectacleSettings s_cached;
|
||
static bool s_lookupDone;
|
||
|
||
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||
public static WLSkillSpectacleSettings Instance
|
||
{
|
||
get
|
||
{
|
||
if (!s_lookupDone)
|
||
{
|
||
s_cached = Resources.Load<WLSkillSpectacleSettings>(ResourcesPath);
|
||
s_lookupDone = true;
|
||
}
|
||
return s_cached;
|
||
}
|
||
}
|
||
|
||
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 스펙터클만 끈다. Play 종료 시 자동 false.</summary>
|
||
public static bool RuntimeDisabled;
|
||
|
||
public static bool Enabled
|
||
{
|
||
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
|
||
}
|
||
|
||
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||
|
||
// ─────────────────────────────────────────── 전체 스위치
|
||
[Header("전체 스위치")]
|
||
[Tooltip("끄면 차지·본체·착탄 레이어가 전부 비활성 = 원본 스킬 이펙트만. 롤백 1순위.")]
|
||
public bool enabled = true;
|
||
|
||
[Tooltip("스폰·스킵·상한 결정을 Console 에 남긴다(검증용 · GC 발생 · 서비스 빌드 off).")]
|
||
public bool verboseLog = false;
|
||
|
||
[Tooltip("메인 PC(IsMainPC)의 스킬만 꾸민다. off 면 적 PC 등 같은 스킬 ID 도 꾸민다.")]
|
||
public bool requireMainPC = true;
|
||
|
||
[Tooltip("스폰 위치가 카메라 화면 밖(EffectBudget.IsInView)이면 스킵(기준서 §B 요소 3).")]
|
||
public bool requireInView = true;
|
||
|
||
[Tooltip("메인 PC 의 첫 공격(AttackStarted) 때 그 클래스 4행의 프리팹을 미리 로드한다(첫 스킬 전에 로드가 끝나게).")]
|
||
public bool preloadOnFirstAttack = true;
|
||
|
||
[Tooltip("첫 SkillCast 때 그 클래스의 4행 프리팹 3단을 미리 로드한다.")]
|
||
public bool preloadClassOnFirstCast = true;
|
||
|
||
[Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")]
|
||
public bool warmInstanceOnLoad = true;
|
||
|
||
[Tooltip("🔴 WL-811q §1-2 ⓑ — 한 틱(프레임)에 만들 워밍 인스턴스 수의 상한. "
|
||
+ "Addressables 완료 콜백은 프레임 끝에 **몰려서** 들어온다(33종 중 여러 개가 같은 프레임에 끝난다) — "
|
||
+ "그때 warmInstanceOnLoad 가 Instantiate 를 한꺼번에 돌리면 그 자체가 히치가 된다. "
|
||
+ "예산을 넘긴 완료는 다음 프레임으로 미뤄진다(재요청 0 · 지연 1~2 프레임). "
|
||
+ "0 = 무제한 = 811i 원래 동작(C8 롤백 값).")]
|
||
public int warmInstancesPerTick = 2;
|
||
|
||
// ─────────────────────────────────────────── 차지
|
||
[Header("차지 (SkillCast · f_CastingTime>0 이면 그 시간 · 0 이면 발동 플래시)")]
|
||
[Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")]
|
||
public float chargeFlashSeconds = 0.35f;
|
||
|
||
[Tooltip("차지 이펙트가 시전자를 따라간다(매 틱 앵커 갱신).")]
|
||
public bool chargeFollowCaster = true;
|
||
|
||
[Tooltip("차지 수명 상한(초 · f_CastingTime 이 비정상적으로 길어도 이 값에 캡).")]
|
||
public float chargeMaxLifetime = 2f;
|
||
|
||
// ─────────────────────────────────────────── 본체 · 상한
|
||
[Header("본체 · 동시 상한 (Heavy = EffectBudget.Heavy 코어 SO heavyEffectMax · Medium = 아래 로컬 상한)")]
|
||
[Tooltip("budgetClass = Heavy 행이 EffectBudget.Heavy 슬롯을 잡는다(차지+본체가 모두 끝나면 반납). off 면 Heavy 도 상한 없이 스폰.")]
|
||
public bool heavyUsesEffectBudget = true;
|
||
|
||
[Tooltip("budgetClass = Medium 차지+본체의 동시 상한(이 시스템 안에서만 센다).")]
|
||
public int mediumMax = 4;
|
||
|
||
// ─────────────────────────────────────────── 착탄
|
||
[Header("착탄 (HitboxHit · 피격자 앵커 · 발동 1회당 상한)")]
|
||
[Tooltip("스킬 발동(SkillFired) 1회당 착탄 이펙트 최대 수(광역 다중 명중 과밀 방지).")]
|
||
public int impactMaxPerFire = 4;
|
||
|
||
[Tooltip("착탄 최소 간격(초 · 같은 행). 같은 프레임 다중 명중은 이 간격으로 1개만 남는다. 0 = 제한 없음.")]
|
||
public float impactMinIntervalSeconds = 0.04f;
|
||
|
||
[Tooltip("noDmg(피해 0 통과) 명중에는 착탄을 내지 않는다.")]
|
||
public bool impactSkipNoDamage = true;
|
||
|
||
[Tooltip("HitboxHit 없이 온 스킬 피해(HitConfirmed · Beater_pd = 스킬)에도 착탄을 낸다(같은 프레임·같은 피격자 중복은 제외).")]
|
||
public bool impactFallbackHitConfirmed = true;
|
||
|
||
// ─────────────────────────────────────────── 수명 · 풀
|
||
[Header("수명 · 풀 (인스턴스는 이 시스템 전용 풀 · 원본 InGameInfo 풀과 분리 · TurnOff_GO 코루틴 대신 자체 틱)")]
|
||
[Tooltip("자동 수명 상한(초). 파티클 duration+startLifetime 이 이보다 길어도 여기서 끈다.")]
|
||
public float autoLifetimeMax = 2.5f;
|
||
|
||
[Tooltip("루프 파티클(looping)이 있는 프리팹의 자동 수명(초).")]
|
||
public float loopingLifetime = 1.5f;
|
||
|
||
[Tooltip("프리팹 1종당 동시 인스턴스 상한(풀 크기 · 초과 요청은 스킵). 첫 사용 때 1개씩 늘어난다.")]
|
||
public int poolMaxPerPrefab = 4;
|
||
|
||
[Tooltip("동시에 추적할 수 있는 활성 이펙트 수(배열 사전 할당 · 초과 요청은 스킵).")]
|
||
public int trackCapacity = 48;
|
||
|
||
// ─────────────────────────────────────────── 틴트
|
||
[Header("클래스 원소 틴트 (MaterialPropertyBlock · 원본 머티리얼 색 × 클래스 색 · 인스턴스 생성 때 1회 실측)")]
|
||
[Tooltip("틴트 사용 여부(각 단의 tint 플래그와 AND).")]
|
||
public bool tintEnabled = true;
|
||
|
||
[Tooltip("틴트 강도 0~1(0 = 원본색 · 1 = 원본색 × 클래스 색).")]
|
||
[Range(0f, 1f)] public float tintStrength = 0.85f;
|
||
|
||
[Tooltip("틴트를 쓸 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것 1개).")]
|
||
public string[] tintProperties = new string[] { "_TintColor", "_Color", "_BaseColor" };
|
||
|
||
[Tooltip("클래스 원소 표(4행).")]
|
||
public SpectacleClassElement[] classElements = new SpectacleClassElement[0];
|
||
|
||
// ─────────────────────────────────────────── 매핑 16행
|
||
[Header("매핑 16행 (813n 프리셋 · 클래스 4 × 슬롯 4)")]
|
||
public SpectacleRow[] rows = new SpectacleRow[0];
|
||
|
||
/// <summary>skillId(+classId 우선) 로 행 인덱스를 찾는다(선형 16 · alloc 0). 없으면 −1.</summary>
|
||
public int FindRow(int skillId, int classId)
|
||
{
|
||
if (rows == null) return -1;
|
||
int any = -1;
|
||
for (int i = 0; i < rows.Length; i++)
|
||
{
|
||
if (rows[i].skillId != skillId) continue;
|
||
if (rows[i].classId == classId) return i;
|
||
if (any < 0) any = i;
|
||
}
|
||
return any;
|
||
}
|
||
|
||
/// <summary>클래스 원소 행 인덱스. 없으면 −1.</summary>
|
||
public int FindClassElement(int classId)
|
||
{
|
||
if (classElements == null) return -1;
|
||
for (int i = 0; i < classElements.Length; i++) if (classElements[i].classId == classId) return i;
|
||
return -1;
|
||
}
|
||
}
|
||
}
|