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

240 lines
18 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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;
// ─────────────────────────────────────────── 보스 연타 대비 피격 후 무적 (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;
}
}