Merge branch 'wl/systems/WL-815b-stage-director'

This commit is contained in:
깃 관리자 2026-09-11 01:10:48 +09:00
commit 669501a447
17 changed files with 2430 additions and 1 deletions

View File

@ -0,0 +1,44 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8d815b01a1b2c3d4e5f60708090a0b13, type: 3}
m_Name: WLStageSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Stage.WLStageSettings
enabled: 1
verboseLog: 0
pollSeconds: 0.1
tickIntervalSeconds: 1
pauseTimerWhileWorldHeld: 1
autoEnterOnMapEnter: 0
warpPlayerToAnchor: 1
useBarrierHook: 1
clearBarrierOnExit: 1
stopRespawnCoroutines: 1
respawnStopRequiresLoadComplete: 1
warpSpawnsToAnchor: 1
spawnScatterFactor: 0.7
resolveSpawnOnNavMesh: 1
recallExcess: 1
maxSpawnersPerStage: 8
failOnPlayerDeath: 1
retryRecallsMonsters: 1
retryResetsPotions: 1
retryHealsPlayer: 1
bindReviveOkToRetry: 1
clearHoldScale: 0
mirrorRunEnded: 1
mirrorRunHudEvents: 1
cardOnStageClear: 1
wrapToFirstStage: 0
suppressRunDirector: 1
disableRunGuide: 1
resetRunDirectorOnEnter: 1
toLobbyCallsReturnToLobby: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b18
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,433 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8d815b01a1b2c3d4e5f60708090a0b12, type: 3}
m_Name: WLStageTable
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Stage.WLStageTable
stageTimeLimitSeconds: 60
retryHealRate: 1
countdownSeconds: 1.5
clearHoldSeconds: 1
wavesPerStage: 1
waveGapSeconds: 1.5
stages:
- id: 1
mapId: -1
anchor: {x: 115, y: 0.4, z: -22}
startRadius: 2
barrierRadius: 14
barrierShape: 0
waves:
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 8
elite: 0
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 1.2
timeLimitOverrideSeconds: 0
- id: 2
mapId: -1
anchor: {x: 71, y: -0.3, z: 18}
startRadius: 2
barrierRadius: 14
barrierShape: 0
waves:
- spawnerId: 813003
monsterIds:
- 173
- 174
- 175
- 146
- 147
- 151
count: 10
elite: 0
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 1.1
timeLimitOverrideSeconds: 0
- id: 3
mapId: -1
anchor: {x: 147, y: 2.6, z: 30}
startRadius: 2
barrierRadius: 14
barrierShape: 0
waves:
- spawnerId: 813002
monsterIds:
- 168
- 169
- 170
- 171
- 172
count: 12
elite: 0
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 1
timeLimitOverrideSeconds: 0
- id: 4
mapId: -1
anchor: {x: 159, y: 0.9, z: -22}
startRadius: 2
barrierRadius: 14
barrierShape: 0
waves:
- spawnerId: 813004
monsterIds:
- 118
- 119
- 120
count: 8
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 6
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813021
monsterIds:
- 1023
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 1
timeLimitOverrideSeconds: 0
- id: 5
mapId: -1
anchor: {x: 139, y: 0.8, z: -20}
startRadius: 2
barrierRadius: 20
barrierShape: 0
waves:
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 6
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813010
monsterIds:
- 10006
count: 1
elite: 0
boss: 1
mode: 0
delaySec: 0
isBoss: 1
bossMonsterId: 10006
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 1
timeLimitOverrideSeconds: 0
- id: 6
mapId: -1
anchor: {x: 55, y: 0.2, z: -38}
startRadius: 2
barrierRadius: 16
barrierShape: 0
waves:
- spawnerId: 813003
monsterIds:
- 173
- 174
- 175
- 146
- 147
- 151
count: 14
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 4
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813023
monsterIds:
- 1009
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 0.95
timeLimitOverrideSeconds: 0
- id: 7
mapId: -1
anchor: {x: 19, y: 0.2, z: 10}
startRadius: 2
barrierRadius: 16
barrierShape: 0
waves:
- spawnerId: 813002
monsterIds:
- 168
- 169
- 170
- 171
- 172
count: 15
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813003
monsterIds:
- 173
- 174
- 175
- 146
- 147
- 151
count: 5
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813022
monsterIds:
- 1028
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 0.95
timeLimitOverrideSeconds: 0
- id: 8
mapId: -1
anchor: {x: 143, y: 0.4, z: 110}
startRadius: 2
barrierRadius: 18
barrierShape: 0
waves:
- spawnerId: 813004
monsterIds:
- 118
- 119
- 120
count: 8
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 12
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813002
monsterIds:
- 168
- 169
- 170
- 171
- 172
count: 4
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813005
monsterIds:
- 1014
- 1015
- 1016
count: 2
elite: 1
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 0.9
timeLimitOverrideSeconds: 0
- id: 9
mapId: -1
anchor: {x: 67, y: 0.1, z: 110}
startRadius: 2
barrierRadius: 18
barrierShape: 0
waves:
- spawnerId: 813002
monsterIds:
- 168
- 169
- 170
- 171
- 172
count: 15
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813003
monsterIds:
- 173
- 174
- 175
- 146
- 147
- 151
count: 12
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813024
monsterIds:
- 1024
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
isBoss: 0
bossMonsterId: 0
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 0.9
timeLimitOverrideSeconds: 0
- id: 10
mapId: -1
anchor: {x: 139, y: 0.8, z: -20}
startRadius: 2
barrierRadius: 20
barrierShape: 0
waves:
- spawnerId: 813001
monsterIds:
- 101
- 102
- 103
- 104
count: 6
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813004
monsterIds:
- 118
- 119
- 120
count: 6
elite: 0
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813005
monsterIds:
- 1014
- 1015
- 1016
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813024
monsterIds:
- 1024
count: 1
elite: 1
boss: 0
mode: 0
delaySec: 0
- spawnerId: 813010
monsterIds:
- 10006
count: 1
elite: 0
boss: 1
mode: 0
delaySec: 0
isBoss: 1
bossMonsterId: 10006
clearRewardCards: 1
goldMultiplier: 1
recommendPower: 0
telegraphScale: 0.85
timeLimitOverrideSeconds: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b17
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -47,7 +47,7 @@ MonoBehaviour:
reviveInvincibleSeconds: 1.5
reviveHealRate: 1
reviveCompanions: 1
reviveWatchdogSeconds: 8
reviveWatchdogSeconds: 0
reviveWatchdogIgnoresDisable: 1
reviveWatchdogLog: 1
reviveTopmostEnabled: 1

View File

