Project_WL/Assets/WL/Combat/Weapon/WLSkillVfx.cs

393 lines
25 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLSkillVfx.cs — 스킬 이펙트 교체(휘두를 때 · 맞은 대상) — 표 기반
//
// PD 원문(#887 · 2026-10-17): 「방패 치기 모션일 때 이펙트를 교체하고 싶어. (Anime Stylized VFX URP 에셋의 Slash를
// 방패를 휘두를 때 발생시키고, 히트 된 대상에게 Aroow Shot Impact를 발생시켜줘)」
//
// ■ 원리 — 원본 SkillEvent 훅(CombatEvents.SkillFired = 스킬 투사체가 나오는 순간)에서 표의 스킬이면 swing 이펙트를 시전자 앞에,
// 타격 확정 훅(CombatEvents.HitConfirmed · 피해 정보 Beater_pd 의 스킬)에서 hit 이펙트를 맞은 대상 중심에 띄운다(시전자 → 대상 방향).
// 원본 스킬 프리팹의 기존 이펙트(hideChildren)는 그 투사체가 켜진 같은 프레임(타격 확정)에 끈다 — 원본 프리팹 무수정.
// ■ 이펙트 = Resources/WL/Fx/ 래퍼(자식 = 팩 프리팹 인스턴스) · 팩 파티클은 크기 모드가 Local 이라 scale ≠ 1 이면 Hierarchy 로 바꿔 키운다.
// ■ 값 = Resources/WL/WLSkillVfx.json (C45) · 끄기 = enabled 0(구독 0) · 원본 수정 0줄
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다) — 구독자 예외도 LastLog 로만 남긴다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLSkillVfxFx
{
public string prefab = "";
public float[] offset = new float[3]; // 기준 회전 기준 (오른쪽, 위, 앞) m
public float[] euler = new float[3];
public float scale = 1f;
public float life = 1f;
public float fade = 0f; // #921 0 보다 크면 life 의 마지막 fade 초 동안 투명해지며 사라진다(방출 멈춤 + 입자 알파 → 0) — 0 = life 에 바로 지움
}
/// <summary>#921 PD 「플레이어 위치에 생성 된 이펙트가 갑자기 사라지지 않고 … 투명해지며 자연스럽게 소멸」 — hold 초 뒤 fade 초 동안 방출을 멈추고 입자 알파를 0 으로 · 끝나면 지운다(셰이더 무관 · 입자 색).</summary>
public sealed class WLFxFade : MonoBehaviour
{
float _start, _fade; bool _began; ParticleSystem[] _ps; ParticleSystem.Particle[] _buf = new ParticleSystem.Particle[64];
readonly Dictionary<ParticleSystem, float> _a0 = new Dictionary<ParticleSystem, float>();
// #934 PD 「(회오리가) 소멸할 때 뭉쳐진 회오리만 남아있어서 어색해 … 스킬 종료 타이밍에 맞게 자연스럽게 소멸」 — 가산(더하기) 재질은 알파만 낮추면 밝기가 남는다:
// dimAdditive 면 가산 재질 입자는 처음 색(입자별 · randomSeed) × 남은 비율로 색 · 알파를 함께 낮춘다(알파 블렌드 입자는 그대로 알파만)
bool _dimAdd; bool[] _add; readonly Dictionary<ParticleSystem, Dictionary<uint, Color32>> _c0 = new Dictionary<ParticleSystem, Dictionary<uint, Color32>>();
public static int DimmedSystems;
public static void Attach(GameObject go, float hold, float fade) { Attach(go, hold, fade, false); }
public static void Attach(GameObject go, float hold, float fade, bool dimAdditive)
{
var f = go.AddComponent<WLFxFade>(); f._start = Time.time + Mathf.Max(0f, hold); f._fade = Mathf.Max(0.05f, fade); f._dimAdd = dimAdditive;
}
static bool IsAdditive(ParticleSystem ps)
{
var r = ps.GetComponent<ParticleSystemRenderer>(); var m = r != null ? r.sharedMaterial : null; if (m == null) return false;
if (m.HasProperty("_DstBlend")) return Mathf.RoundToInt(m.GetFloat("_DstBlend")) == (int)UnityEngine.Rendering.BlendMode.One;
return m.shader != null && m.shader.name.IndexOf("Add", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
void LateUpdate()
{
if (Time.time < _start) return;
if (!_began)
{
_began = true; _ps = GetComponentsInChildren<ParticleSystem>(true); _add = new bool[_ps.Length];
for (int j = 0; j < _ps.Length; j++) { _ps[j].Stop(false, ParticleSystemStopBehavior.StopEmitting); _add[j] = _dimAdd && IsAdditive(_ps[j]); if (_add[j]) DimmedSystems++; }
}
float k = Mathf.Clamp01((Time.time - _start) / _fade), keep = 1f - k;
for (int j = 0; j < _ps.Length; j++)
{
var ps = _ps[j];
if (ps == null) continue;
int n = ps.particleCount; if (n == 0) continue;
if (_buf.Length < n) _buf = new ParticleSystem.Particle[n];
n = ps.GetParticles(_buf, n);
if (_add[j])
{
Dictionary<uint, Color32> c0; if (!_c0.TryGetValue(ps, out c0)) { c0 = new Dictionary<uint, Color32>(); _c0[ps] = c0; }
for (int i = 0; i < n; i++) { Color32 o; uint key = _buf[i].randomSeed; if (!c0.TryGetValue(key, out o)) { o = _buf[i].startColor; c0[key] = o; } _buf[i].startColor = new Color32((byte)(o.r * keep), (byte)(o.g * keep), (byte)(o.b * keep), (byte)(o.a * keep)); }
}
else
for (int i = 0; i < n; i++) { var c = _buf[i].startColor; float a0; if (!_a0.TryGetValue(ps, out a0)) { a0 = c.a; _a0[ps] = a0; } c.a = (byte)Mathf.RoundToInt(Mathf.Min(c.a, a0 * keep)); _buf[i].startColor = c; }
ps.SetParticles(_buf, n);
}
if (k >= 1f) Destroy(gameObject);
}
}
/// <summary>#914 원본 스킬 프리팹 안 파티클 색 · 투명도 바꾸기(이름별) — 풀 인스턴스마다 1 회(표시 컴포넌트 WLSkillVfxTinted).</summary>
[System.Serializable]
public class WLSkillVfxTint
{
public string[] names = new string[0]; // 파티클 오브젝트 이름
public float[] rgb = new float[0]; // 시작색 RGB(비우면 그대로)
public float alpha = -1f; // 시작색 알파(-1 = 그대로)
public float alphaMul = 1f; // 시작색 알파 배율
public float[] colKeys = new float[0]; // 수명색 색 키 t,r,g,b…(비우면 그대로)
public float[] colAlpha = new float[0]; // 수명색 알파 키 t,a…(비우면 그대로)
}
[System.Serializable]
public class WLSkillVfxRule
{
public string skill = ""; // eEffect 이름 = 원본 스킬 클래스 이름
public string[] hideChildren = new string[0]; // 원본 스킬 프리팹에서 끌 자식 이름
public WLSkillVfxFx swing = new WLSkillVfxFx();
public WLSkillVfxFx hit = new WLSkillVfxFx();
public float effectScale = 1f; // #899 원본 스킬 프리팹의 보이는 이펙트(크기 노드) 배율 — 피해 판정(콜라이더)은 그대로
// #902 PD 「해머 점프 내려치기 — 내려친 위치에 스크린샷 이펙트 · 충격 지점 중심 8방향 충격파 · 맞은 적 1m 띄웠다 떨어뜨린 뒤 스턴 1.5초」
public float hitBoxScale = 1f; // 원본 판정 상자 가로·세로 배율(8방향 충격파까지 맞게 · 각성 성장분 유지)
public WLSkillVfxFx centerFx = new WLSkillVfxFx(); // 원본 Set 때 스킬 자리(내려친 위치)에 띄움
public WLSkillVfxFx radialFx = new WLSkillVfxFx(); // 스킬 자리 둘레 radialCount 방향(시전자 정면부터 등간격)
public int radialCount;
public float radialRadius = 1f;
// #904 PD 「충격 지점부터 빠르게 퍼지며 폭파(중간 범위 광역)」 — radialSteps ≥ 2 면 방향마다 radialStartRadius → radialRadius 사이를 radialSteps 번에 나눠
// radialStepDelay 초 간격으로 radialFx 를 잇달아 띄우고(퍼짐), 끝 자리에 radialEndFx 를 띄운다(폭발). 1 이하 = 끝 자리 한 번에(#902).
public int radialSteps;
public float radialStartRadius = 0.4f, radialStepDelay = 0.04f;
public WLSkillVfxFx radialEndFx = new WLSkillVfxFx();
public float launchHeight, launchSeconds = 0.5f, stunSeconds; // 이 스킬에 맞은 적: 띄우기 높이(m) · 걸리는 시간(초) · 내려온 뒤 기절(초)
public WLSkillVfxTint[] tint = new WLSkillVfxTint[0]; // #914 원본 스킬 프리팹 파티클 색 · 투명도(검은 갈라짐 · 연기 → 반투명)
// #917 원본 Set 때 바로 숨김(hideChildren 을 첫 타격까지 기다리지 않음) · 멈춰 서서 판정 상자를 켜는 스킬(Projectile_StopAndColliderControl)의 다단 히트 시각
public int hideOnSet;
public float multiHitDelay = -1f; // 첫 판정까지(초 · ColliderActiveDealyTime) — 음수 = 원본 그대로
public int multiHitCount; // 판정 횟수(Loop) — 0 = 원본 그대로
public float multiHitInterval = -1f; // 판정 간격(초 · DelayTime) — 음수 = 원본 그대로
public float lifeSeconds = -1f; // 화살 비(Skill_ArrowRain.lifetime) 유지 시간 — 음수 = 원본 그대로
public WLSkillVfxFx casterFx = new WLSkillVfxFx(); // 원본 Set 때 시전자 발밑(몸)에 띄움
}
[System.Serializable]
public class WLSkillVfxJson
{
public int enabled = 0;
public WLSkillVfxRule[] rules = new WLSkillVfxRule[0];
public string note = "";
}
public static class WLSkillVfx
{
public const string ResourcesPath = "WL/WLSkillVfx";
public static int Swings, Hits, Hidden, Errors;
public static string LastLog = "";
static WLSkillVfxJson s_cfg;
static readonly Dictionary<string, WLSkillVfxRule> s_rules = new Dictionary<string, WLSkillVfxRule>();
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
static readonly Dictionary<string, System.Type> s_types = new Dictionary<string, System.Type>();
static WLSkillVfxJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLSkillVfxJson();
s_rules.Clear();
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
if (s_cfg.rules != null) foreach (var r in s_cfg.rules) if (r != null && !string.IsNullOrEmpty(r.skill)) s_rules[r.skill] = r;
return s_cfg;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
s_cfg = null; s_prefabs.Clear();
if (Cfg.enabled == 0) { CombatEvents.SkillFired.Remove(OnSkillFired); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); return; }
CombatEvents.SkillFired.Add(OnSkillFired);
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
}
/// <summary>#899 PD 「강력한 일격 이펙트가 너무 과도 — 캐릭터 크기·비율을 고려해 이펙트 크기 조절」 — 원본 스킬 Set() 끝 훅 1줄이 부른다.
/// 표(skill = 클래스 이름)의 effectScale 을 보이는 크기 노드(sizeNode)에만 곱한다(원본이 매번 1 로 되돌린 뒤라 반복 시전에도 누적되지 않음). 꺼져 있으면(enabled 0) 아무것도 안 한다.</summary>
public static void ScaleOriginal(ProjectileBase skill, Transform sizeNode)
{
try
{
if (skill == null || Cfg.enabled == 0) return;
WLSkillVfxRule r;
if (!s_rules.TryGetValue(skill.GetType().Name, out r)) return;
if (sizeNode != null && r.effectScale > 0f && !Mathf.Approximately(r.effectScale, 1f)) { sizeNode.localScale *= r.effectScale; Scaled++; }
if (r.tint != null && r.tint.Length > 0 && skill.GetComponent<WLSkillVfxTinted>() == null) { Tinted += Tint(skill, r.tint); skill.gameObject.AddComponent<WLSkillVfxTinted>(); }
// #917 바로 숨김 · 다단 히트 시각 · 유지 시간 · 시전자 이펙트
if (r.hideOnSet != 0 && r.hideChildren != null)
foreach (var n in r.hideChildren) { var c = skill.transform.Find(n); if (c != null && c.gameObject.activeSelf) { c.gameObject.SetActive(false); Hidden++; } }
var sc = skill as Projectile_StopAndColliderControl;
if (sc != null)
{
if (r.multiHitDelay >= 0f) sc.ColliderActiveDealyTime = r.multiHitDelay;
if (r.multiHitCount > 0) { sc.Loop = r.multiHitCount; sc.LoopTime = 0f; }
if (r.multiHitInterval >= 0f) sc.DelayTime = r.multiHitInterval;
}
var rain = skill as Skill_ArrowRain;
if (rain != null && r.lifeSeconds > 0f) rain.lifetime = r.lifeSeconds;
var pdc = skill.Get_ProjectTileData(); var caster = pdc != null ? pdc.shooter : null;
if (r.casterFx != null && !string.IsNullOrEmpty(r.casterFx.prefab) && !DSUtil.CheckNull(caster))
{
var cf = caster.transform.forward; cf.y = 0f; if (cf.sqrMagnitude < 1e-4f) cf = Vector3.forward;
if (Spawn(r.casterFx, caster.Get_position(), Quaternion.LookRotation(cf.normalized))) Casters++;
}
// #902 판정 상자 · 내려친 위치 이펙트 · 8방향 충격파
if (r.hitBoxScale > 0f && !Mathf.Approximately(r.hitBoxScale, 1f))
{
var bc = skill.GetComponent<BoxCollider>();
if (bc != null)
{
// #917 풀 재사용 때 배율이 누적되지 않게 — 원본 크기를 기억(원본이 Set 에서 크기를 다시 정했으면 그 값이 새 기준)
var bs = skill.GetComponent<WLSkillVfxBase>();
if (bs == null) { bs = skill.gameObject.AddComponent<WLSkillVfxBase>(); bs.box = bc.size; bs.written = new Vector3(-1f, -1f, -1f); }
if (bc.size != bs.written) bs.box = bc.size;
bc.size = new Vector3(bs.box.x * r.hitBoxScale, bs.box.y, bs.box.z * r.hitBoxScale);
bs.written = bc.size;
}
}
var pd = skill.Get_ProjectTileData();
var shooter = pd != null ? pd.shooter : null;
Vector3 fwd = !DSUtil.CheckNull(shooter) ? shooter.transform.forward : skill.transform.forward; fwd.y = 0f;
if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize();
Vector3 at = skill.transform.position;
if (Spawn(r.centerFx, at, Quaternion.LookRotation(fwd))) Centers++;
for (int k = 0; k < r.radialCount; k++)
{
var dir = Quaternion.Euler(0f, k * 360f / r.radialCount, 0f) * fwd;
var rot = Quaternion.LookRotation(dir);
if (r.radialSteps <= 1) { if (Spawn(r.radialFx, at + dir * r.radialRadius, rot)) Radials++; continue; }
for (int i = 0; i < r.radialSteps; i++) // #904 중심에서 바깥으로 퍼짐 → 끝에서 폭발
{
float rr = Mathf.Lerp(r.radialStartRadius, r.radialRadius, i / (float)(r.radialSteps - 1));
Later(i * r.radialStepDelay, r.radialFx, at + dir * rr, rot);
}
Later(r.radialSteps * r.radialStepDelay, string.IsNullOrEmpty(r.radialEndFx.prefab) ? r.radialFx : r.radialEndFx, at + dir * r.radialRadius, rot);
}
}
catch (System.Exception ex) { Errors++; LastLog = "크기 조절 실패 — " + ex.Message; }
}
public static int Scaled, Centers, Radials, Launched, Tinted, Casters;
/// <summary>#914 이름이 맞는 파티클의 시작색(RGB · 알파 · 알파 배율) · 수명색(색 키 · 알파 키)을 바꾼다 — 바꾼 파티클 수.</summary>
static int Tint(ProjectileBase skill, WLSkillVfxTint[] tints)
{
int n = 0;
foreach (var ps in skill.GetComponentsInChildren<ParticleSystem>(true))
foreach (var t in tints)
{
if (t == null || t.names == null || System.Array.IndexOf(t.names, ps.name) < 0) continue;
var m = ps.main; var c = m.startColor;
if (c.mode == ParticleSystemGradientMode.Color) c.color = TintColor(c.color, t);
else if (c.mode == ParticleSystemGradientMode.TwoColors) { c.colorMin = TintColor(c.colorMin, t); c.colorMax = TintColor(c.colorMax, t); }
m.startColor = c;
if ((t.colKeys != null && t.colKeys.Length >= 4) || (t.colAlpha != null && t.colAlpha.Length >= 2))
{
var col = ps.colorOverLifetime; col.enabled = true;
var old = col.color.mode == ParticleSystemGradientMode.Gradient ? col.color.gradient : new Gradient();
var ck = old.colorKeys; var ak = old.alphaKeys;
if (t.colKeys != null && t.colKeys.Length >= 4)
{
ck = new GradientColorKey[t.colKeys.Length / 4];
for (int i = 0; i < ck.Length; i++) ck[i] = new GradientColorKey(new Color(t.colKeys[i * 4 + 1], t.colKeys[i * 4 + 2], t.colKeys[i * 4 + 3]), t.colKeys[i * 4]);
}
if (t.colAlpha != null && t.colAlpha.Length >= 2)
{
ak = new GradientAlphaKey[t.colAlpha.Length / 2];
for (int i = 0; i < ak.Length; i++) ak[i] = new GradientAlphaKey(t.colAlpha[i * 2 + 1], t.colAlpha[i * 2]);
}
var g = new Gradient(); g.SetKeys(ck, ak); col.color = new ParticleSystem.MinMaxGradient(g);
}
n++;
break;
}
return n;
}
static Color TintColor(Color k, WLSkillVfxTint t)
{
if (t.rgb != null && t.rgb.Length >= 3) { k.r = t.rgb[0]; k.g = t.rgb[1]; k.b = t.rgb[2]; }
if (t.alpha >= 0f) k.a = t.alpha;
k.a *= t.alphaMul;
return k;
}
static WLSkillVfxRule RuleOf(SkillListTableData s)
{
if (s == null) return null;
WLSkillVfxRule r;
return s_rules.TryGetValue(s.e_Skill.ToString(), out r) ? r : null;
}
static void OnSkillFired(in SkillFiredEvent e)
{
try
{
var r = RuleOf(e.skill);
if (r == null || DSUtil.CheckNull(e.actor)) return;
var t = e.actor.transform;
if (Spawn(r.swing, t.position, t.rotation)) Swings++;
}
catch (System.Exception ex) { Errors++; LastLog = "swing 실패 — " + ex.Message; }
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
try
{
var pd = e.projectile;
var r = pd != null ? RuleOf(pd.m_SkillListTableData) : null;
if (r == null || DSUtil.CheckNull(e.victim)) return;
HideOriginal(r, pd);
var at = e.victim.Get_Center_position();
var dir = !DSUtil.CheckNull(e.attacker) ? at - e.attacker.Get_Center_position() : e.victim.transform.forward;
dir.y = 0f;
var rot = dir.sqrMagnitude > 1e-4f ? Quaternion.LookRotation(dir) : Quaternion.identity;
if (Spawn(r.hit, at + WL.Combat.Impact.WLImpactFx.TowardCamera(e.victim), rot)) Hits++; // #941 몸 앞(카메라 쪽)
if ((r.launchHeight > 0f || r.stunSeconds > 0f) && WLLaunch.Apply(e.victim, r.launchHeight, r.launchSeconds, r.stunSeconds)) Launched++; // #902
}
catch (System.Exception ex) { Errors++; LastLog = "hit 실패 — " + ex.Message; }
}
/// <summary>그 스킬의 켜진 원본 투사체(이번 피해의 투사체 데이터)에서 hideChildren 자식을 끈다.</summary>
static void HideOriginal(WLSkillVfxRule r, ProjectileData pd)
{
if (r.hideChildren == null || r.hideChildren.Length == 0) return;
System.Type type;
if (!s_types.TryGetValue(r.skill, out type)) { type = System.Type.GetType(r.skill + ", Assembly-CSharp"); s_types[r.skill] = type; }
if (type == null) return;
foreach (var o in Object.FindObjectsByType(type, FindObjectsInactive.Exclude, FindObjectsSortMode.None))
{
var pb = o as ProjectileBase;
if (pb == null || pb.Get_ProjectTileData() != pd) continue;
foreach (var n in r.hideChildren)
{
var c = pb.transform.Find(n);
if (c != null && c.gameObject.activeSelf) { c.gameObject.SetActive(false); Hidden++; }
}
}
}
static bool Spawn(WLSkillVfxFx fx, Vector3 pos, Quaternion rot)
{
if (fx == null || string.IsNullOrEmpty(fx.prefab)) return false;
var p = Prefab(fx.prefab);
if (p == null) return false;
var go = Object.Instantiate(p, pos + rot * V(fx.offset), rot * Quaternion.Euler(V(fx.euler)));
if (!Mathf.Approximately(fx.scale, 1f))
{
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
go.transform.localScale = Vector3.one * fx.scale;
}
if (fx.fade > 0f) WLFxFade.Attach(go, Mathf.Max(0f, fx.life - fx.fade), fx.fade); // #921 투명해지며 소멸
else Object.Destroy(go, Mathf.Max(0.1f, fx.life));
LastLog = "생성 " + fx.prefab + " @" + pos.ToString("F1");
return true;
}
// ── #904 지연 생성(퍼지는 충격파) — 숨김 러너 1개가 시각이 된 것을 띄운다 ──
struct Pending { public float at; public WLSkillVfxFx fx; public Vector3 pos; public Quaternion rot; }
static readonly List<Pending> s_pending = new List<Pending>(64);
static void Later(float delay, WLSkillVfxFx fx, Vector3 pos, Quaternion rot)
{
if (fx == null || string.IsNullOrEmpty(fx.prefab)) return;
if (delay <= 0f) { if (Spawn(fx, pos, rot)) Radials++; return; }
s_pending.Add(new Pending { at = Time.time + delay, fx = fx, pos = pos, rot = rot });
if (s_runner == null) { var go = new GameObject("[WL904] SkillVfxLater"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent<WLSkillVfxLater>(); }
}
static WLSkillVfxLater s_runner;
internal static void TickLater()
{
float now = Time.time;
for (int i = s_pending.Count - 1; i >= 0; i--)
{
var p = s_pending[i];
if (now < p.at) continue;
s_pending.RemoveAt(i);
try { if (Spawn(p.fx, p.pos, p.rot)) Radials++; } catch (System.Exception ex) { Errors++; LastLog = "지연 생성 실패 — " + ex.Message; }
}
}
static GameObject Prefab(string path)
{
GameObject p;
if (s_prefabs.TryGetValue(path, out p)) return p;
p = Resources.Load<GameObject>(path);
if (p == null) LastLog = "이펙트 없음 " + path;
s_prefabs[path] = p;
return p;
}
static Vector3 V(float[] a) { return a != null && a.Length >= 3 ? new Vector3(a[0], a[1], a[2]) : Vector3.zero; }
}
/// <summary>#904 지연 생성 러너(숨김 GameObject 1개).</summary>
public sealed class WLSkillVfxLater : MonoBehaviour { void Update() { WLSkillVfx.TickLater(); } }
/// <summary>#914 이 원본 스킬 인스턴스(풀)는 tint 를 이미 적용함 — 다시 쓰여도 알파 배율이 누적되지 않게.</summary>
public sealed class WLSkillVfxTinted : MonoBehaviour { }
/// <summary>#917 이 원본 스킬 인스턴스(풀)의 판정 상자 원래 크기 · 마지막으로 쓴 크기(hitBoxScale 누적 방지).</summary>
public sealed class WLSkillVfxBase : MonoBehaviour { public Vector3 box, written; }
}