// ───────────────────────────────────────────────────────────────────────────── // 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 { /// 3분 런 상태 머신. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). 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 가 읽는다) /// 현재 단계. public static RunPhase Phase { get { return s_phase; } } /// 이 세션에서 몇 번째 런인가(1부터 · 아직 없으면 0). public static int RunIndex { get { return s_runIndex; } } /// 런이 진행 중인가(Zones · BossGate · Boss). public static bool IsRunning { get { return s_phase == RunPhase.Zones || s_phase == RunPhase.BossGate || s_phase == RunPhase.Boss; } } /// 구독 여부 — 집계가 실제로 이벤트에 붙었는지 확인용. public static bool Subscribed { get { return s_subscribed; } } /// 보스 게이트가 열렸는가 · 보스가 등장했는가. public static bool GateOpened { get { return s_gateOpened; } } public static bool BossStarted { get { return s_bossStarted; } } /// 존 수 · 클리어한 존 수. public static int ZoneCount { get { return s_zoneCount; } } public static int ZonesCleared { get { return s_zonesCleared; } } /// 마지막으로 발행한 RunResult(결과 화면이 늦게 떠도 읽을 수 있게 남긴다). public static RunResult LastResult; /// 진단 카운터. 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; } } // ───────────────────────────────────────────────────────────────────── // 시계 // ───────────────────────────────────────────────────────────────────── /// 런 시작 후 경과 초(세계 정지 시간 제외 옵션 반영). 런이 없으면 0. 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; } } /// 남은 초(0 미만으로 내려가지 않는다). public static float Remaining { get { var c = Cfg; float r = (c != null ? c.runSeconds : 0f) - Elapsed; return r > 0f ? r : 0f; } } // ───────────────────────────────────────────────────────────────────── // 구독 // ───────────────────────────────────────────────────────────────────── /// 집계 구독(중복 구독은 CombatEventList.Add 가 막는다). 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; } /// 구독 해제(프로브·롤백용). 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 선례) // ───────────────────────────────────────────────────────────────────── /// 처치 1건 반영 + 존 진행·승리 재판정. 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(); } /// 보스가 월드에 등장했다(런당 1회 통지). public static void NoteBossSpawned(int spawnerId, Actor boss) { if (!IsRunning || s_bossStarted) return; 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); } /// 연쇄 처치 수 갱신(최대만 남긴다). public static void NoteChain(int chainKills) { if (IsRunning && chainKills > s_maxChain) s_maxChain = chainKills; } /// 피해 갱신 — single = 단일 타격 · burst = 합산(0 이면 무시). 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; } /// 레벨업 반영(levelFrom 은 런 중 첫 레벨업의 이전 레벨로 보정한다). 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; } /// 획득 1건 반영. grade 는 LootBurst.ResolveGrade 결과(1~GradeSlots). 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; } /// 사망 1건(런을 끝내지 않는다 · 813i 부활 유지). public static void NoteDeath() { if (IsRunning) s_deaths++; } /// 물약 1개 사용. public static void NotePotion() { if (IsRunning) s_potions++; } /// 메인 PC 스킬 시전 1회. 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); } /// 존 i 의 진행률 — BossArena 누적값 − 런 시작 스냅샷(무장 시) 과 자체 카운터 중 큰 값. 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; } /// 존 i 의 클리어 임계(SO). public static int ZoneRequired(int i) { return i >= 0 && i < s_zoneCount ? s_zoneNeed[i] : 0; } /// 아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount). 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); } /// 보스 게이트 개방(런당 1회). 813d BossArena.ForceOpenGate() 를 부르고 통지한다. public static void OpenBossGate(RunGateReason reason) { if (!IsRunning || s_gateOpened) return; 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); } // ───────────────────────────────────────────────────────────────────── // 런 시작 · 종료 · 재시작 // ───────────────────────────────────────────────────────────────────── /// 런 시작(진행 중이면 먼저 Abandon 으로 닫는다). 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(); } /// 런 종료 1회(RunEnded 발행). 이미 끝났거나 시작 전이면 아무 일도 하지 않는다. 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); 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, 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); } /// /// "다음 런" — 집계 리셋 · runIndex+1 · PC 를 존 시작점으로 · 보스 게이트 재무장. /// 813q 결과 화면의 [다시 하기] 버튼이 부르는 유일한 공개 API. /// 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(); // 존 누적 카운터는 건드리지 않는다 if (c.restartResetsPotions) PotionUse.ResetRun("run restart"); } catch (System.Exception ex) { Log("재시작 일부 실패 — " + ex.Message); } StartRun("restart"); } // ───────────────────────────────────────────────────────────────────── // 틱 // ───────────────────────────────────────────────────────────────────── /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). 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) { if (!c.pauseTimerWhileWorldHeld) { if (s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } return; } bool held = DeathFlow.WorldHeld; if (held && s_pauseStart < 0f) s_pauseStart = now; else if (!held && s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } } /// 진입 경계 — 813x AutoCombatOnEnter 와 같은 조건(원천 읽기는 그 파일 · 판정은 여기). 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); } // ───────────────────────────────────────────────────────────────────── // 준비 · 진단 // ───────────────────────────────────────────────────────────────────── /// 존 배열을 SO 길이에 맞춘다(길이가 같으면 재할당 0 = GC 0). 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]; } /// 런 시작 시점 레벨(플레이 모드에서만 · 없으면 0). 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); } /// 프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다). 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); } /// 프로브용: 러너 없이 1회 틱(폴링 간격 무시). public static void ForceTick() { s_nextPoll = 0f; Tick(); } /// /// 프로브용: 런 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다). /// 틱·폴링 예약 시각도 같이 당겨서, 에디트 모드에서도 tickIntervalSeconds 스케줄러를 그대로 측정할 수 있게 한다. /// public static void ShiftClockForProbe(float seconds) { s_startTime -= seconds; s_nextTick -= seconds; s_nextPoll -= seconds; } /// 마지막 게이트 개방 사유(진단). public static RunGateReason GateReason { get { return s_gateReason; } } /// 등장한 보스(진단 · 미등장이면 null). 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(); } } }