221 lines
12 KiB
C#
221 lines
12 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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(게임 시간) · 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);
|
||
DeathFlow.Tick(Time.unscaledTime);
|
||
Survival.PollRunBoundary();
|
||
}
|
||
|
||
void OnDisable() { DeathFlow.ReleaseWorld(); }
|
||
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
|
||
}
|
||
}
|