// ───────────────────────────────────────────────────────────────────────────── // WLPartSkillFx.cs — 파츠 스킬 이펙트 풀 (PD 지시 #879 · 2026-10-16) // // 보유 VFX(MasterStylizedFX · EricWang · StylizedFantasyFxPack — 힘민지/WL 레포 안 에셋)를 Resources/WL/PartSkill/Fx/ 로 복사해 쓴다 // (복사 = AgentScripts/WL879_PartSkillAssets.cs · 원본 경로 표 = Tools/PartSkill/partskill_assets.json). // 모바일: 키마다 풀(fxPoolPerKey) · 파티클 stopAction 끔(풀이 재사용) · 크기는 Hierarchy 스케일 · 프리팹이 없으면 조용히 건너뛴다(기능은 그대로). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.PartSkill { public static class WLPartSkillFx { static readonly Dictionary s_prefabs = new Dictionary(); static readonly Dictionary> s_pool = new Dictionary>(); static readonly Dictionary s_keyOf = new Dictionary(); static readonly Dictionary s_ps = new Dictionary(); static readonly Dictionary s_baseScale = new Dictionary(); struct Timed { public GameObject go; public float at; } static readonly List s_timed = new List(64); // #881 켜져 있는 인스턴스 전부 — 스킬 코루틴이 장비 변경(StopAllCoroutines)으로 끊기면 끝에서 Stop 을 못 해 // 역병 장판 등이 영구히 남았다(실측). 실행기 정리 때 ReleaseAll 이 이 목록을 모두 거둔다. static readonly HashSet s_live = new HashSet(); static Transform s_root; public static int Spawned, Reused, MissingPrefab, Live, NullMatHidden; public static readonly List MissingKeys = new List(); static Transform Root() { if (s_root == null) { var go = new GameObject("WL_PartSkillFx"); Object.DontDestroyOnLoad(go); s_root = go.transform; } return s_root; } public static GameObject Prefab(string key) { if (string.IsNullOrEmpty(key)) return null; GameObject p; if (s_prefabs.TryGetValue(key, out p)) return p; p = Resources.Load(WLPartSkillData.S.fxFolder + key); s_prefabs[key] = p; if (p == null) { MissingPrefab++; if (MissingKeys.Count < 40) MissingKeys.Add(key); } return p; } /// 인스턴스 하나(활성 · 재생 시작). 호출자가 Release 로 돌려준다. public static GameObject Get(string key, Vector3 pos, Quaternion rot, float scale) { var prefab = Prefab(key); if (prefab == null) return null; GameObject go = null; Stack st; if (s_pool.TryGetValue(key, out st)) while (st.Count > 0 && go == null) go = st.Pop(); // 파괴된 항목(씬 정리 등)은 건너뛴다 if (go == null) { go = Object.Instantiate(prefab, Root()); go.name = "PS_" + key; s_keyOf[go] = key; s_baseScale[go] = prefab.transform.localScale; var ps = go.GetComponentsInChildren(true); for (int i = 0; i < ps.Length; i++) { var m = ps[i].main; m.stopAction = ParticleSystemStopAction.None; // 풀이 다시 쓴다(자체 파괴 금지) m.scalingMode = ParticleSystemScalingMode.Hierarchy; // 루트 배율로 크기 조절 } s_ps[go] = ps; foreach (var c in go.GetComponentsInChildren(true)) c.enabled = false; // 연출 전용(물리 간섭 0) // #881 원본 팩 재질이 이 프로젝트에 없는 렌더러(재질 빈칸)는 분홍으로만 그려진다 → 끈다 // (실측: fx_hit_star 의 Ring(1)·Ring(2)·CoreRound(2) 가 큰 분홍 덩어리 · 이펙트 39 중 빈칸 6) foreach (var r in go.GetComponentsInChildren(true)) { if (r.sharedMaterial == null) { r.enabled = false; NullMatHidden++; continue; } var psr = r as ParticleSystemRenderer; var rps = psr != null ? r.GetComponent() : null; if (rps != null && rps.trails.enabled && psr.trailMaterial == null) { var tr = rps.trails; tr.enabled = false; NullMatHidden++; } } List hide; var fh = WLPartSkillData.S.fxHide; if (fh != null && fh.TryGetValue(key, out hide) && hide != null) foreach (var t2 in go.GetComponentsInChildren(true)) if (t2 != go.transform && hide.Contains(t2.name)) t2.gameObject.SetActive(false); ps = go.GetComponentsInChildren(false); // 끈 하위는 재생 목록에서 뺀다 s_ps[go] = ps; Spawned++; } else Reused++; var t = go.transform; t.SetPositionAndRotation(pos, rot); Vector3 bs; if (!s_baseScale.TryGetValue(go, out bs)) bs = Vector3.one; t.localScale = bs * Mathf.Max(0.01f, scale); go.SetActive(true); s_live.Add(go); ParticleSystem[] arr; if (s_ps.TryGetValue(go, out arr)) for (int i = 0; i < arr.Length; i++) { if (arr[i] == null) continue; arr[i].Clear(true); arr[i].Play(true); } Live++; return go; } /// 한 번 재생 후 life 초 뒤 자동 반환. public static GameObject Play(string key, Vector3 pos, Quaternion rot, float scale, float life) { var go = Get(key, pos, rot, scale); if (go != null) ReleaseLater(go, life); return go; } public static GameObject Play(string key, Vector3 pos, float scale, float life) { return Play(key, pos, Quaternion.identity, scale, life); } public static void ReleaseLater(GameObject go, float delay) { if (go == null) return; s_timed.Add(new Timed { go = go, at = Time.time + Mathf.Max(0f, delay) }); } /// 방출을 멈추고 fade 초 뒤 풀로(남은 입자가 자연스럽게 사라지게). public static void Stop(GameObject go, float fade) { if (go == null) return; ParticleSystem[] arr; if (s_ps.TryGetValue(go, out arr)) for (int i = 0; i < arr.Length; i++) if (arr[i] != null) arr[i].Stop(true, ParticleSystemStopBehavior.StopEmitting); ReleaseLater(go, fade); } public static void Release(GameObject go) { if (go == null) return; s_live.Remove(go); string key; if (!s_keyOf.TryGetValue(go, out key)) { Object.Destroy(go); return; } if (!go.activeSelf) return; // 이미 반환됨 go.SetActive(false); Live = Mathf.Max(0, Live - 1); Stack st; if (!s_pool.TryGetValue(key, out st)) s_pool[key] = st = new Stack(); if (st.Count < Mathf.Max(1, WLPartSkillData.S.fxPoolPerKey)) st.Push(go); else { s_keyOf.Remove(go); s_ps.Remove(go); s_baseScale.Remove(go); Object.Destroy(go); } } /// 러너 Update 가 부른다 — 시간 지난 것 반환. public static void Tick() { float now = Time.time; for (int i = s_timed.Count - 1; i >= 0; i--) { if (s_timed[i].at > now) continue; var go = s_timed[i].go; s_timed.RemoveAt(i); if (go != null) Release(go); } } /// 켜져 있는 이펙트를 전부 풀로 돌린다(예약 반환 포함) — 실행기 정리 · 장비 변경 때. public static void ReleaseAll() { s_timed.Clear(); if (s_live.Count == 0) return; var arr = new GameObject[s_live.Count]; s_live.CopyTo(arr); for (int i = 0; i < arr.Length; i++) if (arr[i] != null) Release(arr[i]); s_live.Clear(); } } }