[WL-813k] 레벨업 폭발 (#813)
ServerClass.Add_Exp 레벨업 루프에 훅 1줄 → WL.Combat.Growth.GrowthEvents.RaiseLevelUp. 코어 CombatEvents.cs 무수정 — BossEvents 규약대로 새 파일 GrowthEvents.cs 가 CombatEventList<T> 를 재사용해 PlayerLevelUpEvent(레벨 · 스탯 포인트 · 팝업 스탯 3종 prev/new · HP/MP 스냅샷 · 위치)를 낸다. LevelUpBurst 구독자 = FX_FireLevelUp 스폰(EffectBudget.Heavy · 연속 레벨업 스로틀) + HP/MP 전회복(원본 Heal/Heal_MP) + PC 스케일 펀치(카메라 미접촉). 값은 전부 WLGrowthSettings.asset(C45) · 에셋/스위치 off 면 원본 100%(C8).
This commit is contained in:
parent
b518974184
commit
2a140f4a85
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: da1a07717a8d49a5a95d9062d1ed85d0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1acaac6cd13a4fadb13df1accfae4f14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cac26e1046954f05903f4e3c4d1f5099
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 116a2d895c3a4deba7bdcbceae3bf6ea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 29ed1e47916549488430e9cf27b851ce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 408a19563f0443cab085aae117ac3f85
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
Reference in New Issue