// ─────────────────────────────────────────────────────────────────────────────
// 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();
}
}
}