// ─────────────────────────────────────────────────────────────────────────────
// 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 에 바로 지움
}
/// #921 PD 「플레이어 위치에 생성 된 이펙트가 갑자기 사라지지 않고 … 투명해지며 자연스럽게 소멸」 — hold 초 뒤 fade 초 동안 방출을 멈추고 입자 알파를 0 으로 · 끝나면 지운다(셰이더 무관 · 입자 색).
public sealed class WLFxFade : MonoBehaviour
{
float _start, _fade; bool _began; ParticleSystem[] _ps; ParticleSystem.Particle[] _buf = new ParticleSystem.Particle[64];
readonly Dictionary _a0 = new Dictionary();
// #934 PD 「(회오리가) 소멸할 때 뭉쳐진 회오리만 남아있어서 어색해 … 스킬 종료 타이밍에 맞게 자연스럽게 소멸」 — 가산(더하기) 재질은 알파만 낮추면 밝기가 남는다:
// dimAdditive 면 가산 재질 입자는 처음 색(입자별 · randomSeed) × 남은 비율로 색 · 알파를 함께 낮춘다(알파 블렌드 입자는 그대로 알파만)
bool _dimAdd; bool[] _add; readonly Dictionary> _c0 = new Dictionary>();
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(); 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(); 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(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 c0; if (!_c0.TryGetValue(ps, out c0)) { c0 = new Dictionary(); _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);
}
}
/// #914 원본 스킬 프리팹 안 파티클 색 · 투명도 바꾸기(이름별) — 풀 인스턴스마다 1 회(표시 컴포넌트 WLSkillVfxTinted).
[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 s_rules = new Dictionary();
static readonly Dictionary s_prefabs = new Dictionary();
static readonly Dictionary s_types = new Dictionary();
static WLSkillVfxJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLSkillVfxJson();
s_rules.Clear();
var ta = Resources.Load(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);
}
/// #899 PD 「강력한 일격 이펙트가 너무 과도 — 캐릭터 크기·비율을 고려해 이펙트 크기 조절」 — 원본 스킬 Set() 끝 훅 1줄이 부른다.
/// 표(skill = 클래스 이름)의 effectScale 을 보이는 크기 노드(sizeNode)에만 곱한다(원본이 매번 1 로 되돌린 뒤라 반복 시전에도 누적되지 않음). 꺼져 있으면(enabled 0) 아무것도 안 한다.
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() == null) { Tinted += Tint(skill, r.tint); skill.gameObject.AddComponent(); }
// #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();
if (bc != null)
{
// #917 풀 재사용 때 배율이 누적되지 않게 — 원본 크기를 기억(원본이 Set 에서 크기를 다시 정했으면 그 값이 새 기준)
var bs = skill.GetComponent();
if (bs == null) { bs = skill.gameObject.AddComponent(); 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;
/// #914 이름이 맞는 파티클의 시작색(RGB · 알파 · 알파 배율) · 수명색(색 키 · 알파 키)을 바꾼다 — 바꾼 파티클 수.
static int Tint(ProjectileBase skill, WLSkillVfxTint[] tints)
{
int n = 0;
foreach (var ps in skill.GetComponentsInChildren(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; }
}
/// 그 스킬의 켜진 원본 투사체(이번 피해의 투사체 데이터)에서 hideChildren 자식을 끈다.
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(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 s_pending = new List(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(); }
}
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(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; }
}
/// #904 지연 생성 러너(숨김 GameObject 1개).
public sealed class WLSkillVfxLater : MonoBehaviour { void Update() { WLSkillVfx.TickLater(); } }
/// #914 이 원본 스킬 인스턴스(풀)는 tint 를 이미 적용함 — 다시 쓰여도 알파 배율이 누적되지 않게.
public sealed class WLSkillVfxTinted : MonoBehaviour { }
/// #917 이 원본 스킬 인스턴스(풀)의 판정 상자 원래 크기 · 마지막으로 쓴 크기(hitBoxScale 누적 방지).
public sealed class WLSkillVfxBase : MonoBehaviour { public Vector3 box, written; }
}