// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// 무적 해제 게이트 + 부활 무적 창 + 런 경계. 정적 · 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)
// ── WL-813i2 진단(프로브가 읽는다)
/// 피격 후 무적 창이 막은 피격 수(= 연타에서 덜 맞은 타수).
public static int BlockedHitInvincible;
/// 피격 후 무적 창을 연 횟수 · 그중 보스 피격이 연 횟수.
public static int HitInvincibleOpened, HitInvincibleOpenedByBoss;
/// 마지막으로 연 창의 길이(초).
public static float LastHitInvincibleSeconds;
// ── WL-813w8 진단(프로브가 읽는다) — 결과창(런 종료) 정지 중 무적 래치
/// 정지 무적이 막은 피격 수(D-46 = 결과창 중 초당 3~5대).
public static int BlockedHoldInvincible;
/// 래치를 건 횟수 · 푼 횟수.
public static int HoldInvincibleOn, HoldInvincibleOff;
/// 마지막으로 걸거나 푼 사유(진단).
public static string LastHoldInvincibleReason = "";
///
/// WL-813m3 — 이 훅을 거쳐 실제로 깎인 PC HP 누계(중첩 Cal_Damage 포함 · base 호출 전후 실측).
/// DamageGuard 가 "총 감소량 − 이 누계" 로 **훅을 안 거친 피해**(배율 미적용 경로)를 뽑는 기준선이다.
///
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;
/// 마지막 틱의 게임 시간(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)
{
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)
/// 피격 후 무적 창 종료 시각(게임 시간 · 창 없으면 −1).
public static float HitInvincibleUntil { get { return s_hitInvincibleUntil; } }
/// 피격 후 무적 창 잔여(초 · 813j UI 가 읽어도 된다).
public static float HitInvincibleRemaining { get { return Mathf.Max(0f, s_hitInvincibleUntil - s_now); } }
/// 피격 후 무적 창 안인가(부활 창과 별개).
public static bool IsHitInvincible(Actor pc)
{
return pc != null && ReferenceEquals(pc, s_hitInvinciblePc) && s_now < s_hitInvincibleUntil;
}
/// 피격 후 무적 창을 연다(프로브도 직접 부른다). seconds ≤ 0 이면 창을 닫는다.
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;
}
///
/// 이 피격이 창을 열 자격이 있는가(순수 함수 · Actor 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거).
/// 반환 = 창 길이(초 · 0 이면 열지 않는다).
///
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).
/// 결과창 정지 무적이 걸려 있는가(대상 무관 · DamageGuard·프로브가 읽는다).
public static bool HoldInvincibleActive { get { return s_holdInvinciblePc != null; } }
/// 이 PC 에 결과창 정지 무적이 걸려 있는가.
public static bool IsHoldInvincible(Actor pc)
{
return pc != null && ReferenceEquals(pc, s_holdInvinciblePc);
}
///
/// 결과창 정지 무적을 걸거나 푼다(RunGuide 가 부른다 · 프로브도 직접 부른다).
/// on=false 이거나 pc 가 null 이면 해제. 같은 상태로 다시 불러도 카운터는 한 번만 움직인다.
///
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;
}
///
/// 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++; 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)" : ""));
}
///
/// WL-813m3 — base 호출을 HP 실측으로 감싼다. 호출 전후 차이가 "훅이 책임지는 감소량" 이고
/// (원본 Get_Damage 안의 중첩 Cal_Damage · 스턴 추가타 · Extra 까지 포함한다) 그 누계가
/// DamageGuard 의 기준선이 된다. 같은 자리에서 사망 직전 로그 1건도 남긴다(GC 0).
///
/// WL-813i2 — 이 호출이 실제로 깎은 HP(피격 후 무적 창을 열 자격 판정에 쓴다).
static double CallBase(PCActor pc, DamageInfo dinfo, Action 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);
}
// ───────────────────────────────────────── 틱 · 런 경계
/// 러너(또는 프로브)가 게임 시간을 넣는다.
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);
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)
}
/// 프로브용: 카운터·창 초기화.
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;
}
}
/// 생존 축 틱 러너(숨은 오브젝트 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);
AutoPotion.Tick(Time.time); // 813m2 · PotionUse.Tick 뒤(쿨 잔량이 먼저 갱신돼야 IsReady 가 맞다)
DeathFlow.Tick(Time.unscaledTime);
Survival.PollRunBoundary();
DeathDiag.EnsureSubscribed(); // 813m3 · 첫 호출에만 DeathFlow.Died 구독
DamageGuard.Poll(); // 813m3 · 훅을 안 거친 HP 감소 정산
}
///
/// 813m3 — 도트 코루틴(`WaitForSeconds`)은 Update **뒤**에 재개된다. 같은 프레임에 정산하려면
/// LateUpdate 에서 한 번 더 봐야 한다(지연 ≈ 0). 비활성이면 Poll 이 즉시 반환한다(C8).
///
void LateUpdate() { DamageGuard.Poll(); }
void OnDisable() { DeathFlow.ReleaseWorld(); }
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
}
}