// ───────────────────────────────────────────────────────────────────────────── // LevelUpBurstRunner.cs — LevelUpBurst 의 코루틴 호스트(WLHitFeelRunner 선례) // // PD 지시 #813 · 발주서 WL-813k §1-2 (2026-09-09) // // LevelUpBurst 가 런타임에 1개만 만들고 DontDestroyOnLoad 로 들고 있는다. 씬·프리팹을 건드리지 않는다. // 두 코루틴 모두 **되돌리기 안전**을 최우선으로 짰다. // · 이펙트 파괴: 수명이 끝나면 파괴하고 EffectBudget 슬롯을 정확히 1회만 반납한다(러너가 사라져도 OnDisable 이 반납). // · 스케일 펀치: 우리가 쓴 스케일이 그대로 남아 있을 때만 원래 스케일로 복원한다(사망·리스폰·버프가 바꿨으면 포기). // 카메라는 건드리지 않는다(발주서 §1-2 「카메라 미접촉」). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Growth { public class LevelUpBurstRunner : MonoBehaviour { /// 반납하지 않은 EffectBudget.Heavy 슬롯 수(러너가 죽을 때 정산한다). int _heldBudget; /// 수명이 끝나면 이펙트를 파괴한다. releaseBudget 이면 EffectBudget.Heavy 를 정확히 1회 반납. public void RunDespawn(GameObject go, float seconds, bool releaseBudget) { if (go == null) { if (releaseBudget) EffectBudget.Release(EffectBudgetKind.Heavy); return; } if (releaseBudget) _heldBudget++; StartCoroutine(CoDespawn(go, seconds, releaseBudget)); } IEnumerator CoDespawn(GameObject go, float seconds, bool releaseBudget) { if (seconds > 0f) yield return new WaitForSecondsRealtime(seconds); if (go != null) Object.Destroy(go); if (releaseBudget) { _heldBudget--; EffectBudget.Release(EffectBudgetKind.Heavy); } } /// PC 스케일 펀치(0 → 최대 → 0 · unscaled). 되돌리기 안전. public void RunScalePunch(Transform tf, float punch, float duration) { if (tf == null) return; StartCoroutine(CoScalePunch(tf, punch, duration)); } IEnumerator CoScalePunch(Transform tf, float punch, float duration) { if (tf == null) yield break; Vector3 baseScale = tf.localScale; if (punch <= 1.0001f || baseScale.x <= 0.0001f) yield break; // ProjectileBase.cs 는 transform.localScale.x > 2f 로 **보스를 판정**한다. // 펀치가 그 경계를 넘나들면 잠깐 보스 판정이 흔들리므로 아예 건너뛴다(WLHitFeelRunner 와 같은 규칙). if (baseScale.x <= 2f && baseScale.x * punch > 2f) yield break; float dur = Mathf.Max(0.01f, duration); float t = 0f; Vector3 written = baseScale; while (t < dur) { t += Time.unscaledDeltaTime; if (tf == null) yield break; // 외부가 스케일을 바꿨다면(사망·리스폰·버프) 우리 것을 덮어쓰지 않는다. if ((tf.localScale - written).sqrMagnitude > 1e-6f) yield break; float k = Mathf.Clamp01(t / dur); float s = Mathf.Sin(k * Mathf.PI); // 0 → 1 → 0 written = baseScale * (1f + (punch - 1f) * s); tf.localScale = written; yield return null; } if (tf != null && (tf.localScale - written).sqrMagnitude <= 1e-6f) tf.localScale = baseScale; } void OnDisable() { // 러너가 사라지면 잡고 있던 슬롯이 영구히 남는다 — 반드시 반납한다. while (_heldBudget > 0) { _heldBudget--; EffectBudget.Release(EffectBudgetKind.Heavy); } } } }