// ───────────────────────────────────────────────────────────────────────────── // LevelUpBurst.cs — 레벨업 연출 구독자(폭발 이펙트 + HP/MP 전회복 + 스케일 펀치) // // PD 지시 #813 · 발주서 WL-813k §1-2 (2026-09-09) · 기준서 v1 §B 요소 5(레벨업 연출 3요소) // // ■ 무엇을 하나 — GrowthEvents.LevelUp 을 **구독만** 한다(원본·코어 무수정). // ① 폭발 이펙트: WLGrowthSettings.burstPrefab(FX_FireLevelUp · 파티클 9 · 비루프)을 PC 위치에 스폰 // · EffectBudget.Heavy 슬롯을 잡고(상한 초과 = 이펙트만 스킵) 수명이 끝나면 파괴 + 반납 // · 연속 레벨업(Add_Exp while 루프)은 burstMinIntervalSeconds 로 1회만 낸다 // ② HP/MP 전회복: 원본 API `Actor.Heal(double,bool)` · `Actor.Heal_MP(double,bool)` 로만 넣는다(비율 = SO) // ③ 스케일 펀치: PC 트랜스폼만 · 짧게 · 되돌리기 안전(LevelUpBurstRunner) // 카메라는 건드리지 않는다. NGUI `HUDLvUp` 도 건드리지 않는다(미사용 확정 — InGameInfo.Get_HUDLvUp 호출처 0). // // ■ 롤백(C8) // WLGrowthSettings 에셋이 없거나 enabled=false(또는 코어 off) 면 GrowthEvents 가 아예 Raise 하지 않으므로 // 이 파일은 한 줄도 돌지 않는다. burstPrefab 만 비우면 전회복·이벤트는 유지한 채 이펙트만 꺼진다. // // ■ 에디트 모드(프로브) // Application.isPlaying 이 false 면 코루틴이 돌지 않으므로 이펙트를 스폰만 하고 그대로 둔다. // 프로브가 DespawnLast() 로 파괴 + 슬롯 반납한다. // ───────────────────────────────────────────────────────────────────────────── using System; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Growth { public static class LevelUpBurst { // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int Handled, BurstSpawned, SkippedByBudget, SkippedByThrottle, SkippedNoPrefab; public static double LastHealHp, LastHealMp; public static float LastLifetime; public static GameObject LastBurstInstance; public static string LastNote = ""; /// 813w4 §1-3(D-37) 진단 — 화면 점유 상한을 실제로 내린 렌더러 수 · 알파를 적용/건너뛴 파티클 수 · 적용 후 최대 점유 비율. public static int LastScreenClamped, LastAlphaApplied, LastAlphaSkipped; public static float LastMaxScreenFraction = -1f; static float s_lastBurstTime = -999f; static bool s_lastBurstHeldBudget; static LevelUpBurstRunner s_runner; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void RegisterOnLoad() { EnsureSubscribed(); } /// 구독 보장(중복 구독은 CombatEventList 가 막는다). 에디트 모드 프로브도 이걸 부른다. public static void EnsureSubscribed() { GrowthEvents.LevelUp.Add(OnLevelUp); Subscribed = true; } /// 구독 해제(프로브 종료용). public static void Unsubscribe() { GrowthEvents.LevelUp.Remove(OnLevelUp); Subscribed = false; } static LevelUpBurstRunner Runner { get { if (s_runner == null) { // hideFlags 를 주지 않는다 — 검증 때 하이어라키에서 보이는 편이 낫고, // HideAndDontSave 는 DontDestroyOnLoad 와 겹쳐 경고를 낸다(WLHitFeel 선례). var go = new GameObject("__WLLevelUpBurst"); UnityEngine.Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } return s_runner; } } // ───────────────────────────────────────────────────── 구독자 본체 static void OnLevelUp(in PlayerLevelUpEvent e) { var s = WLGrowthSettings.Instance; if (s == null || !s.enabled) return; // GrowthEvents 가 이미 막지만 이중 안전 Handled++; ApplyRecovery(s, e.pc, in e); // ② 전회복 — 레벨마다 적용(멱등) TrySpawnBurst(s, e.pc, e.position); // ① 이펙트 — 스로틀·상한 적용 TryScalePunch(s, e.pc); // ③ 스케일 펀치 if (s.verboseLog) Debug.Log("[LevelUpBurst] Lv" + e.prevLevel + "→" + e.newLevel + " heal HP +" + LastHealHp.ToString("F0") + " (" + e.hpBefore.ToString("F0") + "/" + e.maxHp.ToString("F0") + ")" + " MP +" + LastHealMp.ToString("F0") + " (" + e.mpBefore.ToString("F0") + "/" + e.maxMp.ToString("F0") + ")" + " burst=" + (LastBurstInstance != null) + " " + LastNote); } // ───────────────────────────────────────────────────── ② HP/MP 전회복 /// /// 실제로 넣을 회복량(현재값·최대치·비율만으로 결정되는 순수 함수 — 에디트 모드에서 검증 가능). /// 최대치를 넘지 않도록 남은 공간으로 자른다(원본 Set_Stat 의 클램프와 같은 결과). /// public static void ComputeRecovery(double hp, double maxHp, double mp, double maxMp, float hpRate, float mpRate, out double healHp, out double healMp) { healHp = 0d; healMp = 0d; if (maxHp > 0d && hpRate > 0f) healHp = Math.Max(0d, Math.Min(maxHp * hpRate, maxHp - hp)); if (maxMp > 0d && mpRate > 0f) healMp = Math.Max(0d, Math.Min(maxMp * mpRate, maxMp - mp)); } static void ApplyRecovery(WLGrowthSettings s, Actor pc, in PlayerLevelUpEvent e) { LastHealHp = LastHealMp = 0d; if (pc == null || pc.IsDead()) return; double healHp, healMp; ComputeRecovery(e.hpBefore, e.maxHp, e.mpBefore, e.maxMp, s.hpRecoverRate, s.mpRecoverRate, out healHp, out healMp); LastHealHp = healHp; LastHealMp = healMp; if (healHp > 0d) pc.Heal(healHp, s.showRecoverHud); // 원본 API · Set_Stat 이 MaxHP 로 클램프 if (healMp > 0d) pc.Heal_MP(healMp, s.showRecoverHud); // 원본 API · Set_Stat 이 MaxMP 로 클램프 } // ───────────────────────────────────────────────────── ① 폭발 이펙트 static void TrySpawnBurst(WLGrowthSettings s, Actor pc, Vector3 position) { LastNote = ""; if (s.burstPrefab == null) { SkippedNoPrefab++; LastNote = "no-prefab"; return; } float now = Time.unscaledTime; if (s.burstMinIntervalSeconds > 0f && now - s_lastBurstTime < s.burstMinIntervalSeconds) { SkippedByThrottle++; LastNote = "throttled"; return; } bool held = false; if (s.useEffectBudget) { if (!EffectBudget.TryAcquire(EffectBudgetKind.Heavy)) { SkippedByBudget++; LastNote = "budget"; return; } held = true; } Transform parent = (s.burstParentToPc && pc != null) ? pc.transform : null; var go = UnityEngine.Object.Instantiate(s.burstPrefab, position + s.burstOffset, Quaternion.identity, parent); if (s.burstScale > 0f && Mathf.Abs(s.burstScale - 1f) > 0.0001f) go.transform.localScale = s.burstPrefab.transform.localScale * s.burstScale; // 🔴 813w4 §1-3 (D-37) — 화면 점유 상한·알파는 **스폰된 인스턴스에만** 건다(프리팹·공유 머티리얼 무수정). float measured = ApplyBurstLimits(go, s); float life = s.burstLifetimeSeconds > 0f ? Mathf.Min(s.burstLifetimeSeconds, s.burstLifetimeMaxSeconds) : Mathf.Min(measured > 0f ? measured : s.burstLifetimeMaxSeconds, Mathf.Max(0.1f, s.burstLifetimeMaxSeconds)); s_lastBurstTime = now; LastBurstInstance = go; LastLifetime = life; s_lastBurstHeldBudget = held; BurstSpawned++; // 에디트 모드에서는 코루틴이 돌지 않는다 — 프로브가 DespawnLast() 로 정리한다. if (Application.isPlaying) Runner.RunDespawn(go, life, held); } /// /// 813w4 §1-3(D-37) — 스폰된 인스턴스를 한 번만 훑어 ① 수명 측정 ② 화면 점유 상한 ③ 알파를 함께 처리한다. /// 반환 = 측정 수명(duration + startLifetime + startDelay 최댓값 · 0 이면 측정 실패). /// 🔴 프리팹 에셋과 공유 머티리얼은 건드리지 않는다 — 인스턴스의 ParticleSystem/Renderer 만 바꾼다. /// public static float ApplyBurstLimits(GameObject go, WLGrowthSettings s) { LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0; LastMaxScreenFraction = -1f; if (go == null || s == null) return 0f; float life = 0f; var systems = go.GetComponentsInChildren(true); for (int i = 0; i < systems.Length; i++) { var ps = systems[i]; var m = ps.main; float d = m.duration + m.startLifetime.constantMax + m.startDelay.constantMax; if (d > life) life = d; // ② 화면 점유 상한 — maxParticleSize 는 「파티클 1개가 덮는 뷰포트 높이 비율」이다. if (s.burstMaxScreenFraction > 0f) { var r = ps.GetComponent(); if (r != null) { if (r.maxParticleSize > s.burstMaxScreenFraction) { r.maxParticleSize = s.burstMaxScreenFraction; LastScreenClamped++; } if (r.maxParticleSize > LastMaxScreenFraction) LastMaxScreenFraction = r.maxParticleSize; } } // ③ 알파 — 인스턴스의 startColor 만 낮춘다(머티리얼 미접촉). if (s.burstAlpha >= 0f && s.burstAlpha < 1f) ApplyAlpha(ps, s.burstAlpha); } return life; } static void ApplyAlpha(ParticleSystem ps, float alpha) { var m = ps.main; var g = m.startColor; if (g.mode == ParticleSystemGradientMode.Color) { m.startColor = Fade(g.color, alpha); LastAlphaApplied++; } else if (g.mode == ParticleSystemGradientMode.TwoColors) { m.startColor = new ParticleSystem.MinMaxGradient(Fade(g.colorMin, alpha), Fade(g.colorMax, alpha)); LastAlphaApplied++; } else LastAlphaSkipped++; // Gradient 계열 — 새 Gradient 할당이 필요해 건드리지 않는다 } static Color Fade(Color c, float a) { c.a *= a; return c; } /// 에디트 모드 프로브용: 마지막 이펙트를 즉시 파괴하고 EffectBudget 슬롯을 반납한다. public static void DespawnLast() { if (LastBurstInstance != null) { if (Application.isPlaying) UnityEngine.Object.Destroy(LastBurstInstance); else UnityEngine.Object.DestroyImmediate(LastBurstInstance); } LastBurstInstance = null; if (s_lastBurstHeldBudget) { EffectBudget.Release(EffectBudgetKind.Heavy); s_lastBurstHeldBudget = false; } } // ───────────────────────────────────────────────────── ③ 스케일 펀치 static void TryScalePunch(WLGrowthSettings s, Actor pc) { if (pc == null || s.scalePunch <= 1.0001f) return; if (!Application.isPlaying) return; // 코루틴이 없는 에디트 모드에서는 건너뛴다 Runner.RunScalePunch(pc.transform, s.scalePunch, s.scalePunchDuration); } /// 프로브용: 카운터 초기화(구독·러너는 유지). public static void ResetDiagnostics() { Handled = BurstSpawned = SkippedByBudget = SkippedByThrottle = SkippedNoPrefab = 0; LastHealHp = LastHealMp = 0d; LastLifetime = 0f; LastNote = ""; LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0; LastMaxScreenFraction = -1f; s_lastBurstTime = -999f; } } }