// ───────────────────────────────────────────────────────────────────────────── // PotionUse.cs — 전투 물약(런당 N개 · 최대 HP 비율 회복 · 쿨다운) — 813j 물약 버튼이 부르는 사용 API + 잔량/쿨 이벤트 // // PD 지시 #813 · 발주서 WL-813i §1-2 · 기준서 v1 §B 요소 8 회복(3개/런 · 40 % · 쿨 5 s · Actor.Heal 사용) (2026-09-09) // // ■ 813j(UI) 가 쓰는 API // · 사용 요청: PotionUse.TryUse() → PotionResult (Used 면 회복됨 · 그 외 거절 사유) // · 상태 읽기: Remaining · Max · CooldownRemaining · CooldownTotal · IsReady (매 프레임 읽어도 GC 0) // · 이벤트: PotionUse.Changed(in PotionEvent) — 잔량/쿨 변화 때마다(런 리셋 · 사용 · 쿨 종료 · 거절) · PotionUse.Used — 사용 성공만 // · 런 리셋: PotionUse.ResetRun() (813m 런 시작이 부를 수 있다 · potionResetOnMapLoad 면 맵 진입 때 자동) // ■ 회복 = 원본 Actor.Heal(float rate, bool showhud) — 사망·만충이면 원본이 아무 것도 하지 않으므로 사용 전에 거절한다. // ■ 시간 = 게임 시간(Time.time · 러너 Tick) — 일시정지·부활 대기(timeScale 0) 중엔 쿨이 흐르지 않는다. // ■ 비활성(WLSurvivalSettings off)이면 TryUse 는 Disabled 를 돌려주고 이벤트 0 = 원본 100%(원본엔 전투 물약이 없다). // ───────────────────────────────────────────────────────────────────────────── using System; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Survival { public enum PotionResult { Used = 0, Disabled = 1, NoPC = 2, Dead = 3, Empty = 4, Cooldown = 5, FullHp = 6 } public enum PotionChange { RunReset = 0, Used = 1, CooldownEnded = 2, Refused = 3 } /// 물약 상태 스냅샷(struct · GC 0). Changed/Used 구독자가 받는다. public struct PotionEvent { public PCActor pc; public PotionChange change; public PotionResult result; // Refused 일 때 사유 · 그 외 Used public int remaining, max; public float cooldownRemaining, cooldownTotal; public double healed, hpAfter, maxHp; public float time; public int frame; } public static class PotionUse { /// 잔량/쿨 변화 통지(813j 버튼 갱신). 런 리셋 · 사용 · 쿨 종료 · 거절. public static readonly CombatEventList Changed = new CombatEventList(4); /// 사용 성공 통지(연출 · 로그). public static readonly CombatEventList Used = new CombatEventList(4); static int s_remaining = -1; // -1 = 런 미시작(첫 조회 때 SO 값으로 채운다) static float s_cooldownEnd = -1f; static float s_now; static bool s_cooldownNotified = true; // ── 진단(프로브가 읽는다) public static int UsedCount, RefusedCount, RunResets; public static PotionResult LastResult; public static double LastHealed; static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } /// 남은 개수(런 미시작이면 SO 값). public static int Remaining { get { EnsureRun(); return s_remaining; } } /// 런당 개수(SO). public static int Max { get { var st = St; return st != null ? st.potionCountPerRun : 0; } } /// 쿨 남은 시간(초 · 게임 시간). public static float CooldownRemaining { get { return Mathf.Max(0f, s_cooldownEnd - s_now); } } /// 쿨 전체 길이(SO · 813j 링 비율 = Remaining/Total). public static float CooldownTotal { get { var st = St; return st != null ? st.potionCooldownSeconds : 0f; } } /// 지금 누르면 쓸 수 있는가(활성 · 잔량 · 쿨). HP 만충·사망은 TryUse 가 거절한다. public static bool IsReady { get { return WLSurvivalSettings.Enabled && Remaining > 0 && CooldownRemaining <= 0f; } } static void EnsureRun() { if (s_remaining >= 0) return; var st = St; s_remaining = st != null ? Mathf.Max(0, st.potionCountPerRun) : 0; s_cooldownEnd = -1f; s_cooldownNotified = true; } /// 메인 PC(MyValue.MyPC)에 사용. public static PotionResult TryUse() { return TryUse(MyValue.MyPC); } /// 813j 물약 버튼이 부른다. Used 면 회복됨. 거절이어도 Changed(Refused · 사유) 를 내 UI 가 피드백할 수 있다. public static PotionResult TryUse(PCActor pc) { if (!WLSurvivalSettings.Enabled) return Finish(PotionResult.Disabled, pc, 0d, false); Survival.EnsureRunner(); EnsureRun(); if (pc == null) return Finish(PotionResult.NoPC, pc, 0d, true); if (pc.IsDead()) return Finish(PotionResult.Dead, pc, 0d, true); if (s_remaining <= 0) return Finish(PotionResult.Empty, pc, 0d, true); if (s_now < s_cooldownEnd) return Finish(PotionResult.Cooldown, pc, 0d, true); var st = St; double before = pc.Get_HP(), max = pc.Get_MaxHP(); if (st.potionRefuseAtFullHp && before >= max) return Finish(PotionResult.FullHp, pc, 0d, true); try { pc.Heal(st.potionHealRate, st.potionShowHealNumber); } // 원본 Heal(rate) · HUD 초록 숫자 catch (Exception ex) { Debug.LogException(ex); } // HUD 쪽 예외가 물약 상태를 깨지 않게 격리(회복 자체는 HP 갱신 뒤) double healed = pc.Get_HP() - before; s_remaining--; s_cooldownEnd = s_now + Mathf.Max(0f, st.potionCooldownSeconds); s_cooldownNotified = st.potionCooldownSeconds <= 0f; UsedCount++; LastHealed = healed; if (st.verboseLog) Debug.Log("[PotionUse] used +" + healed.ToString("F0") + " hp=" + pc.Get_HP().ToString("F0") + "/" + max.ToString("F0") + " remaining=" + s_remaining + "/" + st.potionCountPerRun + " cd=" + st.potionCooldownSeconds.ToString("F1")); return Finish(PotionResult.Used, pc, healed, true); } static PotionResult Finish(PotionResult r, PCActor pc, double healed, bool notify) { LastResult = r; if (r != PotionResult.Used) RefusedCount++; if (!notify) return r; var e = Snapshot(pc, r == PotionResult.Used ? PotionChange.Used : PotionChange.Refused, r, healed); Changed.Dispatch(in e); if (r == PotionResult.Used) Used.Dispatch(in e); return r; } static PotionEvent Snapshot(PCActor pc, PotionChange change, PotionResult r, double healed) { var st = St; return new PotionEvent { pc = pc, change = change, result = r, remaining = s_remaining < 0 ? 0 : s_remaining, max = st != null ? st.potionCountPerRun : 0, cooldownRemaining = CooldownRemaining, cooldownTotal = st != null ? st.potionCooldownSeconds : 0f, healed = healed, hpAfter = pc != null ? pc.Get_HP() : 0d, maxHp = pc != null ? pc.Get_MaxHP() : 0d, time = Time.unscaledTime, frame = Time.frameCount }; } /// 런 시작(맵 진입 · 813m) — 잔량을 SO 값으로 · 쿨 해제. 비활성이면 이벤트 0. public static void ResetRun(string reason = "") { var st = St; s_remaining = st != null ? Mathf.Max(0, st.potionCountPerRun) : 0; s_cooldownEnd = -1f; s_cooldownNotified = true; RunResets++; if (!WLSurvivalSettings.Enabled) return; if (st.verboseLog) Debug.Log("[PotionUse] run reset (" + reason + ") remaining=" + s_remaining); var e = Snapshot(MyValue.MyPC, PotionChange.RunReset, PotionResult.Used, 0d); Changed.Dispatch(in e); } /// 러너(또는 프로브)가 게임 시간을 넣는다. 쿨이 끝나는 프레임에 Changed(CooldownEnded) 1회. public static void Tick(float gameNow) { s_now = gameNow; if (s_cooldownNotified || s_now < s_cooldownEnd) return; s_cooldownNotified = true; if (!WLSurvivalSettings.Enabled) return; var e = Snapshot(MyValue.MyPC, PotionChange.CooldownEnded, PotionResult.Used, 0d); Changed.Dispatch(in e); } /// 프로브용: 상태·카운터 초기화(구독은 유지). public static void ResetDiagnostics() { s_remaining = -1; s_cooldownEnd = -1f; s_cooldownNotified = true; UsedCount = RefusedCount = RunResets = 0; LastResult = PotionResult.Used; LastHealed = 0d; Changed.Raised = Changed.Dispatched = 0; Used.Raised = Used.Dispatched = 0; } } }