// ───────────────────────────────────────────────────────────────────────────── // DeathFlow.cs — PC 사망 → 사망 연출(2 s · 카메라 추가 연출 0 · 디졸브 0 · 조작 차단) → 부활 요청(813j 팝업) → 응답 → 존 시작점 On_Regen → 무적 창 // // PD 지시 #813 · 발주서 WL-813i §1-3 · 기준서 v1 §B 요소 8 회복 · §D-1 813i 행 (2026-09-09) // // ■ 원본 실측(Actor.cs) // Cal_Damage :583 HP ≤ 0 → DeadStatus = true → Set_Die :585 → PCActor.Set_Die :481(All_Stop(true) · Sub_WaveLife · DelLockTarget) // Actor.Set_Die :626 → Play_Die · Set_Coroutine(After_Die, 3f)(익명 코루틴 · 게임 시간 · 취소 불가) · 콜라이더/NavMesh off // After_Die :640 → Check_Extra(AfterDie)(PC 는 no-op) → `if (!IsDead()) return;` → PC: On_Regen()(즉시 부활 · 연출 0) + 소환수/펫/일꾼 On_Regen // InGameInfo.Defeat_byMonster :442 = 전부 주석(빈 함수) — 대체하지 않고 이 파일이 새 흐름을 맡는다. // MyActor.On_Regen :388 = base.On_Regen(전회복 · 상태 초기화) + Set_Warp(LoadMapMgr.Get_ReincanationPos) = **존 시작점** ← 부활은 이 공개 API 로. // // ■ 흐름(훅 = PCActor.Set_Die 끝 1줄 `DeathFlow.OnPCDied(this)`) // Dying(deathDisplaySeconds · unscaled) → AwaitingRevive: ReviveRequested 발생 + (holdWorldWhileReviving) Time.timeScale = 0 // · 세계를 세우는 이유 ① 팝업 동안 몹이 시체를 두들기지 않게 ② 원본 After_Die(3 s · 게임 시간)가 먼저 부활시키지 못하게(붙잡아 둠). // · 원본 PauseUI 가 같은 방식(timeScale 0 · 복원 = MyValue.Get_GameSpeed())이라 TimeScaleArbiter 는 이걸 외부 일시정지로 관측한다. // 813j 가 Revive() 로 응답 → 세계 복원 → pc.On_Regen(false, reviveHealRate) → 동료 On_Regen → Survival.SetReviveInvincible → Revived. // 이후 원본 After_Die 가 도착해도 `!IsDead()` 로 아무 것도 하지 않는다(실측 :643). // 구독자 0(813j 미병합)이면 autoReviveWhenNoListener 로 즉시 부활 · autoReviveTimeoutSeconds 로 시간 상한 · 원본이 먼저 부활시키면 external=true 로 통보. // ■ 조작 차단 = 원본이 이미 한다(Run_byJoystick/Play_* 의 IsDead 가드 · Set_Die 의 All_Stop(true)) + 세계 정지. 카메라 = 팔로우 대상이 멈추므로 자연 정지(추가 연출 0). // ■ 비활성(WLSurvivalSettings off)이면 OnPCDied 가 즉시 반환 = 원본 After_Die 3 s 자동 부활 100%(C8). // ───────────────────────────────────────────────────────────────────────────── using System; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Survival { public enum DeathState { None = 0, Dying = 1, AwaitingRevive = 2 } /// PC 사망 통보(연출 시작 · 813j HUD 가 조작 UI 를 잠글 때). public struct DeathEvent { public PCActor pc; public Vector3 position; public int deathCount; public float displaySeconds; public float time; public int frame; } /// 부활 요청(813j 부활 팝업이 띄우고 DeathFlow.Revive() 로 응답한다). public struct ReviveRequestEvent { public PCActor pc; public int deathCount; public bool worldHeld; public float timeoutSeconds; public float time; public int frame; } /// 부활 완료(팝업 닫기 · HUD 잠금 해제 · 무적 연출). external = 원본 After_Die 등 바깥이 먼저 살렸다. public struct RevivedEvent { public PCActor pc; public Vector3 position; public int deathCount; public float invincibleSeconds; public bool external; public bool auto; public float time; public int frame; } public static class DeathFlow { public static readonly CombatEventList Died = new CombatEventList(4); public static readonly CombatEventList ReviveRequested = new CombatEventList(4); public static readonly CombatEventList Revived = new CombatEventList(4); static DeathState s_state; static PCActor s_pc; static float s_now, s_diedAt, s_requestedAt; static bool s_worldHeld, s_acceptEarly; // ── 진단(프로브 · 813m 로그가 읽는다) public static int DeathCount, ReviveCount, ExternalReviveCount, AutoReviveCount, AbortCount; public static float LastDeathToRequest, LastRequestToRevive; public static string LastAbortReason = ""; /// WL-813z(D-3) — 부활 워치독이 발동한 횟수 · 마지막 발동까지 걸린 대기 시간(초 · unscaled). public static int WatchdogCount; public static float LastWatchdogSeconds; /// 프로브 전용 — 부활 실행을 바꿔치기한다(에디트 모드에는 LoadMapMgr·ActorInfo 가 없다). 런타임에서는 null 로 둘 것. public static Action ReviveOverride; public static DeathState State { get { return s_state; } } public static PCActor Current { get { return s_pc; } } public static bool WorldHeld { get { return s_worldHeld; } } /// 사망 연출 남은 시간(초 · unscaled · 813j 가 카운트다운에 써도 된다). public static float DisplayRemaining { get { var st = St; return s_state == DeathState.Dying && st != null ? Mathf.Max(0f, st.deathDisplaySeconds - (s_now - s_diedAt)) : 0f; } } /// 부활 팝업이 응답을 기다린 시간(초 · unscaled · WL-813z 워치독 진단). public static float AwaitingSeconds { get { return s_state == DeathState.AwaitingRevive ? Mathf.Max(0f, s_now - s_requestedAt) : 0f; } } static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } /// PCActor.Set_Die 훅(1줄 · base.Set_Die 뒤). 비활성 · 메인 PC 아님 · 진행 중이면 무시. public static void OnPCDied(PCActor pc) { if (!WLSurvivalSettings.Enabled || pc == null || !pc.IsMainPC()) return; if (s_state != DeathState.None) return; Survival.EnsureRunner(); if (Application.isPlaying) s_now = Time.unscaledTime; // 에디트 모드(프로브)는 Tick 이 넣은 클록을 쓴다 s_pc = pc; s_state = DeathState.Dying; s_diedAt = s_now; s_acceptEarly = false; DeathCount++; var st = St; if (Verbose) Debug.Log("[DeathFlow] died #" + DeathCount + " display=" + st.deathDisplaySeconds.ToString("F1") + "s"); var e = new DeathEvent { pc = pc, position = pc.Get_position(), deathCount = DeathCount, displaySeconds = st.deathDisplaySeconds, time = Time.unscaledTime, frame = Time.frameCount }; Died.Dispatch(in e); } /// 러너(또는 프로브)가 unscaled 시간을 넣는다. public static void Tick(float unscaledNow) { s_now = unscaledNow; if (s_state == DeathState.None) { if (s_worldHeld) ReleaseWorld(); return; } // 상태는 끝났는데 정지만 남은 경우(안전망) var st = St; // ── WL-813z(D-3) 부활 워치독 = 마지막 방어선. Enabled 판정보다 **먼저** 본다. // 공용 Popup 이 에러 모달에 덮여 확인 콜백(m_ok)이 지워져도, SO 스위치가 도중에 꺼져도, // 이 시간이 지나면 반드시 자동 부활하고 ReleaseWorld() 가 timeScale 을 되돌린다. if (s_state == DeathState.AwaitingRevive && st != null && st.reviveWatchdogSeconds > 0f && (st.reviveWatchdogIgnoresDisable || WLSurvivalSettings.Enabled) && s_now - s_requestedAt >= st.reviveWatchdogSeconds) { WatchdogCount++; LastWatchdogSeconds = s_now - s_requestedAt; if (st.reviveWatchdogLog) // Log(=Error 아님) — ErrorLogHookManager 모달을 부르지 않는다 Debug.Log("[DeathFlow] revive watchdog " + LastWatchdogSeconds.ToString("F1") + "s 초과 — 자동 부활 · 세계 정지 해제(D-3)"); Finish(false, true); return; } if (st == null || !WLSurvivalSettings.Enabled) { Abort("disabled"); return; } if (s_pc == null) { Abort("pc destroyed"); return; } if (!s_pc.IsOnlyDead()) { Finish(true, false); return; } // 바깥(원본 After_Die · 리스폰)이 먼저 살렸다 if (s_state == DeathState.Dying) { if (s_now - s_diedAt < st.deathDisplaySeconds) return; s_state = DeathState.AwaitingRevive; s_requestedAt = s_now; LastDeathToRequest = s_now - s_diedAt; if (st.holdWorldWhileReviving) HoldWorld(); var e = new ReviveRequestEvent { pc = s_pc, deathCount = DeathCount, worldHeld = s_worldHeld, timeoutSeconds = st.autoReviveTimeoutSeconds, time = Time.unscaledTime, frame = Time.frameCount }; ReviveRequested.Dispatch(in e); if (Verbose) Debug.Log("[DeathFlow] revive requested listeners=" + ReviveRequested.Count + " held=" + s_worldHeld); if (s_acceptEarly) { Finish(false, false); return; } if (ReviveRequested.Count == 0 && st.autoReviveWhenNoListener) { Finish(false, true); return; } return; } // AwaitingRevive if (st.autoReviveTimeoutSeconds > 0f && s_now - s_requestedAt >= st.autoReviveTimeoutSeconds) Finish(false, true); } /// 813j 부활 팝업의 응답. AwaitingRevive 면 즉시 부활 · Dying 이면 연출이 끝나는 순간 부활 · None 이면 false. public static bool Revive() { switch (s_state) { case DeathState.AwaitingRevive: Finish(false, false); return true; case DeathState.Dying: s_acceptEarly = true; return true; default: return false; } } static void Finish(bool external, bool auto) { var st = St; var pc = s_pc; ReleaseWorld(); LastRequestToRevive = s_state == DeathState.AwaitingRevive ? s_now - s_requestedAt : 0f; s_state = DeathState.None; s_pc = null; s_acceptEarly = false; float inv = st != null ? st.reviveInvincibleSeconds : 0f; if (!external && pc != null) { try { if (ReviveOverride != null) ReviveOverride(pc, st.reviveHealRate); else pc.On_Regen(false, st.reviveHealRate); // MyActor.On_Regen → 전회복 + 존 시작점 Set_Warp · PCActor.On_Regen → All_Stop(false) 등 if (st.reviveCompanions && ReviveOverride == null) ReviveCompanions(pc); } catch (Exception ex) { Debug.LogException(ex); } } Survival.SetReviveInvincible(pc, inv); ReviveCount++; if (external) ExternalReviveCount++; if (auto) AutoReviveCount++; if (Verbose) Debug.Log("[DeathFlow] revived external=" + external + " auto=" + auto + " invincible=" + inv.ToString("F2") + "s"); var e = new RevivedEvent { pc = pc, position = pc != null ? pc.Get_position() : Vector3.zero, deathCount = DeathCount, invincibleSeconds = inv, external = external, auto = auto, time = Time.unscaledTime, frame = Time.frameCount }; Revived.Dispatch(in e); } /// 원본 After_Die 의 PC 분기 재현(소환수 이동 · 펫 부활 · 일꾼 이동) — 전부 공개 API. static void ReviveCompanions(PCActor pc) { if (pc.IsEnemy()) return; var pet = pc.Get_MyPet(); if (!DSUtil.CheckNull(pet)) pet.On_Regen(); var info = ActorInfo.Ins; if (info == null) return; var summ = info.Get_Actor(eRole.Summon); if (!DSUtil.CheckNull(summ)) summ.On_Regen(); var worker = info.Get_Actor(eRole.Worker); if (!DSUtil.CheckNull(worker)) worker.On_Regen(); } /// 흐름 중단(런 경계 · 로비 · PC 소실 · SO off). 세계 정지를 풀고 상태만 비운다(부활 호출 0). public static void Abort(string reason) { if (s_state == DeathState.None && !s_worldHeld) return; ReleaseWorld(); s_state = DeathState.None; s_pc = null; s_acceptEarly = false; AbortCount++; LastAbortReason = reason ?? ""; if (Verbose) Debug.Log("[DeathFlow] abort (" + reason + ")"); } static void HoldWorld() { if (!Application.isPlaying) return; // 에디트 모드에서 timeScale 대입은 TimeManager.asset 에 적힌다(811b-fix 실측) — 프로브 안전 if (s_worldHeld) return; Time.timeScale = 0f; // 원본 PauseUI.Set 과 같은 방식 s_worldHeld = true; } /// 세계 정지 해제(우리가 세웠고 아직 0 일 때만 · 복원값 = 원본 PauseUI 와 같은 MyValue.Get_GameSpeed()). internal static void ReleaseWorld() { if (!s_worldHeld) return; s_worldHeld = false; if (Time.timeScale == 0f) Time.timeScale = MyValue.Get_GameSpeed(); if (Time.timeScale <= 0f) Time.timeScale = 1f; // WL-813z(D-3) 안전망 — Get_GameSpeed 가 0 을 주면 그대로 잠긴다(현재 구현은 항상 1f · 실측) } /// 프로브용: 상태·카운터 초기화(구독은 유지 · 세계 정지 해제). 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 = ""; WatchdogCount = 0; LastWatchdogSeconds = 0f; Died.Raised = Died.Dispatched = 0; ReviveRequested.Raised = ReviveRequested.Dispatched = 0; Revived.Raised = Revived.Dispatched = 0; } } }