// ───────────────────────────────────────────────────────────────────────────── // SlashCrescentMask.cs — 풀링된 슬래시 이펙트의 크레센트 이미터 렌더러 on/off // // PD 지시 #747 (2026-09-05) 부속 컴포넌트. // // 왜 필요한가: // 무기 궤적은 BladeTrail 이 리본으로 그린다. 그런데 원본 슬래시 프리팹에도 호(크레센트) // 이미터가 들어 있어 그대로 두면 리본과 원본 호가 겹쳐 보이고, 원본 호는 여전히 궤적과 // 다른 방향을 향한다(#747 의 원증상). 그래서 크레센트만 골라 끈다. // // 왜 컴포넌트인가 (그냥 psr.enabled = false 로 하지 않는 이유): // InGameInfo 는 이펙트 인스턴스를 **재사용**한다(dic_str_Effect 풀 + TurnOff_GO). // 스폰 때마다 렌더러를 끄기만 하면 그 인스턴스는 영구히 꺼진 채로 돌아다니고, // 설정에서 규칙을 지워도 원복되지 않는다. 그래서 최초 1회 **원본 enabled 상태**를 // 캐시해 두고 매 스폰마다 그 기준에서 다시 계산한다. // // 특히 원본에 이미 꺼져 있는 렌더러를 실수로 켜지 않는 것이 중요하다 — 실측상 // Blue_Slash_1 루트 · Orange_Slash_1 의 eff · Slash_fire_once 루트 등이 // m_Enabled = 0 인 비렌더 이미터다. 단순히 `enabled = !crescent` 로 쓰면 이들이 켜진다. // // 런타임에서만 AddComponent 로 붙인다 — 프리팹 에셋을 건드리지 않으므로 // 설정에서 규칙을 비우면 즉시 원복된다(C8). // // 1단계 산출물(스테이징). 2단계에 PM 지시로 Assets/WL/Combat/ 로 이동한다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Combat { /// /// 풀링된 슬래시 이펙트 인스턴스에 붙어, 크레센트 이미터의 렌더러 on/off 를 관리한다. /// 최초 1회 원래 enabled 상태를 기억해 두고 매 스폰마다 그 기준으로 다시 계산하므로, /// 인스턴스가 재사용돼도 상태가 누적되지 않고 규칙을 비우면 원래대로 돌아온다. /// [DisallowMultipleComponent] public sealed class SlashCrescentMask : MonoBehaviour { private ParticleSystemRenderer[] _renderers; private string[] _names; private bool[] _originalEnabled; // PD #761 (2026-09-06) — 풀 재사용 함정 차단용. // Apply 가 불린 프레임 번호와 최초 1회 캡처한 루트 로컬 스케일. private int _appliedFrame = -1; private Vector3 _originalScale = Vector3.one; private bool _scaleCaptured; /// 캐시한 이미터 수. 검증용. public int EmitterCount { get { return _renderers != null ? _renderers.Length : 0; } } /// i 번째 이미터 이름. 검증용 — 2단계에 크레센트 이름을 실측할 때 쓴다. public string GetEmitterName(int i) { return (_names != null && i >= 0 && i < _names.Length) ? _names[i] : null; } /// i 번째 이미터가 원래 렌더링되고 있었는가. 검증용. public bool WasEnabled(int i) { return _originalEnabled != null && i >= 0 && i < _originalEnabled.Length && _originalEnabled[i]; } /// /// 규칙에 맞춰 렌더러를 다시 계산한다. /// 가 null 이면 전부 원래 상태로 되돌린다. /// public void Apply(SlashPrefabEmitterRule rule) { Apply(rule, SuppressMode.CrescentOnly, null); } /// /// 억제 모드까지 지정해 렌더러를 다시 계산한다 (PD 지시 #760 2-A · TRAIL §2-4). /// /// · — 기존 동작. 에 적힌 이미터만 끈다. /// · — 전부 끈다(트레일 리본만 남는다). /// · 에 든 것만 남기고 전부 끈다. /// /// 어느 경우에도 _originalEnabled 를 AND 로 걸어 **원래 꺼져 있던 이미터는 켜지 않는다**. /// 모드를 CrescentOnly 로 되돌리면 규칙이 비어 있는 한 원본 상태로 100% 복귀한다(C8 롤백 경로). /// public void Apply(SlashPrefabEmitterRule rule, SuppressMode mode, string[] keepNames) { if (_renderers == null) Capture(); _appliedFrame = Time.frameCount; for (int i = 0; i < _renderers.Length; i++) { var psr = _renderers[i]; if (psr == null) continue; bool suppress; switch (mode) { case SuppressMode.All: suppress = true; break; case SuppressMode.AllExceptListed: suppress = !Contains(keepNames, _names[i]); break; default: suppress = IsCrescent(_names[i], rule); break; } // 원래 꺼져 있던 것은 계속 꺼 둔다. 켜져 있던 것만 규칙에 따라 끈다. psr.enabled = _originalEnabled[i] && !suppress; } } /// /// 🔴 풀 재사용 함정 차단 (PD #761 · PM 실측 판정 ⓕ-B). /// /// 는 불릴 때만 이미터 상태를 갱신한다. 그래서 억제된 채 풀에 남은 /// 인스턴스가 **다른 경로**(일반 Show_Effect·스킬·몬스터 이펙트)로 재생되면 억제 상태가 /// 그대로 따라간다 — 설정을 되돌려도 그 인스턴스만 계속 꺼진 채 돌아다닌다. /// /// InGameInfo.Show_EffectEx 는 actSpawned(= Apply)를 SetActive(true) **직전**에 /// 부르므로 두 호출은 같은 프레임이다. 따라서 "이번 활성화 프레임에 Apply 가 없었다"면 /// 다른 경로로 켜진 것이고, 이때는 반드시 원본 상태(렌더러 enabled + 루트 스케일)로 되돌린다. /// private void OnEnable() { if (_appliedFrame == Time.frameCount) return; // 이번 활성화는 Apply 가 세팅했다 RestoreOriginal(); } /// 렌더러 enabled 와 루트 로컬 스케일을 최초 캡처 상태로 되돌린다. public void RestoreOriginal() { if (_renderers != null) { for (int i = 0; i < _renderers.Length; i++) if (_renderers[i] != null) _renderers[i].enabled = _originalEnabled[i]; } // PrefabArc 는 루트 스케일을 검날 길이에 맞춰 바꾼다. 풀에 그 스케일이 남으면 // 모드를 되돌린 뒤에도 그 인스턴스만 커진 채로 나온다 — 여기서 함께 되돌린다. if (_scaleCaptured) transform.localScale = _originalScale; } /// PrefabArc 가 스케일을 바꾸기 직전에 부른다(최초 1회만 원본을 기억한다). public void CaptureScale() { if (_scaleCaptured) return; _originalScale = transform.localScale; _scaleCaptured = true; } /// 이번 프레임에 배치가 적용됐음을 표시한다(PrefabArc 는 억제를 쓰지 않는다). public void MarkApplied() { _appliedFrame = Time.frameCount; } /// 런타임 검증용 — 현재 렌더러 on/off 상태를 "이름=0/1" 로 덤프한다(#760 판정 ⓓ). public string DumpState() { if (_renderers == null) Capture(); var sb = new System.Text.StringBuilder(); for (int i = 0; i < _renderers.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(_names[i]).Append('=') .Append(_renderers[i] != null && _renderers[i].enabled ? '1' : '0') .Append(_originalEnabled[i] ? "" : "(orig off)"); } return sb.ToString(); } private static bool Contains(string[] names, string emitterName) { if (names == null) return false; for (int i = 0; i < names.Length; i++) if (names[i] == emitterName) return true; return false; } /// 최초 1회 — 이미터 목록과 원본 enabled 상태를 캐시한다. private void Capture() { var systems = GetComponentsInChildren(true); var list = new List(systems.Length); var names = new List(systems.Length); for (int i = 0; i < systems.Length; i++) { var psr = systems[i].GetComponent(); if (psr == null) continue; list.Add(psr); names.Add(systems[i].gameObject.name); } _renderers = list.ToArray(); _names = names.ToArray(); _originalEnabled = new bool[_renderers.Length]; for (int i = 0; i < _renderers.Length; i++) _originalEnabled[i] = _renderers[i] != null && _renderers[i].enabled; } private static bool IsCrescent(string emitterName, SlashPrefabEmitterRule rule) { if (rule == null || rule.crescentEmitterNames == null) return false; for (int i = 0; i < rule.crescentEmitterNames.Length; i++) if (rule.crescentEmitterNames[i] == emitterName) return true; return false; } } }