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