716 lines
36 KiB
C#
716 lines
36 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// RunDirector.cs — 3분 런 상태 머신 · 집계 · "다음 런" 재시작
|
||
//
|
||
// PD 지시 #813 · 발주서 WL-813p §1 (2026-09-09)
|
||
// 규격 = 기준서 §D-2 813p 행(3분 런 · 존 4~5 → 보스 → 결과 · 런 상태 이벤트) + §B 요소 1·5·6 + Q3 6-3
|
||
//
|
||
// ■ 런의 정의 (발주서 §1 · Lead 설계 결정)
|
||
// 런 = **현행 필드 맵(WL_Nature) 위의 오버레이**다. 새 던전 타입·DungeonInfo·BattleMapConfig·
|
||
// DungeonMapConfig 는 건드리지 않는다(핫스팟 · 바퀴 4 이월).
|
||
// 진입(시작점) → 존 813001~813004 진행 → 보스 게이트 → Anubis 처치 = 승리
|
||
// 런 타이머 runSeconds(SO · 180) 초과 = 시간 종료(보스 미처치)
|
||
// 사망은 런을 끝내지 않고 카운트만 한다(813i 부활 유지)
|
||
// 로비 복귀·맵 이동 = RunEnded(Abandon) 1회 후 정지
|
||
//
|
||
// ■ 진입 경계 (원본 훅 0 · 폴링)
|
||
// 813x `AutoCombatOnEnter` 와 **같은 조건**을 본다 — 메인 PC 교체 또는 MyValue.MyChoiceMapData.n_MapID 변경.
|
||
// 조건을 복제하지 않으려고 그 파일의 원천 읽기를 `AutoCombatOnEnter.ReadBoundarySources`(internal · 5줄)로
|
||
// 노출해 여기서 부른다. 판정·소비는 전부 이 파일이 한다(그 파일의 동작 변경 0).
|
||
//
|
||
// ■ 존 진행 실측 소스 (발주서 §1)
|
||
// 813d `BossArena.KillsOf(spawnerId)` 의 **누적값**. BossArena 값은 절대 리셋하지 않고,
|
||
// 런 시작 시점 값을 스냅샷(s_zoneBase)해 **차감**한다. 그래서 같은 맵 세션에서 런을 몇 번 돌려도
|
||
// 813d 의 게이트 판정(누적 처치 ≥ requiredKills)이 망가지지 않는다.
|
||
// BossArena 가 무장돼 있지 않을 때(게이트 off · FieldBossData 없음)를 대비해 이 파일도 Killed 를
|
||
// 스포너별로 직접 세고 **둘 중 큰 값**을 진행률로 쓴다(폴백 · 813d 무수정).
|
||
//
|
||
// ■ 집계 소스 (전부 기존 이벤트 구독 · 원본 훅 0)
|
||
// kills·bossKills CombatEvents.Killed(subRole == Boss)
|
||
// skillCasts CombatEvents.SkillCast(actor.IsMainPC())
|
||
// maxDamage CombatEvents.HitConfirmed(byMainPC) · DamageAggregator.Flushed(maxSingle)
|
||
// maxDamageBurst DamageAggregator.Flushed(total · 813e 합산 데미지)
|
||
// maxChain KillChain.TierReached / KillChain.Ended (813e)
|
||
// levelFrom/To GrowthEvents.LevelUp (813k)
|
||
// lootByGrade·gold LootBurst.Picked (813f · grade 는 LootBurst.ResolveGrade 가 이미 확정)
|
||
// deaths DeathFlow.Died (813i)
|
||
// potionsUsed PotionUse.Used (813i)
|
||
//
|
||
// ■ GC 0
|
||
// 틱 경로에서 할당하지 않는다 — 페이로드는 struct(`in` 전달), 결과 배열 3종은 런 사이에 재사용,
|
||
// 로그 문자열은 **만들기 전에** verboseLog 로 막는다(811b FIX-3 규약).
|
||
//
|
||
// ■ C8 롤백
|
||
// WLRunSettings.Enabled(에셋 없음 / enabled=0 / RuntimeDisabled / 코어 off)가 false 면
|
||
// 러너가 뜨지 않고 Tick 이 즉시 반환한다 = 런 구조 이전 100%.
|
||
//
|
||
// 🔴 UI 코드·프리팹은 이 발주 범위 밖(813q 몫). 이 파일은 RunEvents 발행까지만 한다.
|
||
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다.
|
||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using UnityEngine;
|
||
using WL.Combat.Auto;
|
||
using WL.Combat.Boss;
|
||
using WL.Combat.Core;
|
||
using WL.Combat.Growth;
|
||
using WL.Combat.Loot;
|
||
using WL.Combat.Reaction;
|
||
using WL.Combat.Survival;
|
||
|
||
namespace WL.Combat.Run
|
||
{
|
||
/// <summary>3분 런 상태 머신. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
|
||
public static class RunDirector
|
||
{
|
||
// ───────────────────────────────────────── 상태
|
||
static RunPhase s_phase = RunPhase.Idle;
|
||
static int s_runIndex;
|
||
static float s_startTime, s_pausedTotal, s_pauseStart = -1f;
|
||
static float s_nextPoll, s_nextTick;
|
||
static bool s_subscribed, s_faulted, s_gateOpened, s_bossStarted;
|
||
static RunGateReason s_gateReason;
|
||
|
||
// ── 존 진행(길이 = s_zoneCount · 배열은 재사용)
|
||
static int[] s_zoneIds, s_zoneNeed, s_zoneBase, s_zoneOwn, s_zoneKills;
|
||
static float[] s_zoneClearSec;
|
||
static int s_zoneCount, s_zonesCleared;
|
||
|
||
// ── 집계
|
||
static int s_kills, s_bossKills, s_maxChain, s_deaths, s_potions, s_skillCasts, s_lootTotal;
|
||
static double s_maxDamage, s_maxDamageBurst;
|
||
static long s_gold;
|
||
static int s_levelFrom, s_levelTo;
|
||
static int[] s_lootByGrade;
|
||
static float s_bossGateSec = -1f, s_bossStartSec = -1f, s_bossClearSec = -1f;
|
||
static Actor s_boss;
|
||
|
||
// ── 진입 경계(AutoCombatOnEnter 와 같은 조건 · 판정은 여기서)
|
||
static PCActor s_lastPc;
|
||
static int s_lastMapId = int.MinValue;
|
||
|
||
// ───────────────────────────────────────── 진단(프로브·QA 가 읽는다)
|
||
/// <summary>현재 단계.</summary>
|
||
public static RunPhase Phase { get { return s_phase; } }
|
||
/// <summary>이 세션에서 몇 번째 런인가(1부터 · 아직 없으면 0).</summary>
|
||
public static int RunIndex { get { return s_runIndex; } }
|
||
/// <summary>런이 진행 중인가(Zones · BossGate · Boss).</summary>
|
||
public static bool IsRunning { get { return s_phase == RunPhase.Zones || s_phase == RunPhase.BossGate || s_phase == RunPhase.Boss; } }
|
||
/// <summary>구독 여부 — 집계가 실제로 이벤트에 붙었는지 확인용.</summary>
|
||
public static bool Subscribed { get { return s_subscribed; } }
|
||
/// <summary>보스 게이트가 열렸는가 · 보스가 등장했는가.</summary>
|
||
public static bool GateOpened { get { return s_gateOpened; } }
|
||
public static bool BossStarted { get { return s_bossStarted; } }
|
||
/// <summary>존 수 · 클리어한 존 수.</summary>
|
||
public static int ZoneCount { get { return s_zoneCount; } }
|
||
public static int ZonesCleared { get { return s_zonesCleared; } }
|
||
/// <summary>마지막으로 발행한 RunResult(결과 화면이 늦게 떠도 읽을 수 있게 남긴다).</summary>
|
||
public static RunResult LastResult;
|
||
/// <summary>진단 카운터.</summary>
|
||
public static int StartCount, EndCount, RestartCount, TickCount, BoundaryCount;
|
||
public static string LastLog = "";
|
||
|
||
static WLRunSettings Cfg { get { return WLRunSettings.Instance; } }
|
||
static bool Verbose { get { var c = Cfg; return c != null && c.verboseLog; } }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 시계
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>런 시작 후 경과 초(세계 정지 시간 제외 옵션 반영). 런이 없으면 0.</summary>
|
||
public static float Elapsed
|
||
{
|
||
get
|
||
{
|
||
if (s_phase == RunPhase.Idle) return 0f;
|
||
float now = Time.unscaledTime;
|
||
float paused = s_pausedTotal + (s_pauseStart >= 0f ? now - s_pauseStart : 0f);
|
||
float e = now - s_startTime - paused;
|
||
return e > 0f ? e : 0f;
|
||
}
|
||
}
|
||
|
||
/// <summary>남은 초(0 미만으로 내려가지 않는다).</summary>
|
||
public static float Remaining
|
||
{
|
||
get
|
||
{
|
||
var c = Cfg;
|
||
float r = (c != null ? c.runSeconds : 0f) - Elapsed;
|
||
return r > 0f ? r : 0f;
|
||
}
|
||
}
|
||
|
||
/// <summary>외부 타이머 정지(813o 픽업 카드) — 세계 정지 없이 런 타이머만 멈춘다.</summary>
|
||
public static bool ExternalTimerPause { get { return s_extPause; } }
|
||
static bool s_extPause;
|
||
public static void PauseTimer(bool pause)
|
||
{
|
||
if (s_extPause == pause) return;
|
||
s_extPause = pause; var c = Cfg; if (c != null) PollPause(c, Time.unscaledTime);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 구독
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>집계 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
|
||
public static void EnsureSubscribed()
|
||
{
|
||
if (s_subscribed) return;
|
||
CombatEvents.Killed.Add(OnKilled);
|
||
CombatEvents.Spawned.Add(OnSpawned);
|
||
CombatEvents.SkillCast.Add(OnSkillCast);
|
||
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
|
||
DamageAggregator.Flushed.Add(OnDamageMerged);
|
||
KillChain.TierReached.Add(OnChainTier);
|
||
KillChain.Ended.Add(OnChainEnded);
|
||
GrowthEvents.LevelUp.Add(OnLevelUp);
|
||
LootBurst.Picked.Add(OnLootPicked);
|
||
DeathFlow.Died.Add(OnDied);
|
||
PotionUse.Used.Add(OnPotionUsed);
|
||
s_subscribed = true;
|
||
}
|
||
|
||
/// <summary>구독 해제(프로브·롤백용).</summary>
|
||
public static void Unsubscribe()
|
||
{
|
||
if (!s_subscribed) return;
|
||
CombatEvents.Killed.Remove(OnKilled);
|
||
CombatEvents.Spawned.Remove(OnSpawned);
|
||
CombatEvents.SkillCast.Remove(OnSkillCast);
|
||
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
|
||
DamageAggregator.Flushed.Remove(OnDamageMerged);
|
||
KillChain.TierReached.Remove(OnChainTier);
|
||
KillChain.Ended.Remove(OnChainEnded);
|
||
GrowthEvents.LevelUp.Remove(OnLevelUp);
|
||
LootBurst.Picked.Remove(OnLootPicked);
|
||
DeathFlow.Died.Remove(OnDied);
|
||
PotionUse.Used.Remove(OnPotionUsed);
|
||
s_subscribed = false;
|
||
}
|
||
|
||
// ── 핸들러: 이벤트 → Note* 이음매 하나로 모은다(프로브가 같은 경로를 직접 부른다)
|
||
static void OnKilled(in KilledEvent e)
|
||
{
|
||
if (!IsRunning) return;
|
||
NoteKill(DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group(), e.subRole == eSubRol.Boss);
|
||
}
|
||
|
||
static void OnSpawned(in SpawnedEvent e)
|
||
{
|
||
if (!IsRunning || !e.isBoss) return;
|
||
int sid = DSUtil.CheckNull(e.actor) ? 0 : e.actor.Get_Group();
|
||
if (IsBossSpawner(sid)) NoteBossSpawned(sid, e.actor);
|
||
}
|
||
|
||
static void OnSkillCast(in SkillCastEvent e)
|
||
{
|
||
if (!IsRunning || DSUtil.CheckNull(e.actor) || !e.actor.IsMainPC()) return;
|
||
NoteSkillCast();
|
||
}
|
||
|
||
static void OnHitConfirmed(in HitConfirmedEvent e)
|
||
{
|
||
if (!IsRunning || !e.byMainPC) return;
|
||
NoteDamage(e.damage, 0d);
|
||
}
|
||
|
||
static void OnDamageMerged(in DamageMergeEvent e)
|
||
{
|
||
if (!IsRunning || !e.byMainPC) return;
|
||
NoteDamage(e.maxSingle, e.total);
|
||
}
|
||
|
||
static void OnChainTier(in KillChainTierEvent e) { if (IsRunning) NoteChain(e.kills); }
|
||
static void OnChainEnded(in KillChainEndedEvent e) { if (IsRunning) NoteChain(e.kills); }
|
||
static void OnLevelUp(in PlayerLevelUpEvent e) { if (IsRunning) NoteLevel(e.prevLevel, e.newLevel); }
|
||
static void OnLootPicked(in LootPickedEvent e) { if (IsRunning) NoteLoot(e.grade, e.amount, e.isGold); }
|
||
static void OnDied(in DeathEvent e) { if (IsRunning) NoteDeath(); }
|
||
static void OnPotionUsed(in PotionEvent e) { if (IsRunning) NotePotion(); }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 집계 이음매 (프로브가 가짜 값을 넣을 때도 이 경로를 쓴다 — 813d NoteKill 선례)
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>처치 1건 반영 + 존 진행·승리 재판정.</summary>
|
||
public static void NoteKill(int spawnerId, bool isBoss)
|
||
{
|
||
if (!IsRunning) return;
|
||
s_kills++;
|
||
|
||
for (int i = 0; i < s_zoneCount; i++)
|
||
if (s_zoneIds[i] == spawnerId) { s_zoneOwn[i]++; break; }
|
||
|
||
if (isBoss && IsBossSpawner(spawnerId))
|
||
{
|
||
s_bossKills++;
|
||
s_bossClearSec = Elapsed;
|
||
EndRun(RunOutcome.Win);
|
||
return;
|
||
}
|
||
EvaluateZones();
|
||
}
|
||
|
||
/// <summary>보스가 월드에 등장했다(런당 1회 통지).</summary>
|
||
public static void NoteBossSpawned(int spawnerId, Actor boss)
|
||
{
|
||
if (!IsRunning || s_bossStarted) return;
|
||
if (BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) return; // #813w5 D-39 — 런 초반 조기 보스 개시 차단(다음 틱에 재시도)
|
||
s_boss = boss;
|
||
s_bossStarted = true;
|
||
s_bossStartSec = Elapsed;
|
||
if (!s_gateOpened) OpenBossGate(RunGateReason.AlreadySpawned);
|
||
s_phase = RunPhase.Boss;
|
||
|
||
var e = new BossStartedEvent
|
||
{
|
||
elapsedSec = s_bossStartSec, spawnerId = spawnerId, boss = boss,
|
||
time = Time.unscaledTime, frame = Time.frameCount
|
||
};
|
||
RunEvents.Stamp("BossStarted");
|
||
RunEvents.BossStarted.Dispatch(in e);
|
||
if (Verbose) Log("보스 등장 — " + s_bossStartSec.ToString("F1") + "s spawnerId=" + spawnerId);
|
||
}
|
||
|
||
/// <summary>연쇄 처치 수 갱신(최대만 남긴다).</summary>
|
||
public static void NoteChain(int chainKills) { if (IsRunning && chainKills > s_maxChain) s_maxChain = chainKills; }
|
||
|
||
/// <summary>피해 갱신 — single = 단일 타격 · burst = 합산(0 이면 무시).</summary>
|
||
public static void NoteDamage(double single, double burst)
|
||
{
|
||
if (!IsRunning) return;
|
||
if (single > s_maxDamage) s_maxDamage = single;
|
||
if (burst > s_maxDamageBurst) s_maxDamageBurst = burst;
|
||
}
|
||
|
||
/// <summary>레벨업 반영(levelFrom 은 런 중 첫 레벨업의 이전 레벨로 보정한다).</summary>
|
||
public static void NoteLevel(int prevLevel, int newLevel)
|
||
{
|
||
if (!IsRunning) return;
|
||
if (s_levelFrom <= 0 || prevLevel < s_levelFrom) s_levelFrom = prevLevel;
|
||
if (newLevel > s_levelTo) s_levelTo = newLevel;
|
||
}
|
||
|
||
/// <summary>획득 1건 반영. grade 는 LootBurst.ResolveGrade 결과(1~GradeSlots).</summary>
|
||
public static void NoteLoot(int grade, int amount, bool isGold)
|
||
{
|
||
if (!IsRunning) return;
|
||
if (isGold) { s_gold += amount; return; }
|
||
s_lootTotal += amount;
|
||
if (grade >= 0 && grade < s_lootByGrade.Length) s_lootByGrade[grade] += amount;
|
||
}
|
||
|
||
/// <summary>사망 1건(런을 끝내지 않는다 · 813i 부활 유지).</summary>
|
||
public static void NoteDeath() { if (IsRunning) s_deaths++; }
|
||
/// <summary>물약 1개 사용.</summary>
|
||
public static void NotePotion() { if (IsRunning) s_potions++; }
|
||
/// <summary>메인 PC 스킬 시전 1회.</summary>
|
||
public static void NoteSkillCast() { if (IsRunning) s_skillCasts++; }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 존 진행 · 게이트
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
static bool IsBossSpawner(int spawnerId)
|
||
{
|
||
var c = Cfg;
|
||
return c != null && (c.bossSpawnerId == 0 || spawnerId == c.bossSpawnerId);
|
||
}
|
||
|
||
/// <summary>존 i 의 진행률 — BossArena 누적값 − 런 시작 스냅샷(무장 시) 과 자체 카운터 중 큰 값.</summary>
|
||
public static int ZoneProgress(int i)
|
||
{
|
||
if (i < 0 || i >= s_zoneCount) return 0;
|
||
int own = s_zoneOwn[i];
|
||
if (!BossArena.GateArmed) return own;
|
||
int arena = BossArena.KillsOf(s_zoneIds[i]) - s_zoneBase[i];
|
||
return arena > own ? arena : own;
|
||
}
|
||
|
||
/// <summary>존 i 의 클리어 임계(SO).</summary>
|
||
public static int ZoneRequired(int i) { return i >= 0 && i < s_zoneCount ? s_zoneNeed[i] : 0; }
|
||
|
||
/// <summary>아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount).</summary>
|
||
public static int CurrentZone
|
||
{
|
||
get
|
||
{
|
||
for (int i = 0; i < s_zoneCount; i++) if (s_zoneClearSec[i] < 0f) return i;
|
||
return s_zoneCount;
|
||
}
|
||
}
|
||
|
||
static void EvaluateZones()
|
||
{
|
||
for (int i = 0; i < s_zoneCount; i++)
|
||
{
|
||
if (s_zoneClearSec[i] >= 0f) continue;
|
||
int kills = ZoneProgress(i);
|
||
s_zoneKills[i] = kills;
|
||
if (s_zoneNeed[i] > 0 && kills < s_zoneNeed[i]) continue;
|
||
|
||
s_zoneClearSec[i] = Elapsed;
|
||
s_zonesCleared++;
|
||
bool all = s_zonesCleared >= s_zoneCount;
|
||
|
||
var e = new ZoneClearedEvent
|
||
{
|
||
zoneIndex = i, spawnerId = s_zoneIds[i], elapsedSec = s_zoneClearSec[i],
|
||
kills = kills, required = s_zoneNeed[i], allZonesCleared = all,
|
||
time = Time.unscaledTime, frame = Time.frameCount
|
||
};
|
||
RunEvents.Stamp("ZoneCleared");
|
||
RunEvents.ZoneCleared.Dispatch(in e);
|
||
if (Verbose) Log("존 클리어 #" + i + " id=" + s_zoneIds[i] + " " + kills + "/" + s_zoneNeed[i] +
|
||
" @" + s_zoneClearSec[i].ToString("F1") + "s all=" + all);
|
||
}
|
||
|
||
var c = Cfg;
|
||
if (!s_gateOpened && c != null && c.openGateOnZonesCleared && s_zonesCleared >= s_zoneCount && s_zoneCount > 0)
|
||
OpenBossGate(RunGateReason.ZonesCleared);
|
||
}
|
||
|
||
/// <summary>보스 게이트 개방(런당 1회). 813d BossArena.ForceOpenGate() 를 부르고 통지한다.</summary>
|
||
public static void OpenBossGate(RunGateReason reason)
|
||
{
|
||
if (!IsRunning || s_gateOpened) return;
|
||
if (BossArena.GateHeldByMinSeconds(reason)) return; // #813w5 D-39 — gateMinSeconds 전 조기 개방 차단
|
||
var c = Cfg;
|
||
s_gateOpened = true;
|
||
s_gateReason = reason;
|
||
s_bossGateSec = Elapsed;
|
||
bool armed = BossArena.GateArmed;
|
||
if (armed && !BossArena.GateOpen) BossArena.ForceOpenGate();
|
||
if (s_phase == RunPhase.Zones) s_phase = RunPhase.BossGate;
|
||
|
||
var e = new BossGateOpenedEvent
|
||
{
|
||
elapsedSec = s_bossGateSec, reason = reason,
|
||
bossSpawnerId = c != null ? c.bossSpawnerId : 0, arenaGateArmed = armed,
|
||
time = Time.unscaledTime, frame = Time.frameCount
|
||
};
|
||
RunEvents.Stamp("BossGateOpened");
|
||
RunEvents.BossGateOpened.Dispatch(in e);
|
||
if (Verbose) Log("보스 게이트 개방 — " + reason + " @" + s_bossGateSec.ToString("F1") + "s armed=" + armed);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 런 시작 · 종료 · 재시작
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>런 시작(진행 중이면 먼저 Abandon 으로 닫는다).</summary>
|
||
public static void StartRun(string reason)
|
||
{
|
||
var c = Cfg;
|
||
if (c == null || !WLRunSettings.Enabled) return;
|
||
if (IsRunning) EndRun(RunOutcome.Abandon);
|
||
|
||
EnsureSubscribed();
|
||
EnsureZoneArrays(c);
|
||
|
||
s_runIndex++;
|
||
StartCount++;
|
||
s_startTime = Time.unscaledTime;
|
||
s_pausedTotal = 0f; s_pauseStart = -1f;
|
||
s_nextTick = s_startTime + (c.tickIntervalSeconds > 0f ? c.tickIntervalSeconds : 0f); // 시작 틱은 아래에서 직접 1회
|
||
s_phase = RunPhase.Zones;
|
||
s_gateOpened = s_bossStarted = false;
|
||
s_gateReason = RunGateReason.None;
|
||
s_bossGateSec = s_bossStartSec = s_bossClearSec = -1f;
|
||
s_boss = null;
|
||
s_zonesCleared = 0;
|
||
s_kills = s_bossKills = s_maxChain = s_deaths = s_potions = s_skillCasts = s_lootTotal = 0;
|
||
s_maxDamage = s_maxDamageBurst = 0d;
|
||
s_gold = 0L;
|
||
s_levelFrom = s_levelTo = CurrentLevel();
|
||
|
||
for (int i = 0; i < s_zoneCount; i++)
|
||
{
|
||
s_zoneBase[i] = BossArena.KillsOf(s_zoneIds[i]); // 🔴 스냅샷 차감 — BossArena 값은 리셋하지 않는다
|
||
s_zoneOwn[i] = 0;
|
||
s_zoneKills[i] = 0;
|
||
s_zoneClearSec[i] = -1f;
|
||
}
|
||
for (int i = 0; i < s_lootByGrade.Length; i++) s_lootByGrade[i] = 0;
|
||
|
||
var e = new RunStartedEvent
|
||
{
|
||
runIndex = s_runIndex, startTime = s_startTime, runSeconds = c.runSeconds,
|
||
zoneCount = s_zoneCount, reason = reason,
|
||
time = s_startTime, frame = Time.frameCount
|
||
};
|
||
RunEvents.Stamp("RunStarted");
|
||
RunEvents.RunStarted.Dispatch(in e);
|
||
if (Verbose) Log("런 #" + s_runIndex + " 시작(" + reason + ") — " + c.runSeconds.ToString("F0") +
|
||
"s · 존 " + s_zoneCount + " · 존 스냅샷 차감");
|
||
RaiseTick();
|
||
}
|
||
|
||
/// <summary>런 종료 1회(RunEnded 발행). 이미 끝났거나 시작 전이면 아무 일도 하지 않는다.</summary>
|
||
public static void EndRun(RunOutcome outcome)
|
||
{
|
||
if (!IsRunning) return;
|
||
float total = Elapsed;
|
||
s_phase = RunPhase.Ended;
|
||
EndCount++;
|
||
|
||
for (int i = 0; i < s_zoneCount; i++) s_zoneKills[i] = ZoneProgress(i);
|
||
|
||
// 🔴 #813w6 D-44 — 보스 HP 는 BossArena.ParkBoss(만피 복구) **이전**에 뜬다(스냅샷 → 발행 → Park).
|
||
float bossHpRatio; double bossHp, bossHpMax;
|
||
BossArena.SnapshotBossHp(out bossHpRatio, out bossHp, out bossHpMax);
|
||
|
||
var c = Cfg;
|
||
LastResult = new RunResult
|
||
{
|
||
outcome = outcome,
|
||
runIndex = s_runIndex,
|
||
totalSec = total,
|
||
runSeconds = c != null ? c.runSeconds : 0f,
|
||
zoneClearSec = s_zoneClearSec,
|
||
zoneKills = s_zoneKills,
|
||
zoneCount = s_zoneCount,
|
||
zonesCleared = s_zonesCleared,
|
||
bossGateSec = s_bossGateSec,
|
||
bossStartSec = s_bossStartSec,
|
||
bossClearSec = s_bossClearSec,
|
||
bossHpRatio = bossHpRatio,
|
||
bossHp = bossHp,
|
||
bossHpMax = bossHpMax,
|
||
kills = s_kills,
|
||
bossKills = s_bossKills,
|
||
maxChain = s_maxChain,
|
||
maxDamage = s_maxDamage,
|
||
maxDamageBurst = s_maxDamageBurst,
|
||
levelFrom = s_levelFrom,
|
||
levelTo = s_levelTo,
|
||
lootByGrade = s_lootByGrade,
|
||
lootTotal = s_lootTotal,
|
||
gold = s_gold,
|
||
deaths = s_deaths,
|
||
potionsUsed = s_potions,
|
||
skillCasts = s_skillCasts,
|
||
time = Time.unscaledTime,
|
||
frame = Time.frameCount,
|
||
};
|
||
RunEvents.Stamp("RunEnded");
|
||
RunEvents.RunEnded.Dispatch(in LastResult);
|
||
if (Verbose) Log("런 #" + s_runIndex + " 종료 — " + outcome + " " + total.ToString("F1") + "s kills=" + s_kills +
|
||
" boss=" + s_bossKills + " chain=" + s_maxChain + " deaths=" + s_deaths);
|
||
}
|
||
|
||
/// <summary>
|
||
/// "다음 런" — 집계 리셋 · runIndex+1 · PC 를 존 시작점으로 · 보스 게이트 재무장.
|
||
/// 813q 결과 화면의 [다시 하기] 버튼이 부르는 유일한 공개 API.
|
||
/// </summary>
|
||
public static void Restart()
|
||
{
|
||
var c = Cfg;
|
||
if (c == null || !WLRunSettings.Enabled) return;
|
||
RestartCount++;
|
||
|
||
if (IsRunning) EndRun(RunOutcome.Abandon);
|
||
|
||
try
|
||
{
|
||
if (c.restartWarpsPlayer)
|
||
{
|
||
var pc = MyValue.MyPC;
|
||
if (!DSUtil.CheckNull(pc))
|
||
{
|
||
DeathFlow.Abort("run restart"); // 부활 팝업이 떠 있으면 세계 정지부터 푼다
|
||
pc.On_Regen(false, c.restartHealRate); // MyActor.On_Regen = 전회복 + Set_Warp(Get_ReincanationPos)
|
||
}
|
||
}
|
||
if (c.restartRearmsBossGate) BossArena.ResetGate(); // 존 누적 카운터는 건드리지 않는다
|
||
BossArena.RearmZoneSpawns("다음 런"); // 🔴 #813w7 D-45 — 굳은 존 스포너를 1웨이브 되살린다(SO 로 끈다)
|
||
if (c.restartResetsPotions) PotionUse.ResetRun("run restart");
|
||
}
|
||
catch (System.Exception ex) { Log("재시작 일부 실패 — " + ex.Message); }
|
||
|
||
StartRun("restart");
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 틱
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
|
||
internal static void Tick()
|
||
{
|
||
if (s_faulted || !WLRunSettings.Enabled) return;
|
||
var c = Cfg;
|
||
float now = Time.unscaledTime;
|
||
if (now < s_nextPoll) return;
|
||
s_nextPoll = now + Mathf.Max(0.02f, c.pollSeconds);
|
||
|
||
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다.
|
||
try
|
||
{
|
||
PollBoundary(c);
|
||
if (!IsRunning) return;
|
||
PollPause(c, now);
|
||
EvaluateZones();
|
||
|
||
float el = Elapsed;
|
||
if (!s_gateOpened && c.openGateAtSec > 0f && el >= c.openGateAtSec) OpenBossGate(RunGateReason.TimeLimit);
|
||
if (!s_bossStarted && s_gateOpened && BossArena.BossSpawned)
|
||
NoteBossSpawned(c.bossSpawnerId, BossArena.Boss); // 폴백 — Spawned 를 놓쳤을 때(구독 이전 등장 등)
|
||
|
||
if (c.runSeconds > 0f && el >= c.runSeconds) { EndRun(RunOutcome.Timeout); return; }
|
||
|
||
float step = c.tickIntervalSeconds;
|
||
if (now >= s_nextTick) { s_nextTick = now + (step > 0f ? step : 0f); RaiseTick(); }
|
||
}
|
||
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
|
||
}
|
||
|
||
static void PollPause(WLRunSettings c, float now)
|
||
{
|
||
bool held = (c.pauseTimerWhileWorldHeld && DeathFlow.WorldHeld) || s_extPause; // 813o: 외부 정지도 같은 층
|
||
if (held && s_pauseStart < 0f) s_pauseStart = now;
|
||
else if (!held && s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; }
|
||
}
|
||
|
||
/// <summary>진입 경계 — 813x AutoCombatOnEnter 와 같은 조건(원천 읽기는 그 파일 · 판정은 여기).</summary>
|
||
static void PollBoundary(WLRunSettings c)
|
||
{
|
||
PCActor pc; int map; bool lobby;
|
||
if (!AutoCombatOnEnter.ReadBoundarySources(out pc, out map, out lobby)) return; // InGameInfo 없음 = 경계를 소비하지 않는다
|
||
|
||
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
|
||
bool mapChanged = map != s_lastMapId;
|
||
if (!pcChanged && !mapChanged) return;
|
||
|
||
s_lastPc = pc; s_lastMapId = map;
|
||
BoundaryCount++;
|
||
|
||
if (pc == null || (c.skipLobby && lobby))
|
||
{
|
||
if (c.endOnMapLeave && IsRunning) EndRun(RunOutcome.Abandon);
|
||
if (Verbose) Log("경계 — " + (pc == null ? "PC 없음" : "로비") + " map=" + map);
|
||
return;
|
||
}
|
||
|
||
if (c.endOnMapLeave && IsRunning) EndRun(RunOutcome.Abandon);
|
||
if (c.autoStartOnMapEnter) StartRun(pcChanged ? "pc" : ("map " + map));
|
||
}
|
||
|
||
static void RaiseTick()
|
||
{
|
||
int zi = CurrentZone;
|
||
var e = new RunTickEvent
|
||
{
|
||
elapsedSec = Elapsed, remainingSec = Remaining,
|
||
zoneIndex = zi,
|
||
zoneKills = zi < s_zoneCount ? ZoneProgress(zi) : 0,
|
||
zoneRequired = zi < s_zoneCount ? s_zoneNeed[zi] : 0,
|
||
zonesCleared = s_zonesCleared, zoneCount = s_zoneCount,
|
||
phase = s_phase, runIndex = s_runIndex,
|
||
time = Time.unscaledTime, frame = Time.frameCount
|
||
};
|
||
TickCount++;
|
||
RunEvents.Stamp("RunTick");
|
||
RunEvents.RunTick.Dispatch(in e);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 준비 · 진단
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>존 배열을 SO 길이에 맞춘다(길이가 같으면 재할당 0 = GC 0).</summary>
|
||
static void EnsureZoneArrays(WLRunSettings c)
|
||
{
|
||
int ids = c.zoneSpawnerIds != null ? c.zoneSpawnerIds.Length : 0;
|
||
int need = c.zoneClearKills != null ? c.zoneClearKills.Length : 0;
|
||
int n = ids < need ? ids : need; // 짧은 쪽 기준(SO 오설정 방어)
|
||
if (s_zoneIds == null || s_zoneIds.Length != n)
|
||
{
|
||
s_zoneIds = new int[n]; s_zoneNeed = new int[n]; s_zoneBase = new int[n];
|
||
s_zoneOwn = new int[n]; s_zoneKills = new int[n]; s_zoneClearSec = new float[n];
|
||
}
|
||
for (int i = 0; i < n; i++) { s_zoneIds[i] = c.zoneSpawnerIds[i]; s_zoneNeed[i] = c.zoneClearKills[i]; }
|
||
s_zoneCount = n;
|
||
|
||
int slots = WLLootSettings.GradeSlots + 1; // 인덱스 = 등급(1~GradeSlots) · [0] 미사용
|
||
if (s_lootByGrade == null || s_lootByGrade.Length != slots) s_lootByGrade = new int[slots];
|
||
}
|
||
|
||
/// <summary>런 시작 시점 레벨(플레이 모드에서만 · 없으면 0).</summary>
|
||
static int CurrentLevel()
|
||
{
|
||
// 🔴 ServerInfo.Ins 는 없으면 GameObject 를 만든다(ServerInfo.cs:19) — 에디트 모드를 오염시키지 않는다.
|
||
if (!Application.isPlaying) return 0;
|
||
var si = ServerInfo.Ins;
|
||
if (DSUtil.CheckNull(si) || si.m_ServerData == null || si.m_ServerData.PC == null) return 0;
|
||
return si.m_ServerData.PC.Lv;
|
||
}
|
||
|
||
static void Log(string msg)
|
||
{
|
||
LastLog = msg;
|
||
var c = Cfg;
|
||
if (c != null && c.verboseLog) Debug.Log("[RunDirector] " + msg);
|
||
}
|
||
|
||
/// <summary>프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다).</summary>
|
||
public static void ResetAll()
|
||
{
|
||
s_phase = RunPhase.Idle;
|
||
s_runIndex = 0;
|
||
s_startTime = s_pausedTotal = 0f; s_pauseStart = -1f;
|
||
s_nextPoll = s_nextTick = 0f;
|
||
s_faulted = s_gateOpened = s_bossStarted = false;
|
||
s_gateReason = RunGateReason.None;
|
||
s_zoneCount = s_zonesCleared = 0;
|
||
s_kills = s_bossKills = s_maxChain = s_deaths = s_potions = s_skillCasts = s_lootTotal = 0;
|
||
s_maxDamage = s_maxDamageBurst = 0d;
|
||
s_gold = 0L;
|
||
s_levelFrom = s_levelTo = 0;
|
||
s_bossGateSec = s_bossStartSec = s_bossClearSec = -1f;
|
||
s_boss = null;
|
||
s_lastPc = null; s_lastMapId = int.MinValue;
|
||
StartCount = EndCount = RestartCount = TickCount = BoundaryCount = 0;
|
||
LastLog = "";
|
||
LastResult = default(RunResult);
|
||
}
|
||
|
||
/// <summary>프로브용: 러너 없이 1회 틱(폴링 간격 무시).</summary>
|
||
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
|
||
|
||
/// <summary>
|
||
/// 프로브용: 런 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다).
|
||
/// 틱·폴링 예약 시각도 같이 당겨서, 에디트 모드에서도 tickIntervalSeconds 스케줄러를 그대로 측정할 수 있게 한다.
|
||
/// </summary>
|
||
public static void ShiftClockForProbe(float seconds)
|
||
{
|
||
s_startTime -= seconds;
|
||
s_nextTick -= seconds;
|
||
s_nextPoll -= seconds;
|
||
}
|
||
|
||
/// <summary>마지막 게이트 개방 사유(진단).</summary>
|
||
public static RunGateReason GateReason { get { return s_gateReason; } }
|
||
|
||
/// <summary>등장한 보스(진단 · 미등장이면 null).</summary>
|
||
public static Actor Boss { get { return s_boss; } }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 런너
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
internal static void EnsureRunner()
|
||
{
|
||
if (Application.isPlaying) RunDirectorRunner.Ensure();
|
||
}
|
||
|
||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||
static void Boot()
|
||
{
|
||
if (!WLRunSettings.Enabled) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
|
||
EnsureSubscribed();
|
||
EnsureRunner();
|
||
}
|
||
}
|
||
}
|