// ───────────────────────────────────────────────────────────────────────────── // WLSurvivalSettings.cs — "위험과 회복"(무적 해제 게이트 · 받는 피해 배율 · 물약 · 사망/부활) 값의 단일 출처(SOT · C45) // // PD 지시 #813 · 발주서 WL-813i §1-4 · 기준서 v1 §B 요소 8 · §D-1 813i 행 · §F-2 (2026-09-09) // // 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset // → Resources.Load("WL/WLSurvivalSettings") 로 1회 로드 후 캐시(코어 설정 SO 와 같은 관례). // 에셋이 없거나 enabled = false 면 Enabled == false → Survival · PotionUse · DeathFlow 가 아무 것도 하지 않는다 // = 원본 + 기존 WL 동작 100%(플레이어 무적 유지 · 물약 없음 · After_Die 3 s 자동 부활). C8 롤백 1순위 스위치. // // 🔴 기본 enabled = false — 813j(물약 버튼 · 부활 팝업 UI)와 **동시 병합**할 때 Lead 가 켠다(기준서 §F-2). // WLGameplaySettings.playerInvincible 은 손대지 않는다 — 이 SO 가 켜지면 Survival.TakeDamage 가 피격 1회 동안만 // 그 플래그를 내렸다가 되돌린다(디스크 값 무변경). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(PCActor 가 Assembly-CSharp 에 있다). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Survival { [CreateAssetMenu(fileName = "WLSurvivalSettings", menuName = "WL/Survival Settings", order = 325)] public sealed class WLSurvivalSettings : ScriptableObject { public const string ResourcesPath = "WL/WLSurvivalSettings"; static WLSurvivalSettings s_cached; static bool s_lookupDone; /// 설정 에셋(1회 로드 후 캐시). 없으면 null. public static WLSurvivalSettings Instance { get { if (!s_lookupDone) { s_cached = Resources.Load(ResourcesPath); s_lookupDone = true; } return s_cached; } } /// /// QA·A/B 전용 런타임 스위치(에셋 무수정 · Play 종료 시 도메인 리로드로 자동 0). /// 0 = 에셋의 enabled 를 따른다 · 1 = 에셋이 꺼져 있어도 켠다(813j 병합 전 QA) · -1 = 강제 off. /// public static int RuntimeOverride; /// 생존 축이 살아 있는가 = 에셋 존재 · (enabled 또는 강제 on) · 강제 off 아님. false 면 모든 경로가 원본 동작이다. public static bool Enabled { get { var i = Instance; if (i == null || RuntimeOverride < 0) return false; return RuntimeOverride > 0 || i.enabled; } } /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. public static void ClearCache() { s_cached = null; s_lookupDone = false; } // ─────────────────────────────────────────── 전체 스위치 [Header("전체 스위치")] [Tooltip("끄면 무적 해제 · 물약 · 사망/부활 흐름이 전부 비활성 — 플레이어 무적(WLGameplaySettings.playerInvincible) 그대로 · 원본 After_Die 3 s 자동 부활 그대로. 롤백 1순위. 813j UI 와 동시 병합 때 Lead 가 켠다.")] public bool enabled = false; [Tooltip("피해 배율 · 물약 · 사망/부활 결정을 Console 에 남긴다(검증용 · 기본 off · 문자열 GC 발생).")] public bool verboseLog = false; // ─────────────────────────────────────────── 받는 피해 [Header("받는 피해 (기준서 §B 요소 8 위험 · §F-2 · 잡몹/보스 분리)")] [Tooltip("잡몹(Boss 가 아닌 가해자 · 정예 포함)의 피해를 받을지. 끄면 잡몹에게는 원본 무적 그대로.")] public bool takeMobDamage = true; [Tooltip("잡몹 피해에 곱하는 받는 피해 배율. 원본 Actor.Get_Damage(방어 공식) 진입 **전** 원 피해에 곱한다. 기준서 제안 0.5 로 시작.")] public float mobDamageMultiplier = 0.5f; [Tooltip("보스(eSubRol.Boss 가해자)의 피해를 받을지. 끄면 보스에게는 원본 무적 그대로.")] public bool takeBossDamage = true; [Tooltip("보스 피해에 곱하는 받는 피해 배율. 기준서 제안 0.5 로 시작.")] public float bossDamageMultiplier = 0.5f; [Tooltip("켜면 정예(eSubRol.Elite) 가해자를 보스 배율·스위치로 다룬다. 기본은 잡몹 취급.")] public bool eliteCountsAsBoss = false; // ─────────────────────────────────────────── 보스 연타 대비 피격 후 무적 (Q10 D-21 · WL-813i2) // 🔴 실측 근거 = 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**(14건 전부 0건 0 HP) — 813m3 가설은 기각됐다 // → 배율(F-17 로 0.15→0.1)만으로는 「한 방에 26 %」가 안 바뀐다. 연타의 **횟수**를 줄이는 축이 필요하다. // 0.6 s 창이면 0.52 s 간격 5연타에서 1·3·5 타만 들어온다(3/5) → 창을 0.6 s 로 두면 실측 2~3타. // 창 안의 피격은 **부활 무적 창과 같은 원본 경로**(playerInvincible 유지 → base 가 피해를 건너뛰고 // RaiseDamaged(invincible=true) 만 낸다)로 흘려보낸다 → 피격 비네트(WLSurvivalUiSettings // `ignoreInvincibleHits=1`)·타격 반응(WLImpactTierSettings `pcHitSkipWhenInvincible=1`)이 // 스스로 걸러 준다 = **UI 무수정으로 연출 1회만**. [Header("보스 연타 대비 피격 후 무적 (Q10 D-21 · WL-813i2)")] [Tooltip("피해를 실제로 입은 직후 짧은 무적 창을 연다(보스 연타 완화). C8 롤백 = 0(813i 동작 100%).")] public bool hitInvincibleEnabled = true; [Tooltip("보스 피격으로 HP 가 실제로 깎인 직후 무적 창 길이(초 · 게임 시간). " + "0 이면 창 없음 = C8. 실측 보스 타격 간격 0.52 s 기준 0.6 이면 연타 5타 중 2~3타만 들어온다.")] public float bossHitInvincibleSeconds = 0.6f; [Tooltip("잡몹 피격에도 같은 창을 여는가. 실측상 잡몹은 한 방에 0.3~0.4 % 라 기본은 끈다(0) — " + "켜면 잡몹 밭에서도 무적이 상시로 켜져 난이도가 통째로 사라진다.")] public bool mobHitInvincible = false; [Tooltip("잡몹 피격 후 무적 창 길이(초 · mobHitInvincible 가 켜졌을 때만).")] public float mobHitInvincibleSeconds = 0.2f; [Tooltip("창을 여는 최소 실피해(HP). 이보다 적게 깎였으면 창을 열지 않는다(스침 타격으로 창이 열려 " + "보스 본타를 통째로 흘리는 것을 막는다). 0 이면 피해가 조금이라도 있으면 연다.")] public float hitInvincibleMinHpLoss = 1f; [Tooltip("창을 열 때 Console 에 1줄 남긴다(verboseLog 와 별개 · 창 1개당 1줄).")] public bool hitInvincibleLog = 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; // ─────────────────────────────────────────── 자동 물약 (Q5 D-21 · WL-813m2) // 실측 근거: Q5 결과 화면 「사망 / 물약 = 3 / 0」 — 자동전투(813x)로 도는 런에서는 플레이어가 // 물약 버튼(813j/813y)을 누르지 않아 런당 3개가 통째로 남은 채 3번 죽는다. [Header("자동 물약 (Q5 D-21 · 자동전투 중 HP 임계 이하 자동 사용 · WL-813m2)")] [Tooltip("자동전투 중 HP 가 임계 이하로 떨어지면 물약을 자동으로 쓴다. C8 롤백 = 0(코드 무수정 · 물약은 버튼으로만).")] public bool autoPotionEnabled = true; [Tooltip("자동 사용 임계(최대 HP 대비 비율). 0.35 = 35 % 이하. 회복량(potionHealRate)과 합이 1 을 넘지 않아야 회복이 낭비되지 않는다(0.35 + 0.4 = 0.75 → 낭비 0).")] [Range(0.05f, 0.9f)] public float autoPotionHpRatio = 0.35f; [Tooltip("자동전투(eCommon.Auto)일 때만 자동 사용한다. 끄면 수동 전투에서도 자동으로 쓴다(플레이어의 물약 타이밍을 뺏는다).")] public bool autoPotionRequireAutoCombat = true; [Tooltip("HP 를 확인하는 주기(초 · 게임 시간). 짧을수록 반응이 빠르고 폴링 비용이 는다. 0.02 미만은 0.02 로 잘린다.")] public float autoPotionCheckSeconds = 0.25f; [Tooltip("자동 사용 때 Console 에 1줄 남긴다(런당 최대 potionCountPerRun 회 · verboseLog 와 별개로 켤 수 있다).")] public bool autoPotionLog = true; // ─────────────────────────────────────────── 사망 원인 진단 · 배율 미적용 경로 가드 (Q9 D-21 · WL-813m3) // 실측 근거(2026-09-09 · 원본 읽기만): 813i 게이트는 PCActor.Get_Damage 한 곳뿐인데 // Actor.Co_DotDamage:1536(독/화상/출혈) · Check_CC_byAttack:1116(스턴 추가타) · // Check_Extra:1191·1197·1202·1222·1229 · Check_EnchantOption:1402·1412·1453 가 // **Get_Damage 를 거치지 않고 Cal_Damage 로 곧장 HP 를 깎는다**(배율 0 · 방어 공식 0 · PC 무적조차 무시). // → Q9 D-21 "배율을 0.1/0.15 로 내려도 사망이 안 줄었다" 와 일치한다. [Header("사망 원인 진단 · 배율 미적용 경로 가드 (Q9 D-21 · WL-813m3)")] [Tooltip("PC 피해를 링버퍼 16칸에 기록하고 사망마다 파일 1개 + 콘솔 1줄을 남긴다(verboseLog 와 무관). C8 롤백 = 0.")] public bool deathDiagEnabled = true; [Tooltip("사망 로그 폴더(프로젝트 루트 기준 · Assets 밖 · 미추적). 빌드에서는 persistentDataPath 아래.")] public string deathDiagDir = "Screenshots_WL/WL813m3"; [Tooltip("훅(PCActor.Get_Damage)을 거치지 않고 깎인 HP 를 매 프레임 정산해 같은 배율만큼 되돌린다(도트·추가타·인챈트 경로). 배율은 mobDamageMultiplier 를 재사용한다. C8 롤백 = 0.")] public bool unscaledDamageGuardEnabled = true; [Tooltip("가드가 배율 미적용 피해를 잡을 때마다 Console 에 1줄 남긴다(도트는 초당 1~2건이라 기본 off · 사망 파일에는 항상 남는다).")] public bool unscaledDamageGuardLog = false; // ─────────────────────────────────────────── 사망 · 부활 [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; // ─────────────────────────────────────────── 사망 중 영구 정지 방지 (Q4-a D-3 · WL-813z) // 실측 근거(2026-09-09 · Popup.cs 전문 54줄 · ErrorLogHookManager.cs:33) // 공용 Popup 은 노드 1개짜리 싱글턴이고 Set() 이 m_ok 를 **덮어쓴다**. // ErrorLogHookManager 가 Error/Exception 마다 Popup.Ins.Set(One, 174, null, null, log) 을 부르면 // 부활 팝업이 넘긴 확인 콜백(m_ok)이 null 로 지워진다 → 확인을 눌러도 부활이 일어나지 않고 // holdWorldWhileReviving 이 세운 timeScale=0 이 영영 안 풀린다(Q4-a 실측 2분 30초). // → 아래 워치독이 **마지막 방어선**이다. autoReviveTimeoutSeconds 는 정상 흐름의 응답 상한이고, // 이 값은 "무슨 일이 있어도 세계는 다시 흐른다"를 보장하는 안전장치라 별도 필드로 둔다. [Header("사망 중 영구 정지 방지 (Q4-a D-3 · WL-813z)")] [Tooltip("AwaitingRevive(부활 팝업 대기)가 이 시간(초 · unscaled)을 넘기면 자동 부활하고 세계 정지를 푼다. 0 = 워치독 없음(C8 롤백 = 0).")] public float reviveWatchdogSeconds = 20f; [Tooltip("켜면 이 SO 의 enabled·RuntimeOverride 가 꺼져도 워치독만은 계속 돈다(이미 세계를 세워 둔 상태에서 스위치가 꺼져 잠기는 것을 막는다).")] public bool reviveWatchdogIgnoresDisable = true; [Tooltip("워치독이 발동하면 Console 에 Log 1줄을 남긴다(Warning/Error 가 아니라 ErrorLogHookManager 모달을 부르지 않는다).")] public bool reviveWatchdogLog = true; // ─────────────────────────────────────────── 부활 팝업 최상단 보장 (UI · ReviveDialog 가 읽는다) // 🔴 UI 값이지만 이번 발주(WL-813z §2)가 허용한 UI 파일 목록에 WLSurvivalUiSettings.cs 가 없어 // 813q 와의 충돌 0 을 지키려고 사망/부활 소유 SO 인 여기에 둔다(Lead 판단으로 이관 가능). [Header("부활 팝업 최상단 보장 (D-3 · ReviveDialog 가 읽는다 · WL-813z)")] [Tooltip("끄면 최상단 보장·재보장·승격을 전부 하지 않는다(C8 롤백 = 0 · 813y 동작 100%).")] public bool reviveTopmostEnabled = true; [Tooltip("부활 팝업 전용 캔버스의 sortingOrder. 공용 팝업 프리팹 SortOrder_5 의 Canvas.m_SortingOrder = 5 (실측) 보다 커야 한다.")] public int reviveSortingOrder = 60; [Tooltip("표시 중 최상단·레이캐스트를 다시 보장하는 주기(초 · unscaled · 0 이면 표시 순간 1회만). GC 0 경로.")] public float reviveTopmostIntervalSeconds = 0.5f; [Tooltip("공용 Popup 이 다른 팝업(에러 모달 등)에 덮인 것을 감지하면 전용 폴백 팝업으로 승격해 확인 버튼을 되살린다.")] public bool reviveEscalateOnHijack = true; } }