diff --git a/AgentScripts/WL813i_PROBE.txt b/AgentScripts/WL813i_PROBE.txt new file mode 100644 index 000000000..bcaf2a798 --- /dev/null +++ b/AgentScripts/WL813i_PROBE.txt @@ -0,0 +1,28 @@ +# WL813i Probe 2026-09-09 02:15:53 (edit mode · Play 0) +survival asset=found enabled(disk)=False Enabled=False · gameplay asset=found playerInvincible(disk)=True · core Enabled=True + values: mob True×0.5 boss True×0.5 eliteAsBoss=False · potion 3/run 0.4 cd 5 showHeal=True refuseFull=True resetOnMap=True · death display 2 hold=True autoNoListener=True timeout=0 invincible 1.5 heal 1 companions=True +[PASS] ① C8 기본: enabled(disk)=false✔ Enabled=false · RuntimeOverride 1→True -1→off True · playerInvincible 유지 True +[PASS] ② C8(SO off): TakeDamage→base 1회 dmg=100 flag=True PassDisabled=1 · TryUse=Disabled Changed=0 hp=300 · OnPCDied state=None Died=0 +[PASS] ③a 잡몹 100→base 50 (기대 50) flagDuring=False flagAfter=True (기대 True) Lifted=1 +[PASS] ③b 보스 100→base 25 (기대 25) boss=True +[PASS] ③c 보스 스위치 off: base dmg=100 (기대 100) flagDuring=True SkippedBossSwitch=1 +[PASS] ③d 정예: 기본 50 (기대 50) · eliteCountsAsBoss 25 (기대 25) +[PASS] ③e 잡몹 스위치 off 통과=True (SkippedMobSwitch=1) · 적 PC 통과=True (PassNotMainPC=1) +[PASS] ③f base 예외 후 플래그 복원: threw=True flag=True (기대 True) +[PASS] ③g 부활 무적 1.5 s: 창 안 통과(dmg 100 Blocked=1) · t+1.49 True · t+1.5 해제 True → 배율 재적용 True · 다른 PC True +[PASS] ③h GC: TakeDamage×1,000,000 = 힙 0 B / mono 0 B / 수집 0회 (기대 0/0/0) · 훅 줄 델리게이트 ×100k(보존) = 7479296 B / mono 7479296 B (≈74 B/피격) · 대조군 new object()×100k(보존) = 1593344 B / mono 1593344 B (감도 True) +[PASS] ④a 사용 1: Used hp 300→700.000005960464 (기대 700) remaining=2 cd=5.00 IsReady=False +[PASS] ④b 쿨 중 거절=Cooldown · t=4.99 CooldownEnded=0 · t=5.0 CooldownEnded=1 IsReady=True +[PASS] ④c 사용 2: Used hp=1000 (기대 1000 클램프) remaining=1 · 만충 거절=FullHp remaining 유지=True +[PASS] ④d 사용 3: Used hp=500.000005960464 (기대 500) remaining=0 · 빈 통=Empty Used 이벤트=3 (기대 3) +[PASS] ④e 런 리셋: remaining=3 Changed(RunReset)=1 · 사망=Dead · NoPC=NoPC · Changed 총=12 refused=5 +[PASS] ⑤a 사이클: Dying True · t+1 Dying(remain 1.00) · t+1.99 True · t+2 Awaiting+ReviveRequested(held=False · 에디트 모드 기대 false) True · Revive()=True → None/Revived/무적 1.5 True · 창 True → 1.5 s 뒤 해제 True · LastDeathToRequest=2.00 +[PASS] ⑤b 구독자 0 자동 부활: state=None revived=2 auto=1 +[PASS] ⑤c 외부 부활 감지: Awaiting True → external=1 override 미호출 True 무적 True +[PASS] ⑤d 조기 응답: Dying 유지 True → 2 s 뒤 부활 True revived=4 +[PASS] ⑤e 타임아웃 3 s: +2.9 대기 True · +3.0 자동 부활 True · LastRequestToRevive=3.00 +[PASS] ⑤f 중복 사망 무시 True · Abort True · 적 PC 무시 True · timeScale 무변경 True (1) +[PASS] ⑤g 대기 없는 Revive()=false True +[PASS] ⑥ PotionUse.Changed 디스패치×1,000,000 = 힙 0 B / mono 0 B / 수집 0회 (기대 0) handler=1000010 +[PASS] ⑦ 정리: 디스크 에셋 enabled=false 복귀 True · playerInvincible=True · 잔존 오브젝트 0 · timeScale 1(시작 1) · 러너 생성 0 True · MyPC=null True · 씬 '' dirty=False +RESULT PASS diff --git a/AgentScripts/WL813i_Probe.cs b/AgentScripts/WL813i_Probe.cs new file mode 100644 index 000000000..af93e7c86 --- /dev/null +++ b/AgentScripts/WL813i_Probe.cs @@ -0,0 +1,491 @@ +// WL813i_Probe.cs — #813i(무적 해제 게이트 · 물약 · 사망/부활) 에디트 모드 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 에셋/씬/TimeManager 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴) +// +// 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813i_Probe.cs --entry WL813i_Probe.RunAll +// 결과: Console + AgentScripts/WL813i_PROBE.txt(커밋) + Screenshots_WL/WL813i/probe_.txt (RESULT PASS / RESULT FAIL n) +// +// 검사(발주서 WL-813i ⓒ · ⓓ) +// ① 에셋: 디스크 enabled=0(C8 기본) · 값 덤프 · RuntimeOverride +// ② C8: SO off 면 TakeDamage=base 그대로(피해·플래그 무변경) · TryUse=Disabled · OnPCDied 무시 +// ③ 무적 해제 게이트: 잡몹 ×0.5 · 보스 ×배율 · 스위치 off 통과 · 정예 취급 · 적 PC 통과 · 호출 중 플래그 false → 뒤 true 복원 · 예외에도 복원 · 부활 무적 창 통과 · GC +// ④ 물약: 3회/쿨 5 s/만충 거절/빈 통/사망/NoPC · 잔량·쿨 이벤트 · 런 리셋 +// ⑤ 사망/부활: 연출 2 s → ReviveRequested → Revive() → Revived + 무적 1.5 s · 구독자 0 자동 부활 · 외부 부활 감지 · 조기 응답 · 타임아웃 · Abort · 중복 사망 · 적 PC +// ⑥ 이벤트 GC 0 · ⑦ 정리(클론 파괴 · 캐시 복구 · 씬 dirty) +// +// 에디트 모드 한계: Time.timeScale 은 읽기만(대입하면 TimeManager.asset 에 적힌다 · 811b-fix 실측) — DeathFlow 가 Application.isPlaying 으로 스스로 막는다. +// 부활 실행(On_Regen → LoadMapMgr/ActorInfo)은 에디트 모드에 없어 DeathFlow.ReviveOverride 로 바꿔치기한다(런타임 On_Regen 경로 = 병합 후 QA). + +using System; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using WL.Combat.Core; +using WL.Combat.Survival; + +public static class WL813i_Probe +{ + const string OutDir = "Screenshots_WL/WL813i"; + const string OutCommit = "AgentScripts/WL813i_PROBE.txt"; + const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + static WLSurvivalSettings s_clone; + + public static object RunAll() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL813i Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); + int fail = 0; + float ts0 = Time.timeScale; + try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ① 예외 " + ex); } + try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); } + try { fail += DamageGate(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); } + try { fail += Potion(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ④ 예외 " + ex); } + try { fail += Death(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑤ 예외 " + ex); } + try { fail += EventGc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑥ 예외 " + ex); } + try { fail += Cleanup(sb, ts0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑦ 예외 " + ex); } + sb.AppendLine(fail == 0 ? "RESULT PASS" : "RESULT FAIL " + fail); + var s = sb.ToString(); + Debug.Log(s); + try + { + Directory.CreateDirectory(OutDir); + File.WriteAllText(Path.Combine(OutDir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), s); + File.WriteAllText(OutCommit, s); + } + catch { /* 출력 저장 실패는 판정에 영향 없음 */ } + return s; + } + + // ───────────────────────────────────────── 도구 + static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; } + /// 원본 Heal(float rate) 은 0.4f 를 double 로 넓혀 곱하므로(400.000006) 1e-3 허용. + static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; } + + /// Unity Mono 의 GC.GetAllocatedBytesForCurrentThread 는 상수를 돌려준다(실측 · 대조군 0 B) → GetTotalMemory(4 KB 페이지 단위) + 대조군으로 감도를 보인다(813k 선례). + static long Alloc() { return GC.GetTotalMemory(false); } + static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } + + /// 디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(값 변경 실험용). + static WLSurvivalSettings UseClone() + { + var orig = WLSurvivalSettings.Instance; + if (orig == null) return null; + if (s_clone == null) + { + s_clone = UnityEngine.Object.Instantiate(orig); + s_clone.hideFlags = HideFlags.HideAndDontSave; + s_clone.name = "WLSurvivalSettings(probe clone)"; + } + var fc = typeof(WLSurvivalSettings).GetField("s_cached", BindingFlags.NonPublic | BindingFlags.Static); + var fd = typeof(WLSurvivalSettings).GetField("s_lookupDone", BindingFlags.NonPublic | BindingFlags.Static); + if (fc == null || fd == null) return null; + fc.SetValue(null, s_clone); fd.SetValue(null, true); + return s_clone; + } + + static void RestoreAsset() + { + WLSurvivalSettings.ClearCache(); + WLSurvivalSettings.RuntimeOverride = 0; + } + + static PCActor MakePC(string name, double maxHp, double hp, bool enemy = false) + { + var go = new GameObject("__WL813i_" + name); + var pc = go.AddComponent(); + pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; + var stat = new ActorStatInfo(eRole.PC); + stat.Set_Stat(eStat.MaxHP, maxHp); stat.Set_Stat(eStat.HP, hp); + typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); + typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, enemy); + return pc; + } + + static Actor MakeBeater(string name, eSubRol sub) + { + var go = new GameObject("__WL813i_" + name); + var a = go.AddComponent(); + a.m_Role = eRole.Mob; a.m_SubRole = sub; + typeof(Actor).GetField("m_Enemy", NP).SetValue(a, true); + return a; + } + + static void SetDead(Actor a, bool dead) { typeof(Actor).GetField("DeadStatus", NP).SetValue(a, dead); } + static bool IsDead(Actor a) { return (bool)typeof(Actor).GetField("DeadStatus", NP).GetValue(a); } + static void SetHp(Actor a, double hp) { a.Get_StatInfo().Set_Stat(eStat.HP, hp); } + + static void Kill(params UnityEngine.Object[] objs) + { + foreach (var o in objs) + { + if (o == null) continue; + var c = o as Component; + UnityEngine.Object.DestroyImmediate(c != null ? c.gameObject : o); + } + } + + // ── 가짜 base.Get_Damage(MyActor 대역): 호출 수 · 호출 시점의 피해값 · 무적 플래그를 기록한다 + static int s_baseCalls; static double s_baseDamage; static bool s_baseFlag; static bool s_baseThrow; + static void FakeBase(DamageInfo d) + { + s_baseCalls++; s_baseDamage = d != null ? d.Damage : -1d; + s_baseFlag = WL.Settings.WLGameplaySettings.PlayerInvincible; + if (s_baseThrow) throw new InvalidOperationException("probe: base threw"); + } + static readonly Action s_fake = FakeBase; + + static DamageInfo Dmg(Actor beater, double dmg) { var d = new DamageInfo(); d.Beater = beater; d.Damage = dmg; return d; } + + // ───────────────────────────────────────── ① 에셋 + static int Asset(StringBuilder sb) + { + RestoreAsset(); + var st = WLSurvivalSettings.Instance; + var gs = WL.Settings.WLGameplaySettings.Instance; + sb.AppendLine("survival asset=" + (st != null ? "found" : "NULL") + " enabled(disk)=" + (st != null && st.enabled) + + " Enabled=" + WLSurvivalSettings.Enabled + " · gameplay asset=" + (gs != null ? "found" : "NULL") + + " playerInvincible(disk)=" + (gs != null && gs.playerInvincible) + " · core Enabled=" + WLCombatCoreSettings.Enabled); + if (st == null) { sb.AppendLine("[FAIL] ① WLSurvivalSettings.asset 로드 실패"); return 1; } + sb.AppendLine(" values: mob " + st.takeMobDamage + "×" + st.mobDamageMultiplier + " boss " + st.takeBossDamage + "×" + st.bossDamageMultiplier + + " eliteAsBoss=" + st.eliteCountsAsBoss + " · potion " + st.potionCountPerRun + "/run " + st.potionHealRate + " cd " + st.potionCooldownSeconds + + " showHeal=" + st.potionShowHealNumber + " refuseFull=" + st.potionRefuseAtFullHp + " resetOnMap=" + st.potionResetOnMapLoad + + " · death display " + st.deathDisplaySeconds + " hold=" + st.holdWorldWhileReviving + " autoNoListener=" + st.autoReviveWhenNoListener + + " timeout=" + st.autoReviveTimeoutSeconds + " invincible " + st.reviveInvincibleSeconds + " heal " + st.reviveHealRate + " companions=" + st.reviveCompanions); + bool diskOff = !st.enabled && !WLSurvivalSettings.Enabled; + WLSurvivalSettings.RuntimeOverride = 1; bool forceOn = WLSurvivalSettings.Enabled; + WLSurvivalSettings.RuntimeOverride = -1; bool forceOff = !WLSurvivalSettings.Enabled; + WLSurvivalSettings.RuntimeOverride = 0; + bool ok = diskOff && forceOn && forceOff && gs != null && gs.playerInvincible; + sb.AppendLine(P(ok) + " ① C8 기본: enabled(disk)=false✔ Enabled=false · RuntimeOverride 1→" + forceOn + " -1→off " + forceOff + " · playerInvincible 유지 " + (gs != null && gs.playerInvincible)); + return ok ? 0 : 1; + } + + // ───────────────────────────────────────── ② C8 통과 + static int C8(StringBuilder sb) + { + RestoreAsset(); + Survival.ResetDiagnostics(); PotionUse.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); + var pc = MakePC("pc", 1000, 300); var mob = MakeBeater("mob", eSubRol.None); + try + { + s_baseCalls = 0; + var d = Dmg(mob, 100); + Survival.TakeDamage(pc, d, s_fake); + bool a = s_baseCalls == 1 && Math.Abs(s_baseDamage - 100d) < 1e-9 && s_baseFlag && Survival.PassDisabled == 1 && Survival.Applied == 0; + var r = PotionUse.TryUse(pc); + bool b = r == PotionResult.Disabled && PotionUse.Changed.Raised == 0 && Math.Abs(pc.Get_HP() - 300d) < 1e-9; + SetDead(pc, true); + DeathFlow.OnPCDied(pc); + bool c = DeathFlow.State == DeathState.None && DeathFlow.Died.Raised == 0 && DeathFlow.DeathCount == 0; + SetDead(pc, false); + bool ok = a && b && c; + sb.AppendLine(P(ok) + " ② C8(SO off): TakeDamage→base 1회 dmg=" + s_baseDamage + " flag=" + s_baseFlag + " PassDisabled=" + Survival.PassDisabled + + " · TryUse=" + r + " Changed=" + PotionUse.Changed.Raised + " hp=" + pc.Get_HP() + " · OnPCDied state=" + DeathFlow.State + " Died=" + DeathFlow.Died.Raised); + return ok ? 0 : 1; + } + finally { Kill(pc, mob); } + } + + // ───────────────────────────────────────── ③ 무적 해제 게이트 + static int DamageGate(StringBuilder sb) + { + var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ③ 클론 주입 실패"); return 1; } + st.enabled = true; st.verboseLog = false; + st.takeMobDamage = true; st.mobDamageMultiplier = 0.5f; st.takeBossDamage = true; st.bossDamageMultiplier = 0.25f; st.eliteCountsAsBoss = false; + Survival.ResetDiagnostics(); + var gs = WL.Settings.WLGameplaySettings.Instance; + bool flagBefore = WL.Settings.WLGameplaySettings.PlayerInvincible; + var pc = MakePC("pc", 1000, 500); var enemyPc = MakePC("enemyPc", 1000, 500, true); + var mob = MakeBeater("mob", eSubRol.None); var boss = MakeBeater("boss", eSubRol.Boss); var elite = MakeBeater("elite", eSubRol.Elite); + int fails = 0; + try + { + // a) 잡몹 ×0.5 · 호출 중 플래그 false · 뒤 복원 + s_baseCalls = 0; var d = Dmg(mob, 100); Survival.TakeDamage(pc, d, s_fake); + bool a = s_baseCalls == 1 && Math.Abs(s_baseDamage - 50d) < 1e-9 && !s_baseFlag && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore + && Survival.Applied == 1 && Survival.Lifted == (flagBefore ? 1 : 0) && !Survival.LastWasBoss; + sb.AppendLine(P(a) + " ③a 잡몹 100→base " + s_baseDamage + " (기대 50) flagDuring=" + s_baseFlag + " flagAfter=" + WL.Settings.WLGameplaySettings.PlayerInvincible + " (기대 " + flagBefore + ") Lifted=" + Survival.Lifted); + if (!a) fails++; + + // b) 보스 ×0.25 + s_baseCalls = 0; d = Dmg(boss, 100); Survival.TakeDamage(pc, d, s_fake); + bool b = s_baseCalls == 1 && Math.Abs(s_baseDamage - 25d) < 1e-9 && !s_baseFlag && Survival.LastWasBoss && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore; + sb.AppendLine(P(b) + " ③b 보스 100→base " + s_baseDamage + " (기대 25) boss=" + Survival.LastWasBoss); + if (!b) fails++; + + // c) 보스 스위치 off → 원본 그대로(피해 100 · 플래그 true 유지) + st.takeBossDamage = false; + s_baseCalls = 0; d = Dmg(boss, 100); Survival.TakeDamage(pc, d, s_fake); + bool c = s_baseCalls == 1 && Math.Abs(s_baseDamage - 100d) < 1e-9 && s_baseFlag == flagBefore && Survival.SkippedBossSwitch == 1; + sb.AppendLine(P(c) + " ③c 보스 스위치 off: base dmg=" + s_baseDamage + " (기대 100) flagDuring=" + s_baseFlag + " SkippedBossSwitch=" + Survival.SkippedBossSwitch); + if (!c) fails++; + st.takeBossDamage = true; + + // d) 정예: 기본 잡몹 취급(×0.5) · eliteCountsAsBoss → ×0.25 + s_baseCalls = 0; d = Dmg(elite, 100); Survival.TakeDamage(pc, d, s_fake); double e1 = s_baseDamage; + st.eliteCountsAsBoss = true; + s_baseCalls = 0; d = Dmg(elite, 100); Survival.TakeDamage(pc, d, s_fake); double e2 = s_baseDamage; + st.eliteCountsAsBoss = false; + bool dd = Math.Abs(e1 - 50d) < 1e-9 && Math.Abs(e2 - 25d) < 1e-9; + sb.AppendLine(P(dd) + " ③d 정예: 기본 " + e1 + " (기대 50) · eliteCountsAsBoss " + e2 + " (기대 25)"); + if (!dd) fails++; + + // e) 잡몹 스위치 off + 적 PC 통과 + st.takeMobDamage = false; + s_baseCalls = 0; d = Dmg(mob, 100); Survival.TakeDamage(pc, d, s_fake); bool e_mob = s_baseDamage == 100d && Survival.SkippedMobSwitch == 1; + st.takeMobDamage = true; + s_baseCalls = 0; d = Dmg(mob, 100); Survival.TakeDamage(enemyPc, d, s_fake); bool e_pc = s_baseDamage == 100d && Survival.PassNotMainPC == 1 && s_baseFlag == flagBefore; + bool e = e_mob && e_pc; + sb.AppendLine(P(e) + " ③e 잡몹 스위치 off 통과=" + e_mob + " (SkippedMobSwitch=" + Survival.SkippedMobSwitch + ") · 적 PC 통과=" + e_pc + " (PassNotMainPC=" + Survival.PassNotMainPC + ")"); + if (!e) fails++; + + // f) base 예외 → finally 복원 + s_baseThrow = true; bool threw = false; + try { Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); } catch (InvalidOperationException) { threw = true; } + s_baseThrow = false; + bool f = threw && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore; + sb.AppendLine(P(f) + " ③f base 예외 후 플래그 복원: threw=" + threw + " flag=" + WL.Settings.WLGameplaySettings.PlayerInvincible + " (기대 " + flagBefore + ")"); + if (!f) fails++; + + // g) 부활 무적 창(게임 시간 클록) + Survival.Tick(10f); Survival.SetReviveInvincible(pc, 1.5f); + bool g1 = Survival.IsReviveInvincible(pc) && Math.Abs(Survival.ReviveInvincibleRemaining - 1.5f) < 1e-4f; + s_baseCalls = 0; Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); + bool g2 = s_baseCalls == 1 && s_baseDamage == 100d && s_baseFlag == flagBefore && Survival.BlockedReviveInvincible == 1; + Survival.Tick(11.49f); bool g3 = Survival.IsReviveInvincible(pc); + Survival.Tick(11.5f); bool g4 = !Survival.IsReviveInvincible(pc); + s_baseCalls = 0; Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); bool g5 = s_baseDamage == 50d; + bool g6 = !Survival.IsReviveInvincible(enemyPc); + bool g = g1 && g2 && g3 && g4 && g5 && g6; + sb.AppendLine(P(g) + " ③g 부활 무적 1.5 s: 창 안 통과(dmg " + (g2 ? "100" : s_baseDamage.ToString()) + " Blocked=" + Survival.BlockedReviveInvincible + ") · t+1.49 " + g3 + " · t+1.5 해제 " + g4 + " → 배율 재적용 " + g5 + " · 다른 PC " + g6); + if (!g) fails++; + + // h) GC: 힙 크기 델타는 페이지 단위·수집 시점에 좌우되므로 100만 회(할당 시 32 MB) + GC.CollectionCount 로 판정하고, + // 델리게이트·대조군은 배열에 살려 둔 채(100k · 수집 불가) 힙 델타로 개당 비용을 잰다. + var dd2 = Dmg(mob, 100); + for (int i = 0; i < 20; i++) { dd2.Damage = 100; Survival.TakeDamage(pc, dd2, s_fake); } + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + int gc0 = GC.CollectionCount(0); long a0 = Alloc(), p0 = Mono(); + for (int i = 0; i < 1000000; i++) { dd2.Damage = 100; Survival.TakeDamage(pc, dd2, s_fake); } + long a1 = Alloc(), p1 = Mono(); int gc1 = GC.CollectionCount(0); + const int N = 100000; + var keepArr = new Action[N]; + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long b0 = Alloc(), q0 = Mono(); + for (int i = 0; i < N; i++) keepArr[i] = new Action(mob.Get_Damage); + long b1 = Alloc(), q1 = Mono(); + var objArr = new object[N]; + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long c0 = Alloc(), r0 = Mono(); + for (int i = 0; i < N; i++) objArr[i] = new object(); + long c1 = Alloc(), r1 = Mono(); + bool sensitive = (c1 - c0) > 0L || (r1 - r0) > 0L; + bool h = a1 - a0 == 0L && p1 - p0 == 0L && gc1 == gc0 && sensitive && keepArr[N - 1] != null && objArr[N - 1] != null; + sb.AppendLine(P(h) + " ③h GC: TakeDamage×1,000,000 = 힙 " + (a1 - a0) + " B / mono " + (p1 - p0) + " B / 수집 " + (gc1 - gc0) + "회 (기대 0/0/0)" + + " · 훅 줄 델리게이트 ×100k(보존) = " + (b1 - b0) + " B / mono " + (q1 - q0) + " B (≈" + (Math.Max(b1 - b0, q1 - q0) / N) + " B/피격)" + + " · 대조군 new object()×100k(보존) = " + (c1 - c0) + " B / mono " + (r1 - r0) + " B (감도 " + sensitive + ")"); + keepArr = null; objArr = null; + if (!h) fails++; + } + finally { Kill(pc, enemyPc, mob, boss, elite); if (gs != null) gs.playerInvincible = flagBefore; } + return fails; + } + + // ───────────────────────────────────────── ④ 물약 + static int Potion(StringBuilder sb) + { + var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ④ 클론 주입 실패"); return 1; } + st.enabled = true; st.potionCountPerRun = 3; st.potionHealRate = 0.4f; st.potionCooldownSeconds = 5f; st.potionShowHealNumber = false; st.potionRefuseAtFullHp = true; + PotionUse.ResetDiagnostics(); + int changed = 0, used = 0, cdEnded = 0, refused = 0; PotionResult lastRefuse = PotionResult.Used; + CombatHandler onChanged = (in PotionEvent e) => { changed++; if (e.change == PotionChange.CooldownEnded) cdEnded++; if (e.change == PotionChange.Refused) { refused++; lastRefuse = e.result; } }; + CombatHandler onUsed = (in PotionEvent e) => { used++; }; + PotionUse.Changed.Add(onChanged); PotionUse.Used.Add(onUsed); + var pc = MakePC("pc", 1000, 300); + int fails = 0; + try + { + PotionUse.Tick(0f); + bool a0 = PotionUse.Remaining == 3 && PotionUse.Max == 3 && PotionUse.IsReady; + var r1 = PotionUse.TryUse(pc); + bool a = a0 && r1 == PotionResult.Used && Near(pc.Get_HP(), 700d) && PotionUse.Remaining == 2 && Math.Abs(PotionUse.CooldownRemaining - 5f) < 1e-4f && !PotionUse.IsReady && used == 1 && changed == 1; + sb.AppendLine(P(a) + " ④a 사용 1: " + r1 + " hp 300→" + pc.Get_HP() + " (기대 700) remaining=" + PotionUse.Remaining + " cd=" + PotionUse.CooldownRemaining.ToString("F2") + " IsReady=" + PotionUse.IsReady); + if (!a) fails++; + + var r2 = PotionUse.TryUse(pc); + PotionUse.Tick(4.99f); int cd1 = cdEnded; + PotionUse.Tick(5f); int cd2 = cdEnded; + bool b = r2 == PotionResult.Cooldown && lastRefuse == PotionResult.Cooldown && cd1 == 0 && cd2 == 1 && PotionUse.IsReady && Near(pc.Get_HP(), 700d); + sb.AppendLine(P(b) + " ④b 쿨 중 거절=" + r2 + " · t=4.99 CooldownEnded=" + cd1 + " · t=5.0 CooldownEnded=" + cd2 + " IsReady=" + PotionUse.IsReady); + if (!b) fails++; + + var r3 = PotionUse.TryUse(pc); // 700 + 400 → 1000(클램프) + bool c = r3 == PotionResult.Used && Near(pc.Get_HP(), 1000d) && PotionUse.Remaining == 1; + PotionUse.Tick(10f); + var r4 = PotionUse.TryUse(pc); // 만충 거절 · 개수 유지 + bool c2 = r4 == PotionResult.FullHp && PotionUse.Remaining == 1; + sb.AppendLine(P(c && c2) + " ④c 사용 2: " + r3 + " hp=" + pc.Get_HP() + " (기대 1000 클램프) remaining=" + PotionUse.Remaining + " · 만충 거절=" + r4 + " remaining 유지=" + (PotionUse.Remaining == 1)); + if (!(c && c2)) fails++; + + SetHp(pc, 100); + var r5 = PotionUse.TryUse(pc); // 3회째 + bool d = r5 == PotionResult.Used && Near(pc.Get_HP(), 500d) && PotionUse.Remaining == 0 && !PotionUse.IsReady; + PotionUse.Tick(20f); + var r6 = PotionUse.TryUse(pc); + bool d2 = r6 == PotionResult.Empty && PotionUse.Remaining == 0 && used == 3; + sb.AppendLine(P(d && d2) + " ④d 사용 3: " + r5 + " hp=" + pc.Get_HP() + " (기대 500) remaining=" + PotionUse.Remaining + " · 빈 통=" + r6 + " Used 이벤트=" + used + " (기대 3)"); + if (!(d && d2)) fails++; + + int chBefore = changed; + PotionUse.ResetRun("probe"); + int chReset = changed - chBefore; + bool e = PotionUse.Remaining == 3 && PotionUse.IsReady && chReset == 1 && PotionUse.RunResets == 1; + SetDead(pc, true); var r7 = PotionUse.TryUse(pc); SetDead(pc, false); + var r8 = PotionUse.TryUse(null); + bool e2 = r7 == PotionResult.Dead && r8 == PotionResult.NoPC && PotionUse.Remaining == 3; + sb.AppendLine(P(e && e2) + " ④e 런 리셋: remaining=" + PotionUse.Remaining + " Changed(RunReset)=" + chReset + " · 사망=" + r7 + " · NoPC=" + r8 + " · Changed 총=" + changed + " refused=" + refused); + if (!(e && e2)) fails++; + } + finally { PotionUse.Changed.Remove(onChanged); PotionUse.Used.Remove(onUsed); Kill(pc); } + return fails; + } + + // ───────────────────────────────────────── ⑤ 사망/부활 + static int Death(StringBuilder sb) + { + var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ⑤ 클론 주입 실패"); return 1; } + st.enabled = true; st.deathDisplaySeconds = 2f; st.holdWorldWhileReviving = true; st.autoReviveWhenNoListener = true; st.autoReviveTimeoutSeconds = 0f; + st.reviveInvincibleSeconds = 1.5f; st.reviveHealRate = 1f; st.reviveCompanions = true; + DeathFlow.ResetDiagnostics(); Survival.ResetDiagnostics(); + int died = 0, requested = 0, revived = 0, revivedExternal = 0, revivedAuto = 0, overrideCalls = 0; bool lastHeld = true; float lastInv = -1f; + CombatHandler onDied = (in DeathEvent e) => { died++; }; + CombatHandler onReq = (in ReviveRequestEvent e) => { requested++; lastHeld = e.worldHeld; }; + CombatHandler onRev = (in RevivedEvent e) => { revived++; if (e.external) revivedExternal++; if (e.auto) revivedAuto++; lastInv = e.invincibleSeconds; }; + DeathFlow.Died.Add(onDied); DeathFlow.ReviveRequested.Add(onReq); DeathFlow.Revived.Add(onRev); + DeathFlow.ReviveOverride = (pc, rate) => { overrideCalls++; SetDead(pc, false); SetHp(pc, pc.Get_MaxHP() * rate); }; + var pc = MakePC("pc", 1000, 0); var enemyPc = MakePC("enemyPc", 1000, 0, true); + float ts = Time.timeScale; + int fails = 0; + try + { + // a) 정상 사이클: 사망 → 2 s → 요청 → Revive() → 부활 + 무적 1.5 s + Survival.Tick(50f); DeathFlow.Tick(100f); SetDead(pc, true); + DeathFlow.OnPCDied(pc); + bool a1 = DeathFlow.State == DeathState.Dying && died == 1 && DeathFlow.DeathCount == 1 && DeathFlow.Current == pc; + DeathFlow.Tick(101f); bool a2 = DeathFlow.State == DeathState.Dying && requested == 0 && Math.Abs(DeathFlow.DisplayRemaining - 1f) < 1e-4f; + DeathFlow.Tick(101.99f); bool a3 = DeathFlow.State == DeathState.Dying && requested == 0; + DeathFlow.Tick(102f); bool a4 = DeathFlow.State == DeathState.AwaitingRevive && requested == 1 && !lastHeld && !DeathFlow.WorldHeld && IsDead(pc); + bool a5 = DeathFlow.Revive(); + bool a6 = DeathFlow.State == DeathState.None && revived == 1 && revivedExternal == 0 && revivedAuto == 0 && overrideCalls == 1 && !IsDead(pc) && pc.Get_HP() == 1000d && Math.Abs(lastInv - 1.5f) < 1e-4f; + bool a7 = Survival.IsReviveInvincible(pc) && Math.Abs(Survival.ReviveInvincibleRemaining - 1.5f) < 1e-4f; + Survival.Tick(51.5f); bool a8 = !Survival.IsReviveInvincible(pc); + bool a = a1 && a2 && a3 && a4 && a5 && a6 && a7 && a8; + sb.AppendLine(P(a) + " ⑤a 사이클: Dying " + a1 + " · t+1 Dying(remain " + (a2 ? "1.00" : DeathFlow.DisplayRemaining.ToString("F2")) + ") · t+1.99 " + a3 + " · t+2 Awaiting+ReviveRequested(held=" + lastHeld + " · 에디트 모드 기대 false) " + a4 + + " · Revive()=" + a5 + " → None/Revived/무적 1.5 " + a6 + " · 창 " + a7 + " → 1.5 s 뒤 해제 " + a8 + " · LastDeathToRequest=" + DeathFlow.LastDeathToRequest.ToString("F2")); + if (!a) fails++; + + // b) 구독자 0 → 자동 부활 + DeathFlow.ReviveRequested.Remove(onReq); + SetDead(pc, true); DeathFlow.Tick(200f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(202f); + bool b = DeathFlow.State == DeathState.None && revived == 2 && revivedAuto == 1 && !IsDead(pc) && DeathFlow.AutoReviveCount == 1; + sb.AppendLine(P(b) + " ⑤b 구독자 0 자동 부활: state=" + DeathFlow.State + " revived=" + revived + " auto=" + revivedAuto); + if (!b) fails++; + DeathFlow.ReviveRequested.Add(onReq); + + // c) 외부(원본 After_Die)가 먼저 살림 → external + SetDead(pc, true); DeathFlow.Tick(300f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(302f); + bool c1 = DeathFlow.State == DeathState.AwaitingRevive && requested == 2; + SetDead(pc, false); int ov = overrideCalls; DeathFlow.Tick(302.1f); + bool c = c1 && DeathFlow.State == DeathState.None && revived == 3 && revivedExternal == 1 && overrideCalls == ov && Survival.IsReviveInvincible(pc); + sb.AppendLine(P(c) + " ⑤c 외부 부활 감지: Awaiting " + c1 + " → external=" + revivedExternal + " override 미호출 " + (overrideCalls == ov) + " 무적 " + Survival.IsReviveInvincible(pc)); + if (!c) fails++; + + // d) 연출 중 조기 응답 → 연출 끝에 부활 + SetDead(pc, true); DeathFlow.Tick(400f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(400.5f); + bool d1 = DeathFlow.Revive() && DeathFlow.State == DeathState.Dying && IsDead(pc); + DeathFlow.Tick(402f); + bool d = d1 && DeathFlow.State == DeathState.None && revived == 4 && !IsDead(pc) && requested == 3; + sb.AppendLine(P(d) + " ⑤d 조기 응답: Dying 유지 " + d1 + " → 2 s 뒤 부활 " + (DeathFlow.State == DeathState.None) + " revived=" + revived); + if (!d) fails++; + + // e) 타임아웃 자동 부활 + st.autoReviveTimeoutSeconds = 3f; + SetDead(pc, true); DeathFlow.Tick(500f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(502f); + bool e1 = DeathFlow.State == DeathState.AwaitingRevive; + DeathFlow.Tick(504.9f); bool e2 = DeathFlow.State == DeathState.AwaitingRevive; + DeathFlow.Tick(505f); bool e3 = DeathFlow.State == DeathState.None && revived == 5 && revivedAuto == 2; + st.autoReviveTimeoutSeconds = 0f; + bool e = e1 && e2 && e3; + sb.AppendLine(P(e) + " ⑤e 타임아웃 3 s: +2.9 대기 " + e2 + " · +3.0 자동 부활 " + e3 + " · LastRequestToRevive=" + DeathFlow.LastRequestToRevive.ToString("F2")); + if (!e) fails++; + + // f) Abort · 중복 사망 · 적 PC + SetDead(pc, true); DeathFlow.Tick(600f); DeathFlow.OnPCDied(pc); int dc = DeathFlow.DeathCount; + DeathFlow.OnPCDied(pc); bool f1 = DeathFlow.DeathCount == dc; + DeathFlow.Abort("probe"); bool f2 = DeathFlow.State == DeathState.None && DeathFlow.AbortCount == 1 && revived == 5 && IsDead(pc); + SetDead(pc, false); + SetDead(enemyPc, true); DeathFlow.OnPCDied(enemyPc); bool f3 = DeathFlow.State == DeathState.None && DeathFlow.DeathCount == dc; SetDead(enemyPc, false); + bool f4 = Time.timeScale == ts; + bool f = f1 && f2 && f3 && f4; + sb.AppendLine(P(f) + " ⑤f 중복 사망 무시 " + f1 + " · Abort " + f2 + " · 적 PC 무시 " + f3 + " · timeScale 무변경 " + f4 + " (" + Time.timeScale + ")"); + if (!f) fails++; + + // g) Revive() with nothing pending → false + bool g = !DeathFlow.Revive(); + sb.AppendLine(P(g) + " ⑤g 대기 없는 Revive()=false " + g); + if (!g) fails++; + } + finally + { + DeathFlow.Died.Remove(onDied); DeathFlow.ReviveRequested.Remove(onReq); DeathFlow.Revived.Remove(onRev); + DeathFlow.ReviveOverride = null; DeathFlow.ResetDiagnostics(); Survival.ResetDiagnostics(); + Kill(pc, enemyPc); + } + return fails; + } + + // ───────────────────────────────────────── ⑥ 이벤트 GC + static int EventGc(StringBuilder sb) + { + var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ⑥ 클론 주입 실패"); return 1; } + st.enabled = true; st.verboseLog = false; + int n = 0; + CombatHandler h = (in PotionEvent e) => { n++; }; + PotionUse.Changed.Add(h); + try + { + for (int i = 0; i < 10; i++) PotionUse.ResetRun(""); + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + int g0 = GC.CollectionCount(0); long a0 = Alloc(), p0 = Mono(); + for (int i = 0; i < 1000000; i++) PotionUse.ResetRun(""); + long a1 = Alloc(), p1 = Mono(); int g1 = GC.CollectionCount(0); + bool ok = a1 - a0 == 0L && p1 - p0 == 0L && g1 == g0 && n == 1000010; + sb.AppendLine(P(ok) + " ⑥ PotionUse.Changed 디스패치×1,000,000 = 힙 " + (a1 - a0) + " B / mono " + (p1 - p0) + " B / 수집 " + (g1 - g0) + "회 (기대 0) handler=" + n); + return ok ? 0 : 1; + } + finally { PotionUse.Changed.Remove(h); PotionUse.ResetDiagnostics(); } + } + + // ───────────────────────────────────────── ⑦ 정리 + static int Cleanup(StringBuilder sb, float ts0) + { + RestoreAsset(); + if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; } + Survival.ResetDiagnostics(); PotionUse.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); + var st = WLSurvivalSettings.Instance; + bool assetBack = st != null && !st.enabled && !WLSurvivalSettings.Enabled; + bool flag = WL.Settings.WLGameplaySettings.PlayerInvincible; + int leftovers = 0; + foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + if (go != null && go.name.StartsWith("__WL813i_")) { leftovers++; UnityEngine.Object.DestroyImmediate(go); } + bool dirty = false; string scene = "?"; +#if UNITY_EDITOR + var sc = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); dirty = sc.isDirty; scene = sc.name; +#endif + bool ok = assetBack && flag && leftovers == 0 && Time.timeScale == ts0 && !SurvivalRunner.Exists && MyValue.MyPC == null; + sb.AppendLine(P(ok) + " ⑦ 정리: 디스크 에셋 enabled=false 복귀 " + assetBack + " · playerInvincible=" + flag + " · 잔존 오브젝트 " + leftovers + + " · timeScale " + Time.timeScale + "(시작 " + ts0 + ") · 러너 생성 0 " + !SurvivalRunner.Exists + " · MyPC=null " + (MyValue.MyPC == null) + " · 씬 '" + scene + "' dirty=" + dirty); + return ok ? 0 : 1; + } +} diff --git a/Assets/Script/Character/PCActor.cs b/Assets/Script/Character/PCActor.cs index 6e1c5c941..f819d859c 100644 --- a/Assets/Script/Character/PCActor.cs +++ b/Assets/Script/Character/PCActor.cs @@ -264,7 +264,7 @@ public class PCActor : MyActor public override void Get_Damage(DamageInfo _dinfo) { //_dinfo.Damage = 1; // ?뚯뒪??: 留덈? ??二쎄쾶 - base.Get_Damage(_dinfo); + WL.Combat.Survival.Survival.TakeDamage(this, _dinfo, base.Get_Damage); // WL #813i 무적 해제 게이트(원본 = base.Get_Damage(_dinfo) · SO off 면 그대로 base 호출) if (!IsEnemy() && _dinfo.Damage >= Get_MaxHP() * 0.5d && !isSurviveTime) StartCoroutine(Co_Survive(5f)); } @@ -485,6 +485,7 @@ public class PCActor : MyActor //NewGameUI.Ins.m_GuideQuestUI.Add_GuideQuest(ePurpose.DieOrQuit); DungeonInfo.Ins.Sub_WaveLife(); MonsterSimpleInfo.Ins.Set_DelLockTarget(true); + WL.Combat.Survival.DeathFlow.OnPCDied(this); // WL #813i 사망 연출 → 부활 요청 → 존 시작점 On_Regen(SO off 면 즉시 반환) } protected override IEnumerator Co_ScaleControl(bool returntoOriginalScale, float targetscale, Action actComplete = null) diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset new file mode 100644 index 000000000..aa163499c --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset @@ -0,0 +1,34 @@ +%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: 8d2f1434ac6b4b71828c7c8301d4cb5a, type: 3} + m_Name: WLSurvivalSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Survival.WLSurvivalSettings + enabled: 0 + verboseLog: 0 + takeMobDamage: 1 + mobDamageMultiplier: 0.5 + takeBossDamage: 1 + bossDamageMultiplier: 0.5 + eliteCountsAsBoss: 0 + potionCountPerRun: 3 + potionHealRate: 0.4 + potionCooldownSeconds: 5 + potionShowHealNumber: 1 + potionRefuseAtFullHp: 1 + potionResetOnMapLoad: 1 + deathDisplaySeconds: 2 + holdWorldWhileReviving: 1 + autoReviveWhenNoListener: 1 + autoReviveTimeoutSeconds: 0 + reviveInvincibleSeconds: 1.5 + reviveHealRate: 1 + reviveCompanions: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset.meta new file mode 100644 index 000000000..c0a4a7021 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc47d184d5804340b907753ea1b59004 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival.meta b/Assets/WL/Combat/Survival.meta new file mode 100644 index 000000000..68c9179e2 --- /dev/null +++ b/Assets/WL/Combat/Survival.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f937213861694ed48d60dc6f8d9475ea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival/DeathFlow.cs b/Assets/WL/Combat/Survival/DeathFlow.cs new file mode 100644 index 000000000..2ea73d5ca --- /dev/null +++ b/Assets/WL/Combat/Survival/DeathFlow.cs @@ -0,0 +1,197 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DeathFlow.cs — PC 사망 → 사망 연출(2 s · 카메라 추가 연출 0 · 디졸브 0 · 조작 차단) → 부활 요청(813j 팝업) → 응답 → 존 시작점 On_Regen → 무적 창 +// +// PD 지시 #813 · 발주서 WL-813i §1-3 · 기준서 v1 §B 요소 8 회복 · §D-1 813i 행 (2026-09-09) +// +// ■ 원본 실측(Actor.cs) +// Cal_Damage :583 HP ≤ 0 → DeadStatus = true → Set_Die :585 → PCActor.Set_Die :481(All_Stop(true) · Sub_WaveLife · DelLockTarget) +// Actor.Set_Die :626 → Play_Die · Set_Coroutine(After_Die, 3f)(익명 코루틴 · 게임 시간 · 취소 불가) · 콜라이더/NavMesh off +// After_Die :640 → Check_Extra(AfterDie)(PC 는 no-op) → `if (!IsDead()) return;` → PC: On_Regen()(즉시 부활 · 연출 0) + 소환수/펫/일꾼 On_Regen +// InGameInfo.Defeat_byMonster :442 = 전부 주석(빈 함수) — 대체하지 않고 이 파일이 새 흐름을 맡는다. +// MyActor.On_Regen :388 = base.On_Regen(전회복 · 상태 초기화) + Set_Warp(LoadMapMgr.Get_ReincanationPos) = **존 시작점** ← 부활은 이 공개 API 로. +// +// ■ 흐름(훅 = PCActor.Set_Die 끝 1줄 `DeathFlow.OnPCDied(this)`) +// Dying(deathDisplaySeconds · unscaled) → AwaitingRevive: ReviveRequested 발생 + (holdWorldWhileReviving) Time.timeScale = 0 +// · 세계를 세우는 이유 ① 팝업 동안 몹이 시체를 두들기지 않게 ② 원본 After_Die(3 s · 게임 시간)가 먼저 부활시키지 못하게(붙잡아 둠). +// · 원본 PauseUI 가 같은 방식(timeScale 0 · 복원 = MyValue.Get_GameSpeed())이라 TimeScaleArbiter 는 이걸 외부 일시정지로 관측한다. +// 813j 가 Revive() 로 응답 → 세계 복원 → pc.On_Regen(false, reviveHealRate) → 동료 On_Regen → Survival.SetReviveInvincible → Revived. +// 이후 원본 After_Die 가 도착해도 `!IsDead()` 로 아무 것도 하지 않는다(실측 :643). +// 구독자 0(813j 미병합)이면 autoReviveWhenNoListener 로 즉시 부활 · autoReviveTimeoutSeconds 로 시간 상한 · 원본이 먼저 부활시키면 external=true 로 통보. +// ■ 조작 차단 = 원본이 이미 한다(Run_byJoystick/Play_* 의 IsDead 가드 · Set_Die 의 All_Stop(true)) + 세계 정지. 카메라 = 팔로우 대상이 멈추므로 자연 정지(추가 연출 0). +// ■ 비활성(WLSurvivalSettings off)이면 OnPCDied 가 즉시 반환 = 원본 After_Die 3 s 자동 부활 100%(C8). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Survival +{ + public enum DeathState { None = 0, Dying = 1, AwaitingRevive = 2 } + + /// PC 사망 통보(연출 시작 · 813j HUD 가 조작 UI 를 잠글 때). + public struct DeathEvent { public PCActor pc; public Vector3 position; public int deathCount; public float displaySeconds; public float time; public int frame; } + /// 부활 요청(813j 부활 팝업이 띄우고 DeathFlow.Revive() 로 응답한다). + public struct ReviveRequestEvent { public PCActor pc; public int deathCount; public bool worldHeld; public float timeoutSeconds; public float time; public int frame; } + /// 부활 완료(팝업 닫기 · HUD 잠금 해제 · 무적 연출). external = 원본 After_Die 등 바깥이 먼저 살렸다. + public struct RevivedEvent { public PCActor pc; public Vector3 position; public int deathCount; public float invincibleSeconds; public bool external; public bool auto; public float time; public int frame; } + + public static class DeathFlow + { + public static readonly CombatEventList Died = new CombatEventList(4); + public static readonly CombatEventList ReviveRequested = new CombatEventList(4); + public static readonly CombatEventList Revived = new CombatEventList(4); + + static DeathState s_state; + static PCActor s_pc; + static float s_now, s_diedAt, s_requestedAt; + static bool s_worldHeld, s_acceptEarly; + + // ── 진단(프로브 · 813m 로그가 읽는다) + public static int DeathCount, ReviveCount, ExternalReviveCount, AutoReviveCount, AbortCount; + public static float LastDeathToRequest, LastRequestToRevive; + public static string LastAbortReason = ""; + + /// 프로브 전용 — 부활 실행을 바꿔치기한다(에디트 모드에는 LoadMapMgr·ActorInfo 가 없다). 런타임에서는 null 로 둘 것. + public static Action ReviveOverride; + + public static DeathState State { get { return s_state; } } + public static PCActor Current { get { return s_pc; } } + public static bool WorldHeld { get { return s_worldHeld; } } + /// 사망 연출 남은 시간(초 · unscaled · 813j 가 카운트다운에 써도 된다). + public static float DisplayRemaining { get { var st = St; return s_state == DeathState.Dying && st != null ? Mathf.Max(0f, st.deathDisplaySeconds - (s_now - s_diedAt)) : 0f; } } + + static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + /// PCActor.Set_Die 훅(1줄 · base.Set_Die 뒤). 비활성 · 메인 PC 아님 · 진행 중이면 무시. + public static void OnPCDied(PCActor pc) + { + if (!WLSurvivalSettings.Enabled || pc == null || !pc.IsMainPC()) return; + if (s_state != DeathState.None) return; + Survival.EnsureRunner(); + if (Application.isPlaying) s_now = Time.unscaledTime; // 에디트 모드(프로브)는 Tick 이 넣은 클록을 쓴다 + s_pc = pc; s_state = DeathState.Dying; s_diedAt = s_now; s_acceptEarly = false; + DeathCount++; + var st = St; + if (Verbose) Debug.Log("[DeathFlow] died #" + DeathCount + " display=" + st.deathDisplaySeconds.ToString("F1") + "s"); + var e = new DeathEvent { pc = pc, position = pc.Get_position(), deathCount = DeathCount, displaySeconds = st.deathDisplaySeconds, time = Time.unscaledTime, frame = Time.frameCount }; + Died.Dispatch(in e); + } + + /// 러너(또는 프로브)가 unscaled 시간을 넣는다. + public static void Tick(float unscaledNow) + { + s_now = unscaledNow; + if (s_state == DeathState.None) return; + var st = St; + if (st == null || !WLSurvivalSettings.Enabled) { Abort("disabled"); return; } + if (s_pc == null) { Abort("pc destroyed"); return; } + if (!s_pc.IsOnlyDead()) { Finish(true, false); return; } // 바깥(원본 After_Die · 리스폰)이 먼저 살렸다 + + if (s_state == DeathState.Dying) + { + if (s_now - s_diedAt < st.deathDisplaySeconds) return; + s_state = DeathState.AwaitingRevive; s_requestedAt = s_now; + LastDeathToRequest = s_now - s_diedAt; + if (st.holdWorldWhileReviving) HoldWorld(); + var e = new ReviveRequestEvent { pc = s_pc, deathCount = DeathCount, worldHeld = s_worldHeld, timeoutSeconds = st.autoReviveTimeoutSeconds, time = Time.unscaledTime, frame = Time.frameCount }; + ReviveRequested.Dispatch(in e); + if (Verbose) Debug.Log("[DeathFlow] revive requested listeners=" + ReviveRequested.Count + " held=" + s_worldHeld); + if (s_acceptEarly) { Finish(false, false); return; } + if (ReviveRequested.Count == 0 && st.autoReviveWhenNoListener) { Finish(false, true); return; } + return; + } + + // AwaitingRevive + if (st.autoReviveTimeoutSeconds > 0f && s_now - s_requestedAt >= st.autoReviveTimeoutSeconds) Finish(false, true); + } + + /// 813j 부활 팝업의 응답. AwaitingRevive 면 즉시 부활 · Dying 이면 연출이 끝나는 순간 부활 · None 이면 false. + public static bool Revive() + { + switch (s_state) + { + case DeathState.AwaitingRevive: Finish(false, false); return true; + case DeathState.Dying: s_acceptEarly = true; return true; + default: return false; + } + } + + static void Finish(bool external, bool auto) + { + var st = St; + var pc = s_pc; + ReleaseWorld(); + LastRequestToRevive = s_state == DeathState.AwaitingRevive ? s_now - s_requestedAt : 0f; + s_state = DeathState.None; s_pc = null; s_acceptEarly = false; + + float inv = st != null ? st.reviveInvincibleSeconds : 0f; + if (!external && pc != null) + { + try + { + if (ReviveOverride != null) ReviveOverride(pc, st.reviveHealRate); + else pc.On_Regen(false, st.reviveHealRate); // MyActor.On_Regen → 전회복 + 존 시작점 Set_Warp · PCActor.On_Regen → All_Stop(false) 등 + if (st.reviveCompanions && ReviveOverride == null) ReviveCompanions(pc); + } + catch (Exception ex) { Debug.LogException(ex); } + } + Survival.SetReviveInvincible(pc, inv); + ReviveCount++; if (external) ExternalReviveCount++; if (auto) AutoReviveCount++; + if (Verbose) Debug.Log("[DeathFlow] revived external=" + external + " auto=" + auto + " invincible=" + inv.ToString("F2") + "s"); + var e = new RevivedEvent { pc = pc, position = pc != null ? pc.Get_position() : Vector3.zero, deathCount = DeathCount, invincibleSeconds = inv, external = external, auto = auto, time = Time.unscaledTime, frame = Time.frameCount }; + Revived.Dispatch(in e); + } + + /// 원본 After_Die 의 PC 분기 재현(소환수 이동 · 펫 부활 · 일꾼 이동) — 전부 공개 API. + static void ReviveCompanions(PCActor pc) + { + if (pc.IsEnemy()) return; + var pet = pc.Get_MyPet(); + if (!DSUtil.CheckNull(pet)) pet.On_Regen(); + var info = ActorInfo.Ins; + if (info == null) return; + var summ = info.Get_Actor(eRole.Summon); + if (!DSUtil.CheckNull(summ)) summ.On_Regen(); + var worker = info.Get_Actor(eRole.Worker); + if (!DSUtil.CheckNull(worker)) worker.On_Regen(); + } + + /// 흐름 중단(런 경계 · 로비 · PC 소실 · SO off). 세계 정지를 풀고 상태만 비운다(부활 호출 0). + public static void Abort(string reason) + { + if (s_state == DeathState.None && !s_worldHeld) return; + ReleaseWorld(); + s_state = DeathState.None; s_pc = null; s_acceptEarly = false; + AbortCount++; LastAbortReason = reason ?? ""; + if (Verbose) Debug.Log("[DeathFlow] abort (" + reason + ")"); + } + + static void HoldWorld() + { + if (!Application.isPlaying) return; // 에디트 모드에서 timeScale 대입은 TimeManager.asset 에 적힌다(811b-fix 실측) — 프로브 안전 + if (s_worldHeld) return; + Time.timeScale = 0f; // 원본 PauseUI.Set 과 같은 방식 + s_worldHeld = true; + } + + /// 세계 정지 해제(우리가 세웠고 아직 0 일 때만 · 복원값 = 원본 PauseUI 와 같은 MyValue.Get_GameSpeed()). + internal static void ReleaseWorld() + { + if (!s_worldHeld) return; + s_worldHeld = false; + if (Time.timeScale == 0f) Time.timeScale = MyValue.Get_GameSpeed(); + } + + /// 프로브용: 상태·카운터 초기화(구독은 유지 · 세계 정지 해제). + public static void ResetDiagnostics() + { + ReleaseWorld(); + s_state = DeathState.None; s_pc = null; s_acceptEarly = false; s_diedAt = s_requestedAt = 0f; + DeathCount = ReviveCount = ExternalReviveCount = AutoReviveCount = AbortCount = 0; + LastDeathToRequest = LastRequestToRevive = 0f; LastAbortReason = ""; + Died.Raised = Died.Dispatched = 0; ReviveRequested.Raised = ReviveRequested.Dispatched = 0; Revived.Raised = Revived.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Survival/DeathFlow.cs.meta b/Assets/WL/Combat/Survival/DeathFlow.cs.meta new file mode 100644 index 000000000..d913f5ec5 --- /dev/null +++ b/Assets/WL/Combat/Survival/DeathFlow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5bbc2bf1a7246698cf1bcbae6ef5d7e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival/PotionUse.cs b/Assets/WL/Combat/Survival/PotionUse.cs new file mode 100644 index 000000000..663e67f38 --- /dev/null +++ b/Assets/WL/Combat/Survival/PotionUse.cs @@ -0,0 +1,164 @@ +// ───────────────────────────────────────────────────────────────────────────── +// PotionUse.cs — 전투 물약(런당 N개 · 최대 HP 비율 회복 · 쿨다운) — 813j 물약 버튼이 부르는 사용 API + 잔량/쿨 이벤트 +// +// PD 지시 #813 · 발주서 WL-813i §1-2 · 기준서 v1 §B 요소 8 회복(3개/런 · 40 % · 쿨 5 s · Actor.Heal 사용) (2026-09-09) +// +// ■ 813j(UI) 가 쓰는 API +// · 사용 요청: PotionUse.TryUse() → PotionResult (Used 면 회복됨 · 그 외 거절 사유) +// · 상태 읽기: Remaining · Max · CooldownRemaining · CooldownTotal · IsReady (매 프레임 읽어도 GC 0) +// · 이벤트: PotionUse.Changed(in PotionEvent) — 잔량/쿨 변화 때마다(런 리셋 · 사용 · 쿨 종료 · 거절) · PotionUse.Used — 사용 성공만 +// · 런 리셋: PotionUse.ResetRun() (813m 런 시작이 부를 수 있다 · potionResetOnMapLoad 면 맵 진입 때 자동) +// ■ 회복 = 원본 Actor.Heal(float rate, bool showhud) — 사망·만충이면 원본이 아무 것도 하지 않으므로 사용 전에 거절한다. +// ■ 시간 = 게임 시간(Time.time · 러너 Tick) — 일시정지·부활 대기(timeScale 0) 중엔 쿨이 흐르지 않는다. +// ■ 비활성(WLSurvivalSettings off)이면 TryUse 는 Disabled 를 돌려주고 이벤트 0 = 원본 100%(원본엔 전투 물약이 없다). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Survival +{ + public enum PotionResult { Used = 0, Disabled = 1, NoPC = 2, Dead = 3, Empty = 4, Cooldown = 5, FullHp = 6 } + public enum PotionChange { RunReset = 0, Used = 1, CooldownEnded = 2, Refused = 3 } + + /// 물약 상태 스냅샷(struct · GC 0). Changed/Used 구독자가 받는다. + public struct PotionEvent + { + public PCActor pc; + public PotionChange change; + public PotionResult result; // Refused 일 때 사유 · 그 외 Used + public int remaining, max; + public float cooldownRemaining, cooldownTotal; + public double healed, hpAfter, maxHp; + public float time; + public int frame; + } + + public static class PotionUse + { + /// 잔량/쿨 변화 통지(813j 버튼 갱신). 런 리셋 · 사용 · 쿨 종료 · 거절. + public static readonly CombatEventList Changed = new CombatEventList(4); + /// 사용 성공 통지(연출 · 로그). + public static readonly CombatEventList Used = new CombatEventList(4); + + static int s_remaining = -1; // -1 = 런 미시작(첫 조회 때 SO 값으로 채운다) + static float s_cooldownEnd = -1f; + static float s_now; + static bool s_cooldownNotified = true; + + // ── 진단(프로브가 읽는다) + public static int UsedCount, RefusedCount, RunResets; + public static PotionResult LastResult; + public static double LastHealed; + + static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } + + /// 남은 개수(런 미시작이면 SO 값). + public static int Remaining { get { EnsureRun(); return s_remaining; } } + /// 런당 개수(SO). + public static int Max { get { var st = St; return st != null ? st.potionCountPerRun : 0; } } + /// 쿨 남은 시간(초 · 게임 시간). + public static float CooldownRemaining { get { return Mathf.Max(0f, s_cooldownEnd - s_now); } } + /// 쿨 전체 길이(SO · 813j 링 비율 = Remaining/Total). + public static float CooldownTotal { get { var st = St; return st != null ? st.potionCooldownSeconds : 0f; } } + /// 지금 누르면 쓸 수 있는가(활성 · 잔량 · 쿨). HP 만충·사망은 TryUse 가 거절한다. + public static bool IsReady { get { return WLSurvivalSettings.Enabled && Remaining > 0 && CooldownRemaining <= 0f; } } + + static void EnsureRun() + { + if (s_remaining >= 0) return; + var st = St; + s_remaining = st != null ? Mathf.Max(0, st.potionCountPerRun) : 0; + s_cooldownEnd = -1f; s_cooldownNotified = true; + } + + /// 메인 PC(MyValue.MyPC)에 사용. + public static PotionResult TryUse() { return TryUse(MyValue.MyPC); } + + /// 813j 물약 버튼이 부른다. Used 면 회복됨. 거절이어도 Changed(Refused · 사유) 를 내 UI 가 피드백할 수 있다. + public static PotionResult TryUse(PCActor pc) + { + if (!WLSurvivalSettings.Enabled) return Finish(PotionResult.Disabled, pc, 0d, false); + Survival.EnsureRunner(); + EnsureRun(); + if (pc == null) return Finish(PotionResult.NoPC, pc, 0d, true); + if (pc.IsDead()) return Finish(PotionResult.Dead, pc, 0d, true); + if (s_remaining <= 0) return Finish(PotionResult.Empty, pc, 0d, true); + if (s_now < s_cooldownEnd) return Finish(PotionResult.Cooldown, pc, 0d, true); + var st = St; + double before = pc.Get_HP(), max = pc.Get_MaxHP(); + if (st.potionRefuseAtFullHp && before >= max) return Finish(PotionResult.FullHp, pc, 0d, true); + + try { pc.Heal(st.potionHealRate, st.potionShowHealNumber); } // 원본 Heal(rate) · HUD 초록 숫자 + catch (Exception ex) { Debug.LogException(ex); } // HUD 쪽 예외가 물약 상태를 깨지 않게 격리(회복 자체는 HP 갱신 뒤) + + double healed = pc.Get_HP() - before; + s_remaining--; + s_cooldownEnd = s_now + Mathf.Max(0f, st.potionCooldownSeconds); + s_cooldownNotified = st.potionCooldownSeconds <= 0f; + UsedCount++; LastHealed = healed; + if (st.verboseLog) + Debug.Log("[PotionUse] used +" + healed.ToString("F0") + " hp=" + pc.Get_HP().ToString("F0") + "/" + max.ToString("F0") + + " remaining=" + s_remaining + "/" + st.potionCountPerRun + " cd=" + st.potionCooldownSeconds.ToString("F1")); + return Finish(PotionResult.Used, pc, healed, true); + } + + static PotionResult Finish(PotionResult r, PCActor pc, double healed, bool notify) + { + LastResult = r; + if (r != PotionResult.Used) RefusedCount++; + if (!notify) return r; + var e = Snapshot(pc, r == PotionResult.Used ? PotionChange.Used : PotionChange.Refused, r, healed); + Changed.Dispatch(in e); + if (r == PotionResult.Used) Used.Dispatch(in e); + return r; + } + + static PotionEvent Snapshot(PCActor pc, PotionChange change, PotionResult r, double healed) + { + var st = St; + return new PotionEvent + { + pc = pc, change = change, result = r, + remaining = s_remaining < 0 ? 0 : s_remaining, max = st != null ? st.potionCountPerRun : 0, + cooldownRemaining = CooldownRemaining, cooldownTotal = st != null ? st.potionCooldownSeconds : 0f, + healed = healed, + hpAfter = pc != null ? pc.Get_HP() : 0d, maxHp = pc != null ? pc.Get_MaxHP() : 0d, + time = Time.unscaledTime, frame = Time.frameCount + }; + } + + /// 런 시작(맵 진입 · 813m) — 잔량을 SO 값으로 · 쿨 해제. 비활성이면 이벤트 0. + public static void ResetRun(string reason = "") + { + var st = St; + s_remaining = st != null ? Mathf.Max(0, st.potionCountPerRun) : 0; + s_cooldownEnd = -1f; s_cooldownNotified = true; + RunResets++; + if (!WLSurvivalSettings.Enabled) return; + if (st.verboseLog) Debug.Log("[PotionUse] run reset (" + reason + ") remaining=" + s_remaining); + var e = Snapshot(MyValue.MyPC, PotionChange.RunReset, PotionResult.Used, 0d); + Changed.Dispatch(in e); + } + + /// 러너(또는 프로브)가 게임 시간을 넣는다. 쿨이 끝나는 프레임에 Changed(CooldownEnded) 1회. + public static void Tick(float gameNow) + { + s_now = gameNow; + if (s_cooldownNotified || s_now < s_cooldownEnd) return; + s_cooldownNotified = true; + if (!WLSurvivalSettings.Enabled) return; + var e = Snapshot(MyValue.MyPC, PotionChange.CooldownEnded, PotionResult.Used, 0d); + Changed.Dispatch(in e); + } + + /// 프로브용: 상태·카운터 초기화(구독은 유지). + public static void ResetDiagnostics() + { + s_remaining = -1; s_cooldownEnd = -1f; s_cooldownNotified = true; + UsedCount = RefusedCount = RunResets = 0; LastResult = PotionResult.Used; LastHealed = 0d; + Changed.Raised = Changed.Dispatched = 0; Used.Raised = Used.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Survival/PotionUse.cs.meta b/Assets/WL/Combat/Survival/PotionUse.cs.meta new file mode 100644 index 000000000..45ce7dd98 --- /dev/null +++ b/Assets/WL/Combat/Survival/PotionUse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5bf46f3924424ef9b33fe31cc528d489 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival/Survival.cs b/Assets/WL/Combat/Survival/Survival.cs new file mode 100644 index 000000000..31e59bd45 --- /dev/null +++ b/Assets/WL/Combat/Survival/Survival.cs @@ -0,0 +1,220 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Survival.cs — 무적 해제 게이트(받는 피해 배율 · 잡몹/보스 분리) + 부활 무적 창 + 생존 축 공용 틱 러너 +// +// PD 지시 #813 · 발주서 WL-813i §1-1 · 기준서 v1 §B 요소 8 · §F-2 · 설계안 WL-811a §A 7행 (2026-09-09) +// +// ■ 왜 PCActor.Get_Damage 훅인가 (실측 · 발주서 "실측 후 최소 침습") +// PC 피해 경로 = PCActor.Get_Damage :264 → base = MyActor.Get_Damage :301 +// → `wlInvincible = IsRole(PC) && WLGameplaySettings.PlayerInvincible` 이면 Actor.Get_Damage(방어 공식 · Cal_Damage · Set_Die) +// 를 **아예 부르지 않고** RaiseDamaged(invincible=true) 만 낸다. +// · Damaged 이벤트 구독으로는 피해를 넣을 수 없다 — 그 시점엔 이미 base 가 건너뛰어졌고, 다시 Get_Damage 를 부르면 같은 게이트에 막힌다. +// · Actor.Cal_Damage 직접 호출은 방어 공식(:400-462) · 관통 · 감쇠 · 마나실드 · CC · HitConfirmed 를 전부 건너뛴다(스탯 의미 상실). +// · MyActor.cs(무적 분기) 수정 금지 · WLGameplaySettings.asset 값 변경 금지. +// → PCActor.Get_Damage 의 `base.Get_Damage(_dinfo)` 1줄을 `Survival.TakeDamage(this, _dinfo, base.Get_Damage)` 로 바꾼다. +// base 메서드 그룹은 C# 이 **비가상**으로 MyActor.Get_Damage 에 묶어 준다. 이 안에서 +// ① 부활 무적 창이면 원본 그대로(무적 플래그 유지 → 피해 0) ② 잡몹/보스 스위치가 꺼져 있으면 원본 그대로 +// ③ 아니면 원 피해 × 배율 → WLGameplaySettings.Instance.playerInvincible 을 **이 호출 동안만** false 로 내리고 base 호출 → finally 로 복원. +// 비활성(에셋 없음 · enabled=false)이면 base 만 부른다 = 원본 100%(C8). +// 비용: 훅 줄이 base 델리게이트 1개를 만든다(피격 1회당 ≈ 64 B · 원본이 피격마다 new DamageInfo 를 만드는 것과 같은 자릿수). 완료보고에 기재. +// +// ■ 배율은 방어 공식 **전**(원 피해)에 곱한다 — Actor.Get_Damage 뒤에는 PC 에 훅 지점이 없다. 방어 공식은 피해에 선형이라 +// "받는 피해 0.5" 의 체감은 같고, 고정 피해(ATK_FIXED) 항만 배율 밖이다. +// +// ■ 러너(SurvivalRunner · 숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음)는 SO 가 켜져 있을 때만 만든다(C8: 꺼져 있으면 오브젝트 0). +// 틱: Survival(게임 시간) · PotionUse(게임 시간) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; + +namespace WL.Combat.Survival +{ + /// 무적 해제 게이트 + 부활 무적 창 + 런 경계. 정적 · GC 0(verboseLog 때만 문자열). + public static class Survival + { + // ── 진단(프로브가 읽는다) + public static int Applied; // 배율 적용 후 base 호출(무적 해제) 횟수 + public static int Lifted; // 그중 playerInvincible 을 실제로 내렸다 되돌린 횟수 + public static int PassDisabled; // SO off → 원본 그대로 + public static int PassNotMainPC; // 메인 PC 아님(적 PC 등) → 원본 그대로 + public static int BlockedReviveInvincible; // 부활 무적 창 → 원본 그대로(피해 0) + public static int SkippedMobSwitch, SkippedBossSwitch; + public static double LastRawDamage, LastScaledDamage, LastFinalDamage; + public static bool LastWasBoss; + public static bool LastFlagDuringCall; // base 호출 직전 PlayerInvincible 값(기대 false) + + static float s_now; // 게임 시간(러너 Tick · 프로브는 직접 넣는다) + static float s_reviveInvincibleUntil = -1f; + static Actor s_invinciblePc; + + /// 마지막 틱의 게임 시간(Time.time · 프로브 클록). + public static float Now { get { return s_now; } } + public static float ReviveInvincibleUntil { get { return s_reviveInvincibleUntil; } } + public static float ReviveInvincibleRemaining { get { return Mathf.Max(0f, s_reviveInvincibleUntil - s_now); } } + + static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } + static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } + + /// 부활 무적 창 안인가(게임 시간 · 813j HP 바 연출이 읽어도 된다). + public static bool IsReviveInvincible(Actor pc) + { + return pc != null && ReferenceEquals(pc, s_invinciblePc) && s_now < s_reviveInvincibleUntil; + } + + /// DeathFlow 가 부활 직후 부른다. seconds ≤ 0 이면 창 없음. + public static void SetReviveInvincible(Actor pc, float seconds) + { + if (pc == null || seconds <= 0f) { s_invinciblePc = null; s_reviveInvincibleUntil = -1f; return; } + s_invinciblePc = pc; + s_reviveInvincibleUntil = s_now + seconds; + } + + /// + /// PCActor.Get_Damage 훅(1줄). baseCall = base.Get_Damage(MyActor · 비가상). 비활성이면 baseCall 만 = 원본 100%. + /// + public static void TakeDamage(PCActor pc, DamageInfo dinfo, Action baseCall) + { + if (baseCall == null) return; + if (!WLSurvivalSettings.Enabled || pc == null || dinfo == null) { PassDisabled++; baseCall(dinfo); return; } + if (!pc.IsMainPC()) { PassNotMainPC++; baseCall(dinfo); return; } + EnsureRunner(); + + if (IsReviveInvincible(pc)) { BlockedReviveInvincible++; baseCall(dinfo); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true)) + + var st = St; + bool boss = IsBossSource(dinfo, st); + if (boss ? !st.takeBossDamage : !st.takeMobDamage) + { + if (boss) SkippedBossSwitch++; else SkippedMobSwitch++; + baseCall(dinfo); // 스위치 off = 그 진영엔 원본 무적 그대로 + return; + } + + double mult = boss ? st.bossDamageMultiplier : st.mobDamageMultiplier; + if (mult < 0d) mult = 0d; + double raw = dinfo.Damage; + dinfo.Damage = raw * mult; + LastRawDamage = raw; LastScaledDamage = dinfo.Damage; LastWasBoss = boss; + + var gs = WL.Settings.WLGameplaySettings.Instance; + bool lift = gs != null && gs.playerInvincible; // 이미 꺼져 있으면(PD 가 에셋에서 해제) 손대지 않는다 + if (lift) { gs.playerInvincible = false; Lifted++; } + LastFlagDuringCall = WL.Settings.WLGameplaySettings.PlayerInvincible; + try { baseCall(dinfo); } + finally { if (lift) gs.playerInvincible = true; } // 디스크 값 무변경 · 예외가 나도 되돌린다 + Applied++; + LastFinalDamage = dinfo.Damage; // Actor.Get_Damage 가 dinfo 를 제자리에서 갱신 → 방어 후 최종 피해 + + if (Verbose) + Debug.Log("[Survival] " + (boss ? "boss" : "mob") + " dmg " + raw.ToString("F0") + " ×" + mult.ToString("F2") + + " → " + LastScaledDamage.ToString("F0") + " → final " + dinfo.Damage.ToString("F0") + + " hp=" + pc.Get_HP().ToString("F0") + "/" + pc.Get_MaxHP().ToString("F0") + (lift ? " (lifted)" : "")); + } + + static bool IsBossSource(DamageInfo dinfo, WLSurvivalSettings st) + { + var beater = dinfo.Beater; + if (beater == null) return false; // 가해자 없음(함정 등) = 잡몹 취급 + if (beater.IsSubRole(eSubRol.Boss)) return true; + return st.eliteCountsAsBoss && beater.IsSubRole(eSubRol.Elite); + } + + // ───────────────────────────────────────── 틱 · 런 경계 + + /// 러너(또는 프로브)가 게임 시간을 넣는다. + public static void Tick(float gameNow) { s_now = gameNow; } + + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + static int s_lastMode = int.MinValue; + public static int RunStarts; + public static string LastRunReason = ""; + + /// 러너가 매 프레임 부른다. 메인 PC 교체 · Stage/Dungeon 맵 진입을 런 시작으로 본다(원본 훅 0 · 폴링). + internal static void PollRunBoundary() + { + var st = St; + if (st == null) return; + + var pc = MyValue.MyPC; // 파괴된 참조는 Unity != 로 걸러진다 + if (!ReferenceEquals(pc, s_lastPc)) + { + s_lastPc = pc; + if (pc != null) OnRunStart("pc"); + else DeathFlow.Abort("pc lost"); + } + + var info = InGameInfo.Ins; + if (info == null) return; + int mode = (int)info.Get_GameMode(); + int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : -1; + if (mode != s_lastMode || map != s_lastMapId) + { + bool first = s_lastMode == int.MinValue; + s_lastMode = mode; s_lastMapId = map; + if (first) return; // 부팅 직후 첫 관측은 경계가 아니다 + var m = (eGameMode)mode; + if (m == eGameMode.Lobby) DeathFlow.Abort("lobby"); + else if ((m == eGameMode.Stage || m == eGameMode.Dungeon) && st.potionResetOnMapLoad) OnRunStart("map " + map); + } + } + + static void OnRunStart(string reason) + { + RunStarts++; LastRunReason = reason; + DeathFlow.Abort(reason); + PotionUse.ResetRun(reason); + SetReviveInvincible(null, 0f); + if (Verbose) Debug.Log("[Survival] run start (" + reason + ")"); + } + + internal static void EnsureRunner() + { + if (Application.isPlaying) SurvivalRunner.Ensure(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (WLSurvivalSettings.Enabled) SurvivalRunner.Ensure(); // 꺼져 있으면 오브젝트 0(C8) + } + + /// 프로브용: 카운터·창 초기화. + public static void ResetDiagnostics() + { + Applied = Lifted = PassDisabled = PassNotMainPC = BlockedReviveInvincible = SkippedMobSwitch = SkippedBossSwitch = 0; + LastRawDamage = LastScaledDamage = LastFinalDamage = 0d; LastWasBoss = false; LastFlagDuringCall = false; + s_reviveInvincibleUntil = -1f; s_invinciblePc = null; + RunStarts = 0; LastRunReason = ""; + } + } + + /// 생존 축 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음). 파괴·비활성·앱 종료 시 세계 정지를 반드시 푼다. + public sealed class SurvivalRunner : MonoBehaviour + { + static SurvivalRunner s_runner; + + public static bool Exists { get { return s_runner != null; } } + + public static void Ensure() + { + if (s_runner != null) return; + var go = new GameObject("__WLSurvival"); + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + } + + void Update() + { + Survival.Tick(Time.time); + PotionUse.Tick(Time.time); + DeathFlow.Tick(Time.unscaledTime); + Survival.PollRunBoundary(); + } + + void OnDisable() { DeathFlow.ReleaseWorld(); } + void OnApplicationQuit() { DeathFlow.ReleaseWorld(); } + } +} diff --git a/Assets/WL/Combat/Survival/Survival.cs.meta b/Assets/WL/Combat/Survival/Survival.cs.meta new file mode 100644 index 000000000..43de8d438 --- /dev/null +++ b/Assets/WL/Combat/Survival/Survival.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dc93f89305b14490ba351c8953e9871f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs new file mode 100644 index 000000000..acb4c3fb3 --- /dev/null +++ b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs @@ -0,0 +1,132 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSurvivalSettings.cs — "위험과 회복"(무적 해제 게이트 · 받는 피해 배율 · 물약 · 사망/부활) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-813i §1-4 · 기준서 v1 §B 요소 8 · §D-1 813i 행 · §F-2 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset +// → Resources.Load("WL/WLSurvivalSettings") 로 1회 로드 후 캐시(코어 설정 SO 와 같은 관례). +// 에셋이 없거나 enabled = false 면 Enabled == false → Survival · PotionUse · DeathFlow 가 아무 것도 하지 않는다 +// = 원본 + 기존 WL 동작 100%(플레이어 무적 유지 · 물약 없음 · After_Die 3 s 자동 부활). C8 롤백 1순위 스위치. +// +// 🔴 기본 enabled = false — 813j(물약 버튼 · 부활 팝업 UI)와 **동시 병합**할 때 Lead 가 켠다(기준서 §F-2). +// WLGameplaySettings.playerInvincible 은 손대지 않는다 — 이 SO 가 켜지면 Survival.TakeDamage 가 피격 1회 동안만 +// 그 플래그를 내렸다가 되돌린다(디스크 값 무변경). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Survival +{ + [CreateAssetMenu(fileName = "WLSurvivalSettings", menuName = "WL/Survival Settings", order = 325)] + public sealed class WLSurvivalSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLSurvivalSettings"; + + static WLSurvivalSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLSurvivalSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// + /// QA·A/B 전용 런타임 스위치(에셋 무수정 · Play 종료 시 도메인 리로드로 자동 0). + /// 0 = 에셋의 enabled 를 따른다 · 1 = 에셋이 꺼져 있어도 켠다(813j 병합 전 QA) · -1 = 강제 off. + /// + public static int RuntimeOverride; + + /// 생존 축이 살아 있는가 = 에셋 존재 · (enabled 또는 강제 on) · 강제 off 아님. false 면 모든 경로가 원본 동작이다. + public static bool Enabled + { + get + { + var i = Instance; + if (i == null || RuntimeOverride < 0) return false; + return RuntimeOverride > 0 || i.enabled; + } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 무적 해제 · 물약 · 사망/부활 흐름이 전부 비활성 — 플레이어 무적(WLGameplaySettings.playerInvincible) 그대로 · 원본 After_Die 3 s 자동 부활 그대로. 롤백 1순위. 813j UI 와 동시 병합 때 Lead 가 켠다.")] + public bool enabled = false; + + [Tooltip("피해 배율 · 물약 · 사망/부활 결정을 Console 에 남긴다(검증용 · 기본 off · 문자열 GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 받는 피해 + [Header("받는 피해 (기준서 §B 요소 8 위험 · §F-2 · 잡몹/보스 분리)")] + [Tooltip("잡몹(Boss 가 아닌 가해자 · 정예 포함)의 피해를 받을지. 끄면 잡몹에게는 원본 무적 그대로.")] + public bool takeMobDamage = true; + + [Tooltip("잡몹 피해에 곱하는 받는 피해 배율. 원본 Actor.Get_Damage(방어 공식) 진입 **전** 원 피해에 곱한다. 기준서 제안 0.5 로 시작.")] + public float mobDamageMultiplier = 0.5f; + + [Tooltip("보스(eSubRol.Boss 가해자)의 피해를 받을지. 끄면 보스에게는 원본 무적 그대로.")] + public bool takeBossDamage = true; + + [Tooltip("보스 피해에 곱하는 받는 피해 배율. 기준서 제안 0.5 로 시작.")] + public float bossDamageMultiplier = 0.5f; + + [Tooltip("켜면 정예(eSubRol.Elite) 가해자를 보스 배율·스위치로 다룬다. 기본은 잡몹 취급.")] + public bool eliteCountsAsBoss = false; + + // ─────────────────────────────────────────── 물약 + [Header("물약 (기준서 §B 요소 8 회복 · 3개/런 · 40 % · 쿨 5 s)")] + [Tooltip("런(맵 진입)당 물약 개수.")] + public int potionCountPerRun = 3; + + [Tooltip("1회 회복량(최대 HP 비율 · Actor.Heal(rate)). 0.4 = 40 %.")] + public float potionHealRate = 0.4f; + + [Tooltip("사용 후 재사용 대기(초 · 게임 시간 = 일시정지 중 정지).")] + public float potionCooldownSeconds = 5f; + + [Tooltip("회복 수치를 원본 데미지 텍스트(HUDDMGUI · 초록)로 띄운다.")] + public bool potionShowHealNumber = true; + + [Tooltip("HP 가 가득 차 있으면 사용을 거절한다(개수 낭비 방지 · Actor.Heal 도 만충이면 아무 것도 안 한다).")] + public bool potionRefuseAtFullHp = true; + + [Tooltip("Stage/Dungeon 맵에 들어갈 때(맵 ID·게임 모드 변화) 물약 개수를 런 시작값으로 되돌린다. 끄면 PotionUse.ResetRun() 호출(813m 런 시작)로만 리셋된다.")] + public bool potionResetOnMapLoad = true; + + // ─────────────────────────────────────────── 사망 · 부활 + [Header("사망 · 부활 (기준서 사망 연출 2 s → 부활 요청 → 존 시작점 On_Regen · 부활 후 무적 1.5 s)")] + [Tooltip("사망 연출 길이(초 · unscaled). 이 시간이 지나면 ReviveRequested 를 낸다(813j 팝업).")] + public float deathDisplaySeconds = 2f; + + [Tooltip("부활 요청을 낸 뒤 응답까지 Time.timeScale = 0 으로 세계를 세운다(원본 PauseUI 선례). 원본 After_Die(사망 3 s 뒤 자동 부활 · 코루틴 · 게임 시간)를 붙잡아 두는 수단이기도 하다 — 끄면 팝업 응답이 3 s 를 넘길 때 원본이 먼저 부활시킨다(external 부활로 통보).")] + public bool holdWorldWhileReviving = true; + + [Tooltip("ReviveRequested 구독자가 0 이면(813j 미병합 · QA) 요청 즉시 자동 부활한다. 끄면 구독자가 없어도 기다린다(autoReviveTimeoutSeconds 참고).")] + public bool autoReviveWhenNoListener = true; + + [Tooltip("부활 요청 뒤 이 시간(초 · unscaled) 안에 응답이 없으면 자동 부활. 0 이면 무제한 대기.")] + public float autoReviveTimeoutSeconds = 0f; + + [Tooltip("부활 직후 무적 시간(초 · 게임 시간). 이 동안 PC 피격은 원본 무적 경로(피해 0 · Damaged(invincible=true) 발생).")] + public float reviveInvincibleSeconds = 1.5f; + + [Tooltip("부활 시 회복 비율(On_Regen healrate · 1 = 전회복 · 원본 After_Die 와 동일).")] + public float reviveHealRate = 1f; + + [Tooltip("부활 시 소환수·펫·일꾼도 On_Regen 한다(원본 After_Die 의 PC 분기와 동일).")] + public bool reviveCompanions = true; + } +} diff --git a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs.meta b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs.meta new file mode 100644 index 000000000..6ca630a35 --- /dev/null +++ b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d2f1434ac6b4b71828c7c8301d4cb5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: