From 4a22dbca79f3fa17d95acc1c29414ec24f4ff73a Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 11 Sep 2026 01:01:55 +0900 Subject: [PATCH] =?UTF-8?q?[WL-815b]=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A7=84=ED=96=89=EA=B8=B0:=20WLStageTable=2010?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20=C2=B7=20StageDirector(?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EC=88=98=20=EC=A0=84=EB=A9=B8=20=C2=B7=20?= =?UTF-8?q?60=20s=20=C2=B7=20=EB=A6=AC=EC=8A=A4=ED=8F=B0=200=20=C2=B7=20?= =?UTF-8?q?=EC=9E=AC=EB=8F=84=EC=A0=84)=20=C2=B7=20StageEvents=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20(#815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/WL/WLStageSettings.asset | 44 + .../Resources/WL/WLStageSettings.asset.meta | 8 + .../Settings/Resources/WL/WLStageTable.asset | 433 ++++++++++ .../Resources/WL/WLStageTable.asset.meta | 8 + .../Resources/WL/WLSurvivalSettings.asset | 2 +- Assets/WL/Combat/Stage/StageContracts.cs | 279 ++++++ Assets/WL/Combat/Stage/StageContracts.cs.meta | 11 + Assets/WL/Combat/Stage/StageDirector.cs | 801 ++++++++++++++++++ Assets/WL/Combat/Stage/StageDirector.cs.meta | 11 + Assets/WL/Combat/Stage/StageDirectorRunner.cs | 38 + .../Combat/Stage/StageDirectorRunner.cs.meta | 11 + Assets/WL/Combat/Stage/StageSpawner.cs | 357 ++++++++ Assets/WL/Combat/Stage/StageSpawner.cs.meta | 11 + Assets/WL/Combat/Stage/WLStageSettings.cs | 170 ++++ .../WL/Combat/Stage/WLStageSettings.cs.meta | 11 + Assets/WL/Combat/Stage/WLStageTable.cs | 225 +++++ Assets/WL/Combat/Stage/WLStageTable.cs.meta | 11 + 17 files changed, 2430 insertions(+), 1 deletion(-) create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset.meta create mode 100644 Assets/WL/Combat/Stage/StageContracts.cs create mode 100644 Assets/WL/Combat/Stage/StageContracts.cs.meta create mode 100644 Assets/WL/Combat/Stage/StageDirector.cs create mode 100644 Assets/WL/Combat/Stage/StageDirector.cs.meta create mode 100644 Assets/WL/Combat/Stage/StageDirectorRunner.cs create mode 100644 Assets/WL/Combat/Stage/StageDirectorRunner.cs.meta create mode 100644 Assets/WL/Combat/Stage/StageSpawner.cs create mode 100644 Assets/WL/Combat/Stage/StageSpawner.cs.meta create mode 100644 Assets/WL/Combat/Stage/WLStageSettings.cs create mode 100644 Assets/WL/Combat/Stage/WLStageSettings.cs.meta create mode 100644 Assets/WL/Combat/Stage/WLStageTable.cs create mode 100644 Assets/WL/Combat/Stage/WLStageTable.cs.meta diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset new file mode 100644 index 000000000..924902897 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset @@ -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 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset.meta new file mode 100644 index 000000000..7b2c63ac9 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b18 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset b/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset new file mode 100644 index 000000000..01decc49b --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset @@ -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 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset.meta new file mode 100644 index 000000000..ad0b61099 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b17 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset index 41be2c4ed..e2fc7e44f 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset @@ -47,7 +47,7 @@ MonoBehaviour: reviveInvincibleSeconds: 1.5 reviveHealRate: 1 reviveCompanions: 1 - reviveWatchdogSeconds: 8 + reviveWatchdogSeconds: 0 reviveWatchdogIgnoresDisable: 1 reviveWatchdogLog: 1 reviveTopmostEnabled: 1 diff --git a/Assets/WL/Combat/Stage/StageContracts.cs b/Assets/WL/Combat/Stage/StageContracts.cs new file mode 100644 index 000000000..112ca3ed0 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageContracts.cs @@ -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` 재사용 · 구독자 예외는 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 +{ + /// 스테이지 진행 단계(기준서 §B-2 · 로비는 이 축 밖 = Idle). + public enum StageState + { + /// 스테이지 밖(로비 · 타이틀 · 종료 후 대기). + Idle = 0, + /// 입장 — PC 워프 · 장벽 · 몬스터 고정 수 배치. + Entering = 1, + /// 카운트다운(countdownSeconds). + Countdown = 2, + /// 전투 — 고정 수 전멸이 승리 · 제한 시간이 흐른다. + Combat = 3, + /// 클리어 직후 정지(clearHoldSeconds). + Cleared = 4, + /// 결과 — 「다음 스테이지 / 로비로」 대기. + Result = 5, + /// 실패 — 「다시 도전 / 로비로」 대기. + Failed = 6, + } + + /// 실패 사유(기준서 §E). + public enum StageFailReason + { + None = 0, + /// PC 사망(`DeathFlow.Died`). + PlayerDied = 1, + /// 제한 시간 초과(모든 스테이지 60 s · PD 추가 규칙). + TimeLimit = 2, + /// 바깥 사정으로 중단(맵 이탈 · 로비 · 축 off) — 재도전 대상이 아니다. + Aborted = 3, + } + + /// 장벽 모양(1차는 Circle 만 · 815c 소관). + public enum StageBarrierShape { Circle = 0, Box = 1 } + + // ───────────────────────────────────────────── 페이로드(struct · 발생 시점 스냅샷 · GC 0) + + /// 스테이지 입장(배치 직후 · 카운트다운 시작 전). + public struct StageEnteredEvent + { + /// 표 인덱스(0부터) · 표의 `id`(1부터). + public int index, stageId; + /// 이 스테이지의 정의(🔴 SO 의 인스턴스 참조 — 읽기만 할 것). + public StageDef def; + /// 이번에 실제로 배치한 마리 수(= 승리에 필요한 처치 수). + public int totalEnemies; + /// 이 스테이지의 제한 시간(초). + public float timeLimit; + /// 고정 영역 중심 · 반경(815c 장벽이 그대로 쓰는 값). + public Vector3 anchor; + public float barrierRadius; + /// 카운트다운 길이(초). + public float countdownSeconds; + /// 같은 스테이지를 몇 번째 도전하는가(0 = 첫 도전). + public int retries; + public float time; + public int frame; + } + + /// 카운트다운 갱신(SO tickIntervalSeconds 주기 + 시작·종료 시점). + public struct StageCountdownEvent + { + public int index; + /// 남은 초 · 전체 초. + public float remaining, total; + public float time; + public int frame; + } + + /// 전투 시작(카운트다운 종료 · 타이머 시작). + public struct StageStartedEvent + { + public int index, stageId; + public int totalEnemies; + public float timeLimit; + public int retries; + public float time; + public int frame; + } + + /// 남은 적 수가 바뀌었다(처치 1건마다 · 배치 직후 1회). + public struct StageEnemyCountEvent + { + public int index; + /// 남은 적 · 이 스테이지의 고정 수. + public int remaining, total; + public float time; + public int frame; + } + + /// 남은 시간이 바뀌었다(SO tickIntervalSeconds 주기 · 전투 중에만). + public struct StageTimeEvent + { + public int index; + /// 남은 초 · 제한 시간(초). + public float remaining, limit; + public float time; + public int frame; + } + + /// + /// 스테이지 결과 — 클리어 화면(815d)이 이 하나로 전부를 그린다. + /// 실패에도 같은 구조를 쓴다(`win=false` · `failReason` 참조). + /// 🔴 이름이 발주서의 `StageResult` 가 아닌 이유 = **실측 충돌**. 원본 `Assets/Script/UI/Stage/StageResult.cs:7` + /// 에 전역 네임스페이스 `public class StageResult`(BackKeyAdd)가 이미 있어, `using WL.Combat.Stage;` 를 쓴 + /// 전역 파일에서 `StageResult` 가 원본 클래스로 잡힌다(컴파일 에러 CS0453 실측). 그래서 `StageClearResult`. + /// + public struct StageClearResult + { + /// 표 인덱스 · 표의 id. + public int index, stageId; + /// 클리어했는가. + public bool win; + /// 실패 사유(win 이면 None). + public StageFailReason failReason; + /// 전투 시작 후 경과 초(세계 정지 시간 제외) · 제한 시간. + public float elapsedSec, timeLimit; + /// 처치 수 / 고정 수 · 남은 적. + public int killed, total, remaining; + /// 같은 스테이지를 몇 번 재도전했는가(0 = 첫 도전에 성공). + public int retries; + /// 이 스테이지가 마지막인가(다음 스테이지가 없다). + public bool isLastStage; + /// 보스 스테이지인가. + public bool isBoss; + public float time; + public int frame; + } + + /// 실패(사망 · 시간 초과) — 815d 의 「다시 도전 / 로비」 팝업 트리거. + public struct StageFailedEvent + { + public int index, stageId; + public StageFailReason reason; + /// 남은 적 / 고정 수 · 경과 초. + public int remaining, total; + public float elapsedSec; + /// 지금까지의 재도전 횟수(무제한 · PD 3항). + public int retries; + public float time; + public int frame; + } + + /// 스테이지 축에서 빠져나갔다(로비로 · 맵 이탈 · 축 off). + public struct StageExitedEvent + { + public int index; + /// 로비로 나가는 길인가(false = 중단). + public bool toLobby; + /// 사유 문자열(진단 · 로그용). + public string reason; + public float time; + public int frame; + } + + // ───────────────────────────────────────────── 이벤트 허브 + + /// + /// 스테이지 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove. + /// 🔴 815c/815d/815f 는 이 계약과 의 정적 게터만 본다. + /// + public static class StageEvents + { + /// 스테이지 입장(배치 직후). + public static readonly CombatEventList StageEntered = new CombatEventList(4); + /// 카운트다운 갱신. + public static readonly CombatEventList CountdownTick = new CombatEventList(4); + /// 전투 시작. + public static readonly CombatEventList StageStarted = new CombatEventList(4); + /// 남은 적 수 변경. + public static readonly CombatEventList EnemyCountChanged = new CombatEventList(4); + /// 남은 시간 변경. + public static readonly CombatEventList TimeChanged = new CombatEventList(4); + /// 클리어(고정 수 전멸). + public static readonly CombatEventList StageCleared = new CombatEventList(4); + /// 실패(사망 · 시간 초과). + public static readonly CombatEventList StageFailed = new CombatEventList(4); + /// 스테이지 축 이탈. + public static readonly CombatEventList StageExited = new CombatEventList(4); + + /// 스테이지 축이 살아 있는가(= ). + 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; + } + + /// 프로브용: 카운터 초기화(구독은 유지). + 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 계약) + + /// + /// 🔴 815c(장벽)와의 유일한 이음매. 이 태스크는 **인터페이스만** 둔다. + /// · 815c 가 / 에 자기 구현을 대입한다. + /// · 아무도 대입하지 않으면 **no-op** — 예외 0 · 동작 변경 0(장벽 없이 스테이지가 그대로 돈다). + /// · 구독자 예외는 여기서 삼킨다(장벽 하나 때문에 스테이지 흐름이 끊기지 않게). + /// + public static class StageBarrierHook + { + /// 장벽을 세운다 — (중심, 반경). + public static System.Action Set; + /// 장벽을 없앤다. + public static System.Action Clear; + + /// 진단 — 호출 횟수 · 마지막 값 · 구현이 붙어 있는가. + 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; } } + + /// 장벽 요청(구현이 없으면 값만 기록하고 아무 일도 하지 않는다). + 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++; } + } + + /// 장벽 해제(구현이 없으면 아무 일도 하지 않는다). + public static void RaiseClear() + { + ClearCalls++; + var h = Clear; + if (h == null) return; + try { h(); } + catch (System.Exception) { Faults++; } + } + + /// 프로브용: 카운터만 비운다(대입한 구현은 유지). + public static void ResetDiagnostics() { SetCalls = ClearCalls = Faults = 0; LastCenter = Vector3.zero; LastRadius = 0f; } + } +} diff --git a/Assets/WL/Combat/Stage/StageContracts.cs.meta b/Assets/WL/Combat/Stage/StageContracts.cs.meta new file mode 100644 index 000000000..7e0895036 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b11 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Stage/StageDirector.cs b/Assets/WL/Combat/Stage/StageDirector.cs new file mode 100644 index 000000000..4e3a9c650 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageDirector.cs @@ -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 +{ + /// 스테이지 상태기계. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개). + 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 s_revivePrev; + + // ── 진입 경계(813p 와 같은 원천 · 판정은 여기) + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + + // ───────────────────────────────────────── 공개 계약 (815c/815d/815f 가 읽는다) + + /// 현재 스테이지 정의(없으면 null). + public static StageDef Current { get { return s_def; } } + /// 현재 스테이지 표 인덱스(0부터 · 없으면 −1). + public static int Index { get { return s_index; } } + /// 남은 적 수(고정 수 − 처치 수 · 0 미만 없음). + public static int RemainingEnemies { get { int r = s_total - s_killed; return r > 0 ? r : 0; } } + /// 이 스테이지의 고정 수. + public static int TotalEnemies { get { return s_total; } } + /// 남은 초(전투 중에만 줄어든다 · 0 미만 없음). + public static float RemainingSeconds + { + get + { + if (s_state != StageState.Combat) return s_limit; + float r = s_limit - Elapsed; + return r > 0f ? r : 0f; + } + } + /// 스테이지가 진행 중인가(입장 ~ 전투 · 결과·실패 대기는 false). + public static bool IsActive + { + get { return s_state == StageState.Entering || s_state == StageState.Countdown || s_state == StageState.Combat; } + } + /// 스테이지 축이 어떤 형태로든 살아 있는가(결과·실패 대기 포함). + public static bool InStage { get { return s_state != StageState.Idle; } } + + /// 현재 상태. + public static StageState State { get { return s_state; } } + /// 이 스테이지를 몇 번 재도전했는가(무제한 · PD 3항). + public static int Retries { get { return s_retries; } } + /// 이번 스테이지의 처치 수. + public static int Killed { get { return s_killed; } } + /// 이 스테이지의 제한 시간(초). + public static float TimeLimit { get { return s_limit; } } + /// 마지막 실패 사유. + public static StageFailReason LastFailReason { get { return s_fail; } } + /// 마지막 결과(클리어·실패 공통 · 결과 화면이 늦게 떠도 읽을 수 있게 남긴다). + public static StageClearResult LastResult { get { return s_lastResult; } } + /// 현재 묶음(웨이브) 번호 · 전체 묶음 수. + public static int Batch { get { return s_batch; } } + public static int BatchCount { get { return s_batchCount; } } + /// 카운트다운 남은 초(카운트다운 중에만 · 그 밖에는 0). + 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; + } + } + + /// 전투 시작 후 경과 초(세계 정지 시간 제외). + 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 = ""; + /// 기존 축을 재우고 있는가(진단 · C8 증거). + 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; } } + + // ───────────────────────────────────────────────────────────────────── + // 구독 + // ───────────────────────────────────────────────────────────────────── + + /// 집계 구독(중복 구독은 CombatEventList.Add 가 막는다). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + CombatEvents.Killed.Add(OnKilled); + DeathFlow.Died.Add(OnDied); + s_subscribed = true; + } + + /// 구독 해제(프로브·롤백용). + 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); + } + + /// 813z ReviveDialog 의 [확인] 이 여기로 온다(DeathFlow.ReviveOverride · 815d 전 임시 배선). + 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 선례) + // ───────────────────────────────────────────────────────────────────── + + /// 처치 1건 반영 + 전멸 재판정. + 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 로비가 부르는 전부 + // ───────────────────────────────────────────────────────────────────── + + /// 스테이지 (0부터) 로 입장한다. 진행 중이면 먼저 정리한다. + 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; + } + + /// 같은 스테이지를 다시 도전한다(HP 전회복 · 물약 초기화 · 몬스터 재배치 · 타이머 리셋 · 무제한). + 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; + } + + /// 다음 스테이지로(index + 1). 마지막이면 SO wrapToFirstStage 에 따라 0 으로 돌거나 실패한다. + 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"); + } + + /// 로비로 나간다(스테이지 축 종료 · 억제한 기존 축 복원). + 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); } + } + } + + /// 중단(맵 이탈 · PC 소실 · 축 off). 실패로 세지 않는다. + 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 + }; + } + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). + 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; } + + /// 로딩이 안 끝나 미뤘던 리젠 차단을 다시 시도한다(BossArena.cs:1077 가드와 같은 이유). + 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; } + } + + /// 진입 경계 — 813x AutoCombatOnEnter 와 같은 원천(그 파일의 동작 변경 0 · 판정은 여기). + 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); + } + + /// 프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다 · 억제는 복원한다). + 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 = ""; + } + + /// 프로브용: 러너 없이 1회 틱(폴링 간격 무시). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + /// + /// 프로브용: 스테이지 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다). + /// 카운트다운·정지 대기까지 같은 축으로 움직이도록 상태 진입 시각·틱 예약도 함께 당긴다. + /// + 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; + } + + /// 프로브용: PC 사망 실패 경로를 이벤트 없이 직접 태운다. + 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(); + } + } +} diff --git a/Assets/WL/Combat/Stage/StageDirector.cs.meta b/Assets/WL/Combat/Stage/StageDirector.cs.meta new file mode 100644 index 000000000..0aba9cb5b --- /dev/null +++ b/Assets/WL/Combat/Stage/StageDirector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b15 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Stage/StageDirectorRunner.cs b/Assets/WL/Combat/Stage/StageDirectorRunner.cs new file mode 100644 index 000000000..0f2220ea4 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageDirectorRunner.cs @@ -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 +{ + /// StageDirector 를 매 프레임 틱한다(폴링 간격은 SO pollSeconds 가 결정). + internal sealed class StageDirectorRunner : MonoBehaviour + { + static StageDirectorRunner s_runner; + + /// 런너 1개 보장(플레이 모드 전용 · 에디트 모드에서는 만들지 않는다). + 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(); + } + + /// 런너가 살아 있는가(프로브가 읽는다). + internal static bool Exists { get { return s_runner != null; } } + + void Update() { StageDirector.Tick(); } + + void OnDestroy() { if (ReferenceEquals(s_runner, this)) s_runner = null; } + } +} diff --git a/Assets/WL/Combat/Stage/StageDirectorRunner.cs.meta b/Assets/WL/Combat/Stage/StageDirectorRunner.cs.meta new file mode 100644 index 000000000..72a54c0ad --- /dev/null +++ b/Assets/WL/Combat/Stage/StageDirectorRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Stage/StageSpawner.cs b/Assets/WL/Combat/Stage/StageSpawner.cs new file mode 100644 index 000000000..2923148b7 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageSpawner.cs @@ -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 +{ + /// 스테이지 몬스터의 배치 · 회수 · 리젠 차단. 정적 · 새 Manager/Singleton 0. + public static class StageSpawner + { + // ── 스포너 캐시(노드는 움직이지 않는다 · 맵이 바뀌면 다시 훑는다) + static MobControlMgr[] s_mgrs; + static readonly Dictionary s_byId = new Dictionary(16); + + // ── 진단(프로브가 읽는다) + /// 리젠 예약을 끊은 횟수 · 로딩 미완으로 미룬 횟수 · 스포너를 못 찾은 횟수. + public static int RespawnStopCalls, RespawnStopDeferred, SpawnerMissing; + /// 배치한 마리 수 · 회수한 마리 수 · 앵커로 워프한 마리 수 · NavMesh 보정 실패 수. + public static int Deployed, Recalled, WarpedToAnchor, WarpFailed; + /// 스포너 목록을 훑은 횟수. + public static int Scans; + public static string LastLog = ""; + + /// + /// 프로브 전용 대체 배치 — null 이 아니면 `On_Regen()` 대신 이것을 부르고 「배치했다」로 센다. + /// 에디트 모드에는 살아 있는 `MobActor`(스탯·NavMeshAgent·애니메이터)가 없어 원본 호출을 태울 수 없다. + /// 🔴 실기에서는 항상 null(누구도 대입하지 않는다) · Play 종료 시 도메인 리로드로 자동 null. + /// + public static System.Func DeployOverride; + + /// 프로브 전용 대체 회수 · 대체 리젠 차단(같은 이유). + public static System.Func RecallOverride; + public static System.Func StopRespawnOverride; + + static WLStageSettings Cfg { get { return WLStageSettings.Instance; } } + + // ───────────────────────────────────────────────────────────────────── + // 스포너 조회 + // ───────────────────────────────────────────────────────────────────── + + /// 스포너 목록을 다시 훑는다(맵 로드 뒤 1회면 충분하다). + public static void Rescan() + { + Scans++; + s_byId.Clear(); + s_mgrs = Object.FindObjectsByType(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; + } + } + + /// 스포너 1개를 찾는다(캐시 · 없으면 1회 다시 훑는다). + 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; + } + + /// 이 스포너에서 지금 살아 있는(활성 · 미사망) 몹 수. + public static int AliveOf(int spawnerId) + { + var info = ActorInfo.Ins; + if (DSUtil.CheckNull(info)) return 0; + List 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줄) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 이 스테이지가 쓰는 모든 스포너의 리젠 예약을 끊는다. + /// 반환 = 실제로 끊은 스포너 수. 로딩이 안 끝난 스포너는 세지 않고 가 false 가 된다 + /// (호출측이 다음 폴링에 다시 부르면 된다). + /// + 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; + } + + /// 앞선 웨이브가 이미 같은 스포너를 다뤘는가(중복 호출 방지 · 할당 0). + 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; + } + + // ───────────────────────────────────────────────────────────────────── + // 고정 수 배치 + // ───────────────────────────────────────────────────────────────────── + + /// + /// 묶음 를 배치한다. 반환 = 이번에 실제로 깨운 마리 수. + /// 🔴 「고정 수」= 표의 count. 스포너에 등록된 수가 모자라면 모자란 만큼만 나온다(반환값으로 알 수 있다). + /// + 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; + } + + /// 웨이브 1개를 마리만 깨운다. + 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 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; + } + + /// + /// 배치한 몹을 스테이지 앵커 주변으로 옮긴다(황금각 나선 산포 = 겹침 없이 고르게 · 난수 0 = 재현 가능). + /// 🔴 `MobActor.On_Regen()` 은 원본대로 자기 스포너 자리(`OriginalPos`)로 워프하므로, + /// 스테이지 앵커가 스포너 자리와 다르면 이 보정이 없으면 장벽 밖에 선다. + /// + 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++; + } + + // ───────────────────────────────────────────────────────────────────── + // 회수 + // ───────────────────────────────────────────────────────────────────── + + /// 이 스테이지 스포너의 살아 있는 몹을 전부 재운다(재도전 전처리). 반환 = 재운 마리 수. + 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; + } + + /// 스포너 1개의 살아 있는 몹을 전부 재운다. + 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 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); + } + + /// 프로브용: 카운터·캐시 초기화(대체 호출은 유지). + public static void ResetDiagnostics() + { + RespawnStopCalls = RespawnStopDeferred = SpawnerMissing = 0; + Deployed = Recalled = WarpedToAnchor = WarpFailed = Scans = 0; + LastLog = ""; + s_byId.Clear(); s_mgrs = null; + } + } +} diff --git a/Assets/WL/Combat/Stage/StageSpawner.cs.meta b/Assets/WL/Combat/Stage/StageSpawner.cs.meta new file mode 100644 index 000000000..a144f9651 --- /dev/null +++ b/Assets/WL/Combat/Stage/StageSpawner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Stage/WLStageSettings.cs b/Assets/WL/Combat/Stage/WLStageSettings.cs new file mode 100644 index 000000000..a4844c58c --- /dev/null +++ b/Assets/WL/Combat/Stage/WLStageSettings.cs @@ -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; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLStageSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(도메인 리로드로 자동 false). + public static bool RuntimeDisabled; + + /// 스테이지 축이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on. + public static bool Enabled + { + get + { + var i = Instance; + return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled; + } + } + + /// 테스트용: 캐시를 비운다. + 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; + } +} diff --git a/Assets/WL/Combat/Stage/WLStageSettings.cs.meta b/Assets/WL/Combat/Stage/WLStageSettings.cs.meta new file mode 100644 index 000000000..3e3737037 --- /dev/null +++ b/Assets/WL/Combat/Stage/WLStageSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b13 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Stage/WLStageTable.cs b/Assets/WL/Combat/Stage/WLStageTable.cs new file mode 100644 index 000000000..986d8135a --- /dev/null +++ b/Assets/WL/Combat/Stage/WLStageTable.cs @@ -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("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 +{ + /// 웨이브 배치 방식(기준서 §C-2 `mode`). + public enum StageWaveMode + { + /// 일괄 — 스테이지 시작에 전부 배치. + All = 0, + /// 웨이브 — 앞 묶음이 전멸하면 배치(WLStageTable.wavesPerStage 가 묶음 수). + Wave = 1, + /// 시간차 — delaySec 뒤 배치. + Staggered = 2, + } + + /// 스테이지 한 묶음의 몬스터(기준서 §C-2 `waves[]` 행). + [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; + } + + /// 스테이지 1개(기준서 §C-2 스키마). + [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; + + /// 이 스테이지의 고정 수(모든 묶음 합) — 승리에 필요한 처치 수. + 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; + } + } + + /// 이 스포너가 이 스테이지에 속하는가(처치 집계 · 리스폰 차단 대상 판정). + 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; + + /// 표 에셋(1회 로드 후 캐시). 없으면 null. + public static WLStageTable Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + 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; + + /// 스테이지 수. + public int Count { get { return stages != null ? stages.Length : 0; } } + + /// 인덱스로 스테이지를 얻는다(범위 밖이면 null). + public StageDef Get(int index) { return stages != null && index >= 0 && index < stages.Length ? stages[index] : null; } + + /// 표의 `id` 로 인덱스를 찾는다(없으면 −1). + 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; + } + + /// 이 스테이지의 제한 시간(초) — override 가 0 보다 크면 그 값, 아니면 전역값. + public float TimeLimitOf(StageDef def) + { + if (def != null && def.timeLimitOverrideSeconds > 0f) return def.timeLimitOverrideSeconds; + return stageTimeLimitSeconds; + } + + /// 웨이브 묶음 수(1 이상으로 보정). + public int WaveBatches { get { return wavesPerStage > 1 ? wavesPerStage : 1; } } + + /// + /// 묶음 b(0부터) 에서 이 웨이브가 배치할 마리 수 — count 를 WaveBatches 로 균등 분할하고 나머지는 앞 묶음에 준다. + /// wavesPerStage = 1 이면 b=0 에 전부(초안 동작). + /// + 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); + } + } +} diff --git a/Assets/WL/Combat/Stage/WLStageTable.cs.meta b/Assets/WL/Combat/Stage/WLStageTable.cs.meta new file mode 100644 index 000000000..fcfada37c --- /dev/null +++ b/Assets/WL/Combat/Stage/WLStageTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d815b01a1b2c3d4e5f60708090a0b12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: