Merge branch 'wl/gameplay/WL-813k-levelup'

This commit is contained in:
깃 관리자 2026-09-09 00:54:49 +09:00
commit 36679cc358
14 changed files with 1068 additions and 0 deletions

View File

@ -0,0 +1,94 @@
# WL-813k 레벨업 폭발 프로브 — 2026-09-09 00:42:21 · Unity 6000.3.23f1 · isPlaying=False
## ① 설정 에셋 · 프리팹
core asset = found · WLCombatCoreSettings.Enabled = True
growth asset= found · WLGrowthSettings.Enabled = True · GrowthEvents.Enabled = True
burstPrefab = FX_FireLevelUp · path = Assets/ResWork/EricWang/FX_FireSkill/Prefab/FX_FireLevelUp.prefab
ParticleSystem = 9 · Renderer = 9
{
"enabled": true,
"verboseLog": false,
"forwardToCombatEvents": true,
"burstPrefab": {
"instanceID": 84270
},
"burstParentToPc": true,
"burstOffset": {
"x": 0.0,
"y": 0.10000000149011612,
"z": 0.0
},
"burstScale": 1.0,
"burstLifetimeSeconds": 0.0,
"burstLifetimeMaxSeconds": 6.0,
"burstMinIntervalSeconds": 0.5,
"useEffectBudget": true,
"hpRecoverRate": 1.0,
"mpRecoverRate": 1.0,
"showRecoverHud": true,
"scalePunch": 1.1200000047683716,
"scalePunchDuration": 0.2199999988079071,
"statA": 10,
"statB": 18,
"statC": 8,
"refreshStatOnLevelUp": false
}
stat 3종 = MaxHP / MaxMP / ATK (enum int 10/18/8)
EffectBudget.Heavy limit = 2 · count = 0
## ② 가짜 LevelUp 발행 → 이펙트 스폰
subscribed = True · LevelUp.Count = 1
fake SD_PC: Lv=6 · Get_Point()=25 · Get_UsePoint()=0
Raised=1 Dispatched=1 TotalRaised=1 last=LevelUp
payload: prev→new = 6→7 · levelsGained=1 · statPointTotal=25
core forward: CombatEvents.LevelUp.Raised=1 last=LevelUp
burst instance = FX_FireLevelUp(Clone) · pos = (0.00, 0.10, 0.00) · 수명(측정) = 6.00 s
스폰된 ParticleSystem = 9 · Heavy 슬롯 count = 1
counters: Handled=1 BurstSpawned=1 SkippedBudget=0 SkippedThrottle=0 SkippedNoPrefab=0
HP/MP 전회복: pc=null(에디트 모드 · 월드 PC 없음) → healHp=0 healMp=0 (실제 Heal 호출은 Play 필요 — 계산식은 §④에서 검증)
정리 후 Heavy count = 0 · instance = False
## ③ 이펙트 상한 · 연속 레벨업 스로틀
(a) Heavy 슬롯 선점 2/2 → count=2
BurstSpawned=0 SkippedBudget=1 Handled=1 note=budget
슬롯 반납 후 count=0
(b) 연속 5회 (interval=0.5s): Handled=5 BurstSpawned=1 SkippedThrottle=4
levelsGained(마지막)=5 · Raised=5 Dispatched=5
정리 후 Heavy count=0
## ④ HP/MP 전회복 계산식 (LevelUpBurst.ComputeRecovery · 순수 함수)
설정 비율: hpRecoverRate=1 mpRecoverRate=1 showRecoverHud=True
| HP/MaxHP | MP/MaxMP | 비율 | healHP | healMP | 결과 HP | 결과 MP |
| 1/1000 | 5/200 | 1/1 | +999 | +195 | 1000 | 200 |
| 370/1000 | 120/200 | 1/1 | +630 | +80 | 1000 | 200 |
| 1000/1000 | 200/200 | 1/1 | +0 | +0 | 1000 | 200 |
| 0/1000 | 0/200 | 1/1 | +1000 | +200 | 1000 | 200 |
| 370/1000 | 120/200 | 0.5/0.5 | +500 | +80 | 870 | 200 |
| 370/1000 | 120/200 | 0/0 | +0 | +0 | 370 | 120 |
## ⑤ GC (구독자 1 = LevelUpBurst · 스로틀 구간)
(a) RaiseLevelUp(data=null) × 1000 → GC 델타 = 0 B (회당 0.00 B)
(b) RaiseLevelUp(data=SD_PC) × 1000 → GC 델타 = 0 B (회당 0.00 B)
※ (b)(a) 는 페이로드의 statPointTotal 이 부르는 **원본** SD_PC.Get_Point() → Get_UsePoint() 의 LINQ(Select/Sum) 할당이다.
(c) 원본 SD_PC.Get_Point() × 1000 (이벤트 없이) → GC 델타 = 0 B (회당 0.00 B)
BurstSpawned=1 SkippedThrottle=2005(측정 구간은 전부 스로틀)
## ⑥ C8 롤백 (원본 100% 복귀)
(a) WLGrowthSettings.RuntimeDisabled=true → Enabled=False Raised=0 TotalRaised=0 Handled=0 BurstSpawned=0
(b) WLCombatCoreSettings.RuntimeDisabled=true → Enabled=False Raised=0 TotalRaised=0 Handled=0 BurstSpawned=0
(c) 에셋 없음(Instance=null) → WLGrowthSettings.Enabled=False GrowthEvents.Enabled=False Raised=0 TotalRaised=0 Handled=0 BurstSpawned=0
ClearCache 복구 → Instance=found Enabled=True
(d) 복구 후 → Raised=1 Handled=1 BurstSpawned=1 instance=True
## ⑦ 정리
구독 해제 = True · Heavy count = 0 · Acquired/Denied = 5/1
활성 씬 = (none) · dirty = False (저장하지 않는다)
잔존 FX_FireLevelUp 파티클 = 0
# WL-813k GC 재측정 — 00:43:04
대조군 new object() × 1000 → GC.GetTotalMemory 델타 = 4096 B · Profiler.MonoUsed 델타 = 4096 B
대조군 원본 SD_PC.Get_Point() × 1000 (LINQ Select/Sum) → GC.GetTotalMemory 델타 = 0 B · Profiler.MonoUsed 델타 = 0 B
RaiseLevelUp(null) × 1000 → GC.GetTotalMemory 델타 = 0 B · Profiler.MonoUsed 델타 = 0 B
RaiseLevelUp(SD_PC) × 1000 → GC.GetTotalMemory 델타 = 0 B · Profiler.MonoUsed 델타 = 0 B
BurstSpawned=2 SkippedThrottle=2000
Heavy count = 0 · 구독 해제 = True

