2026-09-08 15:44:53 +00:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// 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 = "";
|
2026-09-09 07:19:41 +00:00
|
|
|
/// <summary>813w4 §1-3(D-37) 진단 — 화면 점유 상한을 실제로 내린 렌더러 수 · 알파를 적용/건너뛴 파티클 수 · 적용 후 최대 점유 비율.</summary>
|
|
|
|
|
public static int LastScreenClamped, LastAlphaApplied, LastAlphaSkipped;
|
|
|
|
|
public static float LastMaxScreenFraction = -1f;
|
2026-09-08 15:44:53 +00:00
|
|
|
|
|
|
|
|
static float s_lastBurstTime = -999f;
|
|
|
|
|
static bool s_lastBurstHeldBudget;
|
|
|
|
|
static LevelUpBurstRunner s_runner;
|
|
|
|
|
|
|
|
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
|
|
|
|
static void RegisterOnLoad() { EnsureSubscribed(); }
|
|
|
|
|
|
|
|
|
|
/// <summary>구독 보장(중복 구독은 CombatEventList 가 막는다). 에디트 모드 프로브도 이걸 부른다.</summary>
|
|
|
|
|
public static void EnsureSubscribed()
|
|
|
|
|
{
|
|
|
|
|
GrowthEvents.LevelUp.Add(OnLevelUp);
|
|
|
|
|
Subscribed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>구독 해제(프로브 종료용).</summary>
|
|
|
|
|
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<LevelUpBurstRunner>();
|
|
|
|
|
}
|
|
|
|
|
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 전회복
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 실제로 넣을 회복량(현재값·최대치·비율만으로 결정되는 순수 함수 — 에디트 모드에서 검증 가능).
|
|
|
|
|
/// 최대치를 넘지 않도록 남은 공간으로 자른다(원본 Set_Stat 의 클램프와 같은 결과).
|
|
|
|
|
/// </summary>
|
|
|
|
|
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;
|
|
|
|
|
|
2026-09-09 07:19:41 +00:00
|
|
|
// 🔴 813w4 §1-3 (D-37) — 화면 점유 상한·알파는 **스폰된 인스턴스에만** 건다(프리팹·공유 머티리얼 무수정).
|
|
|
|
|
float measured = ApplyBurstLimits(go, s);
|
|
|
|
|
|
2026-09-08 15:44:53 +00:00
|
|
|
float life = s.burstLifetimeSeconds > 0f
|
|
|
|
|
? Mathf.Min(s.burstLifetimeSeconds, s.burstLifetimeMaxSeconds)
|
2026-09-09 07:19:41 +00:00
|
|
|
: Mathf.Min(measured > 0f ? measured : s.burstLifetimeMaxSeconds, Mathf.Max(0.1f, s.burstLifetimeMaxSeconds));
|
2026-09-08 15:44:53 +00:00
|
|
|
|
|
|
|
|
s_lastBurstTime = now;
|
|
|
|
|
LastBurstInstance = go;
|
|
|
|
|
LastLifetime = life;
|
|
|
|
|
s_lastBurstHeldBudget = held;
|
|
|
|
|
BurstSpawned++;
|
|
|
|
|
|
|
|
|
|
// 에디트 모드에서는 코루틴이 돌지 않는다 — 프로브가 DespawnLast() 로 정리한다.
|
|
|
|
|
if (Application.isPlaying) Runner.RunDespawn(go, life, held);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 07:19:41 +00:00
|
|
|
/// <summary>
|
|
|
|
|
/// 813w4 §1-3(D-37) — 스폰된 인스턴스를 한 번만 훑어 ① 수명 측정 ② 화면 점유 상한 ③ 알파를 함께 처리한다.
|
|
|
|
|
/// 반환 = 측정 수명(duration + startLifetime + startDelay 최댓값 · 0 이면 측정 실패).
|
|
|
|
|
/// 🔴 프리팹 에셋과 공유 머티리얼은 건드리지 않는다 — 인스턴스의 ParticleSystem/Renderer 만 바꾼다.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static float ApplyBurstLimits(GameObject go, WLGrowthSettings s)
|
2026-09-08 15:44:53 +00:00
|
|
|
{
|
2026-09-09 07:19:41 +00:00
|
|
|
LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0;
|
|
|
|
|
LastMaxScreenFraction = -1f;
|
|
|
|
|
if (go == null || s == null) return 0f;
|
|
|
|
|
|
2026-09-08 15:44:53 +00:00
|
|
|
float life = 0f;
|
|
|
|
|
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
|
|
|
|
|
for (int i = 0; i < systems.Length; i++)
|
|
|
|
|
{
|
2026-09-09 07:19:41 +00:00
|
|
|
var ps = systems[i];
|
|
|
|
|
var m = ps.main;
|
2026-09-08 15:44:53 +00:00
|
|
|
float d = m.duration + m.startLifetime.constantMax + m.startDelay.constantMax;
|
|
|
|
|
if (d > life) life = d;
|
2026-09-09 07:19:41 +00:00
|
|
|
|
|
|
|
|
// ② 화면 점유 상한 — maxParticleSize 는 「파티클 1개가 덮는 뷰포트 높이 비율」이다.
|
|
|
|
|
if (s.burstMaxScreenFraction > 0f)
|
|
|
|
|
{
|
|
|
|
|
var r = ps.GetComponent<ParticleSystemRenderer>();
|
|
|
|
|
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);
|
2026-09-08 15:44:53 +00:00
|
|
|
}
|
2026-09-09 07:19:41 +00:00
|
|
|
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 할당이 필요해 건드리지 않는다
|
2026-09-08 15:44:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-09 07:19:41 +00:00
|
|
|
static Color Fade(Color c, float a) { c.a *= a; return c; }
|
|
|
|
|
|
2026-09-08 15:44:53 +00:00
|
|
|
/// <summary>에디트 모드 프로브용: 마지막 이펙트를 즉시 파괴하고 EffectBudget 슬롯을 반납한다.</summary>
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>프로브용: 카운터 초기화(구독·러너는 유지).</summary>
|
|
|
|
|
public static void ResetDiagnostics()
|
|
|
|
|
{
|
|
|
|
|
Handled = BurstSpawned = SkippedByBudget = SkippedByThrottle = SkippedNoPrefab = 0;
|
|
|
|
|
LastHealHp = LastHealMp = 0d; LastLifetime = 0f; LastNote = "";
|
2026-09-09 07:19:41 +00:00
|
|
|
LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0; LastMaxScreenFraction = -1f;
|
2026-09-08 15:44:53 +00:00
|
|
|
s_lastBurstTime = -999f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|