// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
}
}