[WL-813i] 무적 해제·물약·사망/부활 (#813)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-09 02:12:33 +09:00
parent 6d9d8755c1
commit c8787794a9
14 changed files with 1328 additions and 1 deletions

View File

@ -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

View File

@ -0,0 +1,491 @@
// WL813i_Probe.cs — #813i(무적 해제 게이트 · 물약 · 사망/부활) 에디트 모드 검증 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 에셋/씬/TimeManager 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴)
//
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813i_Probe.cs --entry WL813i_Probe.RunAll
// 결과: Console + AgentScripts/WL813i_PROBE.txt(커밋) + Screenshots_WL/WL813i/probe_<HHmmss>.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]"; }
/// <summary>원본 Heal(float rate) 은 0.4f 를 double 로 넓혀 곱하므로(400.000006) 1e-3 허용.</summary>
static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; }
/// <summary>Unity Mono 의 GC.GetAllocatedBytesForCurrentThread 는 상수를 돌려준다(실측 · 대조군 0 B) → GetTotalMemory(4 KB 페이지 단위) + 대조군으로 감도를 보인다(813k 선례).</summary>
static long Alloc() { return GC.GetTotalMemory(false); }
static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); }
/// <summary>디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(값 변경 실험용).</summary>
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<PCActor>();
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<Actor>();
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<DamageInfo> 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<DamageInfo>[N];
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long b0 = Alloc(), q0 = Mono();
for (int i = 0; i < N; i++) keepArr[i] = new Action<DamageInfo>(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<PotionEvent> onChanged = (in PotionEvent e) => { changed++; if (e.change == PotionChange.CooldownEnded) cdEnded++; if (e.change == PotionChange.Refused) { refused++; lastRefuse = e.result; } };
CombatHandler<PotionEvent> 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<DeathEvent> onDied = (in DeathEvent e) => { died++; };
CombatHandler<ReviveRequestEvent> onReq = (in ReviveRequestEvent e) => { requested++; lastHeld = e.worldHeld; };
CombatHandler<RevivedEvent> 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<PotionEvent> 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<GameObject>(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;
}
}

View File

@ -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)

View File

@ -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

View File

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

View File

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

View File

@ -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 }
/// <summary>PC 사망 통보(연출 시작 · 813j HUD 가 조작 UI 를 잠글 때).</summary>
public struct DeathEvent { public PCActor pc; public Vector3 position; public int deathCount; public float displaySeconds; public float time; public int frame; }
/// <summary>부활 요청(813j 부활 팝업이 띄우고 DeathFlow.Revive() 로 응답한다).</summary>
public struct ReviveRequestEvent { public PCActor pc; public int deathCount; public bool worldHeld; public float timeoutSeconds; public float time; public int frame; }
/// <summary>부활 완료(팝업 닫기 · HUD 잠금 해제 · 무적 연출). external = 원본 After_Die 등 바깥이 먼저 살렸다.</summary>
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<DeathEvent> Died = new CombatEventList<DeathEvent>(4);
public static readonly CombatEventList<ReviveRequestEvent> ReviveRequested = new CombatEventList<ReviveRequestEvent>(4);
public static readonly CombatEventList<RevivedEvent> Revived = new CombatEventList<RevivedEvent>(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 = "";
/// <summary>프로브 전용 — 부활 실행을 바꿔치기한다(에디트 모드에는 LoadMapMgr·ActorInfo 가 없다). 런타임에서는 null 로 둘 것.</summary>
public static Action<PCActor, float> 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; } }
/// <summary>사망 연출 남은 시간(초 · unscaled · 813j 가 카운트다운에 써도 된다).</summary>
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; } }
/// <summary>PCActor.Set_Die 훅(1줄 · base.Set_Die 뒤). 비활성 · 메인 PC 아님 · 진행 중이면 무시.</summary>
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);
}
/// <summary>러너(또는 프로브)가 unscaled 시간을 넣는다.</summary>
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);
}
/// <summary>813j 부활 팝업의 응답. AwaitingRevive 면 즉시 부활 · Dying 이면 연출이 끝나는 순간 부활 · None 이면 false.</summary>
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);
}
/// <summary>원본 After_Die 의 PC 분기 재현(소환수 이동 · 펫 부활 · 일꾼 이동) — 전부 공개 API.</summary>
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();
}
/// <summary>흐름 중단(런 경계 · 로비 · PC 소실 · SO off). 세계 정지를 풀고 상태만 비운다(부활 호출 0).</summary>
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;
}
/// <summary>세계 정지 해제(우리가 세웠고 아직 0 일 때만 · 복원값 = 원본 PauseUI 와 같은 MyValue.Get_GameSpeed()).</summary>
internal static void ReleaseWorld()
{
if (!s_worldHeld) return;
s_worldHeld = false;
if (Time.timeScale == 0f) Time.timeScale = MyValue.Get_GameSpeed();
}
/// <summary>프로브용: 상태·카운터 초기화(구독은 유지 · 세계 정지 해제).</summary>
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;
}
}
}

View File

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

View File

