From 2a140f4a85a1212988cc774d7b3f05cce230174d Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 00:44:53 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813k]=20=EB=A0=88=EB=B2=A8=EC=97=85=20?= =?UTF-8?q?=ED=8F=AD=EB=B0=9C=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerClass.Add_Exp 레벨업 루프에 훅 1줄 → WL.Combat.Growth.GrowthEvents.RaiseLevelUp. 코어 CombatEvents.cs 무수정 — BossEvents 규약대로 새 파일 GrowthEvents.cs 가 CombatEventList 를 재사용해 PlayerLevelUpEvent(레벨 · 스탯 포인트 · 팝업 스탯 3종 prev/new · HP/MP 스냅샷 · 위치)를 낸다. LevelUpBurst 구독자 = FX_FireLevelUp 스폰(EffectBudget.Heavy · 연속 레벨업 스로틀) + HP/MP 전회복(원본 Heal/Heal_MP) + PC 스케일 펀치(카메라 미접촉). 값은 전부 WLGrowthSettings.asset(C45) · 에셋/스위치 off 면 원본 100%(C8). --- AgentScripts/WL813k_PROBE.txt | 94 ++++++ AgentScripts/WL813k_Probe.cs | 301 ++++++++++++++++++ Assets/Script/Server/ServerClass.cs | 1 + Assets/WL/Combat/Growth.meta | 8 + Assets/WL/Combat/Growth/GrowthEvents.cs | 165 ++++++++++ Assets/WL/Combat/Growth/GrowthEvents.cs.meta | 11 + Assets/WL/Combat/Growth/LevelUpBurst.cs | 200 ++++++++++++ Assets/WL/Combat/Growth/LevelUpBurst.cs.meta | 11 + Assets/WL/Combat/Growth/LevelUpBurstRunner.cs | 88 +++++ .../Combat/Growth/LevelUpBurstRunner.cs.meta | 11 + Assets/WL/Combat/Growth/WLGrowthSettings.cs | 124 ++++++++ .../WL/Combat/Growth/WLGrowthSettings.cs.meta | 11 + .../Resources/WL/WLGrowthSettings.asset | 35 ++ .../Resources/WL/WLGrowthSettings.asset.meta | 8 + 14 files changed, 1068 insertions(+) create mode 100644 AgentScripts/WL813k_PROBE.txt create mode 100644 AgentScripts/WL813k_Probe.cs create mode 100644 Assets/WL/Combat/Growth.meta create mode 100644 Assets/WL/Combat/Growth/GrowthEvents.cs create mode 100644 Assets/WL/Combat/Growth/GrowthEvents.cs.meta create mode 100644 Assets/WL/Combat/Growth/LevelUpBurst.cs create mode 100644 Assets/WL/Combat/Growth/LevelUpBurst.cs.meta create mode 100644 Assets/WL/Combat/Growth/LevelUpBurstRunner.cs create mode 100644 Assets/WL/Combat/Growth/LevelUpBurstRunner.cs.meta create mode 100644 Assets/WL/Combat/Growth/WLGrowthSettings.cs create mode 100644 Assets/WL/Combat/Growth/WLGrowthSettings.cs.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset.meta diff --git a/AgentScripts/WL813k_PROBE.txt b/AgentScripts/WL813k_PROBE.txt new file mode 100644 index 000000000..3dd504efe --- /dev/null +++ b/AgentScripts/WL813k_PROBE.txt @@ -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 diff --git a/AgentScripts/WL813k_Probe.cs b/AgentScripts/WL813k_Probe.cs new file mode 100644 index 000000000..207d2ee94 --- /dev/null +++ b/AgentScripts/WL813k_Probe.cs @@ -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(true).Length + + " · Renderer = " + g.burstPrefab.GetComponentsInChildren(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(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); + } + + /// GC 재측정 — 측정법의 민감도(대조군)까지 같이 낸다. 단독 실행 entry. + 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(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; + } +} diff --git a/Assets/Script/Server/ServerClass.cs b/Assets/Script/Server/ServerClass.cs index 6d5c5089b..dcc9b5201 100644 --- a/Assets/Script/Server/ServerClass.cs +++ b/Assets/Script/Server/ServerClass.cs @@ -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(); diff --git a/Assets/WL/Combat/Growth.meta b/Assets/WL/Combat/Growth.meta new file mode 100644 index 000000000..2fd30301c --- /dev/null +++ b/Assets/WL/Combat/Growth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: da1a07717a8d49a5a95d9062d1ed85d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Growth/GrowthEvents.cs b/Assets/WL/Combat/Growth/GrowthEvents.cs new file mode 100644 index 000000000..d06ddd92a --- /dev/null +++ b/Assets/WL/Combat/Growth/GrowthEvents.cs @@ -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 재사용 — 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 +{ + /// PC 레벨업 페이로드(발생 시점 스냅샷 · struct · GC 0). + public struct PlayerLevelUpEvent + { + /// 레벨업한 서버 데이터(ServerData.PC). 항상 non-null. + public SD_PC data; + /// 월드의 메인 PC. 로비·오프라인 보상 등 월드에 PC 가 없으면 null — 구독자는 반드시 null 검사할 것. + public Actor pc; + + /// 레벨업 전/후 레벨. + public int prevLevel, newLevel; + /// 같은 Add_Exp 호출(같은 프레임·같은 SD_PC)에서 이번이 몇 번째 레벨업인가(1부터). + public int levelsGained; + /// 레벨업 뒤 남은 미사용 스탯 포인트(SD_PC.Get_Point()). 원본 = (Lv-1)*5 − 사용분. + public int statPointTotal; + + /// 813g 팝업용 스탯 3종의 종류(WLGrowthSettings.statA/B/C · 기본 MaxHP · MaxMP · ATK). + public eStat statAKind, statBKind, statCKind; + /// 스탯 3종의 이전/새 값. refreshStatOnLevelUp 이 꺼져 있으면(기본) 원본대로 prev == new 다 — 원본은 레벨업으로 스탯이 바로 오르지 않고 포인트만 준다. + public double prevStatA, newStatA; + public double prevStatB, newStatB; + public double prevStatC, newStatC; + + /// 전회복 직전의 HP/MP 와 최대치(연출·로그·UI 용 · PC 가 없으면 0). + public double hpBefore, maxHp, mpBefore, maxMp; + + /// 연출 스폰 기준 위치(PC 가 없으면 Vector3.zero). + public Vector3 position; + + public float time; + public int frame; + } + + /// 성장 이벤트 허브. 정적 · GC 0 · 구독은 LevelUp.Add / Remove. + public static class GrowthEvents + { + /// 레벨업 구독자 목록(코어와 같은 구현 · 예외 격리 · Raised/Dispatched 카운터). + public static readonly CombatEventList LevelUp = new CombatEventList(4); + + /// 성장 축이 살아 있는가 = 코어 스위치 · 성장 스위치 둘 다 켜짐. 어느 쪽을 꺼도 원본 동작(C8). + 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; + + /// + /// 원본 훅 1줄이 부른다 — `ServerClass.cs` `SD_PC.Add_Exp` 의 `++Lv;` 직후. + /// 코어/성장 스위치가 꺼져 있거나 에셋이 없으면 즉시 반환한다(원본 100%). + /// + 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")); + } + + /// 프로브용: 카운터 초기화(구독은 유지). + 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; + } + } +} diff --git a/Assets/WL/Combat/Growth/GrowthEvents.cs.meta b/Assets/WL/Combat/Growth/GrowthEvents.cs.meta new file mode 100644 index 000000000..fd1461de0 --- /dev/null +++ b/Assets/WL/Combat/Growth/GrowthEvents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1acaac6cd13a4fadb13df1accfae4f14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Growth/LevelUpBurst.cs b/Assets/WL/Combat/Growth/LevelUpBurst.cs new file mode 100644 index 000000000..e7202a10b --- /dev/null +++ b/Assets/WL/Combat/Growth/LevelUpBurst.cs @@ -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(); } + + /// 구독 보장(중복 구독은 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; + + 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); + } + + /// 파티클에서 실제 수명을 잰다(duration + startLifetime + startDelay 최댓값 · 상한으로 자른다). + static float MeasureLifetime(GameObject go, float max) + { + float life = 0f; + var systems = go.GetComponentsInChildren(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)); + } + + /// 에디트 모드 프로브용: 마지막 이펙트를 즉시 파괴하고 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 = ""; + s_lastBurstTime = -999f; + } + } +} diff --git a/Assets/WL/Combat/Growth/LevelUpBurst.cs.meta b/Assets/WL/Combat/Growth/LevelUpBurst.cs.meta new file mode 100644 index 000000000..03f913cf8 --- /dev/null +++ b/Assets/WL/Combat/Growth/LevelUpBurst.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cac26e1046954f05903f4e3c4d1f5099 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs b/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs new file mode 100644 index 000000000..46b42f5b4 --- /dev/null +++ b/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs @@ -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 + { + /// 반납하지 않은 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); } + } + } +} diff --git a/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs.meta b/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs.meta new file mode 100644 index 000000000..de3851b69 --- /dev/null +++ b/Assets/WL/Combat/Growth/LevelUpBurstRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 116a2d895c3a4deba7bdcbceae3bf6ea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Growth/WLGrowthSettings.cs b/Assets/WL/Combat/Growth/WLGrowthSettings.cs new file mode 100644 index 000000000..54eeb3876 --- /dev/null +++ b/Assets/WL/Combat/Growth/WLGrowthSettings.cs @@ -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("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; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLGrowthSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(WLCombatCoreSettings.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false. + public static bool RuntimeDisabled; + + /// 성장 연출이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off. + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험). + 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; + } +} diff --git a/Assets/WL/Combat/Growth/WLGrowthSettings.cs.meta b/Assets/WL/Combat/Growth/WLGrowthSettings.cs.meta new file mode 100644 index 000000000..117350dd8 --- /dev/null +++ b/Assets/WL/Combat/Growth/WLGrowthSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29ed1e47916549488430e9cf27b851ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset new file mode 100644 index 000000000..2ee307342 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset @@ -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 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset.meta new file mode 100644 index 000000000..1ec3f8317 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 408a19563f0443cab085aae117ac3f85 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: