Project_WL/Assets/WL/Combat/Run/RunDirector.cs

708 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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);
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);
}
/// <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(); // 존 누적 카운터는 건드리지 않는다
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();
}
}
}