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

222 lines
12 KiB
C#
Raw 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 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
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)
static float s_now; // 게임 시간(러너 Tick · 프로브는 직접 넣는다)
static float s_reviveInvincibleUntil = -1f;
static Actor s_invinciblePc;
/// <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)
{
if (pc == null || seconds <= 0f) { s_invinciblePc = null; s_reviveInvincibleUntil = -1f; return; }
s_invinciblePc = pc;
s_reviveInvincibleUntil = s_now + seconds;
}
/// <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++; baseCall(dinfo); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true))
var st = St;
bool boss = IsBossSource(dinfo, st);
if (boss ? !st.takeBossDamage : !st.takeMobDamage)
{
if (boss) SkippedBossSwitch++; else SkippedMobSwitch++;
baseCall(dinfo); // 스위치 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;
try { baseCall(dinfo); }
finally { if (lift) gs.playerInvincible = true; } // 디스크 값 무변경 · 예외가 나도 되돌린다
Applied++;
LastFinalDamage = dinfo.Damage; // Actor.Get_Damage 가 dinfo 를 제자리에서 갱신 → 방어 후 최종 피해
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)" : ""));
}
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);
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 = "";
}
}
/// <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();
}
void OnDisable() { DeathFlow.ReleaseWorld(); }
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
}
}