// ───────────────────────────────────────────────────────────────────────────── // WLStatusFX.cs — 상태이상 VFX 스폰·풀·추종 (#842). 프리팹 = Resources/WL/Status/(Master Stylized FX 사본 · WL842_StatusPrefabs 가 만든다). // · 루프(스턴·수면·중독) = 상태가 끝날 때 Stop → 방출 중지 후 남은 파티클이 꺼지면 비활성(풀 복귀). // · 1회(속도·능력치·출혈) = oneShotSeconds 뒤 방출 중지. // · 추종 = 매 LateUpdate 액터의 Top/Mid/Bottom 지점(원본 TurnOff_GO 의 eEffectLocation 규칙과 같다). // · PD 2026-10-11 「몬스터 크기에 맞게」 = 액터 키(Top−발) ÷ referenceHeight 를 크기에 곱한다. // · PD 2026-10-11 「이미 재생 중이면 중복 재생 금지 · 같은 이펙트가 또 오면 시간을 늘려라」 = (액터, 종류)별 활성 레지스트리. // 같은 것이 살아 있으면 새로 띄우지 않고 Retrigger: 루프형·지속 방출형은 종료 시각 연장, 1회 폭발형(출혈 베기)은 같은 인스턴스를 되감아 재생. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Status { public static class WLStatusFX { public const string ResourcesDir = "WL/Status/"; public sealed class Handle { public WLStatusType type; public WLStatusFollower follower; public bool Active { get { return follower != null && follower.gameObject.activeSelf && !follower.Ending; } } } static readonly Dictionary s_prefabs = new Dictionary(); static readonly Dictionary> s_pool = new Dictionary>(); static readonly Dictionary s_active = new Dictionary(32); // 액터별 종류별 활성 인스턴스(중복 방지) static Transform s_root; static readonly int TypeCount = System.Enum.GetValues(typeof(WLStatusType)).Length; public static int Spawns, Misses, Retriggers; public static string LastLog = ""; static GameObject Load(string name) { GameObject p; if (!s_prefabs.TryGetValue(name, out p)) { p = Resources.Load(ResourcesDir + name); s_prefabs[name] = p; } return p; } static GameObject Get(string name) { List list; if (s_pool.TryGetValue(name, out list)) for (int i = 0; i < list.Count; i++) if (list[i] != null && !list[i].activeSelf) return list[i]; var prefab = Load(name); if (prefab == null) { Misses++; LastLog = "missing prefab " + ResourcesDir + name; return null; } if (s_root == null) { var r = new GameObject("WL_StatusFX"); Object.DontDestroyOnLoad(r); s_root = r.transform; } var go = Object.Instantiate(prefab, s_root); go.name = name; go.SetActive(false); if (list == null) { list = new List(4); s_pool[name] = list; } list.Add(go); return go; } /// 액터 키(Top−발 · 원본 tf_Top 기준) ÷ 기준 키 = 크기 배율. PD 「몬스터 크기에 맞게」. public static float SizeFactor(Actor actor, WLStatusSettings s) { if (actor == null || s == null || s.referenceHeight <= 0f) return 1f; float h = actor.Get_Top_postion().y - actor.Get_position().y; if (h < 0.05f) return 1f; return Mathf.Clamp(h / s.referenceHeight, s.sizeFactorMin, s.sizeFactorMax); } public static WLStatusFollower ActiveOf(Actor actor, WLStatusType type) { WLStatusFollower[] arr; if (actor == null || !s_active.TryGetValue(actor, out arr)) return null; var f = arr[(int)type]; return f != null && f.gameObject.activeSelf && !f.Ending && f.Actor == actor ? f : null; } static void SetActive(Actor actor, WLStatusType type, WLStatusFollower f) { WLStatusFollower[] arr; if (!s_active.TryGetValue(actor, out arr)) { arr = new WLStatusFollower[TypeCount]; s_active[actor] = arr; } arr[(int)type] = f; } internal static void Unregister(Actor actor, WLStatusType type, WLStatusFollower f) { WLStatusFollower[] arr; if (actor != null && s_active.TryGetValue(actor, out arr) && arr[(int)type] == f) arr[(int)type] = null; } public static Handle Play(Actor actor, WLStatusType type, bool loop) { var s = WLStatus.S; if (s == null || actor == null || s.vfx == null) return null; WLStatusVfx v; if (!s.vfx.TryGetValue(type.ToString(), out v) || v == null || string.IsNullOrEmpty(v.prefab)) return null; float seconds = loop ? -1f : s.oneShotSeconds; var existing = ActiveOf(actor, type); if (existing != null) { // 중복 금지 — 살아 있는 같은 이펙트를 연장/재트리거 existing.Retrigger(seconds); Retriggers++; LastLog = "retrigger " + type + " → " + actor.name; return new Handle { type = type, follower = existing }; } var go = Get(v.prefab); if (go == null) return null; var f = go.GetComponent(); if (f == null) f = go.AddComponent(); f.Begin(actor, type, v.loc, seconds, v.scale * s.vfxScale * SizeFactor(actor, s), v.yOffset); SetActive(actor, type, f); go.SetActive(true); Spawns++; LastLog = "play " + type + " (" + v.prefab + ") ×" + f.transform.localScale.x.ToString("F2") + " → " + actor.name; return new Handle { type = type, follower = f }; } public static void PlayOneShot(Actor actor, WLStatusType type) { Play(actor, type, false); } public static void Stop(ref Handle h) { if (h != null && h.follower != null) h.follower.End(); h = null; } /// 프로브용 — 풀 비우기(설정·프리팹 교체 뒤). public static void ClearPool() { foreach (var kv in s_pool) foreach (var g in kv.Value) if (g != null) Object.Destroy(g); s_pool.Clear(); s_prefabs.Clear(); s_active.Clear(); } } public sealed class WLStatusFollower : MonoBehaviour { Actor _actor; WLStatusType _type; string _loc; float _endAt, _offAt, _yOff; ParticleSystem[] _ps; bool _anyLooping, _loop; public bool Ending { get; private set; } public Actor Actor { get { return _actor; } } /// 남은 파티클이 꺼질 때까지 기다리는 시간(초) — 방출 중지 뒤. const float LingerSeconds = 1.5f; public void Begin(Actor a, WLStatusType type, string loc, float seconds, float scale, float yOff) { _actor = a; _type = type; _loc = loc; _yOff = yOff; Ending = false; _loop = seconds <= 0f; transform.localScale = Vector3.one * Mathf.Max(0.01f, scale); _endAt = seconds > 0f ? Time.time + seconds : float.MaxValue; if (_ps == null) { _ps = GetComponentsInChildren(true); _anyLooping = false; for (int i = 0; i < _ps.Length; i++) if (_ps[i].main.loop) _anyLooping = true; } Follow(); } /// 같은 이펙트가 또 발생 — 지속 방출형은 종료 시각만 연장, 1회 폭발형은 같은 인스턴스를 되감아 재생(인스턴스 1개 유지). public void Retrigger(float seconds) { _endAt = seconds > 0f ? Time.time + seconds : float.MaxValue; if (!_anyLooping && _ps != null) for (int i = 0; i < _ps.Length; i++) { _ps[i].Clear(true); _ps[i].Play(true); } } void OnEnable() { if (_ps == null) { _ps = GetComponentsInChildren(true); _anyLooping = false; for (int i = 0; i < _ps.Length; i++) if (_ps[i].main.loop) _anyLooping = true; } for (int i = 0; i < _ps.Length; i++) { _ps[i].Clear(true); _ps[i].Play(true); } } public void End() { if (Ending) return; Ending = true; WLStatusFX.Unregister(_actor, _type, this); if (_loop) { // #843 PD 「스턴 상태에서 몬스터가 이동」 — 상태가 끝나면 별·Z 를 그 자리에서 지운다(남은 파티클이 1.5 s 떠 있으면 걷기 시작한 몹이 스턴처럼 보였다) if (_ps != null) for (int i = 0; i < _ps.Length; i++) _ps[i].Clear(true); gameObject.SetActive(false); return; } if (_ps != null) for (int i = 0; i < _ps.Length; i++) _ps[i].Stop(true, ParticleSystemStopBehavior.StopEmitting); _offAt = Time.time + LingerSeconds; } void OnDisable() { WLStatusFX.Unregister(_actor, _type, this); } void LateUpdate() { if (!Ending && (_actor == null || _actor.IsDead() || !_actor.gameObject.activeInHierarchy)) End(); Follow(); if (!Ending && Time.time >= _endAt) End(); if (Ending && Time.time >= _offAt) gameObject.SetActive(false); } void Follow() { if (_actor == null) return; Vector3 p = _loc == "Top" ? _actor.Get_Top_postion() : _loc == "Bottom" ? _actor.Get_position() : _actor.Get_Center_position(); p.y += _yOff; transform.position = p; } } }