View File

@ -0,0 +1,301 @@
// WL813k_Probe.cs — #813k 레벨업 폭발 검증 프로브 (에디트 모드 전용 · Play 없음 · 에셋 무수정)
//
// unity command run_script --file AgentScripts/WL813k_Probe.cs --entry WL813k_Probe.Run
// 결과: AgentScripts/WL813k_PROBE.txt + Console
//
// 원본·코어·설정 에셋 필드를 수정하지 않는다(런타임 static 스위치와 EffectBudget 카운터만 쓴다).
// 스폰한 이펙트는 DestroyImmediate 로 즉시 정리하고, 씬은 저장하지 않는다.
using System;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEngine;
using WL.Combat.Core;
using WL.Combat.Growth;
public static class WL813k_Probe
{
const string OutPath = "AgentScripts/WL813k_PROBE.txt";
static StringBuilder sb;
public static object Run()
{
sb = new StringBuilder();
L("# WL-813k 레벨업 폭발 프로브 — " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
" · Unity " + Application.unityVersion + " · isPlaying=" + Application.isPlaying);
L("");
Section1_Dump();
Section2_Fake();
Section3_BudgetThrottle();
Section4_Recovery();
Section5_Gc();
Section6_Rollback();
Section7_Cleanup();
try { Directory.CreateDirectory("AgentScripts"); File.WriteAllText(OutPath, sb.ToString(), new UTF8Encoding(true)); }
catch (Exception ex) { Debug.LogException(ex); }
Debug.Log(sb.ToString());
return sb.ToString();
}
static void L(string s) { sb.AppendLine(s); }
// ── ① 설정 에셋 로드 · 프리팹 실존 ──────────────────────────────────────
static void Section1_Dump()
{
L("## ① 설정 에셋 · 프리팹");
var g = WLGrowthSettings.Instance;
var c = WLCombatCoreSettings.Instance;
L("core asset = " + (c != null ? "found" : "NULL") + " · WLCombatCoreSettings.Enabled = " + WLCombatCoreSettings.Enabled);
L("growth asset= " + (g != null ? "found" : "NULL") + " · WLGrowthSettings.Enabled = " + WLGrowthSettings.Enabled +
" · GrowthEvents.Enabled = " + GrowthEvents.Enabled);
if (g == null) { L("!! 에셋 로드 실패 — 이후 검증 불가"); return; }
L("burstPrefab = " + (g.burstPrefab != null ? g.burstPrefab.name : "NULL") +
" · path = " + (g.burstPrefab != null ? UnityEditor.AssetDatabase.GetAssetPath(g.burstPrefab) : "-"));
if (g.burstPrefab != null)
L(" ParticleSystem = " + g.burstPrefab.GetComponentsInChildren<ParticleSystem>(true).Length +
" · Renderer = " + g.burstPrefab.GetComponentsInChildren<Renderer>(true).Length);
L(JsonUtility.ToJson(g, true));
L("stat 3종 = " + g.statA + " / " + g.statB + " / " + g.statC + " (enum int " + (int)g.statA + "/" + (int)g.statB + "/" + (int)g.statC + ")");
L("EffectBudget.Heavy limit = " + EffectBudget.Limit(EffectBudgetKind.Heavy) + " · count = " + EffectBudget.Count(EffectBudgetKind.Heavy));
L("");
}
// ── ② 가짜 LevelUp 발행 → 이펙트 스폰 ───────────────────────────────────
static void Section2_Fake()
{
L("## ② 가짜 LevelUp 발행 → 이펙트 스폰");
LevelUpBurst.EnsureSubscribed();
GrowthEvents.ResetDiagnostics();
LevelUpBurst.ResetDiagnostics();
L("subscribed = " + LevelUpBurst.Subscribed + " · LevelUp.Count = " + GrowthEvents.LevelUp.Count);
var data = MakeFakeData(6); // Lv 6 → 7 로 오르는 상황
L("fake SD_PC: Lv=" + (int)data.Lv + " · Get_Point()=" + data.Get_Point() + " · Get_UsePoint()=" + data.Get_UsePoint());
GrowthEvents.RaiseLevelUp(data, 7);
L("Raised=" + GrowthEvents.LevelUp.Raised + " Dispatched=" + GrowthEvents.LevelUp.Dispatched +
" TotalRaised=" + GrowthEvents.TotalRaised + " last=" + GrowthEvents.LastEvent);
L("payload: prev→new = " + GrowthEvents.LastPrevLevel + "→" + GrowthEvents.LastLevel +
" · levelsGained=" + GrowthEvents.LastLevelsGained + " · statPointTotal=" + GrowthEvents.LastStatPoint);
L("core forward: CombatEvents.LevelUp.Raised=" + CombatEvents.LevelUp.Raised + " last=" + CombatEvents.LastEvent);
var go = LevelUpBurst.LastBurstInstance;
L("burst instance = " + (go != null ? go.name : "NULL") +
" · pos = " + (go != null ? go.transform.position.ToString("F2") : "-") +
" · 수명(측정) = " + LevelUpBurst.LastLifetime.ToString("F2") + " s");
if (go != null)
L(" 스폰된 ParticleSystem = " + go.GetComponentsInChildren<ParticleSystem>(true).Length +
" · Heavy 슬롯 count = " + EffectBudget.Count(EffectBudgetKind.Heavy));
L("counters: Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned +
" SkippedBudget=" + LevelUpBurst.SkippedByBudget + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle +
" SkippedNoPrefab=" + LevelUpBurst.SkippedNoPrefab);
L("HP/MP 전회복: pc=null(에디트 모드 · 월드 PC 없음) → healHp=" + LevelUpBurst.LastHealHp + " healMp=" + LevelUpBurst.LastHealMp +
" (실제 Heal 호출은 Play 필요 — 계산식은 §④에서 검증)");
LevelUpBurst.DespawnLast();
L("정리 후 Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) + " · instance = " + (LevelUpBurst.LastBurstInstance != null));
L("");
}
// ── ③ 상한(EffectBudget) · 연속 레벨업 스로틀 ───────────────────────────
static void Section3_BudgetThrottle()
{
L("## ③ 이펙트 상한 · 연속 레벨업 스로틀");
var g = WLGrowthSettings.Instance;
var data = MakeFakeData(10);
// (a) 상한 초과 = 이펙트만 스킵
LevelUpBurst.ResetDiagnostics();
int limit = EffectBudget.Limit(EffectBudgetKind.Heavy);
int taken = 0;
while (EffectBudget.Count(EffectBudgetKind.Heavy) < limit && taken < 64) { EffectBudget.TryAcquire(EffectBudgetKind.Heavy); taken++; }
L("(a) Heavy 슬롯 선점 " + taken + "/" + limit + " → count=" + EffectBudget.Count(EffectBudgetKind.Heavy));
GrowthEvents.RaiseLevelUp(data, 11);
L(" BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedBudget=" + LevelUpBurst.SkippedByBudget +
" Handled=" + LevelUpBurst.Handled + " note=" + LevelUpBurst.LastNote);
for (int i = 0; i < taken; i++) EffectBudget.Release(EffectBudgetKind.Heavy);
L(" 슬롯 반납 후 count=" + EffectBudget.Count(EffectBudgetKind.Heavy));
// (b) 연속 레벨업(같은 프레임 5회) = 이펙트 1회 + 나머지 스로틀
LevelUpBurst.ResetDiagnostics();
GrowthEvents.ResetDiagnostics();
for (int i = 0; i < 5; i++) GrowthEvents.RaiseLevelUp(data, 12 + i);
L("(b) 연속 5회 (interval=" + g.burstMinIntervalSeconds + "s): Handled=" + LevelUpBurst.Handled +
" BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle);
L(" levelsGained(마지막)=" + GrowthEvents.LastLevelsGained + " · Raised=" + GrowthEvents.LevelUp.Raised +
" Dispatched=" + GrowthEvents.LevelUp.Dispatched);
LevelUpBurst.DespawnLast();
L(" 정리 후 Heavy count=" + EffectBudget.Count(EffectBudgetKind.Heavy));
L("");
}
// ── ④ HP/MP 전회복 계산식 ───────────────────────────────────────────────
static void Section4_Recovery()
{
L("## ④ HP/MP 전회복 계산식 (LevelUpBurst.ComputeRecovery · 순수 함수)");
var g = WLGrowthSettings.Instance;
L("설정 비율: hpRecoverRate=" + g.hpRecoverRate + " mpRecoverRate=" + g.mpRecoverRate + " showRecoverHud=" + g.showRecoverHud);
L("| HP/MaxHP | MP/MaxMP | 비율 | healHP | healMP | 결과 HP | 결과 MP |");
Rec(1d, 1000d, 5d, 200d, g.hpRecoverRate, g.mpRecoverRate);
Rec(370d, 1000d, 120d, 200d, g.hpRecoverRate, g.mpRecoverRate);
Rec(1000d, 1000d, 200d, 200d, g.hpRecoverRate, g.mpRecoverRate);
Rec(0d, 1000d, 0d, 200d, g.hpRecoverRate, g.mpRecoverRate);
Rec(370d, 1000d, 120d, 200d, 0.5f, 0.5f);
Rec(370d, 1000d, 120d, 200d, 0f, 0f);
L("");
}
static void Rec(double hp, double maxHp, double mp, double maxMp, float hpRate, float mpRate)
{
double hHp, hMp;
LevelUpBurst.ComputeRecovery(hp, maxHp, mp, maxMp, hpRate, mpRate, out hHp, out hMp);
L("| " + hp + "/" + maxHp + " | " + mp + "/" + maxMp + " | " + hpRate + "/" + mpRate +
" | +" + hHp + " | +" + hMp + " | " + (hp + hHp) + " | " + (mp + hMp) + " |");
}
// ── ⑤ GC ────────────────────────────────────────────────────────────────
static void Section5_Gc()
{
L("## ⑤ GC (구독자 1 = LevelUpBurst · 스로틀 구간)");
var data = MakeFakeData(30);
GrowthEvents.RaiseLevelUp(data, 31); // 예열 = 첫 스폰(할당 있음)을 측정 밖으로
LevelUpBurst.DespawnLast();
long b0 = Warm();
for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(null, 40 + i);
long a0 = GC.GetTotalMemory(false);
L("(a) RaiseLevelUp(data=null) × 1000 → GC 델타 = " + (a0 - b0) + " B (회당 " + ((a0 - b0) / 1000.0).ToString("F2") + " B)");
long b1 = Warm();
for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(data, 40 + i);
long a1 = GC.GetTotalMemory(false);
L("(b) RaiseLevelUp(data=SD_PC) × 1000 → GC 델타 = " + (a1 - b1) + " B (회당 " + ((a1 - b1) / 1000.0).ToString("F2") + " B)");
L(" ※ (b)(a) 는 페이로드의 statPointTotal 이 부르는 **원본** SD_PC.Get_Point() → Get_UsePoint() 의 LINQ(Select/Sum) 할당이다.");
long b2 = Warm();
for (int i = 0; i < 1000; i++) data.Get_Point();
long a2 = GC.GetTotalMemory(false);
L("(c) 원본 SD_PC.Get_Point() × 1000 (이벤트 없이) → GC 델타 = " + (a2 - b2) + " B (회당 " + ((a2 - b2) / 1000.0).ToString("F2") + " B)");
L(" BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle + "(측정 구간은 전부 스로틀)");
LevelUpBurst.DespawnLast();
L("");
}
static long Warm()
{
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
return GC.GetTotalMemory(true);
}
/// <summary>GC 재측정 — 측정법의 민감도(대조군)까지 같이 낸다. 단독 실행 entry.</summary>
public static object Gc2()
{
sb = new StringBuilder();
L("# WL-813k GC 재측정 — " + DateTime.Now.ToString("HH:mm:ss"));
LevelUpBurst.EnsureSubscribed();
var data = MakeFakeData(30);
GrowthEvents.RaiseLevelUp(data, 31); LevelUpBurst.DespawnLast(); // 예열(첫 스폰을 측정 밖으로)
Meas("대조군 new object() × 1000", () => { object o = null; for (int i = 0; i < 1000; i++) o = new object(); GC.KeepAlive(o); });
Meas("대조군 원본 SD_PC.Get_Point() × 1000 (LINQ Select/Sum)", () => { int s = 0; for (int i = 0; i < 1000; i++) s += data.Get_Point(); GC.KeepAlive(s); });
Meas("RaiseLevelUp(null) × 1000", () => { for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(null, 40 + i); });
Meas("RaiseLevelUp(SD_PC) × 1000", () => { for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(data, 40 + i); });
L("BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle);
LevelUpBurst.DespawnLast(); LevelUpBurst.Unsubscribe();
L("Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) + " · 구독 해제 = " + (GrowthEvents.LevelUp.Count == 0));
try { File.AppendAllText(OutPath, sb.ToString(), new UTF8Encoding(false)); } catch (Exception ex) { Debug.LogException(ex); }
Debug.Log(sb.ToString());
return sb.ToString();
}
static void Meas(string name, Action act)
{
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long g0 = GC.GetTotalMemory(true);
long m0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
act();
long g1 = GC.GetTotalMemory(false);
long m1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
L(" " + name + " → GC.GetTotalMemory 델타 = " + (g1 - g0) + " B · Profiler.MonoUsed 델타 = " + (m1 - m0) + " B");
}
// ── ⑥ C8 롤백 ───────────────────────────────────────────────────────────
static void Section6_Rollback()
{
L("## ⑥ C8 롤백 (원본 100% 복귀)");
var data = MakeFakeData(50);
GrowthEvents.ResetDiagnostics(); LevelUpBurst.ResetDiagnostics();
// (a) 성장 스위치 off
WLGrowthSettings.RuntimeDisabled = true;
GrowthEvents.RaiseLevelUp(data, 51);
L("(a) WLGrowthSettings.RuntimeDisabled=true → Enabled=" + GrowthEvents.Enabled +
" Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised +
" Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned);
WLGrowthSettings.RuntimeDisabled = false;
// (b) 코어 스위치 off
WLCombatCoreSettings.RuntimeDisabled = true;
GrowthEvents.RaiseLevelUp(data, 52);
L("(b) WLCombatCoreSettings.RuntimeDisabled=true → Enabled=" + GrowthEvents.Enabled +
" Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised +
" Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned);
WLCombatCoreSettings.RuntimeDisabled = false;
// (c) 설정 에셋 없음 (Resources 로드 결과를 null 로 강제 = 에셋 삭제와 같은 경로)
var fCached = typeof(WLGrowthSettings).GetField("s_cached", BindingFlags.NonPublic | BindingFlags.Static);
var fDone = typeof(WLGrowthSettings).GetField("s_lookupDone", BindingFlags.NonPublic | BindingFlags.Static);
if (fCached != null && fDone != null)
{
fCached.SetValue(null, null); fDone.SetValue(null, true);
GrowthEvents.RaiseLevelUp(data, 53);
L("(c) 에셋 없음(Instance=null) → WLGrowthSettings.Enabled=" + WLGrowthSettings.Enabled +
" GrowthEvents.Enabled=" + GrowthEvents.Enabled +
" Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised +
" Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned);
WLGrowthSettings.ClearCache();
L(" ClearCache 복구 → Instance=" + (WLGrowthSettings.Instance != null ? "found" : "NULL") + " Enabled=" + WLGrowthSettings.Enabled);
}
else L("(c) 리플렉션 실패 — 미검증");
// (d) 스위치 복구 후 정상 동작 확인
LevelUpBurst.ResetDiagnostics(); GrowthEvents.ResetDiagnostics();
GrowthEvents.RaiseLevelUp(data, 54);
L("(d) 복구 후 → Raised=" + GrowthEvents.LevelUp.Raised + " Handled=" + LevelUpBurst.Handled +
" BurstSpawned=" + LevelUpBurst.BurstSpawned + " instance=" + (LevelUpBurst.LastBurstInstance != null));
LevelUpBurst.DespawnLast();
L("");
}
// ── ⑦ 정리 ──────────────────────────────────────────────────────────────
static void Section7_Cleanup()
{
L("## ⑦ 정리");
LevelUpBurst.DespawnLast();
LevelUpBurst.Unsubscribe();
WLGrowthSettings.RuntimeDisabled = false;
WLCombatCoreSettings.RuntimeDisabled = false;
L("구독 해제 = " + (GrowthEvents.LevelUp.Count == 0) + " · Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) +
" · Acquired/Denied = " + EffectBudget.AcquiredCount + "/" + EffectBudget.DeniedCount);
var sc = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
L("활성 씬 = " + (string.IsNullOrEmpty(sc.name) ? "(none)" : sc.name) + " · dirty = " + sc.isDirty + " (저장하지 않는다)");
var stray = UnityEngine.Object.FindObjectsByType<ParticleSystem>(FindObjectsSortMode.None);
int fx = 0;
for (int i = 0; i < stray.Length; i++) if (stray[i] != null && stray[i].transform.root.name.StartsWith("FX_FireLevelUp")) fx++;
L("잔존 FX_FireLevelUp 파티클 = " + fx);
L("");
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
static SD_PC MakeFakeData(int lv)
{
var d = new SD_PC();
d.Lv = lv; d.Lv.RandomizeCryptoKey();
return d;
}
}

View File

@ -2732,6 +2732,7 @@ public class SD_PC
++Lv;
Lv.RandomizeCryptoKey();
// TODO 정인호 : 유저 레벨업 함
WL.Combat.Growth.GrowthEvents.RaiseLevelUp(this, Lv); // WL #813k: 레벨업 통지(연출·전회복·팝업은 구독자 · 성장/코어 스위치 off 면 즉시 반환)
}
if (MyInfoUI.isIns) MyInfoUI.Ins.Set_Exp();

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: da1a07717a8d49a5a95d9062d1ed85d0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,165 @@
// ─────────────────────────────────────────────────────────────────────────────
// GrowthEvents.cs — WL 성장(레벨업) 통지 허브(정적)
//
// PD 지시 #813 · 발주서 WL-813k §1-1 (2026-09-09)
//
// ■ 왜 별도 파일인가 (813h BossEvents 선례)
// 코어 허브 `Assets/WL/Combat/Core/CombatEvents.cs` 는 811d~j 가 동시에 물고 있어 수정 금지다.
// 811b 가 둔 `CombatEvents.LevelUp` 페이로드는 `pc · newLevel` 2필드뿐이라 813g 팝업이 읽어야 할
// 「이전/새 스탯 3개」를 담을 수 없다. 그래서 같은 규약으로 이 파일에 성장 축을 따로 둔다.
// · 정적 클래스(새 Manager/Singleton 0) · 구조체 페이로드를 `in` 으로 전달(복사·박싱 0)
// · 구독자 = 사전 할당 배열(코어의 CombatEventList<T> 재사용 — Assembly-CSharp 단일 어셈블리)
// · 구독자 예외는 try/catch 로 격리(코어 CombatEventList.Dispatch 안) → 원본 Add_Exp 흐름을 끊지 않는다
// · 코어 스위치(WLCombatCoreSettings)와 성장 스위치(WLGrowthSettings) 둘 다 준수 = C8 롤백
// 코어를 대체하지 않는다 — forwardToCombatEvents 가 켜져 있으면 811b 의 `CombatEvents.RaiseLevelUp`
// 도 그대로 호출해 코어 구독자(있다면)를 잃지 않는다.
//
// ■ 발생 지점 (원본 훅 1줄)
// ServerClass.cs `SD_PC.Add_Exp` 레벨업 while 루프 안 `++Lv;` 직후 — 오른 레벨 1 당 1회.
// (한 번의 Add_Exp 로 여러 레벨이 오르면 그 횟수만큼 발생하고 levelsGained 가 1,2,3… 으로 센다)
//
// ■ 구독
// WL.Combat.Growth.GrowthEvents.LevelUp.Add(handler) / Remove(handler)
// 연출 = LevelUpBurst(G · 이 폴더) · 팝업 = 813g(U). 둘 다 여기를 구독만 한다.
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(SD_PC 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Growth
{
/// <summary>PC 레벨업 페이로드(발생 시점 스냅샷 · struct · GC 0).</summary>
public struct PlayerLevelUpEvent
{
/// <summary>레벨업한 서버 데이터(ServerData.PC). 항상 non-null.</summary>
public SD_PC data;
/// <summary>월드의 메인 PC. 로비·오프라인 보상 등 월드에 PC 가 없으면 null — 구독자는 반드시 null 검사할 것.</summary>
public Actor pc;
/// <summary>레벨업 전/후 레벨.</summary>
public int prevLevel, newLevel;
/// <summary>같은 Add_Exp 호출(같은 프레임·같은 SD_PC)에서 이번이 몇 번째 레벨업인가(1부터).</summary>
public int levelsGained;
/// <summary>레벨업 뒤 남은 미사용 스탯 포인트(SD_PC.Get_Point()). 원본 = (Lv-1)*5 사용분.</summary>
public int statPointTotal;
/// <summary>813g 팝업용 스탯 3종의 종류(WLGrowthSettings.statA/B/C · 기본 MaxHP · MaxMP · ATK).</summary>
public eStat statAKind, statBKind, statCKind;
/// <summary>스탯 3종의 이전/새 값. refreshStatOnLevelUp 이 꺼져 있으면(기본) 원본대로 prev == new 다 — 원본은 레벨업으로 스탯이 바로 오르지 않고 포인트만 준다.</summary>
public double prevStatA, newStatA;
public double prevStatB, newStatB;
public double prevStatC, newStatC;
/// <summary>전회복 직전의 HP/MP 와 최대치(연출·로그·UI 용 · PC 가 없으면 0).</summary>
public double hpBefore, maxHp, mpBefore, maxMp;
/// <summary>연출 스폰 기준 위치(PC 가 없으면 Vector3.zero).</summary>
public Vector3 position;
public float time;
public int frame;
}
/// <summary>성장 이벤트 허브. 정적 · GC 0 · 구독은 LevelUp.Add / Remove.</summary>
public static class GrowthEvents
{
/// <summary>레벨업 구독자 목록(코어와 같은 구현 · 예외 격리 · Raised/Dispatched 카운터).</summary>
public static readonly CombatEventList<PlayerLevelUpEvent> LevelUp = new CombatEventList<PlayerLevelUpEvent>(4);
/// <summary>성장 축이 살아 있는가 = 코어 스위치 · 성장 스위치 둘 다 켜짐. 어느 쪽을 꺼도 원본 동작(C8).</summary>
public static bool Enabled
{
get { return WLCombatCoreSettings.Enabled && WLGrowthSettings.Enabled; }
}
// ── 진단(프로브가 읽는다)
public static int TotalRaised;
public static string LastEvent = "";
public static float LastEventTime;
public static int LastLevel, LastPrevLevel, LastLevelsGained, LastStatPoint;
// ── 연속 레벨업 카운트(같은 프레임 · 같은 SD_PC 면 이어서 센다)
static SD_PC s_lastData;
static int s_lastFrame = -1;
static int s_chain;
/// <summary>
/// 원본 훅 1줄이 부른다 — `ServerClass.cs` `SD_PC.Add_Exp` 의 `++Lv;` 직후.
/// 코어/성장 스위치가 꺼져 있거나 에셋이 없으면 즉시 반환한다(원본 100%).
/// </summary>
public static void RaiseLevelUp(SD_PC data, int newLevel)
{
if (!Enabled) return;
var s = WLGrowthSettings.Instance;
// 월드의 메인 PC. 로비·결과창·오프라인 보상 경로에서는 없을 수 있다(파괴된 참조도 Unity != 로 걸러진다).
var mypc = MyValue.MyPC;
Actor pc = mypc != null ? mypc : null;
int frame = Time.frameCount;
if (ReferenceEquals(data, s_lastData) && s_lastFrame == frame) s_chain++;
else s_chain = 1;
s_lastData = data; s_lastFrame = frame;
var e = new PlayerLevelUpEvent
{
data = data,
pc = pc,
prevLevel = newLevel - 1,
newLevel = newLevel,
levelsGained = s_chain,
statPointTotal = data != null ? data.Get_Point() : 0,
statAKind = s.statA, statBKind = s.statB, statCKind = s.statC,
position = pc != null ? pc.Get_position() : Vector3.zero,
time = Time.unscaledTime,
frame = frame
};
var info = pc != null ? pc.Get_StatInfo() : null;
if (info != null)
{
e.prevStatA = info.Get_Stat(s.statA);
e.prevStatB = info.Get_Stat(s.statB);
e.prevStatC = info.Get_Stat(s.statC);
e.hpBefore = info.Get_Stat(eStat.HP); e.maxHp = info.Get_Stat(eStat.MaxHP);
e.mpBefore = info.Get_Stat(eStat.MP); e.maxMp = info.Get_Stat(eStat.MaxMP);
// 기본 off — 원본은 레벨업으로 스탯이 즉시 오르지 않는다(포인트만 지급). 켜면 재계산 후 새 값을 담는다.
if (s.refreshStatOnLevelUp)
MyValue.Get_ActorStatInfoorNull(eRole.PC, false, "0", 0, true); // showchange=false(StatChangeUI 미호출) · keephpmp=true
e.newStatA = info.Get_Stat(s.statA);
e.newStatB = info.Get_Stat(s.statB);
e.newStatC = info.Get_Stat(s.statC);
}
TotalRaised++;
LastEvent = "LevelUp";
LastEventTime = e.time;
LastPrevLevel = e.prevLevel; LastLevel = e.newLevel;
LastLevelsGained = e.levelsGained; LastStatPoint = e.statPointTotal;
LevelUp.Dispatch(in e);
// 811b 가 API 만 둔 코어 이벤트에도 그대로 흘린다(코어가 꺼져 있으면 내부에서 즉시 반환).
if (s.forwardToCombatEvents) CombatEvents.RaiseLevelUp(pc, newLevel);
if (s.verboseLog)
Debug.Log("[GrowthEvents] LevelUp " + e.prevLevel + "→" + e.newLevel +
" chain=" + e.levelsGained + " point=" + e.statPointTotal +
" pc=" + (pc != null ? pc.name : "none"));
}
/// <summary>프로브용: 카운터 초기화(구독은 유지).</summary>
public static void ResetDiagnostics()
{
TotalRaised = 0; LastEvent = ""; LastEventTime = 0f;
LastLevel = LastPrevLevel = LastLevelsGained = LastStatPoint = 0;
LevelUp.Raised = LevelUp.Dispatched = 0;
s_lastData = null; s_lastFrame = -1; s_chain = 0;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1acaac6cd13a4fadb13df1accfae4f14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,200 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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 = "";
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;
float life = s.burstLifetimeSeconds > 0f
? Mathf.Min(s.burstLifetimeSeconds, s.burstLifetimeMaxSeconds)
: MeasureLifetime(go, s.burstLifetimeMaxSeconds);
s_lastBurstTime = now;
LastBurstInstance = go;
LastLifetime = life;
s_lastBurstHeldBudget = held;
BurstSpawned++;
// 에디트 모드에서는 코루틴이 돌지 않는다 — 프로브가 DespawnLast() 로 정리한다.
if (Application.isPlaying) Runner.RunDespawn(go, life, held);
}
/// <summary>파티클에서 실제 수명을 잰다(duration + startLifetime + startDelay 최댓값 · 상한으로 자른다).</summary>
static float MeasureLifetime(GameObject go, float max)
{
float life = 0f;
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
for (int i = 0; i < systems.Length; i++)
{
var m = systems[i].main;
float d = m.duration + m.startLifetime.constantMax + m.startDelay.constantMax;
if (d > life) life = d;
}
if (life <= 0f) life = max;
return Mathf.Min(life, Mathf.Max(0.1f, max));
}
/// <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 = "";
s_lastBurstTime = -999f;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cac26e1046954f05903f4e3c4d1f5099
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,88 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>반납하지 않은 EffectBudget.Heavy 슬롯 수(러너가 죽을 때 정산한다).</summary>
int _heldBudget;
/// <summary>수명이 끝나면 이펙트를 파괴한다. releaseBudget 이면 EffectBudget.Heavy 를 정확히 1회 반납.</summary>
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); }
}
/// <summary>PC 스케일 펀치(0 → 최대 → 0 · unscaled). 되돌리기 안전.</summary>
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); }
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 116a2d895c3a4deba7bdcbceae3bf6ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,124 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLGrowthSettings.cs — 성장(레벨업) 연출 값의 단일 출처(SOT · C45)
//
// PD 지시 #813 · 발주서 WL-813k §1-2 (2026-09-09)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset
// → Resources.Load<WLGrowthSettings>("WL/WLGrowthSettings") 1회 로드 후 캐시(WLCombatCoreSettings 관례 그대로).
// 에셋이 없거나 enabled = false 면 Enabled == false → LevelUp 이벤트 자체가 발생하지 않고
// ServerClass.Add_Exp 의 훅 1줄은 즉시 반환한다 = 원본 100%(C8 롤백 1순위 스위치).
// 아래 필드 기본값은 "에셋 부재 시의 폴백"이 아니라 에셋 생성 시 초기값이다.
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(ServerClass/Actor 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Growth
{
[CreateAssetMenu(fileName = "WLGrowthSettings", menuName = "WL/Growth Settings", order = 321)]
public sealed class WLGrowthSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLGrowthSettings";
static WLGrowthSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLGrowthSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLGrowthSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(WLCombatCoreSettings.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false.</summary>
public static bool RuntimeDisabled;
/// <summary>성장 연출이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험).</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 LevelUp 이벤트 · 폭발 이펙트 · 전회복 · 스케일 펀치가 전부 비활성 — 원본 Add_Exp 동작 100%. 롤백 1순위.")]
public bool enabled = true;
[Tooltip("레벨업 처리 결과를 Console 에 남긴다(검증용 · 기본 off · GC 가 발생하므로 서비스 빌드에서 켜지 말 것).")]
public bool verboseLog = false;
[Tooltip("코어 CombatEvents.LevelUp(811b 가 API 만 둔 것)에도 같은 레벨업을 전달한다. 코어가 꺼져 있으면 자동으로 무시된다.")]
public bool forwardToCombatEvents = true;
// ─────────────────────────────────────────── 폭발 이펙트
[Header("레벨업 폭발 이펙트 (FX_FireLevelUp · 파티클 9 · 비루프)")]
[Tooltip("스폰할 이펙트 프리팹. 비우면 이펙트를 만들지 않는다(전회복·이벤트는 그대로).")]
public GameObject burstPrefab;
[Tooltip("PC 의 자식으로 붙여 따라다니게 한다. 끄면 발생 위치에 고정된다.")]
public bool burstParentToPc = true;
[Tooltip("PC 발밑 기준 스폰 오프셋(m · 월드 기준).")]
public Vector3 burstOffset = new Vector3(0f, 0.1f, 0f);
[Tooltip("프리팹 스케일 배율(1 = 원본).")]
public float burstScale = 1f;
[Tooltip("이펙트 파괴까지의 수명(초). 0 이면 스폰된 ParticleSystem 들의 duration + startLifetime 최댓값으로 자동 측정한다.")]
public float burstLifetimeSeconds = 0f;
[Tooltip("자동 측정 수명의 상한(초). 루프 파티클이 섞여도 이 시간이면 반드시 파괴된다.")]
public float burstLifetimeMaxSeconds = 6f;
[Tooltip("연속 레벨업(Add_Exp 의 while 루프 · 한 프레임에 여러 번) 때 이펙트를 다시 내는 최소 간격(초 · unscaled). 전회복은 매 레벨 적용된다.")]
public float burstMinIntervalSeconds = 0.5f;
[Tooltip("EffectBudget.Heavy 슬롯을 잡고 스폰한다(상한 초과 시 이펙트만 스킵). 코어가 꺼져 있으면 제한 없음.")]
public bool useEffectBudget = true;
// ─────────────────────────────────────────── HP/MP 전회복
[Header("HP/MP 전회복 (1 = 전회복)")]
[Tooltip("레벨업 시 회복할 최대 HP 비율. 1 = 전회복 · 0 = 회복 없음.")]
[Range(0f, 1f)] public float hpRecoverRate = 1f;
[Tooltip("레벨업 시 회복할 최대 MP 비율. 1 = 전회복 · 0 = 회복 없음.")]
[Range(0f, 1f)] public float mpRecoverRate = 1f;
[Tooltip("회복량을 머리 위 HUD 숫자로 띄운다(원본 Heal/Heal_MP 의 showhud).")]
public bool showRecoverHud = true;
// ─────────────────────────────────────────── 스케일 펀치
[Header("스케일 펀치 (PC · 카메라 미접촉)")]
[Tooltip("PC 스케일 최대 배율. 1 이하면 펀치를 건너뛴다.")]
public float scalePunch = 1.12f;
[Tooltip("스케일 펀치 왕복 시간(초 · unscaled).")]
public float scalePunchDuration = 0.22f;
// ─────────────────────────────────────────── 팝업 스탯 3종
[Header("팝업 스탯 3종 (813g UI 가 페이로드에서 읽는다)")]
[Tooltip("레벨업 팝업 1번 칸 스탯.")]
public eStat statA = eStat.MaxHP;
[Tooltip("레벨업 팝업 2번 칸 스탯.")]
public eStat statB = eStat.MaxMP;
[Tooltip("레벨업 팝업 3번 칸 스탯.")]
public eStat statC = eStat.ATK;
[Tooltip("켜면 레벨업 직후 ActorStatInfo 를 재계산(Reset · HP/MP 유지 · StatChangeUI 미호출)해 페이로드의 new 스탯을 채운다. " +
"원본은 레벨업으로 스탯이 즉시 오르지 않고 스탯 포인트만 주므로 기본 off = 원본 동작(prev == new).")]
public bool refreshStatOnLevelUp = false;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 29ed1e47916549488430e9cf27b851ce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,35 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 29ed1e47916549488430e9cf27b851ce, type: 3}
m_Name: WLGrowthSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Growth.WLGrowthSettings
enabled: 1
verboseLog: 0
forwardToCombatEvents: 1
burstPrefab: {fileID: 8435668252943946532, guid: 1fffcb729a3db4c4197c45283dbdcaa2,
type: 3}
burstParentToPc: 1
burstOffset: {x: 0, y: 0.1, z: 0}
burstScale: 1
burstLifetimeSeconds: 0
burstLifetimeMaxSeconds: 6
burstMinIntervalSeconds: 0.5
useEffectBudget: 1
hpRecoverRate: 1
mpRecoverRate: 1
showRecoverHud: 1
scalePunch: 1.12
scalePunchDuration: 0.22
statA: 10
statB: 18
statC: 8
refreshStatOnLevelUp: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 408a19563f0443cab085aae117ac3f85
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant: