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