@ -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 }
/// <summary>물약 상태 스냅샷(struct · GC 0). Changed/Used 구독자가 받는다.</summary>
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
{
/// <summary>잔량/쿨 변화 통지(813j 버튼 갱신). 런 리셋 · 사용 · 쿨 종료 · 거절.</summary>
public static readonly CombatEventList<PotionEvent> Changed = new CombatEventList<PotionEvent>(4);
/// <summary>사용 성공 통지(연출 · 로그).</summary>
public static readonly CombatEventList<PotionEvent> Used = new CombatEventList<PotionEvent>(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; } }
/// <summary>남은 개수(런 미시작이면 SO 값).</summary>
public static int Remaining { get { EnsureRun(); return s_remaining; } }
/// <summary>런당 개수(SO).</summary>
public static int Max { get { var st = St; return st != null ? st.potionCountPerRun : 0; } }
/// <summary>쿨 남은 시간(초 · 게임 시간).</summary>
public static float CooldownRemaining { get { return Mathf.Max(0f, s_cooldownEnd - s_now); } }
/// <summary>쿨 전체 길이(SO · 813j 링 비율 = Remaining/Total).</summary>
public static float CooldownTotal { get { var st = St; return st != null ? st.potionCooldownSeconds : 0f; } }
/// <summary>지금 누르면 쓸 수 있는가(활성 · 잔량 · 쿨). HP 만충·사망은 TryUse 가 거절한다.</summary>
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;
}
/// <summary>메인 PC(MyValue.MyPC)에 사용.</summary>
public static PotionResult TryUse() { return TryUse(MyValue.MyPC); }
/// <summary>813j 물약 버튼이 부른다. Used 면 회복됨. 거절이어도 Changed(Refused · 사유) 를 내 UI 가 피드백할 수 있다.</summary>
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
};
}
/// <summary>런 시작(맵 진입 · 813m) — 잔량을 SO 값으로 · 쿨 해제. 비활성이면 이벤트 0.</summary>
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);
}
/// <summary>러너(또는 프로브)가 게임 시간을 넣는다. 쿨이 끝나는 프레임에 Changed(CooldownEnded) 1회.</summary>
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);
}
/// <summary>프로브용: 상태·카운터 초기화(구독은 유지).</summary>
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;
}
}
}

View File

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

View File

@ -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
{
/// <summary>무적 해제 게이트 + 부활 무적 창 + 런 경계. 정적 · GC 0(verboseLog 때만 문자열).</summary>
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;
/// <summary>마지막 틱의 게임 시간(Time.time · 프로브 클록).</summary>
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; } }
/// <summary>부활 무적 창 안인가(게임 시간 · 813j HP 바 연출이 읽어도 된다).</summary>
public static bool IsReviveInvincible(Actor pc)
{
return pc != null && ReferenceEquals(pc, s_invinciblePc) && s_now < s_reviveInvincibleUntil;
}
/// <summary>DeathFlow 가 부활 직후 부른다. seconds ≤ 0 이면 창 없음.</summary>
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;
}
/// <summary>
/// PCActor.Get_Damage 훅(1줄). baseCall = base.Get_Damage(MyActor · 비가상). 비활성이면 baseCall 만 = 원본 100%.
/// </summary>
public static void TakeDamage(PCActor pc, DamageInfo dinfo, Action<DamageInfo> 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);
}
// ───────────────────────────────────────── 틱 · 런 경계
/// <summary>러너(또는 프로브)가 게임 시간을 넣는다.</summary>
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 = "";
/// <summary>러너가 매 프레임 부른다. 메인 PC 교체 · Stage/Dungeon 맵 진입을 런 시작으로 본다(원본 훅 0 · 폴링).</summary>
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)
}
/// <summary>프로브용: 카운터·창 초기화.</summary>
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 = "";
}
}
/// <summary>생존 축 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음). 파괴·비활성·앱 종료 시 세계 정지를 반드시 푼다.</summary>
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<SurvivalRunner>();
}
void Update()
{
Survival.Tick(Time.time);
PotionUse.Tick(Time.time);
DeathFlow.Tick(Time.unscaledTime);
Survival.PollRunBoundary();
}
void OnDisable() { DeathFlow.ReleaseWorld(); }
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
}
}

View File

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

View File

@ -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<WLSurvivalSettings>("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;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLSurvivalSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLSurvivalSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>
/// QA·A/B 전용 런타임 스위치(에셋 무수정 · Play 종료 시 도메인 리로드로 자동 0).
/// 0 = 에셋의 enabled 를 따른다 · 1 = 에셋이 꺼져 있어도 켠다(813j 병합 전 QA) · -1 = 강제 off.
/// </summary>
public static int RuntimeOverride;
/// <summary>생존 축이 살아 있는가 = 에셋 존재 · (enabled 또는 강제 on) · 강제 off 아님. false 면 모든 경로가 원본 동작이다.</summary>
public static bool Enabled
{
get
{
var i = Instance;
if (i == null || RuntimeOverride < 0) return false;
return RuntimeOverride > 0 || i.enabled;
}
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
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;
}
}

View File

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