Project_WL/Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs

234 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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;
// ─────────────────────────────────────────── 차지
[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;
}
}
}