Project_WL/Assets/WL/Combat/Survival/Survival.cs

438 lines
27 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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(게임 시간) · AutoPotion(게임 시간 · 813m2) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
// WL-813i2 (2026-09-09 · PD 지시 #813) — 보스 연타 대비 **피격 후 짧은 무적 창**
//
// 🔴 실측 근거 = qa/W2-10_재채점_v8.md §6 (`death_*.txt` **14건 전수**)
// · 사망 14/14 전부 `boss/813010 Anubis(Clone)` — 마지막 5~6행이 전부 보스 타격
// · 보스 1타 원 피해 133.8~547.2 → ×0.15 를 거쳐도 **PC 최대 HP(310)의 6~26 %**
// · 타격 간격 **0.52 s 등간격** · **5대 / 약 2.6 초에 사망**(122→92→63→34→4→사망)
// · 도트(Unscaled) 기여 **0** — 813m3 의 「도트가 범인」 가설은 기각됐다
// → 배율(F-17 0.15→0.1)은 「한 방에 26 %」를 못 바꾼다. **연타의 횟수**를 줄이는 축이 이 창이다.
//
// ■ 어떻게 (새 코드 경로 0 · 813i 무적 API 재사용)
// 실피해가 들어간 직후 `SetHitInvincible(pc, bossHitInvincibleSeconds)` 로 창을 연다.
// 창 안의 피격은 **부활 무적 창과 똑같은 원본 경로**를 탄다 — `playerInvincible` 을 내리지 않으므로
// MyActor.Get_Damage(:308)가 `base.Get_Damage` 를 건너뛰고 `RaiseDamaged(invincible=true)` 만 낸다.
// · 피해 0 · 방어 공식 0 · 사망 판정 0
// · 연출은 **UI 무수정으로 1회만** — `WLSurvivalUiSettings.ignoreInvincibleHits=1`(HitVignette:87)과
// `WLImpactTierSettings.pcHitSkipWhenInvincible=1`(PcHitReaction:89)이 invincible 타를 스스로 거른다.
// · 잡몹에는 기본 미적용(`mobHitInvincible=0`) — 실측상 잡몹은 한 방에 0.3~0.4 % 라 창이 상시로 열리면
// 난이도가 통째로 사라진다.
// C8 롤백 = `bossHitInvincibleSeconds = 0` 또는 `hitInvincibleEnabled = 0` → 813i 동작 100 %.
// ─────────────────────────────────────────────────────────────────────────────
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)
// ── WL-813i2 진단(프로브가 읽는다)
/// <summary>피격 후 무적 창이 막은 피격 수(= 연타에서 덜 맞은 타수).</summary>
public static int BlockedHitInvincible;
/// <summary>피격 후 무적 창을 연 횟수 · 그중 보스 피격이 연 횟수.</summary>
public static int HitInvincibleOpened, HitInvincibleOpenedByBoss;
/// <summary>마지막으로 연 창의 길이(초).</summary>
public static float LastHitInvincibleSeconds;
// ── WL-813w8 진단(프로브가 읽는다) — 결과창(런 종료) 정지 중 무적 래치
/// <summary>정지 무적이 막은 피격 수(D-46 = 결과창 중 초당 3~5대).</summary>
public static int BlockedHoldInvincible;
/// <summary>래치를 건 횟수 · 푼 횟수.</summary>
public static int HoldInvincibleOn, HoldInvincibleOff;
/// <summary>마지막으로 걸거나 푼 사유(진단).</summary>
public static string LastHoldInvincibleReason = "";
/// <summary>
/// WL-813m3 — 이 훅을 거쳐 실제로 깎인 PC HP 누계(중첩 Cal_Damage 포함 · base 호출 전후 실측).
/// DamageGuard 가 "총 감소량 이 누계" 로 **훅을 안 거친 피해**(배율 미적용 경로)를 뽑는 기준선이다.
/// </summary>
public static double HookHpLoss;
static float s_now; // 게임 시간(러너 Tick · 프로브는 직접 넣는다)
static float s_reviveInvincibleUntil = -1f;
static Actor s_invinciblePc;
// WL-813i2 — 피격 후 무적 창(부활 창과 별도 축: 부활 창은 런/부활 경계가, 이 창은 피격이 연다)
static float s_hitInvincibleUntil = -1f;
static Actor s_hitInvinciblePc;
// WL-813w8 — 결과창 정지 무적 「래치」(창이 아니다 = 시간으로 만료되지 않는다).
// 거는 쪽(RunGuide.BeginRunEndHold)이 풀 때까지 유지된다 — 결과창을 얼마나 오래 띄워도 피해 0.
static Actor s_holdInvinciblePc;
/// <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)
{
DamageGuard.Rebase(); // WL-813m3 — 부활·런 경계로 HP 가 튀면 폴링 기준선을 다시 잡는다(오검출 0)
SetHitInvincible(null, 0f); // WL-813i2 — 부활·런 경계는 피격 창도 함께 닫는다
if (pc == null || seconds <= 0f) { s_invinciblePc = null; s_reviveInvincibleUntil = -1f; return; }
s_invinciblePc = pc;
s_reviveInvincibleUntil = s_now + seconds;
}
// ───────────────────────────────────────── WL-813i2 피격 후 무적 창 (Q10 D-21)
/// <summary>피격 후 무적 창 종료 시각(게임 시간 · 창 없으면 1).</summary>
public static float HitInvincibleUntil { get { return s_hitInvincibleUntil; } }
/// <summary>피격 후 무적 창 잔여(초 · 813j UI 가 읽어도 된다).</summary>
public static float HitInvincibleRemaining { get { return Mathf.Max(0f, s_hitInvincibleUntil - s_now); } }
/// <summary>피격 후 무적 창 안인가(부활 창과 별개).</summary>
public static bool IsHitInvincible(Actor pc)
{
return pc != null && ReferenceEquals(pc, s_hitInvinciblePc) && s_now < s_hitInvincibleUntil;
}
/// <summary>피격 후 무적 창을 연다(프로브도 직접 부른다). seconds ≤ 0 이면 창을 닫는다.</summary>
public static void SetHitInvincible(Actor pc, float seconds)
{
if (pc == null || seconds <= 0f) { s_hitInvinciblePc = null; s_hitInvincibleUntil = -1f; return; }
s_hitInvinciblePc = pc;
s_hitInvincibleUntil = s_now + seconds;
LastHitInvincibleSeconds = seconds;
}
/// <summary>
/// 이 피격이 창을 열 자격이 있는가(순수 함수 · Actor 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거).
/// 반환 = 창 길이(초 · 0 이면 열지 않는다).
/// </summary>
public static float HitInvincibleWindow(WLSurvivalSettings st, bool boss, double hpLost)
{
if (st == null || !st.hitInvincibleEnabled) return 0f;
if (hpLost < (double)Mathf.Max(0f, st.hitInvincibleMinHpLoss)) return 0f;
if (boss) return Mathf.Max(0f, st.bossHitInvincibleSeconds);
if (!st.mobHitInvincible) return 0f;
return Mathf.Max(0f, st.mobHitInvincibleSeconds);
}
// ─────────────────────────── WL-813w8 결과창 정지 중 무적 래치 (Q11 D-46)
//
// 🔴 근거 = qa/W2-11_재채점_v9.md D-46 — 813w7 정지(RunGuide.BeginRunEndHold)는 **PC 의 공격만** 막는다.
// 몹은 그대로 때려 **초당 3.4~4.9 HP** 가 깎이고 **55~80 초에 사망**(3판 3/3 · 사망 파일 3건).
// `mobHitInvincible=0`(813i2 설계값)이라 잡몹 연타를 끊는 창이 없다.
//
// ■ 왜 「창」이 아니라 「래치」인가 — 결과창을 얼마나 오래 켜 두는지는 PD 손에 달렸다(Q11: 82 s · 94 s).
// 시간 창을 갱신하는 방식은 갱신이 한 번만 끊겨도 죽는다. 거는 쪽이 풀 때까지 유지되는 래치가 맞다.
// ■ 피해를 어떻게 0 으로 만드나 = 813i2 피격 창과 **같은 원본 경로**다 — `playerInvincible` 을 이 호출
// 동안만 올려 `MyActor.Get_Damage`(:308)가 base 를 건너뛰고 `RaiseDamaged(invincible=true)` 만 내게 한다.
// → 피해 0 · 비네트(`WLSurvivalUiSettings.ignoreInvincibleHits=1`)·타격 반응
// (`WLImpactTierSettings.pcHitSkipWhenInvincible=1`)이 스스로 걸러 준다(UI 무수정).
// ■ 값·스위치는 거는 쪽 SO(`WLRunGuideSettings.invincibleWhileRunEndHold`) — 여기는 API 만 둔다(C8).
/// <summary>결과창 정지 무적이 걸려 있는가(대상 무관 · DamageGuard·프로브가 읽는다).</summary>
public static bool HoldInvincibleActive { get { return s_holdInvinciblePc != null; } }
/// <summary>이 PC 에 결과창 정지 무적이 걸려 있는가.</summary>
public static bool IsHoldInvincible(Actor pc)
{
return pc != null && ReferenceEquals(pc, s_holdInvinciblePc);
}
/// <summary>
/// 결과창 정지 무적을 걸거나 푼다(RunGuide 가 부른다 · 프로브도 직접 부른다).
/// on=false 이거나 pc 가 null 이면 해제. 같은 상태로 다시 불러도 카운터는 한 번만 움직인다.
/// </summary>
public static void SetHoldInvincible(Actor pc, bool on, string reason)
{
if (!on || pc == null)
{
if (s_holdInvinciblePc == null) return;
s_holdInvinciblePc = null;
HoldInvincibleOff++;
LastHoldInvincibleReason = reason;
return;
}
if (ReferenceEquals(pc, s_holdInvinciblePc)) return;
s_holdInvinciblePc = pc;
HoldInvincibleOn++;
LastHoldInvincibleReason = reason;
}
/// <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++; CallBase(pc, dinfo, baseCall, DamageVia.ReviveInv, dinfo.Damage, 0d); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true))
// 🔴 WL-813w8 (Q11 D-46) — 결과창(런 종료) 정지 중에는 피해 0. 813i2 피격 창과 **같은 원본 경로**
// (플래그를 이 호출 동안만 올린다 → base 가 건너뛰고 Damaged(invincible=true) 만 나간다).
// 부활 무적 창보다 **뒤**에 둔다 — 사망/부활 중이면 그쪽이 우선이다(발주서 §1-1 「부활 우선」).
if (IsHoldInvincible(pc))
{
BlockedHoldInvincible++;
var gsr = WL.Settings.WLGameplaySettings.Instance;
bool raiseHold = gsr != null && !gsr.playerInvincible;
if (raiseHold) gsr.playerInvincible = true;
try { CallBase(pc, dinfo, baseCall, DamageVia.HoldInv, dinfo.Damage, 0d); }
finally { if (raiseHold) gsr.playerInvincible = false; }
return;
}
// 🔴 WL-813i2 (Q10 D-21) — 피격 후 무적 창. 부활 창과 **같은 원본 경로**로 흘려보낸다:
// playerInvincible 을 내리지 않으므로 MyActor.Get_Damage 가 base 를 건너뛰고
// RaiseDamaged(invincible=true) 만 낸다 → 피해 0 · 비네트/타격반응은 각 SO 의
// `ignoreInvincibleHits` / `pcHitSkipWhenInvincible` 이 걸러 준다(UI 무수정 · 연출 1회).
// 🔴 그 경로는 `WLGameplaySettings.playerInvincible` 이 **true 일 때만** 성립한다(MyActor.cs:308).
// PD 가 그 값을 해제하면 창이 있어도 원 피해가 그대로 들어간다 → 이 호출 동안만 플래그를
// 올렸다 되돌린다(Scaled 경로가 내렸다 되돌리는 것의 정확한 대칭 · 디스크 값 무변경).
if (IsHitInvincible(pc))
{
BlockedHitInvincible++;
var gsi = WL.Settings.WLGameplaySettings.Instance;
bool raise = gsi != null && !gsi.playerInvincible;
if (raise) gsi.playerInvincible = true;
try { CallBase(pc, dinfo, baseCall, DamageVia.HitInv, dinfo.Damage, 0d); }
finally { if (raise) gsi.playerInvincible = false; }
return;
}
var st = St;
bool boss = IsBossSource(dinfo, st);
if (boss ? !st.takeBossDamage : !st.takeMobDamage)
{
if (boss) SkippedBossSwitch++; else SkippedMobSwitch++;
CallBase(pc, dinfo, baseCall, DamageVia.SwitchOff, dinfo.Damage, 0d); // 스위치 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;
double lost = 0d;
try { lost = CallBase(pc, dinfo, baseCall, DamageVia.Scaled, raw, mult); }
finally { if (lift) gs.playerInvincible = true; } // 디스크 값 무변경 · 예외가 나도 되돌린다
Applied++;
LastFinalDamage = dinfo.Damage; // Actor.Get_Damage 가 dinfo 를 제자리에서 갱신 → 방어 후 최종 피해
// 🔴 WL-813i2 — **실제로 깎였을 때만** 창을 연다(방어로 0 이 된 타격은 창을 열지 않는다).
float win = HitInvincibleWindow(st, boss, lost);
if (win > 0f)
{
SetHitInvincible(pc, win);
HitInvincibleOpened++;
if (boss) HitInvincibleOpenedByBoss++;
if (st.hitInvincibleLog)
Debug.Log("[WL813i2] " + (boss ? "boss" : "mob") + " hit " + lost.ToString("F0")
+ " hp → 무적 " + win.ToString("F2") + "s (hp=" + pc.Get_HP().ToString("F0")
+ "/" + pc.Get_MaxHP().ToString("F0") + " 막은타 " + BlockedHitInvincible + ")");
}
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)" : ""));
}
/// <summary>
/// WL-813m3 — base 호출을 HP 실측으로 감싼다. 호출 전후 차이가 "훅이 책임지는 감소량" 이고
/// (원본 Get_Damage 안의 중첩 Cal_Damage · 스턴 추가타 · Extra 까지 포함한다) 그 누계가
/// DamageGuard 의 기준선이 된다. 같은 자리에서 사망 직전 로그 1건도 남긴다(GC 0).
/// </summary>
/// <returns>WL-813i2 — 이 호출이 실제로 깎은 HP(피격 후 무적 창을 열 자격 판정에 쓴다).</returns>
static double CallBase(PCActor pc, DamageInfo dinfo, Action<DamageInfo> baseCall, DamageVia via, double raw, double mult)
{
double before = pc.Get_HP();
double lost = 0d;
try { baseCall(dinfo); }
finally
{
double after = pc.Get_HP();
lost = before - after;
if (lost < 0d) lost = 0d;
HookHpLoss += lost;
DeathDiag.Record(via, dinfo != null ? dinfo.Beater : null, raw, mult, lost, after, pc.Get_MaxHP());
}
return lost;
}
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);
SetHoldInvincible(null, false, "런 경계(" + reason + ")"); // 813w8 — 맵/PC 가 바뀌면 정지 무적도 반드시 푼다(RunGuide 해제와 이중 안전)
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 = "";
HookHpLoss = 0d; // 813m3
// 813i2
BlockedHitInvincible = HitInvincibleOpened = HitInvincibleOpenedByBoss = 0;
LastHitInvincibleSeconds = 0f;
s_hitInvincibleUntil = -1f; s_hitInvinciblePc = null;
// 813w8
BlockedHoldInvincible = HoldInvincibleOn = HoldInvincibleOff = 0;
LastHoldInvincibleReason = "";
s_holdInvinciblePc = null;
}
}
/// <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);
AutoPotion.Tick(Time.time); // 813m2 · PotionUse.Tick 뒤(쿨 잔량이 먼저 갱신돼야 IsReady 가 맞다)
DeathFlow.Tick(Time.unscaledTime);
Survival.PollRunBoundary();
DeathDiag.EnsureSubscribed(); // 813m3 · 첫 호출에만 DeathFlow.Died 구독
DamageGuard.Poll(); // 813m3 · 훅을 안 거친 HP 감소 정산
}
/// <summary>
/// 813m3 — 도트 코루틴(`WaitForSeconds`)은 Update **뒤**에 재개된다. 같은 프레임에 정산하려면
/// LateUpdate 에서 한 번 더 봐야 한다(지연 ≈ 0). 비활성이면 Poll 이 즉시 반환한다(C8).
/// </summary>
void LateUpdate() { DamageGuard.Poll(); }
void OnDisable() { DeathFlow.ReleaseWorld(); }
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
}
}