@ -0,0 +1,279 @@
// ─────────────────────────────────────────────────────────────────────────────
// StageContracts.cs — 스테이지 축의 **공개 계약**(이벤트 · 상태 · 장벽 훅)
//
// PD 지시 #815 · 발주서 WL-815b §1 (2026-09-11)
// SOT = 기준서 `스테이지_전환_기준서_v1.md` §B-2(상태기계) · §C(스키마) · §E(실패·재도전)
//
// ■ 이 파일이 계약의 전부다 — 815c(장벽) · 815d(실패/결과 UI) · 815f(텔레그래프)는
// `StageEvents` · `StageDirector` 의 정적 게터 · `StageBarrierHook` **만** 본다.
// (813p `RunEvents` 와 같은 규약: 정적 클래스 · struct 페이로드를 `in` 으로 · 코어의
// `CombatEventList<T>` 재사용 · 구독자 예외는 Dispatch 안에서 격리 · 새 Manager/Singleton 0)
//
// ■ 장벽(815c)과의 이음매 — `StageBarrierHook`
// 이 태스크는 **인터페이스만** 둔다. 815c 가 `StageBarrierHook.Set` / `.Clear` 에 자기 구현을
// 대입하면 그 순간부터 장벽이 선다. 아무도 대입하지 않으면 **no-op**(예외 0 · 동작 변경 0).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Stage
{
/// <summary>스테이지 진행 단계(기준서 §B-2 · 로비는 이 축 밖 = Idle).</summary>
public enum StageState
{
/// <summary>스테이지 밖(로비 · 타이틀 · 종료 후 대기).</summary>
Idle = 0,
/// <summary>입장 — PC 워프 · 장벽 · 몬스터 고정 수 배치.</summary>
Entering = 1,
/// <summary>카운트다운(countdownSeconds).</summary>
Countdown = 2,
/// <summary>전투 — 고정 수 전멸이 승리 · 제한 시간이 흐른다.</summary>
Combat = 3,
/// <summary>클리어 직후 정지(clearHoldSeconds).</summary>
Cleared = 4,
/// <summary>결과 — 「다음 스테이지 / 로비로」 대기.</summary>
Result = 5,
/// <summary>실패 — 「다시 도전 / 로비로」 대기.</summary>
Failed = 6,
}
/// <summary>실패 사유(기준서 §E).</summary>
public enum StageFailReason
{
None = 0,
/// <summary>PC 사망(`DeathFlow.Died`).</summary>
PlayerDied = 1,
/// <summary>제한 시간 초과(모든 스테이지 60 s · PD 추가 규칙).</summary>
TimeLimit = 2,
/// <summary>바깥 사정으로 중단(맵 이탈 · 로비 · 축 off) — 재도전 대상이 아니다.</summary>
Aborted = 3,
}
/// <summary>장벽 모양(1차는 Circle 만 · 815c 소관).</summary>
public enum StageBarrierShape { Circle = 0, Box = 1 }
// ───────────────────────────────────────────── 페이로드(struct · 발생 시점 스냅샷 · GC 0)
/// <summary>스테이지 입장(배치 직후 · 카운트다운 시작 전).</summary>
public struct StageEnteredEvent
{
/// <summary>표 인덱스(0부터) · 표의 `id`(1부터).</summary>
public int index, stageId;
/// <summary>이 스테이지의 정의(🔴 SO 의 인스턴스 참조 — 읽기만 할 것).</summary>
public StageDef def;
/// <summary>이번에 실제로 배치한 마리 수(= 승리에 필요한 처치 수).</summary>
public int totalEnemies;
/// <summary>이 스테이지의 제한 시간(초).</summary>
public float timeLimit;
/// <summary>고정 영역 중심 · 반경(815c 장벽이 그대로 쓰는 값).</summary>
public Vector3 anchor;
public float barrierRadius;
/// <summary>카운트다운 길이(초).</summary>
public float countdownSeconds;
/// <summary>같은 스테이지를 몇 번째 도전하는가(0 = 첫 도전).</summary>
public int retries;
public float time;
public int frame;
}
/// <summary>카운트다운 갱신(SO tickIntervalSeconds 주기 + 시작·종료 시점).</summary>
public struct StageCountdownEvent
{
public int index;
/// <summary>남은 초 · 전체 초.</summary>
public float remaining, total;
public float time;
public int frame;
}
/// <summary>전투 시작(카운트다운 종료 · 타이머 시작).</summary>
public struct StageStartedEvent
{
public int index, stageId;
public int totalEnemies;
public float timeLimit;
public int retries;
public float time;
public int frame;
}
/// <summary>남은 적 수가 바뀌었다(처치 1건마다 · 배치 직후 1회).</summary>
public struct StageEnemyCountEvent
{
public int index;
/// <summary>남은 적 · 이 스테이지의 고정 수.</summary>
public int remaining, total;
public float time;
public int frame;
}
/// <summary>남은 시간이 바뀌었다(SO tickIntervalSeconds 주기 · 전투 중에만).</summary>
public struct StageTimeEvent
{
public int index;
/// <summary>남은 초 · 제한 시간(초).</summary>
public float remaining, limit;
public float time;
public int frame;
}
/// <summary>
/// 스테이지 결과 — 클리어 화면(815d)이 이 하나로 전부를 그린다.
/// 실패에도 같은 구조를 쓴다(`win=false` · `failReason` 참조).
/// 🔴 이름이 발주서의 `StageResult` 가 아닌 이유 = **실측 충돌**. 원본 `Assets/Script/UI/Stage/StageResult.cs:7`
/// 에 전역 네임스페이스 `public class StageResult`(BackKeyAdd)가 이미 있어, `using WL.Combat.Stage;` 를 쓴
/// 전역 파일에서 `StageResult` 가 원본 클래스로 잡힌다(컴파일 에러 CS0453 실측). 그래서 `StageClearResult`.
/// </summary>
public struct StageClearResult
{
/// <summary>표 인덱스 · 표의 id.</summary>
public int index, stageId;
/// <summary>클리어했는가.</summary>
public bool win;
/// <summary>실패 사유(win 이면 None).</summary>
public StageFailReason failReason;
/// <summary>전투 시작 후 경과 초(세계 정지 시간 제외) · 제한 시간.</summary>
public float elapsedSec, timeLimit;
/// <summary>처치 수 / 고정 수 · 남은 적.</summary>
public int killed, total, remaining;
/// <summary>같은 스테이지를 몇 번 재도전했는가(0 = 첫 도전에 성공).</summary>
public int retries;
/// <summary>이 스테이지가 마지막인가(다음 스테이지가 없다).</summary>
public bool isLastStage;
/// <summary>보스 스테이지인가.</summary>
public bool isBoss;
public float time;
public int frame;
}
/// <summary>실패(사망 · 시간 초과) — 815d 의 「다시 도전 / 로비」 팝업 트리거.</summary>
public struct StageFailedEvent
{
public int index, stageId;
public StageFailReason reason;
/// <summary>남은 적 / 고정 수 · 경과 초.</summary>
public int remaining, total;
public float elapsedSec;
/// <summary>지금까지의 재도전 횟수(무제한 · PD 3항).</summary>
public int retries;
public float time;
public int frame;
}
/// <summary>스테이지 축에서 빠져나갔다(로비로 · 맵 이탈 · 축 off).</summary>
public struct StageExitedEvent
{
public int index;
/// <summary>로비로 나가는 길인가(false = 중단).</summary>
public bool toLobby;
/// <summary>사유 문자열(진단 · 로그용).</summary>
public string reason;
public float time;
public int frame;
}
// ───────────────────────────────────────────── 이벤트 허브
/// <summary>
/// 스테이지 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove.
/// 🔴 815c/815d/815f 는 이 계약과 <see cref="StageDirector"/> 의 정적 게터만 본다.
/// </summary>
public static class StageEvents
{
/// <summary>스테이지 입장(배치 직후).</summary>
public static readonly CombatEventList<StageEnteredEvent> StageEntered = new CombatEventList<StageEnteredEvent>(4);
/// <summary>카운트다운 갱신.</summary>
public static readonly CombatEventList<StageCountdownEvent> CountdownTick = new CombatEventList<StageCountdownEvent>(4);
/// <summary>전투 시작.</summary>
public static readonly CombatEventList<StageStartedEvent> StageStarted = new CombatEventList<StageStartedEvent>(4);
/// <summary>남은 적 수 변경.</summary>
public static readonly CombatEventList<StageEnemyCountEvent> EnemyCountChanged = new CombatEventList<StageEnemyCountEvent>(4);
/// <summary>남은 시간 변경.</summary>
public static readonly CombatEventList<StageTimeEvent> TimeChanged = new CombatEventList<StageTimeEvent>(4);
/// <summary>클리어(고정 수 전멸).</summary>
public static readonly CombatEventList<StageClearResult> StageCleared = new CombatEventList<StageClearResult>(4);
/// <summary>실패(사망 · 시간 초과).</summary>
public static readonly CombatEventList<StageFailedEvent> StageFailed = new CombatEventList<StageFailedEvent>(4);
/// <summary>스테이지 축 이탈.</summary>
public static readonly CombatEventList<StageExitedEvent> StageExited = new CombatEventList<StageExitedEvent>(4);
/// <summary>스테이지 축이 살아 있는가(= <see cref="WLStageSettings.Enabled"/>).</summary>
public static bool Enabled { get { return WLStageSettings.Enabled; } }
// ── 진단(프로브가 읽는다)
public static int TotalRaised;
public static string LastEvent = "";
public static float LastEventTime;
internal static void Stamp(string name)
{
TotalRaised++;
LastEvent = name;
LastEventTime = Time.unscaledTime;
}
/// <summary>프로브용: 카운터 초기화(구독은 유지).</summary>
public static void ResetDiagnostics()
{
TotalRaised = 0; LastEvent = ""; LastEventTime = 0f;
StageEntered.Raised = StageEntered.Dispatched = 0;
CountdownTick.Raised = CountdownTick.Dispatched = 0;
StageStarted.Raised = StageStarted.Dispatched = 0;
EnemyCountChanged.Raised = EnemyCountChanged.Dispatched = 0;
TimeChanged.Raised = TimeChanged.Dispatched = 0;
StageCleared.Raised = StageCleared.Dispatched = 0;
StageFailed.Raised = StageFailed.Dispatched = 0;
StageExited.Raised = StageExited.Dispatched = 0;
}
}
// ───────────────────────────────────────────── 장벽 훅 (815c 계약)
/// <summary>
/// 🔴 815c(장벽)와의 유일한 이음매. 이 태스크는 **인터페이스만** 둔다.
/// · 815c 가 <see cref="Set"/> / <see cref="Clear"/> 에 자기 구현을 대입한다.
/// · 아무도 대입하지 않으면 **no-op** — 예외 0 · 동작 변경 0(장벽 없이 스테이지가 그대로 돈다).
/// · 구독자 예외는 여기서 삼킨다(장벽 하나 때문에 스테이지 흐름이 끊기지 않게).
/// </summary>
public static class StageBarrierHook
{
/// <summary>장벽을 세운다 — (중심, 반경).</summary>
public static System.Action<Vector3, float> Set;
/// <summary>장벽을 없앤다.</summary>
public static System.Action Clear;
/// <summary>진단 — 호출 횟수 · 마지막 값 · 구현이 붙어 있는가.</summary>
public static int SetCalls, ClearCalls, Faults;
public static Vector3 LastCenter;
public static float LastRadius;
public static bool HasSet { get { return Set != null; } }
public static bool HasClear { get { return Clear != null; } }
/// <summary>장벽 요청(구현이 없으면 값만 기록하고 아무 일도 하지 않는다).</summary>
public static void RaiseSet(Vector3 center, float radius)
{
SetCalls++; LastCenter = center; LastRadius = radius;
var h = Set;
if (h == null) return;
try { h(center, radius); }
catch (System.Exception) { Faults++; }
}
/// <summary>장벽 해제(구현이 없으면 아무 일도 하지 않는다).</summary>
public static void RaiseClear()
{
ClearCalls++;
var h = Clear;
if (h == null) return;
try { h(); }
catch (System.Exception) { Faults++; }
}
/// <summary>프로브용: 카운터만 비운다(대입한 구현은 유지).</summary>
public static void ResetDiagnostics() { SetCalls = ClearCalls = Faults = 0; LastCenter = Vector3.zero; LastRadius = 0f; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b11
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,801 @@
// ─────────────────────────────────────────────────────────────────────────────
// StageDirector.cs — 스테이지 진행기(상태기계 · 고정 수 전멸 승리 · 60 s 제한 · 재도전)
//
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
// SOT = 기준서 §B-2(상태기계 표) · §A(재사용/개조) · §A-1(리스폰 0) · §E(실패·재도전) · §C(표)
//
// ■ 813p `RunDirector` 에서 무엇을 가져오고 무엇을 바꿨나 (기준서 §A 1행)
// 가져온 것 : 상태기계 골격 · 폴링 틱(unscaled) · 세계 정지 중 시계 멈춤 · 이벤트 허브 규약 ·
// `Restart()` 의 복구 순서(DeathFlow.Abort → On_Regen → PotionUse.ResetRun) ·
// 진입 경계 원천(`AutoCombatOnEnter.ReadBoundarySources`) · C8 게이트 구조
// 바꾼 것 : 🔴 **승리 축** — 누적 킬(`zoneClearKills`) → **고정 수 전멸**(`RemainingEnemies == 0`)
// 🔴 **타이머 의미** — 기록용 → **제한 시간(60 s · 초과 = 실패)**
// 🔴 **실패 축 신설** — 사망 / 시간 초과 → `StageFailed` → 재도전 무제한
// 🔴 **리스폰 0** — 스테이지 스포너의 리젠 예약을 끊는다(기준서 §A-1 ⓑ · 원본 0줄)
// 🔴 `RunDirector.cs` 는 **1줄도 고치지 않는다**. 스테이지 중에는 `WLRunSettings.RuntimeDisabled`
// 로 재우고(SO), 이탈하면 자동 복원한다 = C8 이 값 하나가 아니라 **구조로** 성립한다.
//
// ■ 상태 전이 (기준서 §B-2 · 로비 제외)
// Idle ──Enter()──▶ Entering ──배치 완료──▶ Countdown ──countdownSeconds──▶ Combat
// Combat ──남은 적 0(마지막 묶음)──▶ Cleared ──clearHoldSeconds──▶ Result ──Next()──▶ Entering
// Combat ──제한 시간 0──▶ Failed ──Retry()──▶ Entering
// Combat ──PC 사망──▶ Failed ──Retry()──▶ Entering
// Result/Failed ──ToLobby()──▶ Idle 어느 상태든 ──Abort()──▶ Idle
//
// ■ 815c / 815d / 815f 가 보는 것 = `StageEvents` + 아래 정적 게터 + `StageBarrierHook` 뿐이다.
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Auto;
using WL.Combat.Core;
using WL.Combat.Run;
using WL.Combat.Survival;
namespace WL.Combat.Stage
{
/// <summary>스테이지 상태기계. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개).</summary>
public static class StageDirector
{
// ───────────────────────────────────────── 상태
static StageState s_state = StageState.Idle;
static StageDef s_def;
static int s_index = -1;
static int s_total, s_killed, s_retries, s_batch, s_batchCount = 1;
static float s_limit;
static float s_startTime, s_pausedTotal, s_pauseStart = -1f;
static float s_stateEnter; // 현재 상태에 들어온 시각(unscaled)
static float s_nextPoll, s_nextTick;
static float s_nextBatchAt = -1f;
static bool s_subscribed, s_faulted;
static bool s_respawnStopped;
static StageFailReason s_fail = StageFailReason.None;
static StageClearResult s_lastResult;
// ── 기존 축 억제(스테이지 중에만 · 이탈 시 자동 복원)
static bool s_runSuppressed, s_guideSuppressed, s_reviveBound;
static bool s_runPrev, s_guidePrev;
static System.Action<PCActor, float> s_revivePrev;
// ── 진입 경계(813p 와 같은 원천 · 판정은 여기)
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
// ───────────────────────────────────────── 공개 계약 (815c/815d/815f 가 읽는다)
/// <summary>현재 스테이지 정의(없으면 null).</summary>
public static StageDef Current { get { return s_def; } }
/// <summary>현재 스테이지 표 인덱스(0부터 · 없으면 1).</summary>
public static int Index { get { return s_index; } }
/// <summary>남은 적 수(고정 수 처치 수 · 0 미만 없음).</summary>
public static int RemainingEnemies { get { int r = s_total - s_killed; return r > 0 ? r : 0; } }
/// <summary>이 스테이지의 고정 수.</summary>
public static int TotalEnemies { get { return s_total; } }
/// <summary>남은 초(전투 중에만 줄어든다 · 0 미만 없음).</summary>
public static float RemainingSeconds
{
get
{
if (s_state != StageState.Combat) return s_limit;
float r = s_limit - Elapsed;
return r > 0f ? r : 0f;
}
}
/// <summary>스테이지가 진행 중인가(입장 ~ 전투 · 결과·실패 대기는 false).</summary>
public static bool IsActive
{
get { return s_state == StageState.Entering || s_state == StageState.Countdown || s_state == StageState.Combat; }
}
/// <summary>스테이지 축이 어떤 형태로든 살아 있는가(결과·실패 대기 포함).</summary>
public static bool InStage { get { return s_state != StageState.Idle; } }
/// <summary>현재 상태.</summary>
public static StageState State { get { return s_state; } }
/// <summary>이 스테이지를 몇 번 재도전했는가(무제한 · PD 3항).</summary>
public static int Retries { get { return s_retries; } }
/// <summary>이번 스테이지의 처치 수.</summary>
public static int Killed { get { return s_killed; } }
/// <summary>이 스테이지의 제한 시간(초).</summary>
public static float TimeLimit { get { return s_limit; } }
/// <summary>마지막 실패 사유.</summary>
public static StageFailReason LastFailReason { get { return s_fail; } }
/// <summary>마지막 결과(클리어·실패 공통 · 결과 화면이 늦게 떠도 읽을 수 있게 남긴다).</summary>
public static StageClearResult LastResult { get { return s_lastResult; } }
/// <summary>현재 묶음(웨이브) 번호 · 전체 묶음 수.</summary>
public static int Batch { get { return s_batch; } }
public static int BatchCount { get { return s_batchCount; } }
/// <summary>카운트다운 남은 초(카운트다운 중에만 · 그 밖에는 0).</summary>
public static float CountdownRemaining
{
get
{
if (s_state != StageState.Countdown) return 0f;
var t = Table;
float total = t != null ? t.countdownSeconds : 0f;
float r = total - (Now - s_stateEnter);
return r > 0f ? r : 0f;
}
}
/// <summary>전투 시작 후 경과 초(세계 정지 시간 제외).</summary>
public static float Elapsed
{
get
{
if (s_state != StageState.Combat && s_startTime <= 0f) return 0f;
float now = Now;
float paused = s_pausedTotal + (s_pauseStart >= 0f ? now - s_pauseStart : 0f);
float e = now - s_startTime - paused;
return e > 0f ? e : 0f;
}
}
// ───────────────────────────────────────── 진단(프로브·QA)
public static bool Subscribed { get { return s_subscribed; } }
public static int EnterCount, StartCount, ClearCount, FailCount, RetryCount, NextCount, LobbyCount, TickCount, BoundaryCount;
public static string LastLog = "";
/// <summary>기존 축을 재우고 있는가(진단 · C8 증거).</summary>
public static bool RunDirectorSuppressed { get { return s_runSuppressed; } }
public static bool RunGuideSuppressed { get { return s_guideSuppressed; } }
public static bool ReviveBound { get { return s_reviveBound; } }
static WLStageSettings Cfg { get { return WLStageSettings.Instance; } }
static WLStageTable Table { get { return WLStageTable.Instance; } }
static bool Verbose { get { var c = Cfg; return c != null && c.verboseLog; } }
static float Now { get { return Time.unscaledTime; } }
// ─────────────────────────────────────────────────────────────────────
// 구독
// ─────────────────────────────────────────────────────────────────────
/// <summary>집계 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
public static void EnsureSubscribed()
{
if (s_subscribed) return;
CombatEvents.Killed.Add(OnKilled);
DeathFlow.Died.Add(OnDied);
s_subscribed = true;
}
/// <summary>구독 해제(프로브·롤백용).</summary>
public static void Unsubscribe()
{
if (!s_subscribed) return;
CombatEvents.Killed.Remove(OnKilled);
DeathFlow.Died.Remove(OnDied);
s_subscribed = false;
}
static void OnKilled(in KilledEvent e)
{
if (s_state != StageState.Combat || s_def == null) return;
int sid = DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group();
if (!s_def.HasSpawner(sid)) return; // 스테이지 밖 처치는 세지 않는다
NoteKill(sid);
}
static void OnDied(in DeathEvent e)
{
var c = Cfg;
if (c == null || !c.failOnPlayerDeath) return;
if (s_state != StageState.Combat && s_state != StageState.Countdown) return;
Fail(StageFailReason.PlayerDied);
}
/// <summary>813z ReviveDialog 의 [확인] 이 여기로 온다(DeathFlow.ReviveOverride · 815d 전 임시 배선).</summary>
static void OnReviveOk(PCActor pc, float healRate)
{
if (s_state == StageState.Failed || s_state == StageState.Combat) { Retry(); return; }
if (!DSUtil.CheckNull(pc)) pc.On_Regen(false, healRate); // 스테이지 밖이면 813i 기본 동작 그대로
}
// ─────────────────────────────────────────────────────────────────────
// 집계 이음매 (프로브가 가짜 처치를 넣을 때도 이 경로를 쓴다 — 813d/813p 선례)
// ─────────────────────────────────────────────────────────────────────
/// <summary>처치 1건 반영 + 전멸 재판정.</summary>
public static void NoteKill(int spawnerId)
{
if (s_state != StageState.Combat) return;
s_killed++;
RaiseEnemyCount();
if (RemainingEnemies > 0) return;
// 묶음(웨이브)이 더 남았으면 다음 묶음을 예약한다(기준서 §B-2 · 「차단 시점」만 바꾸면 2웨이브).
if (s_batch + 1 < s_batchCount)
{
var t = Table;
s_nextBatchAt = Now + (t != null && t.waveGapSeconds > 0f ? t.waveGapSeconds : 0f);
return;
}
Clear();
}
// ─────────────────────────────────────────────────────────────────────
// 공개 API — 815d 팝업 · 815h 로비가 부르는 전부
// ─────────────────────────────────────────────────────────────────────
/// <summary>스테이지 <paramref name="index"/>(0부터) 로 입장한다. 진행 중이면 먼저 정리한다.</summary>
public static bool Enter(int index, string reason)
{
var c = Cfg; var t = Table;
if (c == null || t == null || !WLStageSettings.Enabled) return false;
var def = t.Get(index);
if (def == null) { Log("입장 실패 — 표 인덱스 " + index + " 없음"); return false; }
bool sameStage = s_index == index;
if (InStage && !sameStage) ReleaseBarrier();
ApplyStageOverrides();
s_index = index;
s_def = def;
if (!sameStage) s_retries = 0;
s_limit = t.TimeLimitOf(def);
s_batchCount = t.WaveBatches;
s_batch = 0;
s_killed = 0;
s_total = def.TotalCount;
s_fail = StageFailReason.None;
s_nextBatchAt = -1f;
s_respawnStopped = false;
s_pausedTotal = 0f; s_pauseStart = -1f; s_startTime = 0f;
SetState(StageState.Entering);
EnterCount++;
EnsureSubscribed();
EnsureRunner();
// ① PC 를 스테이지 앵커로 — 813w3 이 쓴 것과 같은 원본 공개 API(Actor.Set_Warp · Actor.cs:2111)
if (c.warpPlayerToAnchor) WarpPlayerToAnchor(def);
// ② 장벽 = 815c 계약 호출(구현이 없으면 no-op)
if (c.useBarrierHook) StageBarrierHook.RaiseSet(def.anchor, def.barrierRadius);
// ③ 🔴 리스폰 0(기준서 §A-1 ⓑ) — 배치보다 **먼저** 예약을 끊는다.
bool allStopped;
StageSpawner.StopRespawns(def, out allStopped);
s_respawnStopped = allStopped;
// ④ 고정 수 배치(초과분은 비활성 유지)
int placed = StageSpawner.Deploy(def, 0, "스테이지 " + def.id + " 입장");
var ev = new StageEnteredEvent
{
index = index, stageId = def.id, def = def,
totalEnemies = s_total, timeLimit = s_limit,
anchor = def.anchor, barrierRadius = def.barrierRadius,
countdownSeconds = t.countdownSeconds, retries = s_retries,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("StageEntered");
StageEvents.StageEntered.Dispatch(in ev);
RaiseEnemyCount();
if (Verbose) Log("스테이지 " + def.id + " 입장(" + reason + ") — 고정 수 " + s_total +
" · 배치 " + placed + " · 제한 " + s_limit.ToString("F0") + "s · 리젠차단=" + allStopped);
// 🔴 카운트다운은 **다음 틱**에 들어간다 — 로딩이 안 끝난 스포너의 리젠 차단을 한 번 더 시도할 기회를 남긴다
// (BossArena.cs:1077 `Get_LoadComplete()` 가드와 같은 이유). 상태 Entering 은 관측 가능한 실제 상태다.
return true;
}
/// <summary>같은 스테이지를 다시 도전한다(HP 전회복 · 물약 초기화 · 몬스터 재배치 · 타이머 리셋 · 무제한).</summary>
public static bool Retry()
{
var c = Cfg; var t = Table;
if (c == null || t == null || !WLStageSettings.Enabled || s_def == null) return false;
RetryCount++;
s_retries++;
try
{
DeathFlow.Abort("stage retry"); // 부활 팝업이 떠 있으면 세계 정지부터 푼다
var pc = MyValue.MyPC;
if (c.retryHealsPlayer && !DSUtil.CheckNull(pc))
pc.On_Regen(false, t.retryHealRate); // MyActor.On_Regen = 전회복(813z 와 같은 경로)
if (c.retryResetsPotions) PotionUse.ResetRun("stage retry");
if (c.retryRecallsMonsters) StageSpawner.Recall(s_def, "재도전");
}
catch (System.Exception ex) { Log("재도전 일부 실패 — " + ex.Message); }
int idx = s_index;
int keep = s_retries;
bool ok = Enter(idx, "retry");
s_retries = keep; // Enter 가 같은 스테이지라 0 으로 되돌리지 않지만 방어
return ok;
}
/// <summary>다음 스테이지로(index + 1). 마지막이면 SO wrapToFirstStage 에 따라 0 으로 돌거나 실패한다.</summary>
public static bool Next()
{
var c = Cfg; var t = Table;
if (c == null || t == null || !WLStageSettings.Enabled) return false;
NextCount++;
int next = s_index + 1;
if (next >= t.Count)
{
if (!c.wrapToFirstStage) { Log("다음 스테이지 없음 — 마지막(" + s_index + ")"); return false; }
next = 0;
}
s_retries = 0;
return Enter(next, "next");
}
/// <summary>로비로 나간다(스테이지 축 종료 · 억제한 기존 축 복원).</summary>
public static void ToLobby()
{
LobbyCount++;
var c = Cfg;
Exit(true, "lobby");
if (c != null && c.toLobbyCallsReturnToLobby && Application.isPlaying)
{
try { var ig = InGameInfo.Ins; if (!DSUtil.CheckNull(ig)) ig.Return_To_Lobby(); }
catch (System.Exception ex) { Log("로비 복귀 실패 — " + ex.Message); }
}
}
/// <summary>중단(맵 이탈 · PC 소실 · 축 off). 실패로 세지 않는다.</summary>
public static void Abort(string reason) { Exit(false, reason); }
static void Exit(bool toLobby, string reason)
{
if (s_state == StageState.Idle) { ClearStageOverrides(); return; }
int idx = s_index;
ReleaseBarrier();
SetState(StageState.Idle);
s_startTime = 0f; s_pauseStart = -1f; s_nextBatchAt = -1f;
ClearStageOverrides();
var ev = new StageExitedEvent { index = idx, toLobby = toLobby, reason = reason, time = Now, frame = Time.frameCount };
StageEvents.Stamp("StageExited");
StageEvents.StageExited.Dispatch(in ev);
if (Verbose) Log("스테이지 이탈(" + reason + ") — 로비=" + toLobby);
}
// ─────────────────────────────────────────────────────────────────────
// 클리어 · 실패
// ─────────────────────────────────────────────────────────────────────
static void Clear()
{
var c = Cfg; var t = Table;
ClearCount++;
float el = Elapsed;
PausePump(false);
SetState(StageState.Cleared);
if (c != null && c.clearBarrierOnExit) ReleaseBarrier();
// 전멸 직후 정지 — 코어 중재자(TimeScaleArbiter)를 쓴다. 직접 timeScale 대입 0(813z 교훈).
if (c != null && t != null && t.clearHoldSeconds > 0f && c.clearHoldScale < 1f && Application.isPlaying)
TimeScaleArbiter.Request(TimeLayer.Cinematic, c.clearHoldScale, t.clearHoldSeconds);
s_lastResult = BuildResult(true, StageFailReason.None, el);
StageEvents.Stamp("StageCleared");
StageEvents.StageCleared.Dispatch(in s_lastResult);
// 813o 카드 = 「스테이지 클리어 시 1회」(기준서 §A · 트리거만 교체)
if (c != null && c.cardOnStageClear) MirrorZoneCleared(el);
if (c != null && c.mirrorRunEnded) MirrorRunEnded(RunOutcome.Win, el);
if (Verbose) Log("스테이지 " + (s_def != null ? s_def.id : 0) + " 클리어 — " + el.ToString("F1") +
"s · " + s_killed + "/" + s_total + " · 재도전 " + s_retries);
}
static void Fail(StageFailReason reason)
{
var c = Cfg;
FailCount++;
float el = Elapsed;
PausePump(false);
s_fail = reason;
SetState(StageState.Failed);
s_lastResult = BuildResult(false, reason, el);
var ev = new StageFailedEvent
{
index = s_index, stageId = s_def != null ? s_def.id : 0, reason = reason,
remaining = RemainingEnemies, total = s_total, elapsedSec = el, retries = s_retries,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("StageFailed");
StageEvents.StageFailed.Dispatch(in ev);
if (c != null && c.mirrorRunEnded)
MirrorRunEnded(reason == StageFailReason.TimeLimit ? RunOutcome.Timeout : RunOutcome.Abandon, el);
if (Verbose) Log("스테이지 " + (s_def != null ? s_def.id : 0) + " 실패(" + reason + ") — " +
el.ToString("F1") + "s · 남은 적 " + RemainingEnemies + "/" + s_total);
}
static StageClearResult BuildResult(bool win, StageFailReason reason, float el)
{
var t = Table;
return new StageClearResult
{
index = s_index, stageId = s_def != null ? s_def.id : 0,
win = win, failReason = reason,
elapsedSec = el, timeLimit = s_limit,
killed = s_killed, total = s_total, remaining = RemainingEnemies,
retries = s_retries,
isLastStage = t != null && s_index >= t.Count - 1,
isBoss = s_def != null && s_def.isBoss,
time = Now, frame = Time.frameCount
};
}
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
internal static void Tick()
{
if (s_faulted) return;
var c = Cfg;
if (c == null || !WLStageSettings.Enabled) { if (InStage) Abort("disabled"); return; }
float now = Now;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, c.pollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다.
try
{
PollBoundary(c);
if (!InStage) return;
switch (s_state)
{
case StageState.Entering:
RetryRespawnStop();
SetState(StageState.Countdown);
RaiseCountdown();
break;
case StageState.Countdown:
{
RetryRespawnStop();
var t = Table;
float cd = t != null ? t.countdownSeconds : 0f;
if (now >= s_nextTick) { s_nextTick = now + TickStep(c); RaiseCountdown(); }
if (now - s_stateEnter >= cd) BeginCombat();
break;
}
case StageState.Combat:
{
PausePump(c.pauseTimerWhileWorldHeld && DeathFlow.WorldHeld);
if (s_nextBatchAt >= 0f && now >= s_nextBatchAt) DeployNextBatch();
float el = Elapsed;
if (s_limit > 0f && el >= s_limit) { Fail(StageFailReason.TimeLimit); break; }
if (now >= s_nextTick) { s_nextTick = now + TickStep(c); RaiseTime(); }
break;
}
case StageState.Cleared:
{
var t = Table;
float hold = t != null ? t.clearHoldSeconds : 0f;
if (now - s_stateEnter >= hold) SetState(StageState.Result);
break;
}
}
}
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
static float TickStep(WLStageSettings c) { return c.tickIntervalSeconds > 0f ? c.tickIntervalSeconds : 0f; }
/// <summary>로딩이 안 끝나 미뤘던 리젠 차단을 다시 시도한다(BossArena.cs:1077 가드와 같은 이유).</summary>
static void RetryRespawnStop()
{
if (s_respawnStopped || s_def == null) return;
bool all;
StageSpawner.StopRespawns(s_def, out all);
s_respawnStopped = all;
}
static void BeginCombat()
{
var c = Cfg;
SetState(StageState.Combat);
StartCount++;
s_startTime = Now;
s_pausedTotal = 0f; s_pauseStart = -1f;
s_nextTick = s_startTime;
var ev = new StageStartedEvent
{
index = s_index, stageId = s_def != null ? s_def.id : 0,
totalEnemies = s_total, timeLimit = s_limit, retries = s_retries,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("StageStarted");
StageEvents.StageStarted.Dispatch(in ev);
if (c != null && c.mirrorRunHudEvents) MirrorRunStarted();
RaiseTime();
RaiseEnemyCount();
}
static void DeployNextBatch()
{
s_nextBatchAt = -1f;
s_batch++;
if (s_def == null) return;
int placed = StageSpawner.Deploy(s_def, s_batch, "묶음 " + (s_batch + 1));
if (Verbose) Log("다음 묶음 배치 — " + placed + "마리(묶음 " + (s_batch + 1) + "/" + s_batchCount + ")");
RaiseEnemyCount();
}
static void PausePump(bool held)
{
float now = Now;
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 와 같은 원천(그 파일의 동작 변경 0 · 판정은 여기).</summary>
static void PollBoundary(WLStageSettings c)
{
PCActor pc; int map; bool lobby;
if (!AutoCombatOnEnter.ReadBoundarySources(out pc, out map, out lobby)) return;
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 || lobby) { if (InStage) Abort(pc == null ? "pc 없음" : "로비"); return; }
if (InStage) { Abort("맵 이동"); return; }
if (c.autoEnterOnMapEnter) Enter(0, pcChanged ? "pc" : ("map " + map));
}
// ─────────────────────────────────────────────────────────────────────
// 이벤트 발행
// ─────────────────────────────────────────────────────────────────────
static void SetState(StageState s) { s_state = s; s_stateEnter = Now; }
static void RaiseCountdown()
{
var t = Table;
float total = t != null ? t.countdownSeconds : 0f;
var e = new StageCountdownEvent
{
index = s_index, remaining = CountdownRemaining, total = total,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("CountdownTick");
StageEvents.CountdownTick.Dispatch(in e);
}
static void RaiseEnemyCount()
{
var e = new StageEnemyCountEvent
{
index = s_index, remaining = RemainingEnemies, total = s_total,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("EnemyCountChanged");
StageEvents.EnemyCountChanged.Dispatch(in e);
}
static void RaiseTime()
{
TickCount++;
var e = new StageTimeEvent
{
index = s_index, remaining = RemainingSeconds, limit = s_limit,
time = Now, frame = Time.frameCount
};
StageEvents.Stamp("TimeChanged");
StageEvents.TimeChanged.Dispatch(in e);
var c = Cfg;
if (c != null && c.mirrorRunHudEvents) MirrorRunTick();
}
// ─────────────────────────────────────────────────────────────────────
// 813p RunEvents 병행 발행 (기존 구독자 호환 · SO 로 끈다)
// 🔴 RunDirector 는 재워 둔 상태다 — 이 발행은 **스테이지가 낸 것**이고 RunDirector 상태와 무관하다.
// ─────────────────────────────────────────────────────────────────────
static void MirrorRunStarted()
{
var e = new RunStartedEvent
{
runIndex = s_index + 1, startTime = s_startTime, runSeconds = s_limit,
zoneCount = 1, reason = "stage",
time = Now, frame = Time.frameCount
};
RunEvents.Stamp("RunStarted");
RunEvents.RunStarted.Dispatch(in e);
}
static void MirrorRunTick()
{
var e = new RunTickEvent
{
elapsedSec = Elapsed, remainingSec = RemainingSeconds,
zoneIndex = 0, zoneKills = s_killed, zoneRequired = s_total,
zonesCleared = 0, zoneCount = 1,
phase = RunPhase.Zones, runIndex = s_index + 1,
time = Now, frame = Time.frameCount
};
RunEvents.Stamp("RunTick");
RunEvents.RunTick.Dispatch(in e);
}
static void MirrorZoneCleared(float el)
{
var e = new ZoneClearedEvent
{
zoneIndex = 0, spawnerId = FirstSpawnerId(), elapsedSec = el,
kills = s_killed, required = s_total, allZonesCleared = true,
time = Now, frame = Time.frameCount
};
RunEvents.Stamp("ZoneCleared");
RunEvents.ZoneCleared.Dispatch(in e);
}
static void MirrorRunEnded(RunOutcome outcome, float el)
{
var e = new RunResult
{
outcome = outcome, runIndex = s_index + 1,
totalSec = el, runSeconds = s_limit,
zoneClearSec = null, zoneKills = null, zoneCount = 1,
zonesCleared = outcome == RunOutcome.Win ? 1 : 0,
bossGateSec = -1f, bossStartSec = -1f, bossClearSec = -1f, bossHpRatio = -1f,
kills = s_killed, bossKills = 0,
lootByGrade = null,
time = Now, frame = Time.frameCount
};
RunEvents.Stamp("RunEnded");
RunEvents.RunEnded.Dispatch(in e);
}
static int FirstSpawnerId()
{
if (s_def == null || s_def.waves == null) return 0;
for (int i = 0; i < s_def.waves.Length; i++) if (s_def.waves[i] != null) return s_def.waves[i].spawnerId;
return 0;
}
// ─────────────────────────────────────────────────────────────────────
// 기존 축 억제 · 복원 (🔴 C8 이 값이 아니라 구조로 성립하는 곳)
// ─────────────────────────────────────────────────────────────────────
static void ApplyStageOverrides()
{
var c = Cfg;
if (c == null) return;
if (c.suppressRunDirector && !s_runSuppressed)
{
if (c.resetRunDirectorOnEnter) RunDirector.ResetAll(); // 🔴 EndRun 이 아니라 ResetAll — RunEnded 를 내지 않는다
s_runPrev = WLRunSettings.RuntimeDisabled;
WLRunSettings.RuntimeDisabled = true;
s_runSuppressed = true;
}
if (c.disableRunGuide && !s_guideSuppressed)
{
s_guidePrev = WLRunGuideSettings.RuntimeDisabled;
WLRunGuideSettings.RuntimeDisabled = true;
s_guideSuppressed = true;
}
if (c.bindReviveOkToRetry && !s_reviveBound)
{
s_revivePrev = DeathFlow.ReviveOverride;
DeathFlow.ReviveOverride = OnReviveOk;
s_reviveBound = true;
}
}
static void ClearStageOverrides()
{
if (s_runSuppressed) { WLRunSettings.RuntimeDisabled = s_runPrev; s_runSuppressed = false; }
if (s_guideSuppressed) { WLRunGuideSettings.RuntimeDisabled = s_guidePrev; s_guideSuppressed = false; }
if (s_reviveBound) { DeathFlow.ReviveOverride = s_revivePrev; s_revivePrev = null; s_reviveBound = false; }
}
static void ReleaseBarrier()
{
var c = Cfg;
if (c != null && c.clearBarrierOnExit) StageBarrierHook.RaiseClear();
}
static void WarpPlayerToAnchor(StageDef def)
{
if (!Application.isPlaying) return;
try
{
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) return;
Vector3 want = def.anchor;
if (def.startRadius > 0.01f) want += new Vector3(0f, 0f, -def.startRadius); // 앵커 뒤쪽에서 시작(난수 0)
Vector3 target;
var tg = WL.Combat.Nav.WLTerrainGuardSettings.Instance;
if (tg != null &&
WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(tg, want, 0, out target) != WL.Combat.Nav.SpawnPositionGuard.ResultFailed)
pc.Set_Warp(target);
else
pc.Set_Warp(want);
}
catch (System.Exception ex) { Log("PC 워프 실패 — " + ex.Message); }
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 프로브 이음매
// ─────────────────────────────────────────────────────────────────────
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[StageDirector] " + msg);
}
/// <summary>프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다 · 억제는 복원한다).</summary>
public static void ResetAll()
{
ClearStageOverrides();
s_state = StageState.Idle;
s_def = null; s_index = -1;
s_total = s_killed = s_retries = s_batch = 0; s_batchCount = 1;
s_limit = 0f;
s_startTime = s_pausedTotal = 0f; s_pauseStart = -1f;
s_stateEnter = s_nextPoll = s_nextTick = 0f;
s_nextBatchAt = -1f;
s_faulted = s_respawnStopped = false;
s_fail = StageFailReason.None;
s_lastResult = default(StageClearResult);
s_lastPc = null; s_lastMapId = int.MinValue;
EnterCount = StartCount = ClearCount = FailCount = RetryCount = NextCount = LobbyCount = TickCount = BoundaryCount = 0;
LastLog = "";
}
/// <summary>프로브용: 러너 없이 1회 틱(폴링 간격 무시).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>
/// 프로브용: 스테이지 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다).
/// 카운트다운·정지 대기까지 같은 축으로 움직이도록 상태 진입 시각·틱 예약도 함께 당긴다.
/// </summary>
public static void ShiftClockForProbe(float seconds)
{
s_startTime -= seconds;
s_stateEnter -= seconds;
s_nextTick -= seconds;
s_nextPoll -= seconds;
if (s_nextBatchAt >= 0f) s_nextBatchAt -= seconds;
}
/// <summary>프로브용: PC 사망 실패 경로를 이벤트 없이 직접 태운다.</summary>
public static void ForceFailForProbe(StageFailReason reason) { if (s_state == StageState.Combat || s_state == StageState.Countdown) Fail(reason); }
// ─────────────────────────────────────────────────────────────────────
// 러너
// ─────────────────────────────────────────────────────────────────────
internal static void EnsureRunner()
{
if (Application.isPlaying) StageDirectorRunner.Ensure();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!WLStageSettings.Enabled) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
EnsureSubscribed();
EnsureRunner();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b15
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,38 @@
// ─────────────────────────────────────────────────────────────────────────────
// StageDirectorRunner.cs — StageDirector 의 시간 축(숨김 GameObject 1개 · 코루틴 0)
//
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
//
// 새 Manager/Singleton 을 만들지 않는다 — 813p `RunDirectorRunner` · `BossArenaRunner` ·
// `SurvivalRunner` 와 같은 방식이다. HideAndDontSave 라 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다.
// `WLStageSettings.Enabled` 가 false 면 `StageDirector.Boot` 이 이 오브젝트를 아예 만들지 않는다(C8).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Stage
{
/// <summary>StageDirector 를 매 프레임 틱한다(폴링 간격은 SO pollSeconds 가 결정).</summary>
internal sealed class StageDirectorRunner : MonoBehaviour
{
static StageDirectorRunner s_runner;
/// <summary>런너 1개 보장(플레이 모드 전용 · 에디트 모드에서는 만들지 않는다).</summary>
internal static void Ensure()
{
if (!Application.isPlaying || s_runner != null) return;
var go = new GameObject("[WL815b] StageDirectorRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<StageDirectorRunner>();
}
/// <summary>런너가 살아 있는가(프로브가 읽는다).</summary>
internal static bool Exists { get { return s_runner != null; } }
void Update() { StageDirector.Tick(); }
void OnDestroy() { if (ReferenceEquals(s_runner, this)) s_runner = null; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b16
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,357 @@
// ─────────────────────────────────────────────────────────────────────────────
// StageSpawner.cs — 「스테이지마다 정해진 수」 배치 · 회수 · **리스폰 0** (원본 코드 0줄)
//
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
// SOT = 기준서 §A-1 (리스폰 0 최소 방법 · 실측 결론)
//
// ■ 🔴 리스폰 0 = 기준서 §A-1 ⓑ — 원본 수정 0줄
// 원본 스포너 타입 4종(`Util/MyEnum.cs:8` Random/Order/Phase/PhaseAllKill)에 「리젠 없음」이 없다.
// `MobControlMgr.actDie` 가 `Set_Coroutine(…, f_SpawnerDelay)` 로 **예약**만 하므로,
// 그 예약을 `MobControlMgr.StopAllCoroutines()` 로 끊으면 리젠이 영영 오지 않는다.
// `BossArena.HoldNearbyZoneSpawns`(BossArena.cs:1080)가 **이미 하고 있는 그대로**이고,
// 813w7 이 D-45 로 「그 스포너가 맵 세션 내내 영구 소진」됨을 실측했다 —
// **그 부작용이 곧 우리가 원하는 동작**이다.
// 🔴 가드: `Get_LoadComplete()` 가 false 인 스포너를 끊으면 **최초 스폰 코루틴**까지 죽는다
// (BossArena.cs:1077 과 같은 가드를 그대로 따른다 · SO `respawnStopRequiresLoadComplete`).
//
// ■ 🔴 고정 수 배치 — 원본이 스스로 쓰는 공개 경로만 쓴다
// `MobControlMgr` 의 공개 멤버는 `SpawnerID` · `MobGenRange` · `text_lv` · `Get_LoadComplete()` 뿐이라
// 스포너에 「N 마리만 내라」고 시킬 방법이 없다(813w7 실측). 그래서 813w7 `BossArena.RearmZone` 과
// **같은 경로**를 쓴다 — `ActorInfo.Get_Actors(eRole.Mob, spawnerId, true)` 의 **비활성** 몹에
// `MobActor.On_Regen()`(= `MobControlMgr.cs:221` 과 같은 호출 · 좌표·스폰 이펙트·`RaiseSpawned` 원본 그대로).
// 다른 점은 **마리 수를 표가 정한다**는 것 하나다 — 초과분은 깨우지 않으므로 비활성으로 남는다.
//
// ■ 회수(`Recall`) = `Actor.Off()` — 원본이 스폰 직후 쓰는 그 호출(`MobControlMgr.cs:163 mob.Off()`).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using WL.Combat.Nav;
namespace WL.Combat.Stage
{
/// <summary>스테이지 몬스터의 배치 · 회수 · 리젠 차단. 정적 · 새 Manager/Singleton 0.</summary>
public static class StageSpawner
{
// ── 스포너 캐시(노드는 움직이지 않는다 · 맵이 바뀌면 다시 훑는다)
static MobControlMgr[] s_mgrs;
static readonly Dictionary<int, MobControlMgr> s_byId = new Dictionary<int, MobControlMgr>(16);
// ── 진단(프로브가 읽는다)
/// <summary>리젠 예약을 끊은 횟수 · 로딩 미완으로 미룬 횟수 · 스포너를 못 찾은 횟수.</summary>
public static int RespawnStopCalls, RespawnStopDeferred, SpawnerMissing;
/// <summary>배치한 마리 수 · 회수한 마리 수 · 앵커로 워프한 마리 수 · NavMesh 보정 실패 수.</summary>
public static int Deployed, Recalled, WarpedToAnchor, WarpFailed;
/// <summary>스포너 목록을 훑은 횟수.</summary>
public static int Scans;
public static string LastLog = "";
/// <summary>
/// 프로브 전용 대체 배치 — null 이 아니면 `On_Regen()` 대신 이것을 부르고 「배치했다」로 센다.
/// 에디트 모드에는 살아 있는 `MobActor`(스탯·NavMeshAgent·애니메이터)가 없어 원본 호출을 태울 수 없다.
/// 🔴 실기에서는 항상 null(누구도 대입하지 않는다) · Play 종료 시 도메인 리로드로 자동 null.
/// </summary>
public static System.Func<StageDef, StageWave, int, int> DeployOverride;
/// <summary>프로브 전용 대체 회수 · 대체 리젠 차단(같은 이유).</summary>
public static System.Func<StageDef, int, int> RecallOverride;
public static System.Func<StageDef, int, bool> StopRespawnOverride;
static WLStageSettings Cfg { get { return WLStageSettings.Instance; } }
// ─────────────────────────────────────────────────────────────────────
// 스포너 조회
// ─────────────────────────────────────────────────────────────────────
/// <summary>스포너 목록을 다시 훑는다(맵 로드 뒤 1회면 충분하다).</summary>
public static void Rescan()
{
Scans++;
s_byId.Clear();
s_mgrs = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; s_mgrs != null && i < s_mgrs.Length; i++)
{
var m = s_mgrs[i];
if (DSUtil.CheckNull(m)) continue;
s_byId[m.SpawnerID] = m;
}
}
/// <summary>스포너 1개를 찾는다(캐시 · 없으면 1회 다시 훑는다).</summary>
public static MobControlMgr Find(int spawnerId)
{
MobControlMgr m;
if (s_byId.TryGetValue(spawnerId, out m) && !DSUtil.CheckNull(m)) return m;
Rescan();
if (s_byId.TryGetValue(spawnerId, out m) && !DSUtil.CheckNull(m)) return m;
SpawnerMissing++;
return null;
}
/// <summary>이 스포너에서 지금 살아 있는(활성 · 미사망) 몹 수.</summary>
public static int AliveOf(int spawnerId)
{
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0;
List<Actor> actors = null;
int n = 0;
try
{
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
if (actors == null) return 0;
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) n++;
}
}
catch (System.Exception ex) { Log("생존 수 조회 실패 " + spawnerId + " — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
return n;
}
// ─────────────────────────────────────────────────────────────────────
// 🔴 리스폰 0 (기준서 §A-1 ⓑ · 원본 0줄)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 이 스테이지가 쓰는 모든 스포너의 리젠 예약을 끊는다.
/// 반환 = 실제로 끊은 스포너 수. 로딩이 안 끝난 스포너는 세지 않고 <paramref name="allStopped"/> 가 false 가 된다
/// (호출측이 다음 폴링에 다시 부르면 된다).
/// </summary>
public static int StopRespawns(StageDef def, out bool allStopped)
{
allStopped = true;
int done = 0;
var c = Cfg;
if (def == null || def.waves == null || c == null || !c.stopRespawnCoroutines) return 0;
for (int i = 0; i < def.waves.Length; i++)
{
var w = def.waves[i];
if (w == null || w.spawnerId == 0) continue;
if (AlreadyRequested(def, w.spawnerId, i)) continue; // 같은 스포너를 두 번 끊지 않는다
var ov = StopRespawnOverride;
if (ov != null) { if (ov(def, w.spawnerId)) { done++; RespawnStopCalls++; } else { allStopped = false; RespawnStopDeferred++; } continue; }
var mgr = Find(w.spawnerId);
if (mgr == null) { allStopped = false; continue; }
// 🔴 BossArena.cs:1077 과 같은 가드 — Get_LoadComplete() 전에 끊으면 최초 스폰 코루틴이 죽는다.
if (c.respawnStopRequiresLoadComplete && !mgr.Get_LoadComplete()) { allStopped = false; RespawnStopDeferred++; continue; }
mgr.StopAllCoroutines(); // ← 기준서 §A-1 ⓑ · 원본 수정 0줄
RespawnStopCalls++;
done++;
}
if (done > 0) Log("리젠 예약 차단 — 스포너 " + done + "기(전부=" + allStopped + ")");
return done;
}
/// <summary>앞선 웨이브가 이미 같은 스포너를 다뤘는가(중복 호출 방지 · 할당 0).</summary>
static bool AlreadyRequested(StageDef def, int spawnerId, int upTo)
{
for (int j = 0; j < upTo; j++)
{
var p = def.waves[j];
if (p != null && p.spawnerId == spawnerId) return true;
}
return false;
}
// ─────────────────────────────────────────────────────────────────────
// 고정 수 배치
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 묶음 <paramref name="batch"/> 를 배치한다. 반환 = 이번에 실제로 깨운 마리 수.
/// 🔴 「고정 수」= 표의 count. 스포너에 등록된 수가 모자라면 모자란 만큼만 나온다(반환값으로 알 수 있다).
/// </summary>
public static int Deploy(StageDef def, int batch, string reason)
{
var c = Cfg;
var tbl = WLStageTable.Instance;
if (def == null || def.waves == null || c == null || tbl == null) return 0;
int total = 0;
for (int i = 0; i < def.waves.Length; i++)
{
var w = def.waves[i];
if (w == null || w.spawnerId == 0) continue;
int want = tbl.CountInBatch(w, batch);
if (want <= 0) continue;
total += DeployWave(def, w, want, reason);
}
if (total > 0) Log("배치(" + reason + ") — " + total + "마리 · 묶음 " + batch);
return total;
}
/// <summary>웨이브 1개를 <paramref name="want"/> 마리만 깨운다.</summary>
public static int DeployWave(StageDef def, StageWave w, int want, string reason)
{
var ov = DeployOverride;
if (ov != null) { int n = ov(def, w, want); Deployed += n; return n; }
if (!Application.isPlaying || def == null || w == null || want <= 0) return 0;
var c = Cfg;
if (c == null) return 0;
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0;
List<Actor> actors = null;
int done = 0, alive = 0;
try
{
// 🔴 `ActorInfo.Get_Actors` 는 dic_actor[role][spawnerId] 를 직접 인덱싱한다 —
// 등록이 없는 스포너면 KeyNotFoundException 이 난다(813w7 실측). 배치 실패가 스테이지를 죽이면 안 된다.
actors = info.Get_Actors(eRole.Mob, w.spawnerId, true);
if (actors == null) return 0;
// ① 이미 살아 있는 개체를 고정 수에 포함한다(중복 배치 0).
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) alive++;
}
// ② 초과분 회수 — 「스테이지마다 정해진 수」를 넘겨 서 있으면 원본 호출로 재운다.
if (c.recallExcess && alive > want)
{
for (int i = 0; i < actors.Count && alive > want; i++)
{
var a = actors[i];
if (DSUtil.CheckNull(a) || !a.isActiveAndEnabled || a.IsDead()) continue;
a.Off();
Recalled++; alive--;
}
}
// ③ 모자란 만큼만 깨운다 — 초과분은 비활성으로 남는다.
int need = want - alive;
for (int i = 0; i < actors.Count && done < need; i++)
{
var mob = actors[i] as MobActor;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221)
if (c.warpSpawnsToAnchor) WarpIntoStage(def, mob, alive + done);
Deployed++;
done++;
}
}
catch (System.Exception ex) { Log("웨이브 배치 실패 " + w.spawnerId + " — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
return done;
}
/// <summary>
/// 배치한 몹을 스테이지 앵커 주변으로 옮긴다(황금각 나선 산포 = 겹침 없이 고르게 · 난수 0 = 재현 가능).
/// 🔴 `MobActor.On_Regen()` 은 원본대로 자기 스포너 자리(`OriginalPos`)로 워프하므로,
/// 스테이지 앵커가 스포너 자리와 다르면 이 보정이 없으면 장벽 밖에 선다.
/// </summary>
static void WarpIntoStage(StageDef def, MobActor mob, int slot)
{
var c = Cfg;
float rMax = def.barrierRadius * (c.spawnScatterFactor > 0f ? c.spawnScatterFactor : 0.7f);
if (rMax <= 0.01f) return;
float ang = slot * 2.399963f; // 황금각(rad)
float rad = rMax * Mathf.Sqrt((slot + 0.5f) / Mathf.Max(1, def.TotalCount));
Vector3 want = def.anchor + new Vector3(Mathf.Cos(ang) * rad, 0f, Mathf.Sin(ang) * rad);
Vector3 target = want;
if (c.resolveSpawnOnNavMesh)
{
var tg = WLTerrainGuardSettings.Instance;
if (tg != null)
{
Vector3 fixedPos;
int how = SpawnPositionGuard.ResolveSpawnPoint(tg, want, mob.Get_Group(), out fixedPos);
if (how == SpawnPositionGuard.ResultFailed) { WarpFailed++; return; }
target = fixedPos;
}
else
{
NavMeshHit hit;
if (!NavMesh.SamplePosition(want, out hit, 4f, NavMesh.AllAreas)) { WarpFailed++; return; }
target = hit.position;
}
}
mob.Set_Warp(target);
WarpedToAnchor++;
}
// ─────────────────────────────────────────────────────────────────────
// 회수
// ─────────────────────────────────────────────────────────────────────
/// <summary>이 스테이지 스포너의 살아 있는 몹을 전부 재운다(재도전 전처리). 반환 = 재운 마리 수.</summary>
public static int Recall(StageDef def, string reason)
{
if (def == null || def.waves == null) return 0;
int total = 0;
for (int i = 0; i < def.waves.Length; i++)
{
var w = def.waves[i];
if (w == null || w.spawnerId == 0) continue;
if (AlreadyRequested(def, w.spawnerId, i)) continue;
total += RecallSpawner(def, w.spawnerId);
}
if (total > 0) Log("회수(" + reason + ") — " + total + "마리");
return total;
}
/// <summary>스포너 1개의 살아 있는 몹을 전부 재운다.</summary>
public static int RecallSpawner(StageDef def, int spawnerId)
{
var ov = RecallOverride;
if (ov != null) { int n = ov(def, spawnerId); Recalled += n; return n; }
if (!Application.isPlaying) return 0;
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0;
List<Actor> actors = null;
int done = 0;
try
{
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
if (actors == null) return 0;
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (DSUtil.CheckNull(a) || !a.isActiveAndEnabled) continue;
a.Off(); // 원본이 스폰 직후 쓰는 호출(MobControlMgr.cs:163)
Recalled++; done++;
}
}
catch (System.Exception ex) { Log("회수 실패 " + spawnerId + " — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
return done;
}
// ─────────────────────────────────────────────────────────────────────
// 진단
// ─────────────────────────────────────────────────────────────────────
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[StageSpawner] " + msg);
}
/// <summary>프로브용: 카운터·캐시 초기화(대체 호출은 유지).</summary>
public static void ResetDiagnostics()
{
RespawnStopCalls = RespawnStopDeferred = SpawnerMissing = 0;
Deployed = Recalled = WarpedToAnchor = WarpFailed = Scans = 0;
LastLog = "";
s_byId.Clear(); s_mgrs = null;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,170 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLStageSettings.cs — 스테이지 진행기 값의 단일 출처(SOT · C45)
//
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset
//
// ■ C8 롤백 (하나라도 false 면 StageDirector 는 아무 일도 하지 않는다 = 813p 런 루프 100%)
// ① 에셋이 없다 → Enabled false
// ② enabled = false → Enabled false
// ③ RuntimeDisabled = true → Enabled false (진단·A/B 전용 · 도메인 리로드로 자동 해제)
// ④ WLCombatCoreSettings.Enabled = false → Enabled false
// 🔴 ④ 가 필요한 이유: 승리 판정(고정 수 전멸)이 전적으로 `CombatEvents.Killed` 구독이다.
// 코어가 꺼진 채 스테이지를 걸면 처치가 0 으로 보여 「무조건 시간 초과 실패」가 된다.
//
// ■ 🔴 축을 켜도 저절로 시작하지 않는다 — `autoEnterOnMapEnter` 기본 **0**.
// 로비·스테이지 선택 UI 가 아직 없다(기준서 §B-1 실측 · 815h 소관). 그래서 켜 두어도
// `StageDirector.Enter(index)` 를 부르기 전까지 기존 흐름(813p 런)이 그대로 돈다.
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Stage
{
[CreateAssetMenu(fileName = "WLStageSettings", menuName = "WL/Stage Settings", order = 331)]
public sealed class WLStageSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLStageSettings";
static WLStageSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLStageSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLStageSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(도메인 리로드로 자동 false).</summary>
public static bool RuntimeDisabled;
/// <summary>스테이지 축이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on.</summary>
public static bool Enabled
{
get
{
var i = Instance;
return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled;
}
}
/// <summary>테스트용: 캐시를 비운다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치 (C8 롤백 1순위)")]
[Tooltip("끄면 스테이지 상태기계·배치·이벤트가 전부 비활성 — 813p 런 루프가 원래대로 돈다.")]
public bool enabled = true;
[Tooltip("입장·클리어·실패·재도전을 Console 에 남긴다(검증용 · 기본 off).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── 시간 축
[Header("시간 축")]
[Tooltip("상태 폴링 주기(초 · unscaled). 813p RunDirector 와 같은 방식이다.")]
public float pollSeconds = 0.1f;
[Tooltip("TimeChanged / CountdownTick 발행 주기(초 · unscaled). 1 = 1 Hz(HUD 타이머).")]
public float tickIntervalSeconds = 1f;
[Tooltip("813i DeathFlow 가 세계를 멈춘 동안(부활 팝업)은 스테이지 시계를 세운다.")]
public bool pauseTimerWhileWorldHeld = true;
// ─────────────────────────────────────────── 입장
[Header("입장 (기준서 §B-2 1)")]
[Tooltip("🔴 맵 진입 경계에서 스테이지 0 번을 자동으로 연다. 기본 0 — 로비·선택 UI 가 아직 없다(815h).")]
public bool autoEnterOnMapEnter = false;
[Tooltip("입장 시 PC 를 스테이지 앵커로 워프한다(Actor.Set_Warp · 813w3 API 재사용).")]
public bool warpPlayerToAnchor = true;
[Tooltip("입장·재도전 시 815c 장벽 훅(StageBarrierHook.Set)을 부른다. 구현이 없으면 no-op.")]
public bool useBarrierHook = true;
[Tooltip("클리어·이탈 시 장벽 훅(StageBarrierHook.Clear)을 부른다.")]
public bool clearBarrierOnExit = true;
// ─────────────────────────────────────────── 배치 · 리스폰 0
[Header("몬스터 고정 수 배치 · 리스폰 0 (기준서 §A-1 ⓑ)")]
[Tooltip("🔴 리스폰 0 — 스테이지 스포너의 리젠 예약 코루틴을 끊는다(MobControlMgr.StopAllCoroutines · 원본 0줄).")]
public bool stopRespawnCoroutines = true;
[Tooltip("🔴 Get_LoadComplete() 가 true 인 스포너만 끊는다(BossArena.cs:1077 가드 그대로). 끄면 최초 스폰이 죽을 수 있다 — 켜 둘 것.")]
public bool respawnStopRequiresLoadComplete = true;
[Tooltip("배치한 몹을 스테이지 앵커 주변으로 워프한다. 끄면 각자 원래 스포너 자리(MobActor.On_Regen 의 OriginalPos)에 선다.")]
public bool warpSpawnsToAnchor = true;
[Tooltip("몹 산포 반경 = barrierRadius × 이 값. 장벽 안에 들어오도록 1 보다 작게 둔다.")]
public float spawnScatterFactor = 0.7f;
[Tooltip("산포 좌표를 NavMesh 로 보정한다(813t2 SpawnPositionGuard.ResolveSpawnPoint 재사용 · 실패하면 워프하지 않는다).")]
public bool resolveSpawnOnNavMesh = true;
[Tooltip("고정 수를 넘겨 살아 있는 몹은 회수한다(Actor.Off · 원본이 스폰 직후 쓰는 그 호출).")]
public bool recallExcess = true;
[Tooltip("한 스테이지에서 다룰 스포너 수 상한(버퍼 크기 · GC 0).")]
public int maxSpawnersPerStage = 8;
// ─────────────────────────────────────────── 재도전 (기준서 §E)
[Header("재도전 · 실패 (기준서 §E)")]
[Tooltip("PC 사망을 스테이지 실패로 본다(DeathFlow.Died 구독).")]
public bool failOnPlayerDeath = true;
[Tooltip("재도전 때 몬스터를 전부 회수한 뒤 다시 배치한다(끄면 남은 몹 위에 채우기만 한다).")]
public bool retryRecallsMonsters = true;
[Tooltip("재도전 때 813i 물약 잔량을 되돌린다(PotionUse.ResetRun).")]
public bool retryResetsPotions = true;
[Tooltip("재도전 때 PC 를 전회복시킨다(MyActor.On_Regen(false, 표의 retryHealRate) · 813z 와 같은 경로).")]
public bool retryHealsPlayer = true;
[Tooltip("🔴 임시 배선 — 813z ReviveDialog 의 [확인] Retry() (DeathFlow.ReviveOverride). 815d 2 .")]
public bool bindReviveOkToRetry = true;
// ─────────────────────────────────────────── 클리어
[Header("클리어 · 결과")]
[Tooltip("전멸 직후 세계를 늦춘다(TimeScaleArbiter.Cinematic · 표의 clearHoldSeconds 동안). 0 = 완전 정지 · 1 = 늦추지 않음.")]
public float clearHoldScale = 0f;
[Tooltip("클리어·실패 시 813p RunEvents.RunEnded 를 **병행 발행**한다(813q 결과창·813l 등 기존 구독자 호환).")]
public bool mirrorRunEnded = true;
[Tooltip("입장·틱에 RunEvents.RunStarted / RunTick 도 병행 발행한다(기존 RunHud 가 그대로 돈다).")]
public bool mirrorRunHudEvents = true;
[Tooltip("클리어 때 RunEvents.ZoneCleared 를 **1회** 발행한다 = 813o ZonePickup 카드 트리거(기준서 §A 「트리거만 교체」).")]
public bool cardOnStageClear = true;
[Tooltip("[다음 스테이지] ( ) . .")]
public bool wrapToFirstStage = false;
// ─────────────────────────────────────────── 기존 런 요소 처리
[Header("기존 런 요소 (스테이지 중에만 끈다 · 자동 복원 = C8)")]
[Tooltip("🔴 스테이지 중에는 813p RunDirector 를 재운다(WLRunSettings.RuntimeDisabled). 이탈하면 자동 복원.")]
public bool suppressRunDirector = true;
[Tooltip("🔴 스테이지 중에는 RunGuide 존 이동 유도를 끈다(WLRunGuideSettings.RuntimeDisabled). 장벽 안에서는 유도가 필요 없다.")]
public bool disableRunGuide = true;
[Tooltip("스테이지에 들어갈 때 RunDirector 상태를 Idle 로 비운다(RunDirector.ResetAll · 런 밖 사망 자동부활 오작동 방지).")]
public bool resetRunDirectorOnEnter = true;
[Tooltip("로비로 나갈 때 원본 InGameInfo.Return_To_Lobby() 를 부른다. 🔴 기본 0 — 로비 맵 행이 아직 없다(기준서 §B-1 · 815h).")]
public bool toLobbyCallsReturnToLobby = false;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,225 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLStageTable.cs — 스테이지 데이터의 단일 출처(SO · WL 규약 C45 · 기준서 §C)
//
// PD 지시 #815 · 발주서 WL-815b §1-1 (2026-09-11)
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset
// → Resources.Load<WLStageTable>("WL/WLStageTable") 로 1회 로드 후 캐시
// (WLRunSettings · WLBossArenaSettings 등 기존 설정 SO 관례 그대로).
//
// ■ 왜 SO 인가 (기준서 §C-1 실측)
// `MonsterAppear.json` 은 **한 줄 JSON** 이라 텍스트 병합이 안 되고, PD 가 `butler.xlsm` 을
// 재익스포트하면 WL 행이 통째로 사라진다. 그래서 스테이지 표는 WL 소유 SO 로 둔다.
// 🔴 몬스터 자체의 값(HP/ATK/드랍)은 여전히 `MonsterAppear.json` 이 SOT 다 — 이 표는
// 「어느 스포너의 몹을 몇 마리, 어디에」만 정한다(원본 테이블 0줄).
//
// ■ 🔴 스포너 용량 실측 (`Assets/ResWork/Table/Export/MonsterAppear.json` · 2026-09-11)
// 813001 PhaseAllKill 12마리 101|102|103|104 813002 PhaseAllKill 15마리 168|169|170|171|172
// 813003 PhaseAllKill 14마리 173|174|175|146|147|151 813004 PhaseAllKill 8마리 118|119|120
// 813005 Order 5마리 1014|1015|1016 813010 Random 1마리 10006(Anubis)
// 813021 Random 1마리 1023 813022 Random 1마리 1028 813023 Random 1마리 1009 813024 Random 1마리 1024
// → 한 스포너에서 등록 수보다 많이 꺼낼 수 없다. 그래서 기준서 §C-3 의 「수」는 유지하되
// **여러 스포너로 나눠 담았다**(아래 표 주석에 스테이지별 분배가 있다).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Combat.Stage
{
/// <summary>웨이브 배치 방식(기준서 §C-2 `mode`).</summary>
public enum StageWaveMode
{
/// <summary>일괄 — 스테이지 시작에 전부 배치.</summary>
All = 0,
/// <summary>웨이브 — 앞 묶음이 전멸하면 배치(WLStageTable.wavesPerStage 가 묶음 수).</summary>
Wave = 1,
/// <summary>시간차 — delaySec 뒤 배치.</summary>
Staggered = 2,
}
/// <summary>스테이지 한 묶음의 몬스터(기준서 §C-2 `waves[]` 행).</summary>
[System.Serializable]
public sealed class StageWave
{
[Tooltip("원본 MonsterAppear.n_SpawnerID. 이 스포너에 등록된 몹에서 꺼낸다(등록 수가 상한).")]
public int spawnerId;
[Tooltip("이 스포너가 실제로 내보내는 몹 ID(MonsterAppear.s_AppearMonster 실측 · 표시·검증용 · 배치는 원본이 고른다).")]
public int[] monsterIds;
[Tooltip("이 스테이지에서 배치할 마리 수(고정 수). 등록 수를 넘으면 등록 수까지만 나온다.")]
public int count = 1;
[Tooltip("엘리트 묶음인가(813s EliteMarker · 815d 표시 · 815f 예고 배율 대상).")]
public bool elite;
[Tooltip("보스 묶음인가(스테이지 5·10). 보스는 BossArena 게이트/아레나 로직의 대상이다.")]
public bool boss;
[Tooltip("배치 방식 — 일괄 / 웨이브 / 시간차.")]
public StageWaveMode mode = StageWaveMode.All;
[Tooltip("Staggered 일 때 배치까지의 지연(초). 다른 모드에서는 쓰이지 않는다.")]
public float delaySec;
}
/// <summary>스테이지 1개(기준서 §C-2 스키마).</summary>
[System.Serializable]
public sealed class StageDef
{
[Tooltip("스테이지 번호(1부터).")]
public int id = 1;
[Tooltip("BattleMapConfig.n_MapID (1 = WL_Nature). 현재 값은 전부 1 = 한 맵 안에서 영역만 옮긴다.")]
public int mapId = -1;
[Tooltip("시작 영역 중심(월드 좌표 · 기준서 §D-1 실측 앵커).")]
public Vector3 anchor;
[Tooltip("PC 시작 텔레포트 산포 반경(m).")]
public float startRadius = 2f;
[Tooltip("장벽 반경(m) — 815c StageBarrierHook.Set 에 그대로 넘어간다.")]
public float barrierRadius = 14f;
[Tooltip("장벽 모양(1차는 Circle 만).")]
public StageBarrierShape barrierShape = StageBarrierShape.Circle;
[Tooltip("이 스테이지의 몬스터 묶음. 합계가 「고정 수」이고 그 전멸이 승리 조건이다.")]
public StageWave[] waves;
[Tooltip("보스 스테이지인가(기준서 §C-3 5·10).")]
public bool isBoss;
[Tooltip("보스 MonsterList.n_MonsterID(실측 10006 = Anubis). isBoss 가 아니면 0.")]
public int bossMonsterId;
[Tooltip("클리어 보상 — 카드 3택 장수(0 = 카드 없음).")]
public int clearRewardCards = 1;
[Tooltip("클리어 보상 — 골드 배율(1 = 기본).")]
public float goldMultiplier = 1f;
[Tooltip("권장 전투력(813l 산식) · 0 = 미설정(🔴 아직 실측값 없음).")]
public int recommendPower;
[Tooltip("§G 예고(텔레그래프) 시간 배율 — 815f 가 읽는다. 클수록 예고가 길다(쉽다).")]
public float telegraphScale = 1f;
[Tooltip("이 스테이지만의 제한 시간(초). 0 이하면 표 전역 stageTimeLimitSeconds 를 쓴다.")]
public float timeLimitOverrideSeconds;
/// <summary>이 스테이지의 고정 수(모든 묶음 합) — 승리에 필요한 처치 수.</summary>
public int TotalCount
{
get
{
int n = 0;
if (waves != null)
for (int i = 0; i < waves.Length; i++)
if (waves[i] != null && waves[i].count > 0) n += waves[i].count;
return n;
}
}
/// <summary>이 스포너가 이 스테이지에 속하는가(처치 집계 · 리스폰 차단 대상 판정).</summary>
public bool HasSpawner(int spawnerId)
{
if (waves == null) return false;
for (int i = 0; i < waves.Length; i++)
if (waves[i] != null && waves[i].spawnerId == spawnerId) return true;
return false;
}
}
[CreateAssetMenu(fileName = "WLStageTable", menuName = "WL/Stage Table", order = 330)]
public sealed class WLStageTable : ScriptableObject
{
public const string ResourcesPath = "WL/WLStageTable";
static WLStageTable s_cached;
static bool s_lookupDone;
/// <summary>표 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLStageTable Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLStageTable>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전역 값 (기준서 §C-3 · PD 추가 규칙)
[Header("전역 값 (PD 지시 #815 00:2x)")]
[Tooltip("🔴 모든 스테이지 제한 시간(초). 초과 = 실패 → 재도전. 스테이지별 override 가 0 보다 크면 그쪽이 이긴다.")]
public float stageTimeLimitSeconds = 60f;
[Tooltip("재도전 시 HP 회복 비율(1 = 전회복 · 기준서 §E `restartHealRate`).")]
public float retryHealRate = 1f;
[Tooltip("입장 후 전투 시작까지의 카운트다운(초 · 기준서 §B-2 1.5 「제안」).")]
public float countdownSeconds = 1.5f;
[Tooltip("전멸 직후 정지(초 · 기준서 §B-2 3 「정지 1.0 s」).")]
public float clearHoldSeconds = 1f;
[Tooltip("스테이지당 웨이브 묶음 수(1 = 초안 · 2 = 전멸 → 다음 묶음). 각 묶음의 마리 수는 표의 count 를 균등 분할한다.")]
public int wavesPerStage = 1;
[Tooltip("웨이브 묶음 사이의 간격(초 · wavesPerStage ≥ 2 일 때만).")]
public float waveGapSeconds = 1.5f;
// ─────────────────────────────────────────── 표
[Header("스테이지 표 (기준서 §C-3 10스테이지 초안 · 전부 「제안」)")]
[Tooltip("스테이지 목록(순서 = 진행 순서). 인덱스 0 = 스테이지 1.")]
public StageDef[] stages;
/// <summary>스테이지 수.</summary>
public int Count { get { return stages != null ? stages.Length : 0; } }
/// <summary>인덱스로 스테이지를 얻는다(범위 밖이면 null).</summary>
public StageDef Get(int index) { return stages != null && index >= 0 && index < stages.Length ? stages[index] : null; }
/// <summary>표의 `id` 로 인덱스를 찾는다(없으면 1).</summary>
public int IndexOfId(int stageId)
{
if (stages == null) return -1;
for (int i = 0; i < stages.Length; i++) if (stages[i] != null && stages[i].id == stageId) return i;
return -1;
}
/// <summary>이 스테이지의 제한 시간(초) — override 가 0 보다 크면 그 값, 아니면 전역값.</summary>
public float TimeLimitOf(StageDef def)
{
if (def != null && def.timeLimitOverrideSeconds > 0f) return def.timeLimitOverrideSeconds;
return stageTimeLimitSeconds;
}
/// <summary>웨이브 묶음 수(1 이상으로 보정).</summary>
public int WaveBatches { get { return wavesPerStage > 1 ? wavesPerStage : 1; } }
/// <summary>
/// 묶음 b(0부터) 에서 이 웨이브가 배치할 마리 수 — count 를 WaveBatches 로 균등 분할하고 나머지는 앞 묶음에 준다.
/// wavesPerStage = 1 이면 b=0 에 전부(초안 동작).
/// </summary>
public int CountInBatch(StageWave w, int batch)
{
if (w == null || w.count <= 0) return 0;
int n = WaveBatches;
if (n <= 1) return batch == 0 ? w.count : 0;
if (batch < 0 || batch >= n) return 0;
int baseN = w.count / n, rem = w.count % n;
return baseN + (batch < rem ? 1 : 0);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d815b01a1b2c3d4e5f60708090a0b12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: